From 51e908c6977dbc388aebb57713986cf675db7304 Mon Sep 17 00:00:00 2001 From: Sosthene Date: Tue, 23 Dec 2025 05:44:16 +0800 Subject: [PATCH 01/16] psbt: add psbt crates to spdk-core --- Cargo.toml | 7 + spdk-core/Cargo.toml | 7 + spdk-core/src/psbt/core/error.rs | 78 +++ spdk-core/src/psbt/core/extensions.rs | 642 ++++++++++++++++++++ spdk-core/src/psbt/core/mod.rs | 27 + spdk-core/src/psbt/core/shares.rs | 217 +++++++ spdk-core/src/psbt/core/types.rs | 188 ++++++ spdk-core/src/psbt/crypto/bip352.rs | 338 +++++++++++ spdk-core/src/psbt/crypto/dleq.rs | 286 +++++++++ spdk-core/src/psbt/crypto/error.rs | 40 ++ spdk-core/src/psbt/crypto/mod.rs | 17 + spdk-core/src/psbt/crypto/signing.rs | 312 ++++++++++ spdk-core/src/psbt/helpers/mod.rs | 8 + spdk-core/src/psbt/helpers/wallet/mod.rs | 8 + spdk-core/src/psbt/helpers/wallet/types.rs | 611 +++++++++++++++++++ spdk-core/src/psbt/io/error.rs | 31 + spdk-core/src/psbt/io/file_io.rs | 207 +++++++ spdk-core/src/psbt/io/metadata.rs | 205 +++++++ spdk-core/src/psbt/io/mod.rs | 11 + spdk-core/src/psbt/mod.rs | 23 + spdk-core/src/psbt/roles/constructor.rs | 177 ++++++ spdk-core/src/psbt/roles/creator.rs | 101 +++ spdk-core/src/psbt/roles/extractor.rs | 326 ++++++++++ spdk-core/src/psbt/roles/input_finalizer.rs | 282 +++++++++ spdk-core/src/psbt/roles/mod.rs | 33 + spdk-core/src/psbt/roles/signer.rs | 418 +++++++++++++ spdk-core/src/psbt/roles/updater.rs | 131 ++++ spdk-core/src/psbt/roles/validation.rs | 608 ++++++++++++++++++ 28 files changed, 5339 insertions(+) create mode 100644 spdk-core/src/psbt/core/error.rs create mode 100644 spdk-core/src/psbt/core/extensions.rs create mode 100644 spdk-core/src/psbt/core/mod.rs create mode 100644 spdk-core/src/psbt/core/shares.rs create mode 100644 spdk-core/src/psbt/core/types.rs create mode 100644 spdk-core/src/psbt/crypto/bip352.rs create mode 100644 spdk-core/src/psbt/crypto/dleq.rs create mode 100644 spdk-core/src/psbt/crypto/error.rs create mode 100644 spdk-core/src/psbt/crypto/mod.rs create mode 100644 spdk-core/src/psbt/crypto/signing.rs create mode 100644 spdk-core/src/psbt/helpers/mod.rs create mode 100644 spdk-core/src/psbt/helpers/wallet/mod.rs create mode 100644 spdk-core/src/psbt/helpers/wallet/types.rs create mode 100644 spdk-core/src/psbt/io/error.rs create mode 100644 spdk-core/src/psbt/io/file_io.rs create mode 100644 spdk-core/src/psbt/io/metadata.rs create mode 100644 spdk-core/src/psbt/io/mod.rs create mode 100644 spdk-core/src/psbt/mod.rs create mode 100644 spdk-core/src/psbt/roles/constructor.rs create mode 100644 spdk-core/src/psbt/roles/creator.rs create mode 100644 spdk-core/src/psbt/roles/extractor.rs create mode 100644 spdk-core/src/psbt/roles/input_finalizer.rs create mode 100644 spdk-core/src/psbt/roles/mod.rs create mode 100644 spdk-core/src/psbt/roles/signer.rs create mode 100644 spdk-core/src/psbt/roles/updater.rs create mode 100644 spdk-core/src/psbt/roles/validation.rs diff --git a/Cargo.toml b/Cargo.toml index aef5f78..31b85d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,13 @@ reqwest = { version = "0.12.4", features = [ "gzip", ], default-features = false } secp256k1 = { version = "0.29.0", features = ["rand"] } +psbt-v2 = { git = "https://github.com/tcharding/rust-psbt.git", branch = "master", features = ["silent-payments"] } +thiserror = "1.0" +base64 = "0.22" +sha2 = "0.10" +hmac = "0.12" +dnssec-prover = "0.1" +tempfile = "3.8" [workspace.package] repository = "https://github.com/cygnet3/spdk" diff --git a/spdk-core/Cargo.toml b/spdk-core/Cargo.toml index e6f4e83..49dc292 100644 --- a/spdk-core/Cargo.toml +++ b/spdk-core/Cargo.toml @@ -14,3 +14,10 @@ bitcoin.workspace = true futures.workspace = true serde.workspace = true silentpayments.workspace = true +psbt-v2.workspace = true +secp256k1.workspace = true +thiserror.workspace = true +base64.workspace = true +sha2.workspace = true +hmac.workspace = true +dnssec-prover.workspace = true \ No newline at end of file diff --git a/spdk-core/src/psbt/core/error.rs b/spdk-core/src/psbt/core/error.rs new file mode 100644 index 0000000..b92a220 --- /dev/null +++ b/spdk-core/src/psbt/core/error.rs @@ -0,0 +1,78 @@ +//! Error types for BIP-375 operations + +use thiserror::Error; + +/// Result type alias for BIP-375 operations +pub type Result = std::result::Result; + +/// Error types for BIP-375 PSBT operations +#[derive(Debug, Error)] +pub enum Error { + #[error("Invalid PSBT magic bytes")] + InvalidMagic, + + #[error("Invalid PSBT version: expected {expected}, got {actual}")] + InvalidVersion { expected: u32, actual: u32 }, + + #[error("Invalid field type: {0}")] + InvalidFieldType(u8), + + #[error("Missing required field: {0}")] + MissingField(String), + + #[error("Invalid field data: {0}")] + InvalidFieldData(String), + + #[error("Serialization error: {0}")] + Serialization(String), + + #[error("Deserialization error: {0}")] + Deserialization(String), + + #[error("Invalid ECDH share: {0}")] + InvalidEcdhShare(String), + + #[error("Incomplete ECDH coverage for output {0}")] + IncompleteEcdhCoverage(usize), + + #[error("Invalid signature: {0}")] + InvalidSignature(String), + + #[error("DLEQ proof verification failed for input {0}")] + DleqVerificationFailed(usize), + + #[error("Invalid silent payment address: {0}")] + InvalidAddress(String), + + #[error("Transaction extraction failed: {0}")] + ExtractionFailed(String), + + #[error("Invalid input index: {0}")] + InvalidInputIndex(usize), + + #[error("Invalid output index: {0}")] + InvalidOutputIndex(usize), + + #[error("Invalid public key (must be compressed)")] + InvalidPublicKey, + + #[error( + "Cannot add standard field type {0} via generic accessor - use specific method instead" + )] + StandardFieldNotAllowed(u8), + + #[error("Bitcoin error: {0}")] + Bitcoin(#[from] bitcoin::consensus::encode::Error), + + #[error("Secp256k1 error: {0}")] + Secp256k1(#[from] secp256k1::Error), + + #[error("Hex decoding error: {0}")] + Hex(#[from] hex::FromHexError), + + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + + #[error("Other error: {0}")] + Other(String), +} diff --git a/spdk-core/src/psbt/core/extensions.rs b/spdk-core/src/psbt/core/extensions.rs new file mode 100644 index 0000000..a452629 --- /dev/null +++ b/spdk-core/src/psbt/core/extensions.rs @@ -0,0 +1,642 @@ +//! BIP-375 Extension Traits and PSBT Accessors +//! +//! This module provides extension traits that add BIP-375 silent payment functionality +//! to the `psbt_v2::v2::Psbt` type, along with convenience accessor functions for +//! common PSBT field access patterns. +//! +//! # Module Contents +//! +//! - **`Bip375PsbtExt` trait**: Adds BIP-375 specific methods to PSBT +//! - ECDH share management (global and per-input) +//! - DLEQ proof handling +//! - Silent payment address/label fields +//! - SP tweak fields for spending +//! +//! - **Convenience Accessors**: Higher-level functions for extracting typed data +//! - Input field extraction (txid, vout, outpoint, pubkeys) +//! - Output field extraction (SP keys) +//! - Fallback logic for public key detection +//! +//! # Design Philosophy +//! +//! - **Non-invasive**: Uses extension traits rather than wrapping types +//! - **Idiomatic**: Follows rust-psbt patterns and conventions +//! - **Upstreamable**: Clean API that could be contributed to rust-psbt +//! - **Type-safe**: Leverages Rust's type system for correctness + +use super::{ + error::{Error, Result}, + types::{EcdhShareData}, + SilentPaymentPsbt, +}; +use bitcoin::{OutPoint, Txid}; +use psbt_v2::{ + bitcoin::CompressedPublicKey, + raw::Key, + v2::{dleq::DleqProof, Psbt}, +}; +use silentpayments::secp256k1::PublicKey; +use silentpayments::SilentPaymentAddress; + +pub const PSBT_OUT_DNSSEC_PROOF: u8 = 0x35; +pub const PSBT_IN_SP_TWEAK: u8 = 0x1f; +/// Extension trait for BIP-375 silent payment fields on PSBT v2 +/// +/// This trait adds methods to access and modify BIP-375 specific fields: +/// - ECDH shares (global and per-input) +/// - DLEQ proofs (global and per-input) +/// - Silent payment addresses (per-output) +/// - Silent payment labels (per-output) +pub trait Bip375PsbtExt { + // ===== Global ECDH Shares ===== + + /// Get all global ECDH shares + /// + /// Global shares are used when one party knows all input private keys. + /// Field type: PSBT_GLOBAL_SP_ECDH_SHARE (0x07) + fn get_global_ecdh_shares(&self) -> Vec; + + /// Add a global ECDH share + /// + /// # Arguments + /// * `share` - The ECDH share to add + fn add_global_ecdh_share(&mut self, share: &EcdhShareData) -> Result<()>; + + // ===== Per-Input ECDH Shares ===== + + /// Get ECDH shares for a specific input + /// + /// Returns per-input shares if present, otherwise falls back to global shares. + /// Field type: PSBT_IN_SP_ECDH_SHARE (0x1d) + /// + /// # Arguments + /// * `input_index` - Index of the input + fn get_input_ecdh_shares(&self, input_index: usize) -> Vec; + + /// Add an ECDH share to a specific input + /// + /// # Arguments + /// * `input_index` - Index of the input + /// * `share` - The ECDH share to add + fn add_input_ecdh_share(&mut self, input_index: usize, share: &EcdhShareData) -> Result<()>; + + // ===== Silent Payment Outputs ===== + + /// Get silent payment scan and spend keys for an output + /// + /// Field type: PSBT_OUT_SP_V0_INFO (0x09) + /// + /// # Arguments + /// * `output_index` - Index of the output + fn get_output_sp_info_v0(&self, output_index: usize) -> Option<(PublicKey, PublicKey)>; + + /// Set silent payment v0 keys for an output + /// + /// # Arguments + /// * `output_index` - Index of the output + /// * `address` - The silent payment address + fn set_output_sp_info_v0( + &mut self, + output_index: usize, + address: &SilentPaymentAddress, + ) -> Result<()>; + + /// Get silent payment label for an output + /// + /// Field type: PSBT_OUT_SP_V0_LABEL (0x0a) + /// + /// # Arguments + /// * `output_index` - Index of the output + fn get_output_sp_label(&self, output_index: usize) -> Option; + + /// Set silent payment label for an output + /// + /// # Arguments + /// * `output_index` - Index of the output + /// * `label` - The label value + fn set_output_sp_label(&mut self, output_index: usize, label: u32) -> Result<()>; + + // ===== Silent Payment Spending ===== + + /// Get silent payment tweak for an input + /// + /// Returns the 32-byte tweak that should be added to the spend private key + /// to spend this silent payment output. + /// + /// Field type: PSBT_IN_SP_TWEAK + /// + /// # Arguments + /// * `input_index` - Index of the input + fn get_input_sp_tweak(&self, input_index: usize) -> Option<[u8; 32]>; + + /// Set silent payment tweak for an input + /// + /// The tweak is derived from BIP-352 output derivation during wallet scanning. + /// Hardware signer uses this to compute: tweaked_privkey = spend_privkey + tweak + /// + /// Field type: PSBT_IN_SP_TWEAK + /// + /// # Arguments + /// * `input_index` - Index of the input + /// * `tweak` - The 32-byte tweak + fn set_input_sp_tweak(&mut self, input_index: usize, tweak: [u8; 32]) -> Result<()>; + + /// Remove silent payment tweak from an input + /// + /// This is typically called after transaction extraction to clean up the PSBT. + /// Prevents accidental re-use of tweaks and keeps PSBTs cleaner. + /// + /// Field type: PSBT_IN_SP_TWEAK + /// + /// # Arguments + /// * `input_index` - Index of the input + fn remove_input_sp_tweak(&mut self, input_index: usize) -> Result<()>; + + // ===== Convenience Methods ===== + + /// Get the number of inputs + fn num_inputs(&self) -> usize; + + /// Get the number of outputs + fn num_outputs(&self) -> usize; + + /// Get partial signatures for an input + /// + /// # Arguments + /// * `input_index` - Index of the input + fn get_input_partial_sigs(&self, input_index: usize) -> Vec<(Vec, Vec)>; + + // ===== DNSSEC Proof ===== + + /// Set DNSSEC proof for an output + /// + /// # Arguments + /// * `output_index` - Index of the output + /// * `proof` - The DNSSEC proof data + fn set_output_dnssec_proof(&mut self, output_index: usize, proof: Vec) -> Result<()>; +} + +impl Bip375PsbtExt for Psbt { + fn get_global_ecdh_shares(&self) -> Vec { + let mut shares = Vec::new(); + + for (scan_key_compressed, share_compressed) in &self.global.sp_ecdh_shares { + // Convert CompressedPublicKey to secp256k1::PublicKey via the inner field + let scan_key_pk = scan_key_compressed.0; + let share_point = share_compressed.0; + + // Look for corresponding DLEQ proof + let dleq_proof = get_global_dleq_proof(self, &scan_key_pk); + shares.push(EcdhShareData::new(scan_key_pk, share_point, dleq_proof)); + } + + shares + } + + fn add_global_ecdh_share(&mut self, share: &EcdhShareData) -> Result<()> { + // Convert secp256k1::PublicKey -> bitcoin::PublicKey -> CompressedPublicKey + let scan_key = CompressedPublicKey::try_from(bitcoin::PublicKey::new(share.scan_key)) + .map_err(|_| Error::InvalidPublicKey)?; + let ecdh_share = CompressedPublicKey::try_from(bitcoin::PublicKey::new(share.share)) + .map_err(|_| Error::InvalidPublicKey)?; + + self.global.sp_ecdh_shares.insert(scan_key, ecdh_share); + + // Add DLEQ proof if present + if let Some(proof) = share.dleq_proof { + add_global_dleq_proof(self, &share.scan_key, proof)?; + } + + Ok(()) + } + + fn get_input_ecdh_shares(&self, input_index: usize) -> Vec { + let Some(input) = self.inputs.get(input_index) else { + return Vec::new(); + }; + + let mut shares = Vec::new(); + + for (scan_key_compressed, share_compressed) in &input.sp_ecdh_shares { + // Convert CompressedPublicKey to secp256k1::PublicKey via the inner field + let scan_key_pk = scan_key_compressed.0; + let share_point = share_compressed.0; + + // Look for DLEQ proof (input-specific or global) + let dleq_proof = get_input_dleq_proof(self, input_index, &scan_key_pk) + .or_else(|| get_global_dleq_proof(self, &scan_key_pk)); + shares.push(EcdhShareData::new(scan_key_pk, share_point, dleq_proof)); + } + + shares + } + + fn add_input_ecdh_share(&mut self, input_index: usize, share: &EcdhShareData) -> Result<()> { + let input = self + .inputs + .get_mut(input_index) + .ok_or(Error::InvalidInputIndex(input_index))?; + + // Convert secp256k1::PublicKey -> bitcoin::PublicKey -> CompressedPublicKey + let scan_key = CompressedPublicKey::try_from(bitcoin::PublicKey::new(share.scan_key)) + .map_err(|_| Error::InvalidPublicKey)?; + let ecdh_share = CompressedPublicKey::try_from(bitcoin::PublicKey::new(share.share)) + .map_err(|_| Error::InvalidPublicKey)?; + + input.sp_ecdh_shares.insert(scan_key, ecdh_share); + + // Add DLEQ proof if present + if let Some(proof) = share.dleq_proof { + add_input_dleq_proof(self, input_index, &share.scan_key, proof)?; + } + + Ok(()) + } + + fn get_output_sp_info_v0(&self, output_index: usize) -> Option<(PublicKey, PublicKey)> { + let output = self.outputs.get(output_index)?; + + if let Some(bytes) = &output.sp_v0_info { + if bytes.len() != 66 { return None }; + let scan_key = PublicKey::from_slice(&bytes[..33]).ok(); + let spend_key = PublicKey::from_slice(&bytes[33..]).ok(); + if scan_key.is_some() && spend_key.is_some() { + return Some((scan_key.unwrap(), spend_key.unwrap())) + } + } + + None + } + + fn set_output_sp_info_v0( + &mut self, + output_index: usize, + address: &SilentPaymentAddress, + ) -> Result<()> { + let output = self + .outputs + .get_mut(output_index) + .ok_or(Error::InvalidOutputIndex(output_index))?; + + // PSBT_OUT_SP_V0_INFO contains only the keys (66 bytes) + // Label is stored separately in PSBT_OUT_SP_V0_LABEL + let mut bytes = Vec::with_capacity(66); + bytes.extend_from_slice(&address.get_scan_key().serialize()); + bytes.extend_from_slice(&address.get_spend_key().serialize()); + output.sp_v0_info = Some(bytes); + + Ok(()) + } + + fn get_output_sp_label(&self, output_index: usize) -> Option { + let output = self.outputs.get(output_index)?; + + if let Some(label) = output.sp_v0_label { + return Some(label); + } + + None + } + + fn set_output_sp_label(&mut self, output_index: usize, label: u32) -> Result<()> { + let output = self + .outputs + .get_mut(output_index) + .ok_or(Error::InvalidOutputIndex(output_index))?; + + output.sp_v0_label = Some(label); + + Ok(()) + } + + fn get_input_sp_tweak(&self, input_index: usize) -> Option<[u8; 32]> { + let input = self.inputs.get(input_index)?; + + for (key, value) in &input.unknowns { + if key.type_value == PSBT_IN_SP_TWEAK && key.key.is_empty() && value.len() == 32 { + let mut tweak = [0u8; 32]; + tweak.copy_from_slice(value); + return Some(tweak); + } + } + None + } + + fn set_input_sp_tweak(&mut self, input_index: usize, tweak: [u8; 32]) -> Result<()> { + let input = self + .inputs + .get_mut(input_index) + .ok_or(Error::InvalidInputIndex(input_index))?; + + let key = Key { + type_value: PSBT_IN_SP_TWEAK, + key: vec![], + }; + + input.unknowns.insert(key, tweak.to_vec()); + Ok(()) + } + + fn remove_input_sp_tweak(&mut self, input_index: usize) -> Result<()> { + let input = self + .inputs + .get_mut(input_index) + .ok_or(Error::InvalidInputIndex(input_index))?; + + let key = Key { + type_value: PSBT_IN_SP_TWEAK, + key: vec![], + }; + + input.unknowns.remove(&key); + Ok(()) + } + + fn num_inputs(&self) -> usize { + self.inputs.len() + } + + fn num_outputs(&self) -> usize { + self.outputs.len() + } + + fn get_input_partial_sigs(&self, input_index: usize) -> Vec<(Vec, Vec)> { + if let Some(input) = self.inputs.get(input_index) { + input + .partial_sigs + .iter() + .map(|(pk, sig)| (pk.inner.serialize().to_vec(), sig.to_vec())) + .collect() + } else { + Vec::new() + } + } + + fn set_output_dnssec_proof(&mut self, output_index: usize, proof: Vec) -> Result<()> { + const PSBT_OUT_DNSSEC_PROOF: u8 = 0x35; + + let output = self + .outputs + .get_mut(output_index) + .ok_or(Error::InvalidOutputIndex(output_index))?; + + let key = Key { + type_value: PSBT_OUT_DNSSEC_PROOF, + key: vec![], + }; + output.unknowns.insert(key, proof); + Ok(()) + } +} + +// Private helper functions for DLEQ proof management +fn get_global_dleq_proof(psbt: &Psbt, scan_key: &PublicKey) -> Option<[u8; 64]> { + let scan_key_compressed = + CompressedPublicKey::try_from(bitcoin::PublicKey::new(*scan_key)).ok()?; + psbt.global + .sp_dleq_proofs + .get(&scan_key_compressed) + .map(|proof| *proof.as_bytes()) +} + +fn add_global_dleq_proof(psbt: &mut Psbt, scan_key: &PublicKey, proof: [u8; 64]) -> Result<()> { + let scan_key_compressed = CompressedPublicKey::try_from(bitcoin::PublicKey::new(*scan_key)) + .map_err(|_| Error::InvalidPublicKey)?; + let dleq_proof = DleqProof::new(proof); + + psbt.global + .sp_dleq_proofs + .insert(scan_key_compressed, dleq_proof); + + Ok(()) +} + +fn get_input_dleq_proof(psbt: &Psbt, input_index: usize, scan_key: &PublicKey) -> Option<[u8; 64]> { + let input = psbt.inputs.get(input_index)?; + let scan_key_compressed = + CompressedPublicKey::try_from(bitcoin::PublicKey::new(*scan_key)).ok()?; + + input + .sp_dleq_proofs + .get(&scan_key_compressed) + .map(|proof| *proof.as_bytes()) +} + +fn add_input_dleq_proof( + psbt: &mut Psbt, + input_index: usize, + scan_key: &PublicKey, + proof: [u8; 64], +) -> Result<()> { + let input = psbt + .inputs + .get_mut(input_index) + .ok_or(Error::InvalidInputIndex(input_index))?; + + let scan_key_compressed = CompressedPublicKey::try_from(bitcoin::PublicKey::new(*scan_key)) + .map_err(|_| Error::InvalidPublicKey)?; + let dleq_proof = DleqProof::new(proof); + + input.sp_dleq_proofs.insert(scan_key_compressed, dleq_proof); + + Ok(()) +} + +// ============================================================================ +// Convenience Accessor Functions +// ============================================================================ +// +// These provide ergonomic access patterns for common PSBT field operations. + +/// Get the transaction ID (TXID) for an input +pub fn get_input_txid(psbt: &SilentPaymentPsbt, input_idx: usize) -> Result { + let input = psbt + .inputs + .get(input_idx) + .ok_or_else(|| Error::InvalidInputIndex(input_idx))?; + + // PSBT v2 inputs have explicit previous_txid field + Ok(input.previous_txid) +} + +/// Get the output index (vout) for an input +pub fn get_input_vout(psbt: &SilentPaymentPsbt, input_idx: usize) -> Result { + let input = psbt + .inputs + .get(input_idx) + .ok_or_else(|| Error::InvalidInputIndex(input_idx))?; + + Ok(input.spent_output_index) +} + +/// Get the outpoint (TXID + vout) for an input as raw bytes +pub fn get_input_outpoint_bytes(psbt: &SilentPaymentPsbt, input_idx: usize) -> Result> { + let txid = get_input_txid(psbt, input_idx)?; + let vout = get_input_vout(psbt, input_idx)?; + + let mut outpoint = Vec::with_capacity(36); + outpoint.extend_from_slice(&txid[..]); + outpoint.extend_from_slice(&vout.to_le_bytes()); + Ok(outpoint) +} + +/// Get the outpoint (TXID + vout) for an input as a typed OutPoint +pub fn get_input_outpoint(psbt: &SilentPaymentPsbt, input_idx: usize) -> Result { + let txid = get_input_txid(psbt, input_idx)?; + let vout = get_input_vout(psbt, input_idx)?; + Ok(OutPoint { txid, vout }) +} + +/// Get all BIP32 derivation public keys for an input +pub fn get_input_bip32_pubkeys(psbt: &SilentPaymentPsbt, input_idx: usize) -> Vec { + let mut pubkeys = Vec::new(); + + if let Some(input) = psbt.inputs.get(input_idx) { + for key in input.bip32_derivations.keys() { + // key is bitcoin::PublicKey, inner is secp256k1::PublicKey + pubkeys.push(*key); + } + } + + pubkeys +} + +/// Get input public key from PSBT fields with fallback priority +/// +/// Tries multiple sources in this order: +/// 1. BIP32 derivation field (highest priority) +/// 2. Taproot internal key (for Taproot inputs) +/// 3. Partial signature field +pub fn get_input_pubkey(psbt: &SilentPaymentPsbt, input_idx: usize) -> Result { + let input = psbt + .inputs + .get(input_idx) + .ok_or_else(|| Error::InvalidInputIndex(input_idx))?; + + // Method 1: Extract from BIP32 derivation field (HIGHEST PRIORITY) + if !input.bip32_derivations.is_empty() { + // Return the first key + if let Some(key) = input.bip32_derivations.keys().next() { + return Ok(*key); + } + } + + // Method 2: Extract from Taproot internal key (for Taproot inputs) + if let Some(tap_key) = input.tap_internal_key { + // tap_key is bitcoin::XOnlyPublicKey + // We need to convert to secp256k1::PublicKey (even y) + // bitcoin::XOnlyPublicKey has into_inner() -> secp256k1::XOnlyPublicKey + let x_only = tap_key; + + // Convert x-only to full pubkey (assumes even y - prefix 0x02) + let mut pubkey_bytes = vec![0x02]; + pubkey_bytes.extend_from_slice(&x_only.serialize()); + if let Ok(pubkey) = PublicKey::from_slice(&pubkey_bytes) { + return Ok(pubkey); + } + } + + // Method 3: Extract from partial signature field + if !input.partial_sigs.is_empty() { + if let Some(key) = input.partial_sigs.keys().next() { + return Ok(key.inner); + } + } + + Err(Error::Other(format!( + "Input {} missing public key (no BIP32 derivation, Taproot key, or partial signature found)", + input_idx + ))) +} + +/// Get silent payment keys (scan_key, spend_key) from output SP_V0_INFO field + +#[cfg(test)] +mod tests { + use super::*; + use secp256k1::{Secp256k1, SecretKey}; + + fn create_test_psbt() -> Psbt { + // Create a minimal valid PSBT v2 + Psbt { + global: psbt_v2::v2::Global::default(), + inputs: vec![], + outputs: vec![], + } + } + + #[test] + fn test_global_ecdh_share() { + let mut psbt = create_test_psbt(); + + let secp = Secp256k1::new(); + let scan_key = + PublicKey::from_secret_key(&secp, &SecretKey::from_slice(&[1u8; 32]).unwrap()); + let share_point = + PublicKey::from_secret_key(&secp, &SecretKey::from_slice(&[2u8; 32]).unwrap()); + + let share = EcdhShareData::without_proof(scan_key, share_point); + + // Add share + psbt.add_global_ecdh_share(&share).unwrap(); + + // Retrieve shares + let shares = psbt.get_global_ecdh_shares(); + assert_eq!(shares.len(), 1); + assert_eq!(shares[0].scan_key, scan_key); + assert_eq!(shares[0].share, share_point); + } + + #[test] + fn test_global_dleq_proof() { + let mut psbt = create_test_psbt(); + + let secp = Secp256k1::new(); + let scan_key = + PublicKey::from_secret_key(&secp, &SecretKey::from_slice(&[1u8; 32]).unwrap()); + let proof = [0x42u8; 64]; + + // Add proof + add_global_dleq_proof(&mut psbt, &scan_key, proof).unwrap(); + + // Retrieve proof + let retrieved = get_global_dleq_proof(&psbt, &scan_key); + assert_eq!(retrieved, Some(proof)); + } + + #[test] + fn test_output_sp_address() { + let mut psbt = create_test_psbt(); + psbt.outputs.push(psbt_v2::v2::Output::default()); + + let secp = Secp256k1::new(); + let scan_key = + PublicKey::from_secret_key(&secp, &SecretKey::from_slice(&[1u8; 32]).unwrap()); + let spend_key = + PublicKey::from_secret_key(&secp, &SecretKey::from_slice(&[2u8; 32]).unwrap()); + + let address = SilentPaymentAddress::new(scan_key, spend_key, silentpayments::Network::Regtest, 0).unwrap(); + + // Set address + psbt.set_output_sp_info_v0(0, &address).unwrap(); + + // Retrieve address + let retrieved = psbt.get_output_sp_info_v0(0); + assert_eq!(retrieved.map(|res| (res.0, res.1)), Some((address.get_scan_key(), address.get_spend_key()))); + } + + #[test] + fn test_output_sp_label() { + let mut psbt = create_test_psbt(); + psbt.outputs.push(psbt_v2::v2::Output::default()); + + let label = 42u32; + + // Set label + psbt.set_output_sp_label(0, label).unwrap(); + + // Retrieve label + let retrieved = psbt.get_output_sp_label(0); + assert_eq!(retrieved, Some(label)); + } +} diff --git a/spdk-core/src/psbt/core/mod.rs b/spdk-core/src/psbt/core/mod.rs new file mode 100644 index 0000000..61cca85 --- /dev/null +++ b/spdk-core/src/psbt/core/mod.rs @@ -0,0 +1,27 @@ +//! BIP-375 Core Library +//! +//! Core data structures and types for BIP-375 (Sending Silent Payments with PSBTs). +//! +//! This crate provides: +//! - PSBT v2 data structures +//! - Silent payment address types +//! - ECDH share types +//! - UTXO types + +pub mod error; +pub mod extensions; +pub mod shares; +pub mod types; + +pub use error::{Error, Result}; +pub use extensions::{ + get_input_bip32_pubkeys, get_input_outpoint, get_input_outpoint_bytes, get_input_pubkey, + get_input_txid, get_input_vout, Bip375PsbtExt, +}; +pub use shares::{aggregate_ecdh_shares, AggregatedShare, AggregatedShares}; +pub use types::{EcdhShareData, PsbtInput, PsbtOutput}; + +/// Type alias for PSBT v2 with BIP-375 extensions +/// +/// Use the `Bip375PsbtExt` trait to access BIP-375 specific functionality. +pub type SilentPaymentPsbt = psbt_v2::v2::Psbt; diff --git a/spdk-core/src/psbt/core/shares.rs b/spdk-core/src/psbt/core/shares.rs new file mode 100644 index 0000000..c9bde08 --- /dev/null +++ b/spdk-core/src/psbt/core/shares.rs @@ -0,0 +1,217 @@ +//! Silent Payment ECDH Share Aggregation +//! +//! Provides functions for aggregating ECDH shares across PSBT inputs according to BIP-375. +//! +//! # Global vs Per-Input Shares +//! +//! BIP-375 supports two modes of ECDH share distribution: +//! +//! - **Global Shares**: All inputs have the same ECDH share point for a given scan key. +//! These are stored in PSBT_GLOBAL_SP_ECDH_SHARE (0x07) and should NOT be summed. +//! Used when one party knows all input private keys. +//! +//! - **Per-Input Shares**: Each input has a unique ECDH share computed from its private key. +//! These are stored in PSBT_IN_SP_ECDH_SHARE (0x1d) and MUST be summed. +//! Used in multi-party signing scenarios. +//! +//! This module automatically detects which mode is being used and aggregates accordingly. + +use super::{Bip375PsbtExt, Error, Result, SilentPaymentPsbt}; +use secp256k1::PublicKey; +use std::collections::HashMap; + +/// Result of ECDH share aggregation for a single scan key +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AggregatedShare { + /// The scan key this aggregation is for + pub scan_key: PublicKey, + /// The aggregated ECDH share (single point) + pub aggregated_share: PublicKey, + /// Whether this was a global share (true) or per-input shares (false) + pub is_global: bool, + /// Number of inputs that contributed shares + pub num_inputs: usize, +} + +/// Collection of aggregated ECDH shares for all scan keys in a PSBT +#[derive(Debug, Clone)] +pub struct AggregatedShares { + /// Map of scan_key -> aggregated result + shares: HashMap, +} + +impl AggregatedShares { + /// Get the aggregated share for a specific scan key + pub fn get(&self, scan_key: &PublicKey) -> Option<&AggregatedShare> { + self.shares.get(scan_key) + } + + /// Get the aggregated share point for a specific scan key + pub fn get_share_point(&self, scan_key: &PublicKey) -> Option { + self.shares.get(scan_key).map(|s| s.aggregated_share) + } + + /// Get all scan keys that have aggregated shares + pub fn scan_keys(&self) -> Vec { + self.shares.keys().copied().collect() + } + + /// Check if shares exist for a given scan key + pub fn has_scan_key(&self, scan_key: &PublicKey) -> bool { + self.shares.contains_key(scan_key) + } + + /// Get the number of scan keys with aggregated shares + pub fn len(&self) -> usize { + self.shares.len() + } + + /// Check if there are no aggregated shares + pub fn is_empty(&self) -> bool { + self.shares.is_empty() + } + + /// Iterate over all aggregated shares + pub fn iter(&self) -> impl Iterator { + self.shares.iter() + } +} + +/// Aggregate ECDH shares from all inputs in a PSBT +/// +/// This function: +/// 1. Collects all ECDH shares from all inputs, grouped by scan key +/// 2. Detects whether shares are global (all identical) or per-input (unique) +/// 3. For global shares: returns the share without summing +/// 4. For per-input shares: sums all shares using elliptic curve addition +/// +/// # Arguments +/// * `psbt` - The PSBT containing ECDH shares +/// +/// # Returns +/// * `AggregatedShares` - Collection of aggregated shares for all scan keys +/// +/// # Errors +/// * If no inputs exist in the PSBT +/// * If elliptic curve operations fail during aggregation +/// +/// # Example +/// ```rust,ignore +/// let aggregated = aggregate_ecdh_shares(&psbt)?; +/// let share = aggregated.get_share_point(&scan_key) +/// .ok_or_else(|| Error::Other("Missing share".to_string()))?; +/// ``` +pub fn aggregate_ecdh_shares(psbt: &SilentPaymentPsbt) -> Result { + let num_inputs = psbt.num_inputs(); + if num_inputs == 0 { + return Err(Error::Other( + "Cannot aggregate ECDH shares: no inputs".to_string(), + )); + } + + let mut result_shares = HashMap::new(); + + // Step 0: Collect explicit Global Shares + // These are stored in PSBT_GLOBAL_SP_ECDH_SHARE (0x07) and take precedence + let global_shares = psbt.get_global_ecdh_shares(); + for share in global_shares { + result_shares.insert( + share.scan_key, + AggregatedShare { + scan_key: share.scan_key, + aggregated_share: share.share, + is_global: true, + num_inputs, // Global share implicitly covers all inputs + }, + ); + } + + // Step 1: Collect input shares grouped by scan key + let mut shares_by_scan_key: HashMap> = HashMap::new(); + + for input_idx in 0..num_inputs { + let shares = psbt.get_input_ecdh_shares(input_idx); + for share in shares { + shares_by_scan_key + .entry(share.scan_key) + .or_default() + .push(share.share); + } + } + + // Step 2: Detect implicit global vs per-input shares and aggregate + for (scan_key, shares) in shares_by_scan_key { + // If we already have an explicit global share for this key, skip input aggregation + if result_shares.contains_key(&scan_key) { + continue; + } + + if shares.is_empty() { + continue; // Should never happen + } + + // Detect implicit global shares: all inputs have the exact same share point + // AND there are shares from all inputs + let first_share = shares[0]; + let is_global = shares.len() == num_inputs && shares.iter().all(|s| *s == first_share); + + let aggregated_share = if is_global { + // Global share: use it directly without summing + first_share + } else { + // Per-input shares: sum them using elliptic curve addition + aggregate_public_keys(&shares)? + }; + + result_shares.insert( + scan_key, + AggregatedShare { + scan_key, + aggregated_share, + is_global, + num_inputs: shares.len(), + }, + ); + } + + Ok(AggregatedShares { + shares: result_shares, + }) +} + +/// Sum multiple public keys using elliptic curve addition +/// +/// This is used to aggregate per-input ECDH shares. Each share is a point on the curve, +/// and we sum them to get the total ECDH secret. +/// +/// # Arguments +/// * `pubkeys` - Slice of public keys to sum +/// +/// # Returns +/// * The sum of all public keys (P1 + P2 + ... + Pn) +/// +/// # Errors +/// * If the input slice is empty +/// * If elliptic curve addition fails (e.g., adding a point to its negation) +fn aggregate_public_keys(pubkeys: &[PublicKey]) -> Result { + if pubkeys.is_empty() { + return Err(Error::Other( + "Cannot aggregate zero public keys".to_string(), + )); + } + + let mut result = pubkeys[0]; + for pubkey in &pubkeys[1..] { + result = result + .combine(pubkey) + .map_err(|e| Error::Other(format!("Failed to aggregate ECDH shares: {}", e)))?; + } + + Ok(result) +} + +#[cfg(test)] +mod tests { + // use super::*; + // Tests will be added during implementation +} diff --git a/spdk-core/src/psbt/core/types.rs b/spdk-core/src/psbt/core/types.rs new file mode 100644 index 0000000..82ab713 --- /dev/null +++ b/spdk-core/src/psbt/core/types.rs @@ -0,0 +1,188 @@ +//! BIP-375 Type Definitions +//! +//! Core types for silent payments in PSBTs. + +use bitcoin::{Amount, OutPoint, ScriptBuf, Sequence, TxOut}; +use secp256k1::{PublicKey, SecretKey}; +use silentpayments::SilentPaymentAddress; + +// ============================================================================ +// Core BIP-352/BIP-375 Protocol Types +// ============================================================================ + +/// ECDH share for a silent payment output +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EcdhShareData { + /// Scan public key this share is for (33 bytes) + pub scan_key: PublicKey, + /// ECDH share value (33 bytes compressed public key) + pub share: PublicKey, + /// Optional DLEQ proof (64 bytes) + pub dleq_proof: Option<[u8; 64]>, +} + +impl EcdhShareData { + /// Create a new ECDH share + pub fn new(scan_key: PublicKey, share: PublicKey, dleq_proof: Option<[u8; 64]>) -> Self { + Self { + scan_key, + share, + dleq_proof, + } + } + + /// Create an ECDH share without a DLEQ proof + pub fn without_proof(scan_key: PublicKey, share: PublicKey) -> Self { + Self::new(scan_key, share, None) + } + + /// Serialize share data (scan_key || share) + pub fn to_bytes(&self) -> Vec { + let mut bytes = Vec::with_capacity(66); + bytes.extend_from_slice(&self.scan_key.serialize()); + bytes.extend_from_slice(&self.share.serialize()); + bytes + } + + /// Deserialize share data + pub fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() != 66 { + return Err(super::Error::InvalidEcdhShare(format!( + "Invalid length: expected 66 bytes, got {}", + bytes.len() + ))); + } + + let scan_key = PublicKey::from_slice(&bytes[0..33]) + .map_err(|e| super::Error::InvalidEcdhShare(e.to_string()))?; + let share = PublicKey::from_slice(&bytes[33..66]) + .map_err(|e| super::Error::InvalidEcdhShare(e.to_string()))?; + + Ok(Self { + scan_key, + share, + dleq_proof: None, + }) + } +} + +// ============================================================================ +// PSBT Construction Helper Types +// ============================================================================ + +/// Input data for PSBT construction +/// +/// Combines bitcoin primitives with optional signing key for BIP-375 workflows. +/// This is a construction helper, not part of the serialized PSBT format. +#[derive(Debug, Clone)] +pub struct PsbtInput { + /// The previous output being spent + pub outpoint: OutPoint, + /// The UTXO being spent (value + script) + pub witness_utxo: TxOut, + /// Sequence number for this input + pub sequence: Sequence, + /// Optional private key for signing (not serialized) + pub private_key: Option, +} + +impl PsbtInput { + /// Create a new PSBT input + pub fn new( + outpoint: OutPoint, + witness_utxo: TxOut, + sequence: Sequence, + private_key: Option, + ) -> Self { + Self { + outpoint, + witness_utxo, + sequence, + private_key, + } + } +} + +/// Output data for PSBT construction +/// +/// Either a regular bitcoin output or a silent payment output. +/// For silent payments, the script is computed during finalization. +#[derive(Debug, Clone)] +pub enum PsbtOutput { + /// Regular bitcoin output with known script + Regular(TxOut), + /// Silent payment output (script computed during finalization) + SilentPayment { + /// Amount to send + amount: Amount, + /// Silent payment address + address: SilentPaymentAddress, + /// Optional label (useful for detecting change outputs) + label: Option, + }, +} + +impl PsbtOutput { + /// Create a regular output + pub fn regular(amount: Amount, script_pubkey: ScriptBuf) -> Self { + Self::Regular(TxOut { + value: amount, + script_pubkey, + }) + } + + /// Create a silent payment output + pub fn silent_payment(amount: Amount, address: SilentPaymentAddress, label: Option) -> Self { + Self::SilentPayment { amount, address, label } + } + + /// Check if this is a silent payment output + pub fn is_silent_payment(&self) -> bool { + matches!(self, Self::SilentPayment { .. }) + } + + /// Get the amount + pub fn amount(&self) -> Amount { + match self { + Self::Regular(txout) => txout.value, + Self::SilentPayment { amount, .. } => *amount, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use secp256k1::Secp256k1; + use silentpayments::Network; + + #[test] + fn test_silent_payment_address_serialization() { + let secp = Secp256k1::new(); + let scan_key = + PublicKey::from_secret_key(&secp, &SecretKey::from_slice(&[1u8; 32]).unwrap()); + let spend_key = + PublicKey::from_secret_key(&secp, &SecretKey::from_slice(&[2u8; 32]).unwrap()); + + let addr = SilentPaymentAddress::new(scan_key, spend_key, Network::Regtest, 0).unwrap(); + let bytes: Vec = addr.to_string().into_bytes(); + let decoded = SilentPaymentAddress::try_from(String::from_utf8(bytes).unwrap()).unwrap(); + + assert_eq!(addr, decoded); + } + + #[test] + fn test_ecdh_share_serialization() { + let secp = Secp256k1::new(); + let scan_key = + PublicKey::from_secret_key(&secp, &SecretKey::from_slice(&[1u8; 32]).unwrap()); + let share = PublicKey::from_secret_key(&secp, &SecretKey::from_slice(&[2u8; 32]).unwrap()); + + let ecdh = EcdhShareData::without_proof(scan_key, share); + let bytes = ecdh.to_bytes(); + let decoded = EcdhShareData::from_bytes(&bytes).unwrap(); + + assert_eq!(ecdh.scan_key, decoded.scan_key); + assert_eq!(ecdh.share, decoded.share); + } +} diff --git a/spdk-core/src/psbt/crypto/bip352.rs b/spdk-core/src/psbt/crypto/bip352.rs new file mode 100644 index 0000000..ec00083 --- /dev/null +++ b/spdk-core/src/psbt/crypto/bip352.rs @@ -0,0 +1,338 @@ +//! BIP-352 Silent Payment Cryptography +//! +//! Implements cryptographic primitives for BIP-352 silent payments. + +use super::error::{CryptoError, Result}; +use bitcoin::hashes::{sha256, Hash, HashEngine}; +use bitcoin::key::TapTweak; +use bitcoin::ScriptBuf; +use secp256k1::{PublicKey, Scalar, Secp256k1, SecretKey}; + +/// Compute label tweak for a silent payment address +/// +/// BIP 352: hash_BIP0352/Label(ser₂₅₆(scan_privkey) || ser₃₂(label)) +/// Uses tagged hash as per BIP 340 +pub fn compute_label_tweak(scan_privkey: &SecretKey, label: u32) -> Result { + // BIP 352 tagged hash: tag_hash || tag_hash || data + let tag = b"BIP0352/Label"; + let tag_hash = sha256::Hash::hash(tag); + + let mut engine = sha256::Hash::engine(); + engine.input(tag_hash.as_ref()); + engine.input(tag_hash.as_ref()); + engine.input(&scan_privkey.secret_bytes()); + engine.input(&label.to_le_bytes()); + let hash = sha256::Hash::from_engine(engine); + + Scalar::from_be_bytes(hash.to_byte_array()) + .map_err(|_| CryptoError::Other("Failed to create scalar from label tweak".to_string())) +} + +/// Compute input hash for BIP-352 silent payments +/// +/// BIP 352: hash_BIP0352/Inputs(smallest_outpoint || ser₃₃(A)) +/// where A is the sum of all eligible input public keys +/// Uses tagged hash as per BIP 340 +pub fn compute_input_hash(smallest_outpoint: &[u8], summed_pubkey: &PublicKey) -> Result { + // BIP 352 tagged hash: tag_hash || tag_hash || data + let tag = b"BIP0352/Inputs"; + let tag_hash = sha256::Hash::hash(tag); + + let mut engine = sha256::Hash::engine(); + engine.input(tag_hash.as_ref()); + engine.input(tag_hash.as_ref()); + engine.input(smallest_outpoint); + engine.input(&summed_pubkey.serialize()); + let hash = sha256::Hash::from_engine(engine); + + Scalar::from_be_bytes(hash.to_byte_array()) + .map_err(|_| CryptoError::Other("Failed to create scalar from input hash".to_string())) +} + +/// Compute shared secret tweak for output derivation +/// +/// BIP 352: hash_BIP0352/SharedSecret(ecdh_secret || ser₃₂(k)) +/// Uses tagged hash as per BIP 340 +pub fn compute_shared_secret_tweak(ecdh_secret: &[u8; 33], k: u32) -> Result { + // BIP 352 tagged hash: tag_hash || tag_hash || data + let tag = b"BIP0352/SharedSecret"; + let tag_hash = sha256::Hash::hash(tag); + + let mut engine = sha256::Hash::engine(); + engine.input(tag_hash.as_ref()); + engine.input(tag_hash.as_ref()); + engine.input(ecdh_secret); + engine.input(&k.to_be_bytes()); + let hash = sha256::Hash::from_engine(engine); + + Scalar::from_be_bytes(hash.to_byte_array()).map_err(|_| { + CryptoError::Other("Failed to create scalar from shared secret tweak".to_string()) + }) +} + +/// Apply label to spend public key +/// +/// labeled_spend_key = spend_key + label_tweak * G +pub fn apply_label_to_spend_key( + secp: &Secp256k1, + spend_key: &PublicKey, + scan_privkey: &SecretKey, + label: u32, +) -> Result { + let label_tweak = compute_label_tweak(scan_privkey, label)?; + let label_tweak_key = SecretKey::from_slice(&label_tweak.to_be_bytes())?; + let label_point = PublicKey::from_secret_key(secp, &label_tweak_key); + + spend_key + .combine(&label_point) + .map_err(|e| CryptoError::Other(format!("Failed to apply label: {}", e))) +} + +/// Derive silent payment output public key +/// +/// output_pubkey = spend_key + hash(ecdh_secret || ser₃₂(k)) * G +pub fn derive_silent_payment_output_pubkey( + secp: &Secp256k1, + spend_key: &PublicKey, + ecdh_secret: &[u8; 33], + k: u32, +) -> Result { + let tweak = compute_shared_secret_tweak(ecdh_secret, k)?; + let tweak_key = SecretKey::from_slice(&tweak.to_be_bytes())?; + let tweak_point = PublicKey::from_secret_key(secp, &tweak_key); + + spend_key + .combine(&tweak_point) + .map_err(|e| CryptoError::Other(format!("Failed to derive output pubkey: {}", e))) +} + +/// Convert public key to P2WPKH script +/// +/// Returns: OP_0 <20-byte-hash> +pub fn pubkey_to_p2wpkh_script(pubkey: &PublicKey) -> ScriptBuf { + let pubkey_hash = bitcoin::PublicKey::new(*pubkey) + .wpubkey_hash() + .expect("Compressed key"); + + ScriptBuf::new_p2wpkh(&pubkey_hash) +} + +/// Convert public key to P2TR (Taproot) script +/// +/// Returns: OP_1 <32-byte-xonly-pubkey> +pub fn pubkey_to_p2tr_script(pubkey: &PublicKey) -> ScriptBuf { + let xonly = pubkey.x_only_public_key().0; + ScriptBuf::new_p2tr_tweaked(xonly.dangerous_assume_tweaked()) +} + +/// Detect script type from ScriptBuf +/// +/// Returns a human-readable string identifying the script type. +/// Supports common Bitcoin script types including SegWit v0/v1. +pub fn script_type_string(script: &ScriptBuf) -> &'static str { + if script.is_p2wpkh() { + "P2WPKH" + } else if script.is_p2tr() { + "P2TR" + } else if script.is_p2pkh() { + "P2PKH" + } else if script.is_p2sh() { + "P2SH" + } else if script.is_p2wsh() { + "P2WSH" + } else if script.is_op_return() { + "OP_RETURN" + } else { + "Unknown" + } +} + +/// Compute ECDH shared secret +/// +/// ecdh_secret = privkey * pubkey +pub fn compute_ecdh_share( + secp: &Secp256k1, + privkey: &SecretKey, + pubkey: &PublicKey, +) -> Result { + // Multiply pubkey by privkey scalar + let scalar: Scalar = (*privkey).into(); + let shared = pubkey.mul_tweak(secp, &scalar)?; + Ok(shared) +} + +/// Apply tweak to spend private key for spending silent payment output +/// +/// Computes: tweaked_privkey = spend_privkey + tweak +/// +/// This is used by hardware signers to apply the output-specific tweak +/// to the spend private key before signing. +pub fn apply_tweak_to_privkey(spend_privkey: &SecretKey, tweak: &[u8; 32]) -> Result { + let tweak_scalar = Scalar::from_be_bytes(*tweak) + .map_err(|_| CryptoError::Other("Invalid tweak scalar".to_string()))?; + + let mut tweaked = *spend_privkey; + tweaked = tweaked + .add_tweak(&tweak_scalar) + .map_err(|e| CryptoError::Other(format!("Failed to apply tweak: {}", e)))?; + + Ok(tweaked) +} + +/// Sign a Taproot input with BIP-340 Schnorr signature +/// +/// Creates a key path spend signature for a tweaked silent payment output. +/// +/// # Arguments +/// * `secp` - Secp256k1 context +/// * `tx` - The transaction being signed +/// * `input_index` - Index of the input to sign +/// * `prevouts` - All previous outputs (needed for Taproot sighash) +/// * `tweaked_privkey` - The private key with tweak already applied +pub fn sign_p2tr_input( + secp: &Secp256k1, + tx: &bitcoin::Transaction, + input_index: usize, + prevouts: &[bitcoin::TxOut], + tweaked_privkey: &SecretKey, +) -> Result { + use bitcoin::sighash::{Prevouts, SighashCache, TapSighashType}; + + let mut sighash_cache = SighashCache::new(tx); + + let sighash = sighash_cache + .taproot_key_spend_signature_hash( + input_index, + &Prevouts::All(prevouts), + TapSighashType::Default, + ) + .map_err(|e| CryptoError::Other(format!("Taproot sighash: {}", e)))?; + + let message = secp256k1::Message::from_digest(sighash.to_byte_array()); + let keypair = secp256k1::Keypair::from_secret_key(secp, tweaked_privkey); + let sig = secp.sign_schnorr_no_aux_rand(&message, &keypair); + + Ok(bitcoin::taproot::Signature { + signature: sig, + sighash_type: TapSighashType::Default, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_label_tweak() { + let _secp = Secp256k1::new(); + let scan_privkey = SecretKey::from_slice(&[1u8; 32]).unwrap(); + let label = 42; + + let tweak = compute_label_tweak(&scan_privkey, label).unwrap(); + assert!(tweak.to_be_bytes().len() == 32); + } + + #[test] + fn test_shared_secret_tweak() { + let ecdh_secret = [2u8; 33]; + let k = 0; + + let tweak = compute_shared_secret_tweak(&ecdh_secret, k).unwrap(); + assert!(tweak.to_be_bytes().len() == 32); + } + + #[test] + fn test_ecdh_computation() { + let secp = Secp256k1::new(); + let privkey = SecretKey::from_slice(&[1u8; 32]).unwrap(); + let pubkey = PublicKey::from_secret_key(&secp, &SecretKey::from_slice(&[2u8; 32]).unwrap()); + + let share = compute_ecdh_share(&secp, &privkey, &pubkey).unwrap(); + assert!(share.serialize().len() == 33); + } + + #[test] + fn test_p2wpkh_script() { + let secp = Secp256k1::new(); + let privkey = SecretKey::from_slice(&[1u8; 32]).unwrap(); + let pubkey = PublicKey::from_secret_key(&secp, &privkey); + + let script = pubkey_to_p2wpkh_script(&pubkey); + assert_eq!(script.len(), 22); // OP_0 + 20 bytes + } + + #[test] + fn test_derive_from_test_vector() { + let secp = Secp256k1::new(); + + // From valid test vector 1 + let spend_key_hex = "024d518353f4bd18d769cf68ff62ef10669b7086246b0a6403fe57bde49211448b"; + let ecdh_secret_hex = "0255164e7926d50d52a09ff990647a5e95c1db1bfc68a616fbc2da213927f98bff"; + + let spend_key = PublicKey::from_slice(&hex::decode(spend_key_hex).unwrap()).unwrap(); + let mut ecdh_secret: [u8; 33] = [0; 33]; + ecdh_secret.copy_from_slice(&hex::decode(ecdh_secret_hex).unwrap()); + + let output_pubkey = + derive_silent_payment_output_pubkey(&secp, &spend_key, &ecdh_secret, 0).unwrap(); + + let xonly = output_pubkey.x_only_public_key().0; + let xonly_hex = hex::encode(xonly.serialize()); + + println!("Derived x-only: {}", xonly_hex); + println!( + "Expected x-only: ae19fbee2730a1a952d7d2598cc703fddf3b972b25148b1ed1a79ae8739d5e07" + ); + + // This should match the test vector + assert_eq!( + xonly_hex, + "ae19fbee2730a1a952d7d2598cc703fddf3b972b25148b1ed1a79ae8739d5e07" + ); + } + + #[test] + fn test_p2tr_script_creation() { + let secp = Secp256k1::new(); + let privkey = SecretKey::from_slice(&[1u8; 32]).unwrap(); + let pubkey = PublicKey::from_secret_key(&secp, &privkey); + + let script = pubkey_to_p2tr_script(&pubkey); + + // P2TR scripts are 34 bytes: OP_1 (0x51) + PUSH_32 (0x20) + 32-byte x-only key + assert_eq!(script.len(), 34); + assert!(script.is_p2tr()); + + // Verify script structure + let bytes = script.as_bytes(); + assert_eq!(bytes[0], 0x51); // OP_1 + assert_eq!(bytes[1], 0x20); // PUSH_32 + + // Verify x-only key matches + let (expected_xonly, _) = pubkey.x_only_public_key(); + assert_eq!(&bytes[2..34], &expected_xonly.serialize()[..]); + } + + #[test] + fn test_p2tr_script_compatibility() { + // Verify that pubkey_to_p2tr_script produces identical results + // to the manual construction previously used in validation.rs and input_finalizer.rs + let secp = Secp256k1::new(); + let privkey = SecretKey::from_slice(&[42u8; 32]).unwrap(); + let pubkey = PublicKey::from_secret_key(&secp, &privkey); + + // New method + let new_script = pubkey_to_p2tr_script(&pubkey); + + // Old method (manual construction) + let (xonly, _parity) = pubkey.x_only_public_key(); + let mut script_bytes = Vec::with_capacity(34); + script_bytes.push(0x51); // OP_1 + script_bytes.push(0x20); // PUSH_32 + script_bytes.extend_from_slice(&xonly.serialize().as_ref()); + let old_script = ScriptBuf::from_bytes(script_bytes); + + // Should be identical + assert_eq!(new_script, old_script); + } +} diff --git a/spdk-core/src/psbt/crypto/dleq.rs b/spdk-core/src/psbt/crypto/dleq.rs new file mode 100644 index 0000000..e8205af --- /dev/null +++ b/spdk-core/src/psbt/crypto/dleq.rs @@ -0,0 +1,286 @@ +//! BIP-374 DLEQ (Discrete Log Equality) Proofs +//! +//! Implements DLEQ proof generation and verification for secp256k1. +//! Based on BIP-374 specification. + +use super::error::{CryptoError, Result}; +use bitcoin::hashes::{sha256, Hash, HashEngine}; +use secp256k1::{PublicKey, Scalar, Secp256k1, SecretKey}; + +// Tagged hash tags for BIP-374 +const DLEQ_TAG_AUX: &str = "BIP0374/aux"; +const DLEQ_TAG_NONCE: &str = "BIP0374/nonce"; +const DLEQ_TAG_CHALLENGE: &str = "BIP0374/challenge"; + +/// Compute a tagged hash as defined in BIP-340 +fn tagged_hash(tag: &str, data: &[u8]) -> [u8; 32] { + let tag_hash = sha256::Hash::hash(tag.as_bytes()); + let mut engine = sha256::Hash::engine(); + engine.input(tag_hash.as_byte_array()); + engine.input(tag_hash.as_byte_array()); + engine.input(data); + sha256::Hash::from_engine(engine).to_byte_array() +} + +/// XOR two 32-byte arrays +fn xor_bytes(lhs: &[u8; 32], rhs: &[u8; 32]) -> [u8; 32] { + let mut result = [0u8; 32]; + for i in 0..32 { + result[i] = lhs[i] ^ rhs[i]; + } + result +} + +/// Compute DLEQ challenge value +/// +/// e = H_challenge(A || B || C || G || R1 || R2 || m) +fn dleq_challenge( + a: &PublicKey, + b: &PublicKey, + c: &PublicKey, + g: &PublicKey, + r1: &PublicKey, + r2: &PublicKey, + m: Option<&[u8; 32]>, +) -> Scalar { + let mut data = Vec::with_capacity(6 * 33 + if m.is_some() { 32 } else { 0 }); + data.extend_from_slice(&a.serialize()); + data.extend_from_slice(&b.serialize()); + data.extend_from_slice(&c.serialize()); + data.extend_from_slice(&g.serialize()); + data.extend_from_slice(&r1.serialize()); + data.extend_from_slice(&r2.serialize()); + if let Some(msg) = m { + data.extend_from_slice(msg); + } + + let hash = tagged_hash(DLEQ_TAG_CHALLENGE, &data); + Scalar::from_be_bytes(hash).expect("Valid scalar from hash") +} + +/// Generate a DLEQ proof +/// +/// Proves that log_G(A) = log_B(C), i.e., A = a*G and C = a*B for some secret a. +/// +/// # Arguments +/// * `secp` - Secp256k1 context +/// * `a` - Secret scalar (private key) +/// * `b` - Public key B +/// * `r` - 32 bytes of randomness for aux randomization +/// * `g` - Generator point (default: secp256k1 generator G) +/// * `m` - Optional 32-byte message to include in proof +/// +/// # Returns +/// 64-byte proof: e (32 bytes) || s (32 bytes) +pub fn dleq_generate_proof( + secp: &Secp256k1, + a: &SecretKey, + b: &PublicKey, + r: &[u8; 32], + m: Option<&[u8; 32]>, +) -> Result<[u8; 64]> { + // Compute A = a*G and C = a*B + let a_point = PublicKey::from_secret_key(secp, a); + let a_scalar: Scalar = (*a).into(); + let c_point = b.mul_tweak(secp, &a_scalar)?; + + // Get generator G + let g_point = PublicKey::from_secret_key( + secp, + &SecretKey::from_slice(&[ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 1, + ]) + .unwrap(), + ); + + // Compute t = a XOR H_aux(r) + let aux_hash = tagged_hash(DLEQ_TAG_AUX, r); + let a_bytes = a.secret_bytes(); + let t = xor_bytes(&a_bytes, &aux_hash); + + // Compute nonce: k = H_nonce(t || A || C || m) mod n + let mut nonce_data = Vec::with_capacity(32 + 33 + 33 + if m.is_some() { 32 } else { 0 }); + nonce_data.extend_from_slice(&t); + nonce_data.extend_from_slice(&a_point.serialize()); + nonce_data.extend_from_slice(&c_point.serialize()); + if let Some(msg) = m { + nonce_data.extend_from_slice(msg); + } + + let nonce_hash = tagged_hash(DLEQ_TAG_NONCE, &nonce_data); + let k = Scalar::from_be_bytes(nonce_hash) + .map_err(|_| CryptoError::DleqGenerationFailed("Invalid nonce scalar".to_string()))?; + + // Check if k is zero by trying to convert to SecretKey + let k_key = SecretKey::from_slice(&k.to_be_bytes())?; + + // Compute R1 = k*G and R2 = k*B + let r1 = PublicKey::from_secret_key(secp, &k_key); + let r2 = b.mul_tweak(secp, &k)?; + + // Compute challenge e = H_challenge(A, B, C, G, R1, R2, m) + let e = dleq_challenge(&a_point, b, &c_point, &g_point, &r1, &r2, m); + + // Compute s = k + e*a (mod n) + // We need to do scalar arithmetic. Since `Scalar` doesn't support arithmetic directly, + // we convert to SecretKey for operations + let e_key = SecretKey::from_slice(&e.to_be_bytes())?; + let ea = e_key.mul_tweak(&a_scalar)?; + let s_key = k_key.add_tweak(&ea.into())?; + let s = Scalar::from(s_key); + + // Construct proof: e || s + let mut proof = [0u8; 64]; + proof[0..32].copy_from_slice(&e.to_be_bytes()); + proof[32..64].copy_from_slice(&s.to_be_bytes()); + + // Verify the proof before returning + if !dleq_verify_proof(secp, &a_point, b, &c_point, &proof, m)? { + return Err(CryptoError::DleqGenerationFailed( + "Self-verification failed".to_string(), + )); + } + + Ok(proof) +} + +/// Verify a DLEQ proof +/// +/// Verifies that log_G(A) = log_B(C). +/// +/// # Arguments +/// * `secp` - Secp256k1 context +/// * `a` - Public key A = a*G +/// * `b` - Public key B +/// * `c` - Public key C = a*B +/// * `proof` - 64-byte proof +/// * `m` - Optional 32-byte message +pub fn dleq_verify_proof( + secp: &Secp256k1, + a: &PublicKey, + b: &PublicKey, + c: &PublicKey, + proof: &[u8; 64], + m: Option<&[u8; 32]>, +) -> Result { + // Parse proof: e || s + let mut e_bytes = [0u8; 32]; + let mut s_bytes = [0u8; 32]; + e_bytes.copy_from_slice(&proof[0..32]); + s_bytes.copy_from_slice(&proof[32..64]); + + let e = Scalar::from_be_bytes(e_bytes).map_err(|_| CryptoError::DleqVerificationFailed)?; + let s = Scalar::from_be_bytes(s_bytes).map_err(|_| CryptoError::DleqVerificationFailed)?; + + // Get generator G + let g_point = PublicKey::from_secret_key( + secp, + &SecretKey::from_slice(&[ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 1, + ]) + .unwrap(), + ); + + // Compute R1 = s*G - e*A + let s_key = SecretKey::from_slice(&s.to_be_bytes())?; + let s_g = PublicKey::from_secret_key(secp, &s_key); + + let e_key = SecretKey::from_slice(&e.to_be_bytes())?; + let e_a = a.mul_tweak(secp, &e_key.into())?; + + let r1 = s_g + .combine(&e_a.negate(secp)) + .map_err(|_| CryptoError::DleqVerificationFailed)?; + + // Compute R2 = s*B - e*C + let s_b = b.mul_tweak(secp, &s)?; + let e_c = c.mul_tweak(secp, &e)?; + + let r2 = s_b + .combine(&e_c.negate(secp)) + .map_err(|_| CryptoError::DleqVerificationFailed)?; + + // Verify challenge + let e_prime = dleq_challenge(a, b, c, &g_point, &r1, &r2, m); + + Ok(e == e_prime) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_tagged_hash() { + let data = b"test data"; + let hash = tagged_hash(DLEQ_TAG_AUX, data); + assert_eq!(hash.len(), 32); + } + + #[test] + fn test_xor_bytes() { + let a = [0xFFu8; 32]; + let b = [0xAAu8; 32]; + let result = xor_bytes(&a, &b); + assert_eq!(result, [0x55u8; 32]); + } + + #[test] + fn test_dleq_proof_generation_and_verification() { + let secp = Secp256k1::new(); + + // Generate random keypair for party A + let a = SecretKey::from_slice(&[1u8; 32]).unwrap(); + let a_pub = PublicKey::from_secret_key(&secp, &a); + + // Generate random public key for party B + let b_priv = SecretKey::from_slice(&[2u8; 32]).unwrap(); + let b = PublicKey::from_secret_key(&secp, &b_priv); + + // Compute shared secret C = a*B + let c = b.mul_tweak(&secp, &a.into()).unwrap(); + + // Generate proof + let rand_aux = [3u8; 32]; + let proof = dleq_generate_proof(&secp, &a, &b, &rand_aux, None).unwrap(); + + // Verify proof + let valid = dleq_verify_proof(&secp, &a_pub, &b, &c, &proof, None).unwrap(); + assert!(valid); + + // Test with message + let message = [4u8; 32]; + let proof_with_msg = dleq_generate_proof(&secp, &a, &b, &rand_aux, Some(&message)).unwrap(); + let valid_with_msg = + dleq_verify_proof(&secp, &a_pub, &b, &c, &proof_with_msg, Some(&message)).unwrap(); + assert!(valid_with_msg); + + // Verify that proof without message doesn't verify with message + let invalid = dleq_verify_proof(&secp, &a_pub, &b, &c, &proof, Some(&message)).unwrap(); + assert!(!invalid); + } + + #[test] + fn test_dleq_proof_invalid() { + let secp = Secp256k1::new(); + + let a = SecretKey::from_slice(&[1u8; 32]).unwrap(); + let a_pub = PublicKey::from_secret_key(&secp, &a); + let b_priv = SecretKey::from_slice(&[2u8; 32]).unwrap(); + let b = PublicKey::from_secret_key(&secp, &b_priv); + let c = b.mul_tweak(&secp, &a.into()).unwrap(); + + // Generate valid proof + let rand_aux = [3u8; 32]; + let mut proof = dleq_generate_proof(&secp, &a, &b, &rand_aux, None).unwrap(); + + // Corrupt the proof by flipping a bit + proof[0] ^= 1; + + // Verification should fail + let valid = dleq_verify_proof(&secp, &a_pub, &b, &c, &proof, None).unwrap(); + assert!(!valid); + } +} diff --git a/spdk-core/src/psbt/crypto/error.rs b/spdk-core/src/psbt/crypto/error.rs new file mode 100644 index 0000000..a3f4015 --- /dev/null +++ b/spdk-core/src/psbt/crypto/error.rs @@ -0,0 +1,40 @@ +//! Error types for cryptographic operations + +use thiserror::Error; + +/// Result type for cryptographic operations +pub type Result = std::result::Result; + +/// Cryptographic errors +#[derive(Debug, Error)] +pub enum CryptoError { + #[error("Secp256k1 error: {0}")] + Secp256k1(#[from] secp256k1::Error), + + #[error("Invalid private key")] + InvalidPrivateKey, + + #[error("Invalid public key")] + InvalidPublicKey, + + #[error("Invalid signature")] + InvalidSignature, + + #[error("DLEQ proof generation failed: {0}")] + DleqGenerationFailed(String), + + #[error("DLEQ proof verification failed")] + DleqVerificationFailed, + + #[error("Invalid DLEQ proof length: expected 64 bytes, got {0}")] + InvalidDleqProofLength(usize), + + #[error("Invalid ECDH result")] + InvalidEcdh, + + #[error("Hash function error: {0}")] + HashError(String), + + #[error("Other error: {0}")] + Other(String), +} diff --git a/spdk-core/src/psbt/crypto/mod.rs b/spdk-core/src/psbt/crypto/mod.rs new file mode 100644 index 0000000..561e14d --- /dev/null +++ b/spdk-core/src/psbt/crypto/mod.rs @@ -0,0 +1,17 @@ +//! BIP-375 Cryptographic Primitives +//! +//! This crate provides cryptographic functions for BIP-375 silent payments: +//! - BIP-352 silent payment cryptography +//! - BIP-374 DLEQ proofs +//! - Transaction signing (P2WPKH) +//! - Script type utilities + +pub mod bip352; +pub mod dleq; +pub mod error; +pub mod signing; + +pub use bip352::*; +pub use dleq::*; +pub use error::{CryptoError, Result}; +pub use signing::*; diff --git a/spdk-core/src/psbt/crypto/signing.rs b/spdk-core/src/psbt/crypto/signing.rs new file mode 100644 index 0000000..2c3b4fd --- /dev/null +++ b/spdk-core/src/psbt/crypto/signing.rs @@ -0,0 +1,312 @@ +//! Transaction Signing for P2WPKH Inputs +//! +//! Implements ECDSA signature generation with RFC 6979 deterministic nonces. + +use super::error::{CryptoError, Result}; +use bitcoin::hashes::{sha256, Hash}; +use bitcoin::{sighash::SighashCache, Amount, ScriptBuf, Transaction}; +use hmac::{Hmac, Mac}; +use secp256k1::{ecdsa::Signature, Message, Secp256k1, SecretKey}; +use sha2::Sha256; + +type HmacSha256 = Hmac; + +/// Generate a deterministic nonce using RFC 6979 +/// +/// This generates a deterministic k value for ECDSA signing based on the +/// private key and message hash, ensuring signatures are deterministic while +/// remaining secure. +pub fn deterministic_nonce(privkey: &SecretKey, message_hash: &[u8; 32]) -> Result { + let privkey_bytes = privkey.secret_bytes(); + + // Step b: V = 0x01 0x01 0x01 ... 0x01 + let mut v = [0x01u8; 32]; + + // Step c: K = 0x00 0x00 0x00 ... 0x00 + let mut k = [0x00u8; 32]; + + // Step d: K = HMAC_K(V || 0x00 || int2octets(x) || bits2octets(h1)) + let mut mac = HmacSha256::new_from_slice(&k).unwrap(); + mac.update(&v); + mac.update(&[0x00]); + mac.update(&privkey_bytes); + mac.update(message_hash); + k = mac.finalize().into_bytes().into(); + + // Step e: V = HMAC_K(V) + let mut mac = HmacSha256::new_from_slice(&k).unwrap(); + mac.update(&v); + v = mac.finalize().into_bytes().into(); + + // Step f: K = HMAC_K(V || 0x01 || int2octets(x) || bits2octets(h1)) + let mut mac = HmacSha256::new_from_slice(&k).unwrap(); + mac.update(&v); + mac.update(&[0x01]); + mac.update(&privkey_bytes); + mac.update(message_hash); + k = mac.finalize().into_bytes().into(); + + // Step g: V = HMAC_K(V) + let mut mac = HmacSha256::new_from_slice(&k).unwrap(); + mac.update(&v); + v = mac.finalize().into_bytes().into(); + + // Step h: Generate nonce + loop { + // Generate candidate k from V + let mut mac = HmacSha256::new_from_slice(&k).unwrap(); + mac.update(&v); + v = mac.finalize().into_bytes().into(); + + // Try to create a valid secret key from v + if let Ok(nonce) = SecretKey::from_slice(&v) { + return Ok(nonce); + } + + // If not valid, continue: K = HMAC_K(V || 0x00) + let mut mac = HmacSha256::new_from_slice(&k).unwrap(); + mac.update(&v); + mac.update(&[0x00]); + k = mac.finalize().into_bytes().into(); + + // V = HMAC_K(V) + let mut mac = HmacSha256::new_from_slice(&k).unwrap(); + mac.update(&v); + v = mac.finalize().into_bytes().into(); + } +} + +/// Sign a P2WPKH input using ECDSA with SIGHASH_ALL +/// +/// # Arguments +/// * `secp` - Secp256k1 context +/// * `tx` - The transaction being signed +/// * `input_index` - Index of the input to sign +/// * `script_pubkey` - The script pubkey of the UTXO being spent +/// * `amount` - The amount of the UTXO being spent +/// * `privkey` - Private key to sign with +/// +/// # Returns +/// Serialized signature with SIGHASH_ALL flag appended (DER + 0x01) +pub fn sign_p2wpkh_input( + secp: &Secp256k1, + tx: &Transaction, + input_index: usize, + script_pubkey: &ScriptBuf, + amount: Amount, + privkey: &SecretKey, +) -> Result> { + // Create sighash cache + let mut sighash_cache = SighashCache::new(tx); + + // Compute sighash for this input + let sighash = sighash_cache + .p2wpkh_signature_hash( + input_index, + script_pubkey, + amount, + bitcoin::sighash::EcdsaSighashType::All, + ) + .map_err(|e| CryptoError::Other(format!("Sighash computation failed: {}", e)))?; + + // Convert sighash to message + let message = Message::from_digest(sighash.to_byte_array()); + + // Sign the message + let signature = secp.sign_ecdsa(&message, privkey); + + // Serialize to DER format and append SIGHASH_ALL (0x01) + let mut sig_bytes = signature.serialize_der().to_vec(); + sig_bytes.push(0x01); // SIGHASH_ALL + + Ok(sig_bytes) +} + +/// Sign a P2PKH input using ECDSA with SIGHASH_ALL +/// +/// # Arguments +/// * `secp` - Secp256k1 context +/// * `tx` - The transaction being signed +/// * `input_index` - Index of the input to sign +/// * `script_pubkey` - The script pubkey of the UTXO being spent +/// * `amount` - The amount of the UTXO being spent (not strictly needed for legacy, but good for API consistency) +/// * `privkey` - Private key to sign with +/// +/// # Returns +/// Serialized signature with SIGHASH_ALL flag appended (DER + 0x01) +pub fn sign_p2pkh_input( + secp: &Secp256k1, + tx: &Transaction, + input_index: usize, + script_pubkey: &ScriptBuf, + _amount: Amount, + privkey: &SecretKey, +) -> Result> { + // Create sighash cache + let sighash_cache = SighashCache::new(tx); + + // Compute sighash for this input + let sighash = sighash_cache + .legacy_signature_hash( + input_index, + script_pubkey, + bitcoin::sighash::EcdsaSighashType::All.to_u32(), + ) + .map_err(|e| CryptoError::Other(format!("Sighash computation failed: {}", e)))?; + + // Convert sighash to message + let message = Message::from_digest(sighash.to_byte_array()); + + // Sign the message + let signature = secp.sign_ecdsa(&message, privkey); + + // Serialize to DER format and append SIGHASH_ALL (0x01) + let mut sig_bytes = signature.serialize_der().to_vec(); + sig_bytes.push(0x01); // SIGHASH_ALL + + Ok(sig_bytes) +} + +/// Sign a message hash with ECDSA (low-level function) +/// +/// This is a lower-level function that signs a raw 32-byte hash. +/// For transaction signing, use `sign_p2wpkh_input` instead. +pub fn sign_hash( + secp: &Secp256k1, + privkey: &SecretKey, + message_hash: &[u8; 32], +) -> Result { + let message = Message::from_digest(*message_hash); + Ok(secp.sign_ecdsa(&message, privkey)) +} + +/// Verify an ECDSA signature +pub fn verify_signature( + secp: &Secp256k1, + pubkey: &secp256k1::PublicKey, + message_hash: &[u8; 32], + signature: &Signature, +) -> bool { + let message = Message::from_digest(*message_hash); + secp.verify_ecdsa(&message, signature, pubkey).is_ok() +} + +/// Compute SHA256 hash +pub fn sha256_hash(data: &[u8]) -> [u8; 32] { + sha256::Hash::hash(data).to_byte_array() +} + +/// Compute double SHA256 hash (Bitcoin's hash256) +pub fn double_sha256_hash(data: &[u8]) -> [u8; 32] { + let first = sha256::Hash::hash(data); + sha256::Hash::hash(&first.to_byte_array()).to_byte_array() +} + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin::{ + absolute::LockTime, transaction::Version, OutPoint, Sequence, TxIn, TxOut, Txid, Witness, + }; + + #[test] + fn test_deterministic_nonce() { + let privkey = SecretKey::from_slice(&[1u8; 32]).unwrap(); + let message_hash = [2u8; 32]; + + let nonce1 = deterministic_nonce(&privkey, &message_hash).unwrap(); + let nonce2 = deterministic_nonce(&privkey, &message_hash).unwrap(); + + // Same inputs should produce same nonce + assert_eq!(nonce1.secret_bytes(), nonce2.secret_bytes()); + + // Different message should produce different nonce + let different_message = [3u8; 32]; + let nonce3 = deterministic_nonce(&privkey, &different_message).unwrap(); + assert_ne!(nonce1.secret_bytes(), nonce3.secret_bytes()); + } + + #[test] + fn test_sign_hash() { + let secp = Secp256k1::new(); + let privkey = SecretKey::from_slice(&[1u8; 32]).unwrap(); + let pubkey = secp256k1::PublicKey::from_secret_key(&secp, &privkey); + let message_hash = [2u8; 32]; + + let signature = sign_hash(&secp, &privkey, &message_hash).unwrap(); + + // Verify the signature + assert!(verify_signature(&secp, &pubkey, &message_hash, &signature)); + + // Wrong message should fail + let wrong_message = [3u8; 32]; + assert!(!verify_signature( + &secp, + &pubkey, + &wrong_message, + &signature + )); + } + + #[test] + fn test_sign_p2wpkh_input() { + let secp = Secp256k1::new(); + let privkey = SecretKey::from_slice(&[1u8; 32]).unwrap(); + let pubkey = secp256k1::PublicKey::from_secret_key(&secp, &privkey); + + // Create a simple transaction + let tx = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint { + txid: Txid::all_zeros(), + vout: 0, + }, + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::new(), + }], + output: vec![TxOut { + value: Amount::from_sat(100000), + script_pubkey: ScriptBuf::new(), + }], + }; + + // Create P2WPKH script + let wpubkey_hash = bitcoin::PublicKey::new(pubkey).wpubkey_hash().unwrap(); + let script_pubkey = ScriptBuf::new_p2wpkh(&wpubkey_hash); + + let amount = Amount::from_sat(50000); + + // Sign the input + let signature = sign_p2wpkh_input(&secp, &tx, 0, &script_pubkey, amount, &privkey).unwrap(); + + // Signature should be DER encoded + SIGHASH_ALL flag + assert!(signature.len() >= 70); // Typical DER signature size + assert_eq!(signature.last(), Some(&0x01)); // SIGHASH_ALL flag + } + + #[test] + fn test_sha256_hash() { + let data = b"test data"; + let hash = sha256_hash(data); + assert_eq!(hash.len(), 32); + + // Same data should produce same hash + let hash2 = sha256_hash(data); + assert_eq!(hash, hash2); + } + + #[test] + fn test_double_sha256_hash() { + let data = b"test data"; + let hash = double_sha256_hash(data); + assert_eq!(hash.len(), 32); + + // Verify it's actually double hashed + let single = sha256_hash(data); + let double = sha256_hash(&single); + assert_eq!(hash, double); + } +} diff --git a/spdk-core/src/psbt/helpers/mod.rs b/spdk-core/src/psbt/helpers/mod.rs new file mode 100644 index 0000000..d6d6667 --- /dev/null +++ b/spdk-core/src/psbt/helpers/mod.rs @@ -0,0 +1,8 @@ +//! BIP-375 Helper Library +//! +//! Shared utilities for building BIP-375 examples, tools, and demos. +//! +//! This library provides: +//! - **Wallet**: Virtual wallet and transaction configuration for demos + +pub mod wallet; diff --git a/spdk-core/src/psbt/helpers/wallet/mod.rs b/spdk-core/src/psbt/helpers/wallet/mod.rs new file mode 100644 index 0000000..ac470ae --- /dev/null +++ b/spdk-core/src/psbt/helpers/wallet/mod.rs @@ -0,0 +1,8 @@ +//! Wallet utilities for examples and demos +//! +//! Provides virtual wallet and transaction configuration types for building +//! BIP-375 demonstration applications. + +pub mod types; + +pub use types::{SimpleWallet, TransactionConfig, Utxo, VirtualWallet}; diff --git a/spdk-core/src/psbt/helpers/wallet/types.rs b/spdk-core/src/psbt/helpers/wallet/types.rs new file mode 100644 index 0000000..85ab37c --- /dev/null +++ b/spdk-core/src/psbt/helpers/wallet/types.rs @@ -0,0 +1,611 @@ +//! Wallet types for examples and demos +//! +//! Provides virtual wallet, UTXO management, and transaction configuration +//! utilities for building BIP-375 demonstration applications. + +use crate::psbt::core::PsbtInput; +use crate::psbt::crypto::{pubkey_to_p2tr_script, pubkey_to_p2wpkh_script, script_type_string}; +use bitcoin::{hashes::Hash, Amount, OutPoint, ScriptBuf, Sequence, TxOut, Txid}; +use secp256k1::{PublicKey, Secp256k1, SecretKey}; +use sha2::{Digest, Sha256}; + +// ============================================================================ +// UTXO Type +// ============================================================================ + +/// UTXO information for creating PSBTs +/// +/// This is a helper type used by the examples' VirtualWallet. +/// For actual PSBT construction, convert to `PsbtInput` using `to_psbt_input()`. +#[derive(Debug, Clone)] +pub struct Utxo { + /// Previous transaction ID + pub txid: Txid, + /// Output index + pub vout: u32, + /// Amount in satoshis + pub amount: Amount, + /// ScriptPubKey of the output + pub script_pubkey: ScriptBuf, + /// Private key for signing (if available) + pub private_key: Option, + /// Sequence number + pub sequence: Sequence, +} + +impl Utxo { + /// Create a new UTXO + pub fn new( + txid: Txid, + vout: u32, + amount: Amount, + script_pubkey: ScriptBuf, + private_key: Option, + sequence: Sequence, + ) -> Self { + Self { + txid, + vout, + amount, + script_pubkey, + private_key, + sequence, + } + } + + /// Get the outpoint for this UTXO + pub fn outpoint(&self) -> OutPoint { + OutPoint { + txid: self.txid, + vout: self.vout, + } + } + + /// Convert to PsbtInput for use with bip375-roles + pub fn to_psbt_input(&self) -> PsbtInput { + PsbtInput::new( + self.outpoint(), + TxOut { + value: self.amount, + script_pubkey: self.script_pubkey.clone(), + }, + self.sequence, + self.private_key, + ) + } +} + +/// Simple wallet for generating deterministic keys from a seed +pub struct SimpleWallet { + seed: String, +} + +impl SimpleWallet { + pub fn new(seed: &str) -> Self { + Self { + seed: seed.to_string(), + } + } + + /// Generate a deterministic private key from the seed + pub fn input_key_pair(&self, index: u32) -> (SecretKey, PublicKey) { + let secp = Secp256k1::new(); + + // Match Python's key derivation: f"{seed}_input_{index}" + let key_material = format!("{}_input_{}", self.seed, index); + let mut hasher = Sha256::new(); + hasher.update(key_material.as_bytes()); + let hash = hasher.finalize(); + + let privkey = SecretKey::from_slice(&hash).expect("valid private key"); + let pubkey = PublicKey::from_secret_key(&secp, &privkey); + + (privkey, pubkey) + } + + /// Generate scan key pair + /// + /// Must match Python's Wallet.create_key_pair("scan", 0): + /// data = f"{self.seed}_scan_0".encode() + pub fn scan_key_pair(&self) -> (SecretKey, PublicKey) { + let secp = Secp256k1::new(); + + // Match Python's key derivation: f"{seed}_scan_0" + let key_material = format!("{}_scan_0", self.seed); + let mut hasher = Sha256::new(); + hasher.update(key_material.as_bytes()); + let scan_hash = hasher.finalize(); + + let scan_privkey = SecretKey::from_slice(&scan_hash).expect("valid scan private key"); + let scan_pubkey = PublicKey::from_secret_key(&secp, &scan_privkey); + + (scan_privkey, scan_pubkey) + } + + /// Generate spend key pair + /// + /// Must match Python's Wallet.create_key_pair("spend", 0): + /// data = f"{self.seed}_spend_0".encode() + pub fn spend_key_pair(&self) -> (SecretKey, PublicKey) { + let secp = Secp256k1::new(); + + // Match Python's key derivation: f"{seed}_spend_0" + let key_material = format!("{}_spend_0", self.seed); + let mut hasher = Sha256::new(); + hasher.update(key_material.as_bytes()); + let spend_hash = hasher.finalize(); + + let spend_privkey = SecretKey::from_slice(&spend_hash).expect("valid spend private key"); + let spend_pubkey = PublicKey::from_secret_key(&secp, &spend_privkey); + + (spend_privkey, spend_pubkey) + } + + /// Get scan and spend public keys (convenience method) + pub fn scan_spend_keys(&self) -> (PublicKey, PublicKey) { + (self.scan_key_pair().1, self.spend_key_pair().1) + } +} + +// ============================================================================= +// Virtual Wallet - Configurable UTXO Selection +// ============================================================================= + +/// Script type for UTXOs +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScriptType { + P2WPKH, + P2TR, +} + +impl ScriptType { + pub fn as_str(&self) -> &'static str { + match self { + ScriptType::P2WPKH => "P2WPKH", + ScriptType::P2TR => "P2TR", + } + } +} + +/// Virtual UTXO with metadata for display and selection +#[derive(Debug, Clone)] +pub struct VirtualUtxo { + pub id: usize, + pub utxo: Utxo, + pub script_type: ScriptType, + pub description: String, + pub has_sp_tweak: bool, // Is it a received silent payment? + pub tweak: Option<[u8; 32]>, // The tweak data if SP output +} + +/// Virtual wallet containing a pool of pre-generated UTXOs +pub struct VirtualWallet { + utxos: Vec, + wallet_seed: String, +} + +impl VirtualWallet { + /// Create a new virtual wallet with pre-populated UTXOs + /// + /// # Arguments + /// * `wallet_seed` - Seed for generating deterministic keys + /// * `utxo_configs` - List of (amount, script_type, has_sp_tweak) configurations + pub fn new(wallet_seed: &str, utxo_configs: &[(u64, ScriptType, bool)]) -> Self { + let wallet = SimpleWallet::new(wallet_seed); + let secp = Secp256k1::new(); + + let utxos = utxo_configs + .iter() + .enumerate() + .map(|(idx, &(amount, script_type, has_sp_tweak))| { + // For Silent Payment UTXOs, use the spend key (not input key) + // This matches the BIP-352 protocol where SP outputs are created + // by tweaking the spend public key + let (privkey, pubkey) = if has_sp_tweak { + wallet.spend_key_pair() + } else { + wallet.input_key_pair(idx as u32) + }; + + // If this is a silent payment output, apply a demo tweak to the pubkey + let (final_pubkey, tweak) = if has_sp_tweak { + // Generate a deterministic tweak for this UTXO + let tweak = Self::generate_demo_tweak(idx); + let tweaked_privkey = crate::psbt::crypto::apply_tweak_to_privkey(&privkey, &tweak) + .expect("Valid tweak"); + let tweaked_pubkey = PublicKey::from_secret_key(&secp, &tweaked_privkey); + (tweaked_pubkey, Some(tweak)) + } else { + (pubkey, None) + }; + + let script_pubkey = match script_type { + ScriptType::P2WPKH => pubkey_to_p2wpkh_script(&final_pubkey), + ScriptType::P2TR => pubkey_to_p2tr_script(&final_pubkey), + }; + + let description = match (script_type, has_sp_tweak) { + (ScriptType::P2WPKH, false) => "Standard SegWit (P2WPKH)".to_string(), + (ScriptType::P2TR, false) => "Taproot (P2TR)".to_string(), + (ScriptType::P2TR, true) => "Received Silent Payment (P2TR)".to_string(), + (ScriptType::P2WPKH, true) => { + "Invalid: P2WPKH cannot be SP".to_string() // SP requires P2TR + } + }; + + // Generate a unique TXID for each UTXO + let txid_bytes = Self::generate_demo_txid(idx); + let txid = Txid::from_slice(&txid_bytes).expect("valid txid"); + + VirtualUtxo { + id: idx, + utxo: Utxo::new( + txid, + 0, + Amount::from_sat(amount), + script_pubkey, + None, // Private key set later when signing + Sequence::from_consensus(0xfffffffe), + ), + script_type, + description, + has_sp_tweak, + tweak, + } + }) + .collect(); + + Self { + utxos, + wallet_seed: wallet_seed.to_string(), + } + } + + /// Create default hardware wallet virtual wallet + pub fn hardware_wallet_default() -> Self { + Self::new( + "hardware_wallet_coldcard_demo", + &[ + (50_000, ScriptType::P2WPKH, false), // ID 0 + (100_000, ScriptType::P2TR, true), // ID 1 - SP + (150_000, ScriptType::P2WPKH, false), // ID 2 + (200_000, ScriptType::P2TR, true), // ID 3 - SP + (75_000, ScriptType::P2WPKH, false), // ID 4 + (300_000, ScriptType::P2TR, true), // ID 5 - SP + (125_000, ScriptType::P2WPKH, false), // ID 6 + (250_000, ScriptType::P2TR, false), // ID 7 + ], + ) + } + + /// Create default multi-signer virtual wallet (per-party wallets) + pub fn multi_signer_wallet(party_seed: &str) -> Self { + Self::new( + party_seed, + &[ + (100_000, ScriptType::P2WPKH, false), // ID 0 + (150_000, ScriptType::P2TR, false), // ID 1 + (200_000, ScriptType::P2WPKH, false), // ID 2 + (75_000, ScriptType::P2TR, false), // ID 3 + ], + ) + } + + /// Get all available UTXOs + pub fn list_utxos(&self) -> &[VirtualUtxo] { + &self.utxos + } + + /// Select UTXOs by IDs and return cloned Utxo objects + pub fn select_by_ids(&self, ids: &[usize]) -> Vec { + ids.iter() + .filter_map(|id| self.utxos.iter().find(|u| u.id == *id)) + .map(|vu| vu.utxo.clone()) + .collect() + } + + /// Get UTXO by ID + pub fn get_utxo(&self, id: usize) -> Option<&VirtualUtxo> { + self.utxos.iter().find(|u| u.id == id) + } + + /// Get total amount for selected UTXOs + pub fn total_amount(&self, ids: &[usize]) -> u64 { + ids.iter() + .filter_map(|id| self.get_utxo(*id)) + .map(|vu| vu.utxo.amount.to_sat()) + .sum() + } + + /// Get the wallet seed + pub fn wallet_seed(&self) -> &str { + &self.wallet_seed + } + + /// Get private key for a UTXO ID (for signing) + pub fn get_privkey(&self, id: usize) -> Option { + let wallet = SimpleWallet::new(&self.wallet_seed); + if id < self.utxos.len() { + Some(wallet.input_key_pair(id as u32).0) + } else { + None + } + } + + /// Generate a deterministic tweak for demo purposes + fn generate_demo_tweak(index: usize) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(format!("demo_sp_tweak_{}", index).as_bytes()); + let hash = hasher.finalize(); + let mut tweak = [0u8; 32]; + tweak.copy_from_slice(&hash); + tweak + } + + /// Generate a deterministic TXID for demo purposes + fn generate_demo_txid(index: usize) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(format!("demo_txid_{}", index).as_bytes()); + let hash = hasher.finalize(); + let mut txid = [0u8; 32]; + txid.copy_from_slice(&hash); + txid + } +} + +/// Transaction configuration for demos +#[derive(Debug, Clone)] +pub struct TransactionConfig { + pub selected_utxo_ids: Vec, + pub recipient_amount: u64, + pub change_amount: u64, + pub fee: u64, +} + +impl TransactionConfig { + /// Create a new transaction configuration + pub fn new( + selected_utxo_ids: Vec, + recipient_amount: u64, + change_amount: u64, + fee: u64, + ) -> Self { + Self { + selected_utxo_ids, + recipient_amount, + change_amount, + fee, + } + } + + /// Default configuration for hardware wallet (mimics current behavior) + pub fn hardware_wallet_auto() -> Self { + Self { + selected_utxo_ids: vec![1, 2], // 1 SP + 1 P2WPKH + recipient_amount: 195_000, + change_amount: 50_000, + fee: 5_000, + } + } + + /// Default configuration for multi-signer + pub fn multi_signer_auto() -> Self { + Self { + selected_utxo_ids: vec![0], // One input per party + recipient_amount: 340_000, + change_amount: 100_000, + fee: 10_000, + } + } + + /// Parse from command-line arguments + pub fn from_args(args: &[String], default_config: Self) -> Self { + let mut config = default_config; + + // Parse --utxos flag + if let Some(pos) = args.iter().position(|arg| arg == "--utxos") { + if let Some(utxo_str) = args.get(pos + 1) { + if let Ok(ids) = Self::parse_utxo_ids(utxo_str) { + config.selected_utxo_ids = ids; + } + } + } + + // Parse --recipient flag + if let Some(pos) = args.iter().position(|arg| arg == "--recipient") { + if let Some(amount_str) = args.get(pos + 1) { + if let Ok(amount) = amount_str.parse::() { + config.recipient_amount = amount; + } + } + } + + // Parse --change flag + if let Some(pos) = args.iter().position(|arg| arg == "--change") { + if let Some(amount_str) = args.get(pos + 1) { + if let Ok(amount) = amount_str.parse::() { + config.change_amount = amount; + } + } + } + + // Parse --fee flag + if let Some(pos) = args.iter().position(|arg| arg == "--fee") { + if let Some(amount_str) = args.get(pos + 1) { + if let Ok(amount) = amount_str.parse::() { + config.fee = amount; + } + } + } + + config + } + + /// Parse comma-separated UTXO IDs + fn parse_utxo_ids(s: &str) -> Result, std::num::ParseIntError> { + s.split(',').map(|id| id.trim().parse::()).collect() + } + + /// Validate configuration against virtual wallet + pub fn validate(&self, wallet: &VirtualWallet) -> Result<(), String> { + // Check all selected UTXOs exist + for id in &self.selected_utxo_ids { + if wallet.get_utxo(*id).is_none() { + return Err(format!("UTXO ID {} not found in wallet", id)); + } + } + + // Check amounts balance + let total_input = wallet.total_amount(&self.selected_utxo_ids); + let total_output = self.recipient_amount + self.change_amount + self.fee; + + if total_input != total_output { + return Err(format!( + "Transaction does not balance: input {} sats != output {} sats", + total_input, total_output + )); + } + + Ok(()) + } + + /// Display configuration summary + pub fn display(&self, wallet: &VirtualWallet) { + println!("\n{}", "=".repeat(60)); + println!(" Transaction Configuration"); + println!("{}", "=".repeat(60)); + + println!("\nSelected UTXOs:"); + for id in &self.selected_utxo_ids { + if let Some(vu) = wallet.get_utxo(*id) { + let sp_indicator = if vu.has_sp_tweak { " [SP]" } else { "" }; + println!( + " [{}] {} sats - {} - {}{}", + id, + vu.utxo.amount.to_sat(), + vu.script_type.as_str(), + vu.description, + sp_indicator + ); + } + } + + let total_input = wallet.total_amount(&self.selected_utxo_ids); + println!("\nTotal Input: {:>10} sats", total_input); + println!("Recipient: {:>10} sats", self.recipient_amount); + println!("Change: {:>10} sats", self.change_amount); + println!("Fee: {:>10} sats", self.fee); + println!( + "Total Output: {:>10} sats", + self.recipient_amount + self.change_amount + self.fee + ); + println!(); + } +} + +/// Interactive configuration builder +pub struct InteractiveConfig; + +impl InteractiveConfig { + /// Build configuration interactively via CLI prompts + pub fn build( + wallet: &VirtualWallet, + default_config: TransactionConfig, + ) -> Result> { + println!("\\n{}", "=".repeat(60)); + println!(" Configure Transaction Inputs"); + println!("{}", "=".repeat(60)); + + println!("\\nAvailable UTXOs in your virtual wallet:\\n"); + for vu in wallet.list_utxos() { + let sp_indicator = if vu.has_sp_tweak { " [SP]" } else { "" }; + println!( + " [{}] {:>7} sats - {} - {}{}", + vu.id, + vu.utxo.amount.to_sat(), + vu.script_type.as_str(), + vu.description, + sp_indicator + ); + } + + let default_ids: Vec = default_config + .selected_utxo_ids + .iter() + .map(|id| id.to_string()) + .collect(); + + println!("\\nSelect UTXOs to spend (comma-separated, e.g., '1,2,4'):"); + println!("Or press Enter for default [{}]", default_ids.join(",")); + print!("\\n> "); + std::io::Write::flush(&mut std::io::stdout())?; + + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + let input = input.trim(); + + let selected_ids = if input.is_empty() { + default_config.selected_utxo_ids.clone() + } else { + TransactionConfig::parse_utxo_ids(input).map_err(|_| "Invalid UTXO IDs format")? + }; + + // Validate selection + for id in &selected_ids { + if wallet.get_utxo(*id).is_none() { + return Err(format!("Invalid UTXO ID: {}", id).into()); + } + } + + let total_input = wallet.total_amount(&selected_ids); + + println!( + "\\n✓ Selected {} inputs totaling {} sats\\n", + selected_ids.len(), + total_input + ); + for id in &selected_ids { + if let Some(vu) = wallet.get_utxo(*id) { + let sp_indicator = if vu.has_sp_tweak { " (SP)" } else { "" }; + println!( + " Input: {} sats [{}]{}", + vu.utxo.amount.to_sat(), + script_type_string(&vu.utxo.script_pubkey), + sp_indicator + ); + } + } + + // Use default amounts or prompt (for now just use defaults) + let config = TransactionConfig::new( + selected_ids, + default_config.recipient_amount, + default_config.change_amount, + default_config.fee, + ); + + // Auto-adjust amounts to balance + let total_input = wallet.total_amount(&config.selected_utxo_ids); + let total_output = config.recipient_amount + config.change_amount + config.fee; + + let final_config = if total_input != total_output { + println!( + "\\n⚠ Auto-adjusting amounts to balance (input: {} sats)", + total_input + ); + let new_recipient = total_input - config.change_amount - config.fee; + TransactionConfig::new( + config.selected_utxo_ids, + new_recipient, + config.change_amount, + config.fee, + ) + } else { + config + }; + + final_config.display(wallet); + + Ok(final_config) + } +} diff --git a/spdk-core/src/psbt/io/error.rs b/spdk-core/src/psbt/io/error.rs new file mode 100644 index 0000000..a492d83 --- /dev/null +++ b/spdk-core/src/psbt/io/error.rs @@ -0,0 +1,31 @@ +//! Error types for I/O operations + +use thiserror::Error; + +/// Result type for I/O operations +pub type Result = std::result::Result; + +/// I/O error types +#[derive(Debug, Error)] +pub enum IoError { + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + #[error("PSBT error: {0}")] + Psbt(#[from] crate::psbt::core::error::Error), + + #[error("Hex decoding error: {0}")] + Hex(#[from] hex::FromHexError), + + #[error("Invalid file format: {0}")] + InvalidFormat(String), + + #[error("File not found: {0}")] + NotFound(String), + + #[error("Other error: {0}")] + Other(String), +} diff --git a/spdk-core/src/psbt/io/file_io.rs b/spdk-core/src/psbt/io/file_io.rs new file mode 100644 index 0000000..22a15c8 --- /dev/null +++ b/spdk-core/src/psbt/io/file_io.rs @@ -0,0 +1,207 @@ +//! File I/O operations for PSBTs + +use super::error::{IoError, Result}; +use super::metadata::{PsbtFile, PsbtMetadata}; +use crate::psbt::core::SilentPaymentPsbt; +use std::fs; +use std::path::Path; + +/// Save a PSBT to a file (binary format) +pub fn save_psbt_binary>(psbt: &SilentPaymentPsbt, path: P) -> Result<()> { + let bytes = psbt.serialize(); + fs::write(path, bytes)?; + Ok(()) +} + +/// Load a PSBT from a file (binary format) +pub fn load_psbt_binary>(path: P) -> Result { + let bytes = fs::read(path)?; + let psbt = SilentPaymentPsbt::deserialize(&bytes) + .map_err(|e| IoError::Other(format!("PSBT deserialization error: {:?}", e)))?; + Ok(psbt) +} + +/// Save a PSBT to a JSON file with metadata +pub fn save_psbt_with_metadata>( + psbt: &SilentPaymentPsbt, + metadata: Option, + path: P, +) -> Result<()> { + // Serialize PSBT to base64 + let psbt_bytes = psbt.serialize(); + let psbt_base64 = base64_encode(&psbt_bytes); + + // Create PSBT file structure + let psbt_file = if let Some(mut meta) = metadata { + meta.update_timestamps(); + PsbtFile::with_metadata(psbt_base64, meta) + } else { + PsbtFile::new(psbt_base64) + }; + + // Serialize to JSON + let json = serde_json::to_string_pretty(&psbt_file)?; + fs::write(path, json)?; + + Ok(()) +} + +/// Load a PSBT from a JSON file (with or without metadata) +pub fn load_psbt_with_metadata>( + path: P, +) -> Result<(SilentPaymentPsbt, Option)> { + let json = fs::read_to_string(path)?; + let psbt_file: PsbtFile = serde_json::from_str(&json)?; + + // Decode base64 PSBT + let psbt_bytes = base64_decode(&psbt_file.psbt)?; + let psbt = SilentPaymentPsbt::deserialize(&psbt_bytes) + .map_err(|e| IoError::Other(format!("PSBT deserialization error: {:?}", e)))?; + + Ok((psbt, psbt_file.metadata)) +} + +/// Save a PSBT in the format determined by file extension +/// +/// - `.psbt` -> binary format +/// - `.json` -> JSON format with metadata +pub fn save_psbt>( + psbt: &SilentPaymentPsbt, + metadata: Option, + path: P, +) -> Result<()> { + let path_ref = path.as_ref(); + + match path_ref.extension().and_then(|s| s.to_str()) { + Some("json") => save_psbt_with_metadata(psbt, metadata, path), + Some("psbt") => save_psbt_binary(psbt, path), + _ => Err(IoError::InvalidFormat(format!( + "Unsupported file extension: {}", + path_ref.display() + ))), + } +} + +/// Load a PSBT from a file (auto-detect format) +/// +/// Tries to parse as JSON first, falls back to binary format. +pub fn load_psbt>(path: P) -> Result<(SilentPaymentPsbt, Option)> { + let path_ref = path.as_ref(); + + // Try JSON first + if let Ok((psbt, metadata)) = load_psbt_with_metadata(path_ref) { + return Ok((psbt, metadata)); + } + + // Fall back to binary + let psbt = load_psbt_binary(path_ref)?; + Ok((psbt, None)) +} + +/// Encode bytes to base64 +fn base64_encode(data: &[u8]) -> String { + use std::io::Write; + let mut buf = Vec::new(); + { + let mut encoder = + base64::write::EncoderWriter::new(&mut buf, &base64::engine::general_purpose::STANDARD); + encoder + .write_all(data) + .expect("writing to Vec should never fail"); + } + String::from_utf8(buf).expect("base64 encoding always produces valid UTF-8") +} + +/// Decode base64 to bytes +fn base64_decode(data: &str) -> Result> { + use base64::Engine; + base64::engine::general_purpose::STANDARD + .decode(data) + .map_err(|e| IoError::InvalidFormat(format!("Base64 decode error: {}", e))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::psbt::core::Bip375PsbtExt; + use tempfile::TempDir; + + fn create_test_psbt() -> SilentPaymentPsbt { + use bitcoin::transaction::Version; + use psbt_v2::v2::Global; + + SilentPaymentPsbt { + global: Global { + version: psbt_v2::V2, + tx_version: Version(2), + fallback_lock_time: None, + input_count: 0, + output_count: 0, + tx_modifiable_flags: 0, + sp_dleq_proofs: std::collections::BTreeMap::new(), + sp_ecdh_shares: std::collections::BTreeMap::new(), + unknowns: std::collections::BTreeMap::new(), + xpubs: std::collections::BTreeMap::new(), + proprietaries: std::collections::BTreeMap::new(), + }, + inputs: Vec::new(), + outputs: Vec::new(), + } + } + + #[test] + fn test_binary_save_load() { + let temp_dir = TempDir::new().unwrap(); + let path = temp_dir.path().join("test.psbt"); + + let psbt = create_test_psbt(); + save_psbt_binary(&psbt, &path).unwrap(); + + let loaded = load_psbt_binary(&path).unwrap(); + assert_eq!(psbt.num_inputs(), loaded.num_inputs()); + assert_eq!(psbt.num_outputs(), loaded.num_outputs()); + } + + #[test] + fn test_json_save_load() { + let temp_dir = TempDir::new().unwrap(); + let path = temp_dir.path().join("test.json"); + + let psbt = create_test_psbt(); + let mut metadata = PsbtMetadata::new(); + metadata.set_creator("test").set_stage("created"); + + save_psbt_with_metadata(&psbt, Some(metadata.clone()), &path).unwrap(); + + let (loaded, loaded_metadata) = load_psbt_with_metadata(&path).unwrap(); + assert_eq!(psbt.num_inputs(), loaded.num_inputs()); + assert!(loaded_metadata.is_some()); + assert_eq!(loaded_metadata.unwrap().creator, metadata.creator); + } + + #[test] + fn test_auto_detect_format() { + let temp_dir = TempDir::new().unwrap(); + + // Test JSON format + let json_path = temp_dir.path().join("test.json"); + let psbt = create_test_psbt(); + save_psbt(&psbt, None, &json_path).unwrap(); + let (loaded, _) = load_psbt(&json_path).unwrap(); + assert_eq!(psbt.num_inputs(), loaded.num_inputs()); + + // Test binary format + let binary_path = temp_dir.path().join("test.psbt"); + save_psbt(&psbt, None, &binary_path).unwrap(); + let (loaded, _) = load_psbt(&binary_path).unwrap(); + assert_eq!(psbt.num_inputs(), loaded.num_inputs()); + } + + #[test] + fn test_base64_encoding() { + let data = b"test data"; + let encoded = base64_encode(data); + let decoded = base64_decode(&encoded).unwrap(); + assert_eq!(data, decoded.as_slice()); + } +} diff --git a/spdk-core/src/psbt/io/metadata.rs b/spdk-core/src/psbt/io/metadata.rs new file mode 100644 index 0000000..dd4153c --- /dev/null +++ b/spdk-core/src/psbt/io/metadata.rs @@ -0,0 +1,205 @@ +//! Metadata structures for PSBT files +//! +//! Provides optional JSON metadata that can be stored alongside PSBTs. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Metadata for a PSBT file +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct PsbtMetadata { + /// Human-readable description of the PSBT + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Creation timestamp (Unix timestamp) + #[serde(skip_serializing_if = "Option::is_none")] + pub created_at: Option, + + /// Last modified timestamp (Unix timestamp) + #[serde(skip_serializing_if = "Option::is_none")] + pub modified_at: Option, + + /// Creator software name and version + #[serde(skip_serializing_if = "Option::is_none")] + pub creator: Option, + + /// Current role/stage in the workflow + #[serde(skip_serializing_if = "Option::is_none")] + pub stage: Option, + + /// Number of inputs + #[serde(skip_serializing_if = "Option::is_none")] + pub num_inputs: Option, + + /// Number of outputs + #[serde(skip_serializing_if = "Option::is_none")] + pub num_outputs: Option, + + /// Number of silent payment outputs + #[serde(skip_serializing_if = "Option::is_none")] + pub num_silent_payment_outputs: Option, + + /// Whether all inputs have ECDH shares + #[serde(skip_serializing_if = "Option::is_none")] + pub ecdh_complete: Option, + + /// Whether all inputs are signed + #[serde(skip_serializing_if = "Option::is_none")] + pub signatures_complete: Option, + + /// Whether output scripts have been computed + #[serde(skip_serializing_if = "Option::is_none")] + pub scripts_computed: Option, + + /// Custom key-value pairs + #[serde(flatten)] + pub custom: HashMap, +} + +impl PsbtMetadata { + /// Create new empty metadata + pub fn new() -> Self { + Self::default() + } + + /// Create metadata with a description + pub fn with_description(description: impl Into) -> Self { + Self { + description: Some(description.into()), + ..Default::default() + } + } + + /// Set the creator software + pub fn set_creator(&mut self, creator: impl Into) -> &mut Self { + self.creator = Some(creator.into()); + self + } + + /// Set the current stage + pub fn set_stage(&mut self, stage: impl Into) -> &mut Self { + self.stage = Some(stage.into()); + self + } + + /// Set input/output counts + pub fn set_counts(&mut self, num_inputs: usize, num_outputs: usize) -> &mut Self { + self.num_inputs = Some(num_inputs); + self.num_outputs = Some(num_outputs); + self + } + + /// Set silent payment output count + pub fn set_silent_payment_count(&mut self, count: usize) -> &mut Self { + self.num_silent_payment_outputs = Some(count); + self + } + + /// Set completion status flags + pub fn set_completion_status( + &mut self, + ecdh_complete: bool, + signatures_complete: bool, + scripts_computed: bool, + ) -> &mut Self { + self.ecdh_complete = Some(ecdh_complete); + self.signatures_complete = Some(signatures_complete); + self.scripts_computed = Some(scripts_computed); + self + } + + /// Add a custom metadata field + pub fn add_custom(&mut self, key: impl Into, value: serde_json::Value) -> &mut Self { + self.custom.insert(key.into(), value); + self + } + + /// Update timestamps + pub fn update_timestamps(&mut self) { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time should be after Unix epoch (1970-01-01)") + .as_secs(); + + if self.created_at.is_none() { + self.created_at = Some(now); + } + self.modified_at = Some(now); + } +} + +/// PSBT file format with metadata +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PsbtFile { + /// PSBT data (base64 encoded) + pub psbt: String, + + /// Optional metadata + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, +} + +impl PsbtFile { + /// Create a new PSBT file with base64-encoded PSBT + pub fn new(psbt_base64: String) -> Self { + Self { + psbt: psbt_base64, + metadata: None, + } + } + + /// Create a PSBT file with metadata + pub fn with_metadata(psbt_base64: String, metadata: PsbtMetadata) -> Self { + Self { + psbt: psbt_base64, + metadata: Some(metadata), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_metadata_creation() { + let mut metadata = PsbtMetadata::new(); + metadata + .set_creator("test-app v1.0") + .set_stage("signing") + .set_counts(2, 3); + + assert_eq!(metadata.creator, Some("test-app v1.0".to_string())); + assert_eq!(metadata.stage, Some("signing".to_string())); + assert_eq!(metadata.num_inputs, Some(2)); + assert_eq!(metadata.num_outputs, Some(3)); + } + + #[test] + fn test_metadata_serialization() { + let mut metadata = PsbtMetadata::with_description("Test PSBT"); + metadata.set_creator("rust-impl"); + metadata.update_timestamps(); + + let json = serde_json::to_string_pretty(&metadata).unwrap(); + let deserialized: PsbtMetadata = serde_json::from_str(&json).unwrap(); + + assert_eq!(metadata.description, deserialized.description); + assert_eq!(metadata.creator, deserialized.creator); + } + + #[test] + fn test_psbt_file() { + let psbt_base64 = "cHNidP8="; // "psbt" in base64 + let mut metadata = PsbtMetadata::new(); + metadata.set_creator("test"); + + let file = PsbtFile::with_metadata(psbt_base64.to_string(), metadata); + + let json = serde_json::to_string(&file).unwrap(); + let deserialized: PsbtFile = serde_json::from_str(&json).unwrap(); + + assert_eq!(file.psbt, deserialized.psbt); + } +} diff --git a/spdk-core/src/psbt/io/mod.rs b/spdk-core/src/psbt/io/mod.rs new file mode 100644 index 0000000..f29faea --- /dev/null +++ b/spdk-core/src/psbt/io/mod.rs @@ -0,0 +1,11 @@ +//! BIP-375 I/O Library +//! +//! Provides file I/O operations for PSBTs with optional JSON metadata. + +pub mod error; +pub mod file_io; +pub mod metadata; + +pub use error::{IoError, Result}; +pub use file_io::*; +pub use metadata::*; diff --git a/spdk-core/src/psbt/mod.rs b/spdk-core/src/psbt/mod.rs new file mode 100644 index 0000000..f5e661f --- /dev/null +++ b/spdk-core/src/psbt/mod.rs @@ -0,0 +1,23 @@ +//! BIP-375 PSBT Module +//! +//! This module contains all BIP-375 PSBT functionality, organized into submodules: +//! - `core`: Core data structures and types +//! - `crypto`: Cryptographic primitives +//! - `io`: File I/O operations +//! - `helpers`: Helper utilities for display and wallet operations +//! - `roles`: PSBT role implementations (creator, constructor, updater, signer, etc.) + +pub mod core; +pub mod crypto; +pub mod helpers; +pub mod io; +pub mod roles; + +// Re-export commonly used types from core +pub use core::{ + aggregate_ecdh_shares, get_input_bip32_pubkeys, get_input_outpoint, + get_input_outpoint_bytes, get_input_pubkey, get_input_txid, get_input_vout, + AggregatedShare, AggregatedShares, Bip375PsbtExt, EcdhShareData, + Error, PsbtInput, PsbtOutput, Result, SilentPaymentPsbt, +}; + diff --git a/spdk-core/src/psbt/roles/constructor.rs b/spdk-core/src/psbt/roles/constructor.rs new file mode 100644 index 0000000..2ea8ef7 --- /dev/null +++ b/spdk-core/src/psbt/roles/constructor.rs @@ -0,0 +1,177 @@ +//! PSBT Constructor Role +//! +//! Adds inputs and outputs to the PSBT. + +use crate::psbt::core::{Bip375PsbtExt, Error, PsbtInput, PsbtOutput, Result, SilentPaymentPsbt}; + +/// Add inputs to the PSBT +pub fn add_inputs(psbt: &mut SilentPaymentPsbt, inputs: &[PsbtInput]) -> Result<()> { + if psbt.num_inputs() != inputs.len() { + return Err(Error::Other(format!( + "PSBT has {} input slots but {} inputs provided", + psbt.num_inputs(), + inputs.len() + ))); + } + + for (i, input) in inputs.iter().enumerate() { + let psbt_input = &mut psbt.inputs[i]; + + psbt_input.previous_txid = input.outpoint.txid; + psbt_input.spent_output_index = input.outpoint.vout; + psbt_input.sequence = Some(input.sequence); + + // Add PSBT_IN_WITNESS_UTXO (required for SegWit inputs) + let witness_utxo = psbt_v2::bitcoin::TxOut { + value: psbt_v2::bitcoin::Amount::from_sat(input.witness_utxo.value.to_sat()), + script_pubkey: psbt_v2::bitcoin::ScriptBuf::from( + input.witness_utxo.script_pubkey.to_bytes(), + ), + }; + + psbt_input.witness_utxo = Some(witness_utxo); + psbt_input.final_script_witness = None; // Clear any existing witness + + // For P2TR inputs, we should set the tap_internal_key if possible. + // In the absence of separate internal key info in PsbtInput, we assume for this + // demo/constructor that the key in the script is what we want to track. + if input.witness_utxo.script_pubkey.is_p2tr() { + // P2TR script: OP_1 <32-byte x-only pubkey> + if input.witness_utxo.script_pubkey.len() == 34 + && input.witness_utxo.script_pubkey.as_bytes()[0] == 0x51 + { + if let Ok(x_only) = bitcoin::key::XOnlyPublicKey::from_slice( + &input.witness_utxo.script_pubkey.as_bytes()[2..34], + ) { + psbt_input.tap_internal_key = Some(x_only); + } + } + } + } + + Ok(()) +} + +/// Add outputs to the PSBT +pub fn add_outputs(psbt: &mut SilentPaymentPsbt, outputs: &[PsbtOutput]) -> Result<()> { + for (i, output) in outputs.iter().enumerate() { + match output { + PsbtOutput::Regular(txout) => { + let psbt_output = &mut psbt.outputs[i]; + // Convert between potentially different bitcoin::Amount types + psbt_output.amount = psbt_v2::bitcoin::Amount::from_sat(txout.value.to_sat()); + psbt_output.script_pubkey = txout.script_pubkey.clone(); + } + PsbtOutput::SilentPayment { amount, address, label } => { + let psbt_output = &mut psbt.outputs[i]; + // Convert between potentially different bitcoin::Amount types + psbt_output.amount = psbt_v2::bitcoin::Amount::from_sat(amount.to_sat()); + // Note: SilentPayment outputs will have empty script_pubkey here, + // which is computed during finalization + + // Set BIP-375 fields + psbt.set_output_sp_info_v0(i, address)?; + + if let Some(label) = label { + psbt.set_output_sp_label(i, *label)?; + } + } + } + } + + Ok(()) +} + +/// Add both inputs and outputs to the PSBT (convenience function) +pub fn construct_psbt( + psbt: &mut SilentPaymentPsbt, + inputs: &[PsbtInput], + outputs: &[PsbtOutput], +) -> Result<()> { + add_inputs(psbt, inputs)?; + add_outputs(psbt, outputs)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::psbt::roles::creator::create_psbt; + use bitcoin::{hashes::Hash, Amount, OutPoint, ScriptBuf, Sequence, TxOut, Txid}; + + #[test] + fn test_add_inputs() { + let mut psbt = create_psbt(2, 1); + + let inputs = vec![ + PsbtInput::new( + OutPoint::new(Txid::all_zeros(), 0), + TxOut { + value: Amount::from_sat(50000), + script_pubkey: ScriptBuf::new(), + }, + Sequence::MAX, + None, + ), + PsbtInput::new( + OutPoint::new(Txid::all_zeros(), 1), + TxOut { + value: Amount::from_sat(30000), + script_pubkey: ScriptBuf::new(), + }, + Sequence::MAX, + None, + ), + ]; + + add_inputs(&mut psbt, &inputs).unwrap(); + + // Verify inputs were added + assert_eq!(psbt.inputs[0].previous_txid, inputs[0].outpoint.txid); + assert_eq!(psbt.inputs[0].spent_output_index, inputs[0].outpoint.vout); + assert_eq!(psbt.inputs[1].previous_txid, inputs[1].outpoint.txid); + } + + #[test] + fn test_add_outputs() { + let mut psbt = create_psbt(1, 2); + + let outputs = vec![ + PsbtOutput::regular(Amount::from_sat(40000), ScriptBuf::new()), + PsbtOutput::regular(Amount::from_sat(10000), ScriptBuf::new()), + ]; + + add_outputs(&mut psbt, &outputs).unwrap(); + + // Verify outputs were added + assert_eq!(psbt.outputs[0].amount, Amount::from_sat(40000)); + assert!(psbt.outputs[0].script_pubkey.len() == 0); // Empty script in test + assert_eq!(psbt.outputs[1].amount, Amount::from_sat(10000)); + } + + #[test] + fn test_construct_psbt() { + let mut psbt = create_psbt(1, 1); + + let inputs = vec![PsbtInput::new( + OutPoint::new(Txid::all_zeros(), 0), + TxOut { + value: Amount::from_sat(50000), + script_pubkey: ScriptBuf::new(), + }, + Sequence::MAX, + None, + )]; + + let outputs = vec![PsbtOutput::regular( + Amount::from_sat(49000), + ScriptBuf::new(), + )]; + + construct_psbt(&mut psbt, &inputs, &outputs).unwrap(); + + // Verify both inputs and outputs were added + assert_eq!(psbt.inputs[0].previous_txid, inputs[0].outpoint.txid); + assert_eq!(psbt.outputs[0].amount, Amount::from_sat(49000)); + } +} diff --git a/spdk-core/src/psbt/roles/creator.rs b/spdk-core/src/psbt/roles/creator.rs new file mode 100644 index 0000000..02a6d4d --- /dev/null +++ b/spdk-core/src/psbt/roles/creator.rs @@ -0,0 +1,101 @@ +//! PSBT Creator Role +//! +//! Creates the initial PSBT structure. + +use crate::psbt::core::SilentPaymentPsbt; +use bitcoin::hashes::Hash; +use bitcoin::transaction::Version; +use bitcoin::Txid; +use psbt_v2::v2::{Global, Psbt as PsbtV2}; + +/// Create a new PSBT with specified number of inputs and outputs +pub fn create_psbt(num_inputs: usize, num_outputs: usize) -> SilentPaymentPsbt { + // Create inputs + let mut inputs = Vec::with_capacity(num_inputs); + for _ in 0..num_inputs { + inputs.push(psbt_v2::v2::Input { + previous_txid: Txid::all_zeros(), + spent_output_index: 0, + sequence: None, + witness_utxo: None, + partial_sigs: std::collections::BTreeMap::new(), + sighash_type: None, + redeem_script: None, + witness_script: None, + bip32_derivations: std::collections::BTreeMap::new(), + final_script_sig: None, + final_script_witness: None, + ripemd160_preimages: std::collections::BTreeMap::new(), + sha256_preimages: std::collections::BTreeMap::new(), + hash160_preimages: std::collections::BTreeMap::new(), + hash256_preimages: std::collections::BTreeMap::new(), + tap_key_sig: None, + tap_script_sigs: std::collections::BTreeMap::new(), + tap_internal_key: None, + tap_merkle_root: None, + sp_ecdh_shares: std::collections::BTreeMap::new(), + sp_dleq_proofs: std::collections::BTreeMap::new(), + unknowns: std::collections::BTreeMap::new(), + min_time: None, + min_height: None, + non_witness_utxo: None, + tap_scripts: std::collections::BTreeMap::new(), + tap_key_origins: std::collections::BTreeMap::new(), + proprietaries: std::collections::BTreeMap::new(), + }); + } + + // Create outputs + let mut outputs = Vec::with_capacity(num_outputs); + for _ in 0..num_outputs { + outputs.push(psbt_v2::v2::Output { + amount: bitcoin::Amount::ZERO, + script_pubkey: bitcoin::ScriptBuf::new(), + redeem_script: None, + witness_script: None, + bip32_derivations: std::collections::BTreeMap::new(), + tap_internal_key: None, + tap_tree: None, + tap_key_origins: std::collections::BTreeMap::new(), + sp_v0_info: None, + sp_v0_label: None, + unknowns: std::collections::BTreeMap::new(), + proprietaries: std::collections::BTreeMap::new(), + }); + } + + PsbtV2 { + global: Global { + version: psbt_v2::V2, + tx_version: Version(2), + fallback_lock_time: None, + input_count: num_inputs, + output_count: num_outputs, + tx_modifiable_flags: 3, + sp_dleq_proofs: std::collections::BTreeMap::new(), + sp_ecdh_shares: std::collections::BTreeMap::new(), + unknowns: std::collections::BTreeMap::new(), + xpubs: std::collections::BTreeMap::new(), + proprietaries: std::collections::BTreeMap::new(), + }, + inputs, + outputs, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::psbt::core::Bip375PsbtExt; + + #[test] + fn test_create_psbt() { + let psbt = create_psbt(2, 3); + + assert_eq!(psbt.num_inputs(), 2); + assert_eq!(psbt.num_outputs(), 3); + + // Check version field + assert_eq!(psbt.global.version, psbt_v2::V2); + } +} diff --git a/spdk-core/src/psbt/roles/extractor.rs b/spdk-core/src/psbt/roles/extractor.rs new file mode 100644 index 0000000..df5c779 --- /dev/null +++ b/spdk-core/src/psbt/roles/extractor.rs @@ -0,0 +1,326 @@ +//! PSBT Extractor Role +//! +//! Extracts the final Bitcoin transaction from a completed PSBT. +//! +//! Supports both P2WPKH and P2TR witness extraction: +//! - **P2WPKH**: Extracts from `partial_sigs` → witness: `[, ]` +//! - **P2TR**: Extracts from `tap_key_sig` → witness: `[]` +//! +//! After successful extraction, `PSBT_IN_SP_TWEAK` fields are cleaned up to prevent +//! accidental re-use and keep PSBTs cleaner. + +use crate::psbt::core::{Bip375PsbtExt, Error, Result, SilentPaymentPsbt}; +use bitcoin::{ + absolute::LockTime, Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Witness, +}; + +/// Extract the final signed transaction from a PSBT +/// +/// After successful extraction, cleans up `PSBT_IN_SP_TWEAK` fields to prevent +/// accidental re-use and keep PSBTs cleaner. +/// +/// # Note +/// This function takes a mutable reference to allow cleanup of SP tweaks. +pub fn extract_transaction(psbt: &mut SilentPaymentPsbt) -> Result { + let global = &psbt.global; + let version = global.tx_version; + let lock_time = global.fallback_lock_time.unwrap_or(LockTime::ZERO); + + // Extract inputs with witnesses + let mut inputs = Vec::new(); + for input_idx in 0..psbt.num_inputs() { + inputs.push(extract_input(psbt, input_idx)?); + } + + // Extract outputs + let mut outputs = Vec::new(); + for output_idx in 0..psbt.num_outputs() { + outputs.push(extract_output(psbt, output_idx)?); + } + + let tx = Transaction { + version, + lock_time, + input: inputs, + output: outputs, + }; + + // Clean up SP tweaks after successful extraction + // This prevents accidental re-use of tweaks and keeps PSBTs cleaner + for input_idx in 0..psbt.num_inputs() { + if psbt.get_input_sp_tweak(input_idx).is_some() { + psbt.remove_input_sp_tweak(input_idx)?; + } + } + + Ok(tx) +} + +/// Extract a single input from the PSBT +fn extract_input(psbt: &SilentPaymentPsbt, input_idx: usize) -> Result { + let input = psbt + .inputs + .get(input_idx) + .ok_or(Error::InvalidInputIndex(input_idx))?; + + // Build witness from partial signatures + let witness = extract_witness(psbt, input_idx)?; + + Ok(TxIn { + previous_output: OutPoint { + txid: input.previous_txid, + vout: input.spent_output_index, + }, + script_sig: ScriptBuf::new(), // SegWit inputs have empty script_sig + sequence: input.sequence.unwrap_or(Sequence::MAX), + witness, + }) +} + +/// Extract witness data from input +/// +/// Handles both P2WPKH and P2TR witness formats: +/// - **P2TR** (Taproot key path): Check for `tap_key_sig` first +/// - Witness: `[]` (single element, 65 bytes) +/// - **P2WPKH**: Fall back to `partial_sigs` +/// - Witness: `[, ]` (two elements) +fn extract_witness(psbt: &SilentPaymentPsbt, input_idx: usize) -> Result { + let input = psbt + .inputs + .get(input_idx) + .ok_or(Error::InvalidInputIndex(input_idx))?; + + // Check for P2TR tap_key_sig first (Taproot key-path spend) + if let Some(tap_sig) = &input.tap_key_sig { + // P2TR witness has single element: BIP-340 Schnorr signature + let mut witness = Witness::new(); + witness.push(tap_sig.to_vec()); + return Ok(witness); + } + + // Fall back to P2WPKH partial_sigs (ECDSA) + let sigs = psbt.get_input_partial_sigs(input_idx); + + if sigs.is_empty() { + return Err(Error::ExtractionFailed(format!( + "Input {} has no signatures (neither tap_key_sig nor partial_sigs)", + input_idx + ))); + } + + // For P2WPKH, witness is: + // We expect exactly one signature for single-key P2WPKH + if sigs.len() != 1 { + return Err(Error::ExtractionFailed(format!( + "Input {} has {} partial signatures, expected 1 for P2WPKH", + input_idx, + sigs.len() + ))); + } + + let (pubkey, signature) = &sigs[0]; + + // Build P2WPKH witness stack + let mut witness = Witness::new(); + witness.push(signature); + witness.push(pubkey); + + Ok(witness) +} + +/// Extract a single output from the PSBT +fn extract_output(psbt: &SilentPaymentPsbt, output_idx: usize) -> Result { + let output = psbt + .outputs + .get(output_idx) + .ok_or(Error::InvalidOutputIndex(output_idx))?; + + Ok(TxOut { + value: Amount::from_sat(output.amount.to_sat()), + script_pubkey: output.script_pubkey.clone(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::psbt::roles::{ + constructor::{add_inputs, add_outputs}, + creator::create_psbt, + input_finalizer::finalize_inputs, + signer::{add_ecdh_shares_full, sign_inputs}, + }; + use crate::psbt::core::{PsbtInput, PsbtOutput}; + use crate::psbt::crypto::pubkey_to_p2wpkh_script; + use bitcoin::{hashes::Hash, Amount, OutPoint, ScriptBuf, Sequence, TxOut, Txid}; + use secp256k1::{PublicKey, Secp256k1, SecretKey}; + use silentpayments::SilentPaymentAddress; + + #[test] + fn test_extract_transaction_regular_output() { + let secp = Secp256k1::new(); + + // Create PSBT with 2 inputs and 1 regular output + let mut psbt = create_psbt(2, 1); + + // Create inputs with private keys + let privkey1 = SecretKey::from_slice(&[1u8; 32]).unwrap(); + let privkey2 = SecretKey::from_slice(&[2u8; 32]).unwrap(); + let pubkey1 = PublicKey::from_secret_key(&secp, &privkey1); + + // Create P2WPKH script for output + let output_script = pubkey_to_p2wpkh_script(&pubkey1); + + let inputs = vec![ + PsbtInput::new( + OutPoint { + txid: Txid::all_zeros(), + vout: 0, + }, + TxOut { + value: Amount::from_sat(30000), + script_pubkey: pubkey_to_p2wpkh_script(&pubkey1), + }, + Sequence::MAX, + Some(privkey1), + ), + PsbtInput::new( + OutPoint { + txid: Txid::all_zeros(), + vout: 1, + }, + TxOut { + value: Amount::from_sat(30000), + script_pubkey: pubkey_to_p2wpkh_script(&pubkey1), + }, + Sequence::MAX, + Some(privkey2), + ), + ]; + + let outputs = vec![PsbtOutput::regular(Amount::from_sat(55000), output_script)]; + + // Construct PSBT + add_inputs(&mut psbt, &inputs).unwrap(); + add_outputs(&mut psbt, &outputs).unwrap(); + + // Sign inputs + sign_inputs(&secp, &mut psbt, &inputs).unwrap(); + + // Extract transaction + let tx = extract_transaction(&mut psbt).unwrap(); + + // Verify transaction structure + assert_eq!(tx.input.len(), 2); + assert_eq!(tx.output.len(), 1); + assert_eq!(tx.output[0].value, Amount::from_sat(55000)); + + // Verify inputs have witnesses + assert!(!tx.input[0].witness.is_empty()); + assert!(!tx.input[1].witness.is_empty()); + } + + #[test] + fn test_extract_transaction_silent_payment() { + let secp = Secp256k1::new(); + + // Create PSBT with 2 inputs and 1 silent payment output + let mut psbt = create_psbt(2, 1); + + // Create scan and spend keys + let scan_privkey = SecretKey::from_slice(&[10u8; 32]).unwrap(); + let scan_key = PublicKey::from_secret_key(&secp, &scan_privkey); + let spend_privkey = SecretKey::from_slice(&[20u8; 32]).unwrap(); + let spend_key = PublicKey::from_secret_key(&secp, &spend_privkey); + + let sp_address = SilentPaymentAddress::new(scan_key, spend_key, silentpayments::Network::Regtest, 0).unwrap(); + + // Create inputs with private keys + let privkey1 = SecretKey::from_slice(&[1u8; 32]).unwrap(); + let privkey2 = SecretKey::from_slice(&[2u8; 32]).unwrap(); + let pubkey1 = PublicKey::from_secret_key(&secp, &privkey1); + + let inputs = vec![ + PsbtInput::new( + OutPoint::new(Txid::all_zeros(), 0), + TxOut { + value: Amount::from_sat(30000), + script_pubkey: pubkey_to_p2wpkh_script(&pubkey1), + }, + Sequence::MAX, + Some(privkey1), + ), + PsbtInput::new( + OutPoint::new(Txid::all_zeros(), 1), + TxOut { + value: Amount::from_sat(30000), + script_pubkey: pubkey_to_p2wpkh_script(&pubkey1), + }, + Sequence::MAX, + Some(privkey2), + ), + ]; + + let outputs = vec![PsbtOutput::silent_payment( + Amount::from_sat(55000), + sp_address, + None, + )]; + + // Construct PSBT + add_inputs(&mut psbt, &inputs).unwrap(); + add_outputs(&mut psbt, &outputs).unwrap(); + + // Add ECDH shares + add_ecdh_shares_full(&secp, &mut psbt, &inputs, &[scan_key], false).unwrap(); + + // Finalize inputs (compute output scripts) + finalize_inputs(&secp, &mut psbt, None).unwrap(); + + // Sign inputs + sign_inputs(&secp, &mut psbt, &inputs).unwrap(); + + // Extract transaction + let tx = extract_transaction(&mut psbt).unwrap(); + + // Verify transaction structure + assert_eq!(tx.input.len(), 2); + assert_eq!(tx.output.len(), 1); + assert_eq!(tx.output[0].value, Amount::from_sat(55000)); + + // Verify output is P2TR (silent payment outputs are taproot) + assert!(tx.output[0].script_pubkey.is_p2tr()); + + // Verify inputs have witnesses + assert!(!tx.input[0].witness.is_empty()); + assert!(!tx.input[1].witness.is_empty()); + } + + #[test] + fn test_extract_fails_without_signatures() { + let mut psbt = create_psbt(1, 1); + + let inputs = vec![PsbtInput::new( + OutPoint::new(Txid::all_zeros(), 0), + TxOut { + value: Amount::from_sat(30000), + script_pubkey: ScriptBuf::new(), + }, + Sequence::MAX, + None, // No private key = no signature + )]; + + let outputs = vec![PsbtOutput::regular( + Amount::from_sat(29000), + ScriptBuf::new(), + )]; + + add_inputs(&mut psbt, &inputs).unwrap(); + add_outputs(&mut psbt, &outputs).unwrap(); + + // Extraction should fail without signatures + let result = extract_transaction(&mut psbt); + assert!(result.is_err()); + assert!(matches!(result, Err(Error::ExtractionFailed(_)))); + } +} diff --git a/spdk-core/src/psbt/roles/input_finalizer.rs b/spdk-core/src/psbt/roles/input_finalizer.rs new file mode 100644 index 0000000..503055d --- /dev/null +++ b/spdk-core/src/psbt/roles/input_finalizer.rs @@ -0,0 +1,282 @@ +//! PSBT Input Finalizer Role +//! +//! Aggregates ECDH shares and computes final output scripts for silent payments. + +use crate::psbt::core::{aggregate_ecdh_shares, Bip375PsbtExt, Error, Result, SilentPaymentPsbt}; +use crate::psbt::crypto::{ + apply_label_to_spend_key, derive_silent_payment_output_pubkey, pubkey_to_p2tr_script, +}; +use secp256k1::{PublicKey, Secp256k1, SecretKey}; +use std::collections::HashMap; + +/// Finalize inputs by computing output scripts from ECDH shares +pub fn finalize_inputs( + secp: &Secp256k1, + psbt: &mut SilentPaymentPsbt, + scan_privkeys: Option<&HashMap>, +) -> Result<()> { + // Aggregate ECDH shares by scan key (detects global vs per-input automatically) + let aggregated_shares = aggregate_ecdh_shares(psbt)?; + + // Track output index per scan key (for BIP 352 k parameter) + let mut scan_key_output_indices: HashMap = HashMap::new(); + + // Process each output + for output_idx in 0..psbt.num_outputs() { + // Check if this is a silent payment output + let (scan_key, spend_key) = match psbt.get_output_sp_info_v0(output_idx) { + Some(keys) => keys, + None => continue, // Not a silent payment output, skip + }; + + // Get the aggregated share for this scan key + let aggregated = aggregated_shares + .get(&scan_key) + .ok_or(Error::IncompleteEcdhCoverage(output_idx))?; + + // Verify all inputs contributed shares (unless it's a global share) + if !aggregated.is_global && aggregated.num_inputs != psbt.num_inputs() { + return Err(Error::IncompleteEcdhCoverage(output_idx)); + } + + let aggregated_share = aggregated.aggregated_share; + + // Check for label and apply if present and we have scan private key + let mut spend_key_to_use = spend_key.clone(); + + if let Some(label) = psbt.get_output_sp_label(output_idx) { + // If we have the scan private key, apply the label tweak to spend key + if let Some(privkeys) = scan_privkeys { + if let Some(scan_privkey) = privkeys.get(&scan_key) { + spend_key_to_use = + apply_label_to_spend_key(secp, &spend_key, scan_privkey, label) + .map_err(|e| { + Error::Other(format!("Failed to apply label tweak: {}", e)) + })?; + } + } + } + + // Get or initialize the output index for this scan key + let k = *scan_key_output_indices + .get(&scan_key) + .unwrap_or(&0); + + // Derive the output public key using BIP-352 + let ecdh_secret = aggregated_share.serialize(); + let output_pubkey = derive_silent_payment_output_pubkey( + secp, + &spend_key_to_use, // Use labeled spend key if label was applied + &ecdh_secret, + k, // Use per-scan-key index + ) + .map_err(|e| Error::Other(format!("Output derivation failed: {}", e)))?; + + // Create P2TR output script + let output_script = pubkey_to_p2tr_script(&output_pubkey); + + // Add output script to PSBT + psbt.outputs[output_idx].script_pubkey = output_script; + + // Increment the output index for this scan key + scan_key_output_indices.insert(scan_key, k + 1); + } + + // BIP-370: Clear tx_modifiable_flags after finalizing outputs + // Once output scripts are computed, the transaction structure is locked + psbt.global.tx_modifiable_flags = 0x00; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::psbt::roles::{constructor::add_outputs, creator::create_psbt, signer::add_ecdh_shares_full}; + use crate::psbt::core::{PsbtInput, PsbtOutput}; + use bitcoin::hashes::Hash; + use bitcoin::{Amount, OutPoint, ScriptBuf, Sequence, TxOut, Txid}; + use secp256k1::SecretKey; + use silentpayments::{SilentPaymentAddress, Network as SpNetwork}; + + #[test] + fn test_finalize_inputs_basic() { + let secp = Secp256k1::new(); + + // Create PSBT with 2 inputs and 1 silent payment output + let mut psbt = create_psbt(2, 1); + + // Create scan and spend keys + let scan_privkey = SecretKey::from_slice(&[10u8; 32]).unwrap(); + let scan_key = PublicKey::from_secret_key(&secp, &scan_privkey); + let spend_privkey = SecretKey::from_slice(&[20u8; 32]).unwrap(); + let spend_key = PublicKey::from_secret_key(&secp, &spend_privkey); + + let sp_address = SilentPaymentAddress::new(scan_key, spend_key, SpNetwork::Regtest, 0).unwrap(); + + // Add output + let outputs = vec![PsbtOutput::silent_payment( + Amount::from_sat(50000), + sp_address, + None + )]; + add_outputs(&mut psbt, &outputs).unwrap(); + + // Create inputs with private keys + let privkey1 = SecretKey::from_slice(&[1u8; 32]).unwrap(); + let privkey2 = SecretKey::from_slice(&[2u8; 32]).unwrap(); + + let inputs = vec![ + PsbtInput::new( + OutPoint { + txid: Txid::all_zeros(), + vout: 0, + }, + TxOut { + value: Amount::from_sat(30000), + script_pubkey: ScriptBuf::new(), + }, + Sequence::MAX, + Some(privkey1), + ), + PsbtInput::new( + OutPoint { + txid: Txid::all_zeros(), + vout: 1, + }, + TxOut { + value: Amount::from_sat(30000), + script_pubkey: ScriptBuf::new(), + }, + Sequence::MAX, + Some(privkey2), + ), + ]; + + // Add ECDH shares + add_ecdh_shares_full(&secp, &mut psbt, &inputs, &[scan_key], false).unwrap(); + + // Finalize inputs (compute output scripts) + finalize_inputs(&secp, &mut psbt, None).unwrap(); + + // Verify output script was added + let script = &psbt.outputs[0].script_pubkey; + assert!(!script.is_empty()); + + // P2TR scripts are 34 bytes: OP_1 + 32-byte x-only pubkey + assert_eq!(script.len(), 34); + assert!(script.is_p2tr()); + } + + #[test] + fn test_incomplete_ecdh_coverage() { + let secp = Secp256k1::new(); + + // Create PSBT with 2 inputs and 1 silent payment output + let mut psbt = create_psbt(2, 1); + + // Create scan and spend keys + let scan_privkey = SecretKey::from_slice(&[10u8; 32]).unwrap(); + let scan_key = PublicKey::from_secret_key(&secp, &scan_privkey); + let spend_privkey = SecretKey::from_slice(&[20u8; 32]).unwrap(); + let spend_key = PublicKey::from_secret_key(&secp, &spend_privkey); + + let sp_address = SilentPaymentAddress::new(scan_key, spend_key, SpNetwork::Regtest, 0).unwrap(); + + // Add output + let outputs = vec![PsbtOutput::silent_payment( + Amount::from_sat(50000), + sp_address, + None, + )]; + add_outputs(&mut psbt, &outputs).unwrap(); + + // Only add ECDH share for one input (incomplete) + let privkey1 = SecretKey::from_slice(&[1u8; 32]).unwrap(); + let inputs = vec![PsbtInput::new( + OutPoint::new(Txid::all_zeros(), 0), + TxOut { + value: Amount::from_sat(30000), + script_pubkey: ScriptBuf::new(), + }, + Sequence::MAX, + Some(privkey1), + )]; + + // Use partial signing to only add share for input 0 + use crate::psbt::roles::signer::add_ecdh_shares_partial; + add_ecdh_shares_partial(&secp, &mut psbt, &inputs, &[scan_key], &[0], false).unwrap(); + + // Finalize should fail due to incomplete coverage + let result = finalize_inputs(&secp, &mut psbt, None); + assert!(result.is_err()); + assert!(matches!(result, Err(Error::IncompleteEcdhCoverage(0)))); + } + + #[test] + fn test_tx_modifiable_flags_cleared_after_finalization() { + let secp = Secp256k1::new(); + + // Create PSBT with 2 inputs and 1 silent payment output + let mut psbt = create_psbt(2, 1); + + // Verify initial tx_modifiable_flags is non-zero + assert_ne!( + psbt.global.tx_modifiable_flags, 0x00, + "Initial flags should be non-zero" + ); + + // Create scan and spend keys + let scan_privkey = SecretKey::from_slice(&[10u8; 32]).unwrap(); + let scan_key = PublicKey::from_secret_key(&secp, &scan_privkey); + let spend_privkey = SecretKey::from_slice(&[20u8; 32]).unwrap(); + let spend_key = PublicKey::from_secret_key(&secp, &spend_privkey); + + let sp_address = SilentPaymentAddress::new(scan_key, spend_key, SpNetwork::Regtest, 0).unwrap(); + + // Add output + let outputs = vec![PsbtOutput::silent_payment( + Amount::from_sat(50000), + sp_address, + None + )]; + add_outputs(&mut psbt, &outputs).unwrap(); + + // Create inputs with private keys + let privkey1 = SecretKey::from_slice(&[1u8; 32]).unwrap(); + let privkey2 = SecretKey::from_slice(&[2u8; 32]).unwrap(); + + let inputs = vec![ + PsbtInput::new( + OutPoint::new(Txid::all_zeros(), 0), + TxOut { + value: Amount::from_sat(30000), + script_pubkey: ScriptBuf::new(), + }, + Sequence::MAX, + Some(privkey1), + ), + PsbtInput::new( + OutPoint::new(Txid::all_zeros(), 1), + TxOut { + value: Amount::from_sat(30000), + script_pubkey: ScriptBuf::new(), + }, + Sequence::MAX, + Some(privkey2), + ), + ]; + + // Add ECDH shares + add_ecdh_shares_full(&secp, &mut psbt, &inputs, &[scan_key], false).unwrap(); + + // Finalize inputs (compute output scripts) + finalize_inputs(&secp, &mut psbt, None).unwrap(); + + // Verify tx_modifiable_flags is cleared after finalization + assert_eq!( + psbt.global.tx_modifiable_flags, 0x00, + "tx_modifiable_flags should be 0x00 after finalization (BIP-370)" + ); + } +} diff --git a/spdk-core/src/psbt/roles/mod.rs b/spdk-core/src/psbt/roles/mod.rs new file mode 100644 index 0000000..226a906 --- /dev/null +++ b/spdk-core/src/psbt/roles/mod.rs @@ -0,0 +1,33 @@ +//! BIP-375 PSBT Roles +//! +//! Implements the six PSBT roles defined in BIP-174/370/375: +//! - Creator +//! - Constructor +//! - Updater +//! - Signer +//! - Input Finalizer +//! - Extractor +//! +//! ## TODO: Future Enhancements +//! +//! - **Combiner role**: For async multi-party signing workflows +//! - Current examples use sequential signing (hardware-signer, multi-signer) +//! - Future enhancement: Merge PSBTs from concurrent signers +//! - Would handle union of ECDH shares, DLEQ proofs, and signatures +//! - Conflict detection for same-field different-value scenarios + +pub mod constructor; +pub mod creator; +pub mod extractor; +pub mod input_finalizer; +pub mod signer; +pub mod updater; +pub mod validation; + +pub use constructor::*; +pub use creator::*; +pub use extractor::*; +pub use input_finalizer::*; +pub use signer::*; +pub use updater::*; +pub use validation::*; diff --git a/spdk-core/src/psbt/roles/signer.rs b/spdk-core/src/psbt/roles/signer.rs new file mode 100644 index 0000000..f86f3cb --- /dev/null +++ b/spdk-core/src/psbt/roles/signer.rs @@ -0,0 +1,418 @@ +//! PSBT Signer Role +//! +//! Adds ECDH shares and signatures to the PSBT. +//! +//! This module handles both regular P2WPKH signing and Silent Payment P2TR signing: +//! - **P2WPKH inputs**: Use [`sign_inputs()`] with ECDSA signatures → `partial_sigs` +//! - **P2TR SP inputs**: Use [`sign_sp_inputs()`] with tweaked key + Schnorr → `tap_key_sig` +//! - **Mixed transactions**: Call both functions as needed for different input types + +use crate::psbt::core::{Bip375PsbtExt, EcdhShareData, Error, PsbtInput, Result, SilentPaymentPsbt}; +use crate::psbt::crypto::{ + apply_tweak_to_privkey, compute_ecdh_share, dleq_generate_proof, sign_p2pkh_input, + sign_p2tr_input, sign_p2wpkh_input, +}; +use bitcoin::ScriptBuf; +use secp256k1::{PublicKey, Secp256k1, SecretKey}; +use std::collections::HashSet; + +/// Add ECDH shares for all inputs (full signing) +pub fn add_ecdh_shares_full( + secp: &Secp256k1, + psbt: &mut SilentPaymentPsbt, + inputs: &[PsbtInput], + scan_keys: &[PublicKey], + include_dleq: bool, +) -> Result<()> { + for (input_idx, input) in inputs.iter().enumerate() { + let Some(ref privkey) = input.private_key else { + return Err(Error::Other(format!( + "Input {} missing private key", + input_idx + ))); + }; + + for scan_key in scan_keys { + let share_point = compute_ecdh_share(secp, privkey, scan_key) + .map_err(|e| Error::Other(format!("ECDH computation failed: {}", e)))?; + + let dleq_proof = if include_dleq { + let rand_aux = [input_idx as u8; 32]; + Some( + dleq_generate_proof(secp, privkey, scan_key, &rand_aux, None) + .map_err(|e| Error::Other(format!("DLEQ generation failed: {}", e)))?, + ) + } else { + None + }; + + let ecdh_share = EcdhShareData::new(*scan_key, share_point, dleq_proof); + psbt.add_input_ecdh_share(input_idx, &ecdh_share)?; + } + } + Ok(()) +} + +pub fn add_ecdh_shares_partial( + secp: &Secp256k1, + psbt: &mut SilentPaymentPsbt, + inputs: &[PsbtInput], + scan_keys: &[PublicKey], + controlled_indices: &[usize], + include_dleq: bool, +) -> Result<()> { + let controlled_set: HashSet = controlled_indices.iter().copied().collect(); + + for (input_idx, input) in inputs.iter().enumerate() { + if !controlled_set.contains(&input_idx) { + continue; + } + + let Some(ref base_privkey) = input.private_key else { + return Err(Error::Other(format!( + "Controlled input {} missing private key", + input_idx + ))); + }; + + // Check for Silent Payment tweak in PSBT and apply if present + // This ensures DLEQ proofs match the on-chain tweaked public key + let mut privkey = if let Some(tweak) = psbt.get_input_sp_tweak(input_idx) { + apply_tweak_to_privkey(base_privkey, &tweak) + .map_err(|e| Error::Other(format!("Tweak application failed: {}", e)))? + } else { + *base_privkey + }; + + // For P2TR inputs, the public key is x-only (implicitly even Y). + // If our private key produces an odd Y point, we must negate it + // to match the on-chain public key for DLEQ verification. + if input.witness_utxo.script_pubkey.is_p2tr() { + let keypair = secp256k1::Keypair::from_secret_key(secp, &privkey); + let (_, parity) = keypair.x_only_public_key(); + if parity == secp256k1::Parity::Odd { + privkey = privkey.negate(); + } + } + + for scan_key in scan_keys { + let share_point = compute_ecdh_share(secp, &privkey, scan_key) + .map_err(|e| Error::Other(format!("ECDH computation failed: {}", e)))?; + + let dleq_proof = if include_dleq { + let rand_aux = [input_idx as u8; 32]; + Some( + dleq_generate_proof(secp, &privkey, scan_key, &rand_aux, None) + .map_err(|e| Error::Other(format!("DLEQ generation failed: {}", e)))?, + ) + } else { + None + }; + + let ecdh_share = EcdhShareData::new(*scan_key, share_point, dleq_proof); + psbt.add_input_ecdh_share(input_idx, &ecdh_share)?; + } + } + Ok(()) +} + +/// Sign inputs based on their script type (P2PKH, P2WPKH, P2TR) +/// +/// This function automatically detects the input type and applies the correct signing logic: +/// - **P2PKH**: Signs with ECDSA (legacy) +/// - **P2WPKH**: Signs with ECDSA (SegWit v0) +/// - **P2TR**: Signs with Schnorr (Taproot v1). Checks for Silent Payment tweaks (`PSBT_IN_SP_TWEAK`) +/// and applies them to the private key if present. +pub fn sign_inputs( + secp: &Secp256k1, + psbt: &mut SilentPaymentPsbt, + inputs: &[PsbtInput], +) -> Result<()> { + let tx = extract_tx_for_signing(psbt)?; + let mut prevouts: Option> = None; // Lazy loaded for P2TR + + for (input_idx, input) in inputs.iter().enumerate() { + let Some(ref privkey) = input.private_key else { + continue; + }; + + if input.witness_utxo.script_pubkey.is_p2pkh() { + let signature = sign_p2pkh_input( + secp, + &tx, + input_idx, + &input.witness_utxo.script_pubkey, + input.witness_utxo.value, // Not needed for legacy but passed + privkey, + ) + .map_err(|e| Error::Other(format!("P2PKH signing failed: {}", e)))?; + + let pubkey = PublicKey::from_secret_key(secp, privkey); + let bitcoin_pubkey = bitcoin::PublicKey::new(pubkey); + + let sig = bitcoin::ecdsa::Signature::from_slice(&signature) + .map_err(|e| Error::Other(format!("Invalid signature DER: {}", e)))?; + + psbt.inputs[input_idx] + .partial_sigs + .insert(bitcoin_pubkey, sig); + } else if input.witness_utxo.script_pubkey.is_p2wpkh() { + let signature = sign_p2wpkh_input( + secp, + &tx, + input_idx, + &input.witness_utxo.script_pubkey, + input.witness_utxo.value, + privkey, + ) + .map_err(|e| Error::Other(format!("P2WPKH signing failed: {}", e)))?; + + let pubkey = PublicKey::from_secret_key(secp, privkey); + let bitcoin_pubkey = bitcoin::PublicKey::new(pubkey); + + let sig = bitcoin::ecdsa::Signature::from_slice(&signature) + .map_err(|e| Error::Other(format!("Invalid signature DER: {}", e)))?; + + psbt.inputs[input_idx] + .partial_sigs + .insert(bitcoin_pubkey, sig); + } else if input.witness_utxo.script_pubkey.is_p2tr() { + // Load prevouts if not already loaded (needed for Taproot sighash) + if prevouts.is_none() { + prevouts = Some( + psbt.inputs + .iter() + .enumerate() + .map(|(idx, input)| { + input.witness_utxo.clone().ok_or(Error::Other(format!( + "Input {} missing witness_utxo (required for P2TR)", + idx + ))) + }) + .collect::>>()?, + ); + } + + // Check for SP tweak + let tweak = psbt.get_input_sp_tweak(input_idx); + let signing_key = if let Some(tweak) = tweak { + apply_tweak_to_privkey(privkey, &tweak) + .map_err(|e| Error::Other(format!("Tweak application failed: {}", e)))? + } else { + *privkey + }; + + // Sign with BIP-340 Schnorr + let signature = sign_p2tr_input( + secp, + &tx, + input_idx, + prevouts.as_ref().unwrap(), + &signing_key, + ) + .map_err(|e| Error::Other(format!("Schnorr signing failed: {}", e)))?; + + // Add tap_key_sig to PSBT + psbt.inputs[input_idx].tap_key_sig = Some(signature); + } + } + Ok(()) +} + +/// Sign Silent Payment P2TR inputs using tweaked private keys +/// +/// For each input with `PSBT_IN_SP_TWEAK` (0x1f): +/// 1. Apply tweak: `tweaked_privkey = spend_privkey + tweak` +/// 2. Sign with BIP-340 Schnorr signature +/// 3. Add `tap_key_sig` to PSBT input +/// +/// The tweak is derived from BIP-352 output derivation during wallet scanning. +/// This allows spending silent payment outputs without revealing the connection +/// between the scan key and spend key. +/// +/// # Arguments +/// * `secp` - Secp256k1 context +/// * `psbt` - PSBT to sign +/// * `spend_privkey` - The spend private key (before tweaking) +/// * `input_indices` - Indices of inputs to sign +/// +/// # Witness Format +/// Creates P2TR key-path witness: `[]` (65 bytes: 64-byte sig + sighash byte) +/// +/// See also: [`sign_inputs()`] for P2WPKH signing +pub fn sign_sp_inputs( + secp: &Secp256k1, + psbt: &mut SilentPaymentPsbt, + spend_privkey: &SecretKey, + input_indices: &[usize], +) -> Result<()> { + // Build prevouts array for Taproot sighash + let prevouts: Vec = psbt + .inputs + .iter() + .enumerate() + .map(|(idx, input)| { + input + .witness_utxo + .clone() + .ok_or(Error::Other(format!("Input {} missing witness_utxo", idx))) + }) + .collect::>>()?; + + // Build unsigned transaction for signing + let tx = extract_tx_for_signing(psbt)?; + + // Sign each input with tweak + for &input_idx in input_indices { + let Some(tweak) = psbt.get_input_sp_tweak(input_idx) else { + continue; // Not a silent payment input, skip + }; + + // Apply tweak to spend key: tweaked_privkey = spend_privkey + tweak + let tweaked_privkey = apply_tweak_to_privkey(spend_privkey, &tweak) + .map_err(|e| Error::Other(format!("Tweak application failed: {}", e)))?; + + // Sign with BIP-340 Schnorr + let signature = sign_p2tr_input(secp, &tx, input_idx, &prevouts, &tweaked_privkey) + .map_err(|e| Error::Other(format!("Schnorr signing failed: {}", e)))?; + + // Add tap_key_sig to PSBT + psbt.inputs[input_idx].tap_key_sig = Some(signature); + } + + Ok(()) +} + +/// Extract transaction data needed for signing +fn extract_tx_for_signing(psbt: &SilentPaymentPsbt) -> Result { + use bitcoin::{absolute::LockTime, OutPoint, Sequence, Transaction, TxIn, TxOut}; + + let global = &psbt.global; + let version = global.tx_version; // Already Version type + let lock_time = global.fallback_lock_time.unwrap_or(LockTime::ZERO); + + let mut inputs = Vec::new(); + for input in &psbt.inputs { + inputs.push(TxIn { + previous_output: OutPoint { + txid: input.previous_txid, + vout: input.spent_output_index, + }, + script_sig: ScriptBuf::new(), + sequence: input.sequence.unwrap_or(Sequence::MAX), + witness: bitcoin::Witness::new(), + }); + } + + let mut outputs = Vec::new(); + for output in &psbt.outputs { + outputs.push(TxOut { + value: output.amount, // Already Amount type + script_pubkey: output.script_pubkey.clone(), + }); + } + + Ok(Transaction { + version, + lock_time, + input: inputs, + output: outputs, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::psbt::roles::{constructor::add_inputs, creator::create_psbt}; + use crate::psbt::core::PsbtInput; + use bitcoin::{hashes::Hash, Amount, OutPoint, ScriptBuf, Sequence, TxOut, Txid}; + use secp256k1::SecretKey; + + #[test] + fn test_add_ecdh_shares_full() { + let secp = Secp256k1::new(); + let mut psbt = create_psbt(2, 1); + + let privkey1 = SecretKey::from_slice(&[1u8; 32]).unwrap(); + let privkey2 = SecretKey::from_slice(&[2u8; 32]).unwrap(); + let scan_privkey = SecretKey::from_slice(&[3u8; 32]).unwrap(); + let scan_key = PublicKey::from_secret_key(&secp, &scan_privkey); + + let inputs = vec![ + PsbtInput::new( + OutPoint::new(Txid::all_zeros(), 0), + TxOut { + value: Amount::from_sat(50000), + script_pubkey: ScriptBuf::new(), + }, + Sequence::MAX, + Some(privkey1), + ), + PsbtInput::new( + OutPoint::new(Txid::all_zeros(), 1), + TxOut { + value: Amount::from_sat(30000), + script_pubkey: ScriptBuf::new(), + }, + Sequence::MAX, + Some(privkey2), + ), + ]; + + add_inputs(&mut psbt, &inputs).unwrap(); + add_ecdh_shares_full(&secp, &mut psbt, &inputs, &[scan_key], true).unwrap(); + + // Verify ECDH shares were added + let shares0 = psbt.get_input_ecdh_shares(0); + assert_eq!(shares0.len(), 1); + assert_eq!(shares0[0].scan_key, scan_key); + + let shares1 = psbt.get_input_ecdh_shares(1); + assert_eq!(shares1.len(), 1); + } + + #[test] + fn test_add_ecdh_shares_partial() { + let secp = Secp256k1::new(); + let mut psbt = create_psbt(2, 1); + + let privkey1 = SecretKey::from_slice(&[1u8; 32]).unwrap(); + let privkey2 = SecretKey::from_slice(&[2u8; 32]).unwrap(); + let scan_privkey = SecretKey::from_slice(&[3u8; 32]).unwrap(); + let scan_key = PublicKey::from_secret_key(&secp, &scan_privkey); + + let inputs = vec![ + PsbtInput::new( + OutPoint::new(Txid::all_zeros(), 0), + TxOut { + value: Amount::from_sat(50000), + script_pubkey: ScriptBuf::new(), + }, + Sequence::MAX, + Some(privkey1), + ), + PsbtInput::new( + OutPoint::new(Txid::all_zeros(), 1), + TxOut { + value: Amount::from_sat(30000), + script_pubkey: ScriptBuf::new(), + }, + Sequence::MAX, + Some(privkey2), + ), + ]; + + add_inputs(&mut psbt, &inputs).unwrap(); + + // Only sign input 0 + add_ecdh_shares_partial(&secp, &mut psbt, &inputs, &[scan_key], &[0], false).unwrap(); + + // Input 0 should have shares + let shares0 = psbt.get_input_ecdh_shares(0); + assert_eq!(shares0.len(), 1); + + // Input 1 should not have shares + let shares1 = psbt.get_input_ecdh_shares(1); + assert_eq!(shares1.len(), 0); + } +} diff --git a/spdk-core/src/psbt/roles/updater.rs b/spdk-core/src/psbt/roles/updater.rs new file mode 100644 index 0000000..f558a53 --- /dev/null +++ b/spdk-core/src/psbt/roles/updater.rs @@ -0,0 +1,131 @@ +//! PSBT Updater Role +//! +//! Adds additional information like BIP32 derivation paths. + +use crate::psbt::core::{Error, Result, SilentPaymentPsbt}; +use bitcoin::bip32::{ChildNumber, DerivationPath, Fingerprint}; +use bitcoin::EcdsaSighashType; +use psbt_v2::PsbtSighashType; + +/// BIP32 derivation information +pub struct Bip32Derivation { + /// Master fingerprint (4 bytes) + pub master_fingerprint: [u8; 4], + /// Derivation path + pub path: Vec, +} + +impl Bip32Derivation { + /// Create a new BIP32 derivation + pub fn new(master_fingerprint: [u8; 4], path: Vec) -> Self { + Self { + master_fingerprint, + path, + } + } +} + +/// Add BIP32 derivation information for an input +pub fn add_input_bip32_derivation( + psbt: &mut SilentPaymentPsbt, + input_index: usize, + pubkey: &secp256k1::PublicKey, + derivation: &Bip32Derivation, +) -> Result<()> { + let input = psbt + .inputs + .get_mut(input_index) + .ok_or(Error::InvalidInputIndex(input_index))?; + + let fingerprint = Fingerprint::from(derivation.master_fingerprint); + let path: DerivationPath = derivation + .path + .iter() + .map(|&i| ChildNumber::from(i)) + .collect(); + + input.bip32_derivations.insert(*pubkey, (fingerprint, path)); + + Ok(()) +} + +/// Add BIP32 derivation information for an output +pub fn add_output_bip32_derivation( + psbt: &mut SilentPaymentPsbt, + output_index: usize, + pubkey: &secp256k1::PublicKey, + derivation: &Bip32Derivation, +) -> Result<()> { + let output = psbt + .outputs + .get_mut(output_index) + .ok_or(Error::InvalidOutputIndex(output_index))?; + + let fingerprint = Fingerprint::from(derivation.master_fingerprint); + let path: DerivationPath = derivation + .path + .iter() + .map(|&i| ChildNumber::from(i)) + .collect(); + + output + .bip32_derivations + .insert(*pubkey, (fingerprint, path)); + + Ok(()) +} + +/// Add sighash type for an input +pub fn add_input_sighash_type( + psbt: &mut SilentPaymentPsbt, + input_index: usize, + sighash_type: u32, +) -> Result<()> { + let input = psbt + .inputs + .get_mut(input_index) + .ok_or(Error::InvalidInputIndex(input_index))?; + + let sighash = EcdsaSighashType::from_consensus(sighash_type); + input.sighash_type = Some(PsbtSighashType::from(sighash)); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::psbt::roles::creator::create_psbt; + use secp256k1::{Secp256k1, SecretKey}; + + #[test] + fn test_add_input_bip32_derivation() { + let mut psbt = create_psbt(1, 1); + let secp = Secp256k1::new(); + let privkey = SecretKey::from_slice(&[1u8; 32]).unwrap(); + let pubkey = secp256k1::PublicKey::from_secret_key(&secp, &privkey); + + let derivation = Bip32Derivation::new([0xAA, 0xBB, 0xCC, 0xDD], vec![0x8000002C]); + + add_input_bip32_derivation(&mut psbt, 0, &pubkey, &derivation).unwrap(); + + // Verify derivation was added + let input = &psbt.inputs[0]; + assert!(input.bip32_derivations.contains_key(&pubkey)); + + let (fp, path) = input.bip32_derivations.get(&pubkey).unwrap(); + assert_eq!(fp.as_bytes(), &[0xAA, 0xBB, 0xCC, 0xDD]); + assert_eq!(path.len(), 1); + } + + #[test] + fn test_add_sighash_type() { + let mut psbt = create_psbt(1, 1); + + add_input_sighash_type(&mut psbt, 0, 0x01).unwrap(); // SIGHASH_ALL + + let input = &psbt.inputs[0]; + assert!(input.sighash_type.is_some()); + assert_eq!(input.sighash_type.unwrap().to_u32(), 0x01); + } +} diff --git a/spdk-core/src/psbt/roles/validation.rs b/spdk-core/src/psbt/roles/validation.rs new file mode 100644 index 0000000..c2c9b28 --- /dev/null +++ b/spdk-core/src/psbt/roles/validation.rs @@ -0,0 +1,608 @@ +//! PSBT Validation Functions +//! +//! Validates PSBTs according to BIP-375 rules. + +use crate::psbt::core::{Bip375PsbtExt, Error, Result, SilentPaymentPsbt}; +use crate::psbt::crypto::dleq_verify_proof; +use secp256k1::{PublicKey, Secp256k1}; +use std::collections::HashSet; + +/// Validation level for PSBT checks +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ValidationLevel { + /// Basic structural validation only + Basic, + /// Validate existing DLEQ proofs without requiring complete ECDH coverage (for partial signing) + DleqOnly, + /// Full validation including complete ECDH coverage and DLEQ proofs + Full, +} + +/// Validate a PSBT according to BIP-375 rules +pub fn validate_psbt( + secp: &Secp256k1, + psbt: &SilentPaymentPsbt, + level: ValidationLevel, +) -> Result<()> { + // Basic validations + validate_psbt_version(psbt)?; + validate_input_fields(psbt)?; + validate_output_fields(psbt)?; + + // TODO: is this correct? + // Validate SP tweak fields (if any inputs have them) + validate_sp_tweak_fields(psbt, false)?; + + // Check if this PSBT has silent payment outputs + let has_sp_outputs = (0..psbt.num_outputs()).any(|i| psbt.get_output_sp_info_v0(i).is_some()); + + if has_sp_outputs { + // Rule 6: Segwit version restrictions (must be v0 or v1 for silent payments) + validate_segwit_versions(psbt)?; + // Rule 7: SIGHASH_ALL requirement (only SIGHASH_ALL allowed with silent payments) + validate_sighash_types(psbt)?; + } + + // DLEQ-only validation (for partial signing workflows) + if level == ValidationLevel::DleqOnly && has_sp_outputs { + validate_dleq_proofs(secp, psbt)?; + } + + // Full validation + if level == ValidationLevel::Full && has_sp_outputs { + // Check DLEQ proofs first (includes global ECDH/DLEQ pairing check) + validate_dleq_proofs(secp, psbt)?; + // Then verify ECDH coverage + validate_ecdh_coverage(psbt)?; + validate_output_scripts(secp, psbt)?; + } + + Ok(()) +} + +/// Validate PSBT version is v2 +fn validate_psbt_version(psbt: &SilentPaymentPsbt) -> Result<()> { + if psbt.global.version != psbt_v2::V2 { + return Err(Error::InvalidVersion { + expected: 2, + actual: psbt.global.version.into(), + }); + } + Ok(()) +} + +/// Validate all inputs have required fields +fn validate_input_fields(psbt: &SilentPaymentPsbt) -> Result<()> { + for (i, input) in psbt.inputs.iter().enumerate() { + // previous_txid and spent_output_index are mandatory in struct + + if input.sequence.is_none() { + return Err(Error::MissingField(format!("Input {} missing sequence", i))); + } + + // SegWit inputs require WITNESS_UTXO + if input.witness_utxo.is_none() { + return Err(Error::MissingField(format!( + "Input {} missing witness_utxo", + i + ))); + } + } + + Ok(()) +} + +/// Validate all outputs have required fields +fn validate_output_fields(psbt: &SilentPaymentPsbt) -> Result<()> { + for (i, output) in psbt.outputs.iter().enumerate() { + // Amount is mandatory in struct + + // Check if this is a silent payment output + let has_sp_address = psbt.get_output_sp_info_v0(i).is_some(); + let has_script = !output.script_pubkey.is_empty(); + + // Output must have either a script OR a silent payment address + if !has_sp_address && !has_script { + return Err(Error::MissingField(format!( + "Output {} missing both script and SP address", + i + ))); + } + + // Rule 3: PSBT_OUT_SP_V0_LABEL requires SP address + let has_sp_label = psbt.get_output_sp_label(i).is_some(); + + if has_sp_label && !has_sp_address { + return Err(Error::MissingField(format!( + "Output {} has label but missing SP address", + i + ))); + } + } + + Ok(()) +} + +/// Validate SP tweak fields on inputs +/// +/// Checks that inputs with `PSBT_IN_SP_TWEAK` (0x1f) are properly configured: +/// - Tweak must be exactly 32 bytes +/// - Input must have `witness_utxo` that is P2TR (SegWit v1) +/// - For pre-extraction validation: Input must have `tap_key_sig` +fn validate_sp_tweak_fields(psbt: &SilentPaymentPsbt, require_signatures: bool) -> Result<()> { + for (input_idx, input) in psbt.inputs.iter().enumerate() { + // Check if this input has an SP tweak + if let Some(tweak) = psbt.get_input_sp_tweak(input_idx) { + // Tweak must be 32 bytes (already enforced by return type, but document it) + assert_eq!(tweak.len(), 32, "SP tweak must be 32 bytes"); + + // Input must have witness_utxo + let witness_utxo = input.witness_utxo.as_ref().ok_or_else(|| { + Error::Other(format!( + "Input {} has PSBT_IN_SP_TWEAK but missing witness_utxo", + input_idx + )) + })?; + + // witness_utxo must be P2TR (SegWit v1) + let script = &witness_utxo.script_pubkey; + if !script.is_p2tr() { + return Err(Error::InvalidFieldData(format!( + "Input {} has PSBT_IN_SP_TWEAK but witness_utxo is not P2TR (SegWit v1)", + input_idx + ))); + } + + // If requiring signatures (pre-extraction), verify tap_key_sig exists + if require_signatures && input.tap_key_sig.is_none() { + return Err(Error::Other(format!( + "Input {} has PSBT_IN_SP_TWEAK but missing tap_key_sig (not yet signed)", + input_idx + ))); + } + } + } + + Ok(()) +} + +/// Validate segwit version restrictions (Rule 6) +fn validate_segwit_versions(psbt: &SilentPaymentPsbt) -> Result<()> { + for (i, input) in psbt.inputs.iter().enumerate() { + if let Some(witness_utxo) = &input.witness_utxo { + let script = &witness_utxo.script_pubkey; + + if let Some(version) = script.witness_version() { + // version is WitnessVersion enum. + use bitcoin::WitnessVersion; + match version { + WitnessVersion::V0 | WitnessVersion::V1 => {} + _ => { + return Err(Error::InvalidFieldData(format!( + "Input {} uses segwit version {:?} (incompatible with silent payments)", + i, version + ))); + } + } + } + } + } + Ok(()) +} + +/// Validate SIGHASH_ALL requirement (Rule 7) +fn validate_sighash_types(psbt: &SilentPaymentPsbt) -> Result<()> { + for (i, input) in psbt.inputs.iter().enumerate() { + if let Some(sighash_type) = input.sighash_type { + // Check if it is SIGHASH_ALL (0x01) + // PsbtSighashType wraps EcdsaSighashType + // EcdsaSighashType::All is 0x01 + if sighash_type.to_u32() != 0x01 { + return Err(Error::InvalidFieldData(format!( + "Input {} uses non-SIGHASH_ALL (0x{:02x}) with silent payments", + i, + sighash_type.to_u32() + ))); + } + } + } + Ok(()) +} + +/// Validate ECDH coverage for silent payment outputs +fn validate_ecdh_coverage(psbt: &SilentPaymentPsbt) -> Result<()> { + let num_inputs = psbt.num_inputs(); + + // Collect all scan keys from outputs + let mut scan_keys = HashSet::new(); + for i in 0..psbt.num_outputs() { + if let Some((scan_key, _spend_key)) = psbt.get_output_sp_info_v0(i) { + scan_keys.insert(scan_key); + } + } + + // For each scan key, verify coverage + for scan_key in scan_keys { + // Check for global share first + let global_shares = psbt.get_global_ecdh_shares(); + let has_global = global_shares.iter().any(|s| s.scan_key == scan_key); + + if has_global { + // Rule: If global share exists, there MUST NOT be any per-input shares for this scan key + for i in 0..num_inputs { + let shares = psbt.get_input_ecdh_shares(i); + if shares.iter().any(|s| s.scan_key == scan_key) { + return Err(Error::InvalidFieldData(format!( + "Scan key {} has both global ECDH share and per-input share at input {}", + scan_key, i + ))); + } + } + continue; + } + + // If no global share, MUST have per-input shares for ALL inputs + for i in 0..num_inputs { + let shares = psbt.get_input_ecdh_shares(i); + if !shares.iter().any(|s| s.scan_key == scan_key) { + return Err(Error::Other(format!( + "Scan key {} missing ECDH share for input {} (and no global share found)", + scan_key, i + ))); + } + } + } + + Ok(()) +} + +/// Validate that output scripts match computed silent payment addresses +fn validate_output_scripts( + secp: &Secp256k1, + psbt: &SilentPaymentPsbt, +) -> Result<()> { + use crate::psbt::core::{ + aggregate_ecdh_shares, get_input_bip32_pubkeys, get_input_outpoint_bytes + }; + use crate::psbt::crypto::{ + compute_input_hash, derive_silent_payment_output_pubkey, pubkey_to_p2tr_script, + }; + use std::collections::HashMap; + + // First, collect outpoints and public keys for input_hash computation + let mut outpoints: Vec> = Vec::new(); + let mut input_pubkeys: Vec = Vec::new(); + + for input_idx in 0..psbt.num_inputs() { + let outpoint = get_input_outpoint_bytes(psbt, input_idx)?; + outpoints.push(outpoint); + + let bip32_pubkeys = get_input_bip32_pubkeys(psbt, input_idx); + if bip32_pubkeys.is_empty() { + eprintln!( + "Warning: Input {} has no BIP32 derivation, skipping output script validation", + input_idx + ); + return Ok(()); + } + input_pubkeys.push(bip32_pubkeys[0]); + } + + if input_pubkeys.is_empty() { + return Ok(()); + } + + let mut summed_pubkey = input_pubkeys[0]; + for pubkey in &input_pubkeys[1..] { + summed_pubkey = summed_pubkey + .combine(pubkey) + .map_err(|e| Error::Other(format!("Failed to sum input pubkeys: {}", e)))?; + } + + let smallest_outpoint = outpoints + .iter() + .min() + .ok_or_else(|| Error::Other("No inputs found".to_string()))?; + + let aggregated_shares = aggregate_ecdh_shares(psbt)?; + + let input_hash = compute_input_hash(smallest_outpoint, &summed_pubkey) + .map_err(|e| Error::Other(format!("Failed to compute input hash: {}", e)))?; + + let mut shared_secrets: HashMap = HashMap::new(); + for (scan_key, aggregated_share_data) in aggregated_shares.iter() { + let shared_secret = aggregated_share_data + .aggregated_share + .mul_tweak(secp, &input_hash) + .map_err(|e| { + Error::Other(format!( + "Failed to multiply ECDH share by input_hash: {}", + e + )) + })?; + shared_secrets.insert(*scan_key, shared_secret); + } + + let mut scan_key_output_indices: HashMap = HashMap::new(); + + for output_idx in 0..psbt.num_outputs() { + let output = &psbt.outputs[output_idx]; + if output.script_pubkey.is_empty() { + continue; + } + + let (scan_key, spend_key) = match psbt.get_output_sp_info_v0(output_idx) { + Some(keys) => keys, + None => continue, + }; + + let shared_secret = shared_secrets.get(&scan_key).ok_or_else(|| { + Error::Other(format!( + "Output {} missing shared secret for scan key", + output_idx + )) + })?; + + let k = *scan_key_output_indices.get(&scan_key).unwrap_or(&0); + + let shared_secret_bytes = shared_secret.serialize(); + let expected_pubkey = + derive_silent_payment_output_pubkey(secp, &spend_key, &shared_secret_bytes, k) + .map_err(|e| Error::Other(format!("Failed to derive output pubkey: {}", e)))?; + + let expected_script = pubkey_to_p2tr_script(&expected_pubkey); + + if output.script_pubkey != expected_script { + return Err(Error::Other(format!( + "Output {} script mismatch: expected silent payment address doesn't match actual script", + output_idx + ))); + } + + scan_key_output_indices.insert(scan_key, k + 1); + } + + Ok(()) +} + +/// Validate all DLEQ proofs in the PSBT +fn validate_dleq_proofs(secp: &Secp256k1, psbt: &SilentPaymentPsbt) -> Result<()> { + use crate::psbt::core::get_input_pubkey; + + // Check global DLEQ if global ECDH exists + let global_shares = psbt.get_global_ecdh_shares(); + for share in global_shares { + if share.dleq_proof.is_none() { + // Global shares MUST have DLEQ proofs? + // BIP-375 says DLEQ proof is required. + // But my EcdhShare struct has Option. + // If it's missing, it's invalid. + return Err(Error::Other( + "Global ECDH share missing DLEQ proof".to_string(), + )); + } + } + + // Validate per-input ECDH shares + for input_idx in 0..psbt.num_inputs() { + let shares = psbt.get_input_ecdh_shares(input_idx); + + for share in shares { + if share.dleq_proof.is_none() { + return Err(Error::Other(format!( + "Input {} missing required DLEQ proof for ECDH share", + input_idx + ))); + } + + if let Some(proof) = share.dleq_proof { + let input_pubkey = get_input_pubkey(psbt, input_idx)?; + + let is_valid = dleq_verify_proof( + secp, + &input_pubkey, + &share.scan_key, + &share.share, + &proof, + None, + ) + .map_err(|e| Error::Other(format!("DLEQ verification error: {}", e)))?; + + if !is_valid { + return Err(Error::DleqVerificationFailed(input_idx)); + } + } + } + } + + Ok(()) +} + +/// Validate that a PSBT is ready for extraction +/// +/// Checks that all inputs are signed (either P2WPKH with `partial_sigs` or P2TR with `tap_key_sig`) +/// and all outputs have scripts. +/// +/// For SP tweak inputs, validates that they have required signatures. +pub fn validate_ready_for_extraction(psbt: &SilentPaymentPsbt) -> Result<()> { + // Validate SP tweak fields with signature requirement + validate_sp_tweak_fields(psbt, true)?; + + for input_idx in 0..psbt.num_inputs() { + let input = &psbt.inputs[input_idx]; + + // Check for either tap_key_sig (P2TR) or partial_sigs (P2WPKH) + let has_tap_sig = input.tap_key_sig.is_some(); + let has_partial_sigs = !psbt.get_input_partial_sigs(input_idx).is_empty(); + + if !has_tap_sig && !has_partial_sigs { + return Err(Error::ExtractionFailed(format!( + "Input {} is not signed (no tap_key_sig or partial_sigs)", + input_idx + ))); + } + } + + for output_idx in 0..psbt.num_outputs() { + if psbt.outputs[output_idx].script_pubkey.is_empty() { + return Err(Error::ExtractionFailed(format!( + "Output {} missing script", + output_idx + ))); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::psbt::roles::{ + constructor::{add_inputs, add_outputs}, + creator::create_psbt, + }; + use crate::psbt::core::{PsbtInput, PsbtOutput}; + use crate::psbt::crypto::pubkey_to_p2wpkh_script; + use bitcoin::hashes::Hash; + use bitcoin::{Amount, OutPoint, Sequence, TxOut, Txid}; + use secp256k1::SecretKey; + use silentpayments::{SilentPaymentAddress, Network as SpNetwork}; + + #[test] + fn test_validate_psbt_version() { + let psbt = create_psbt(1, 1); + assert!(validate_psbt_version(&psbt).is_ok()); + } + + #[test] + fn test_validate_input_fields() { + let secp = Secp256k1::new(); + let mut psbt = create_psbt(1, 1); + + let privkey = SecretKey::from_slice(&[1u8; 32]).unwrap(); + let pubkey = PublicKey::from_secret_key(&secp, &privkey); + + let inputs = vec![PsbtInput::new( + OutPoint::new(Txid::all_zeros(), 0), + TxOut { + value: Amount::from_sat(30000), + script_pubkey: pubkey_to_p2wpkh_script(&pubkey), + }, + Sequence::MAX, + Some(privkey), + )]; + + add_inputs(&mut psbt, &inputs).unwrap(); + + assert!(validate_input_fields(&psbt).is_ok()); + } + + #[test] + fn test_validate_output_fields() { + let secp = Secp256k1::new(); + let mut psbt = create_psbt(1, 1); + + // Create a valid output with a real script (P2WPKH) + let privkey = SecretKey::from_slice(&[1u8; 32]).unwrap(); + let pubkey = PublicKey::from_secret_key(&secp, &privkey); + let script = pubkey_to_p2wpkh_script(&pubkey); + + let outputs = vec![PsbtOutput::regular(Amount::from_sat(29000), script)]; + + add_outputs(&mut psbt, &outputs).unwrap(); + + assert!(validate_output_fields(&psbt).is_ok()); + } + + #[test] + fn test_validate_ecdh_coverage() { + use crate::psbt::core::{Bip375PsbtExt, EcdhShareData}; + + let secp = Secp256k1::new(); + let mut psbt = create_psbt(2, 1); // 2 inputs, 1 output + + // Setup keys + let scan_priv = SecretKey::from_slice(&[2u8; 32]).unwrap(); + let scan_pub = PublicKey::from_secret_key(&secp, &scan_priv); + let spend_priv = SecretKey::from_slice(&[3u8; 32]).unwrap(); + let spend_pub = PublicKey::from_secret_key(&secp, &spend_priv); + + let sp_addr = SilentPaymentAddress::new(scan_pub, spend_pub, SpNetwork::Regtest, 0).unwrap(); + + // Add SP output + let outputs = vec![PsbtOutput::silent_payment(Amount::from_sat(10000), sp_addr, None)]; + add_outputs(&mut psbt, &outputs).unwrap(); + + // Add dummy inputs so we have something to check against + let input_priv = SecretKey::from_slice(&[1u8; 32]).unwrap(); + let input_pub = PublicKey::from_secret_key(&secp, &input_priv); + let inputs = vec![ + PsbtInput::new( + OutPoint::new(Txid::all_zeros(), 0), + TxOut { + value: Amount::from_sat(10000), + script_pubkey: pubkey_to_p2wpkh_script(&input_pub), + }, + Sequence::MAX, + Some(input_priv), + ), + PsbtInput::new( + OutPoint::new(Txid::all_zeros(), 1), + TxOut { + value: Amount::from_sat(10000), + script_pubkey: pubkey_to_p2wpkh_script(&input_pub), + }, + Sequence::MAX, + Some(input_priv), + ), + ]; + add_inputs(&mut psbt, &inputs).unwrap(); + + // Case 1: No shares at all -> Invalid + assert!(validate_ecdh_coverage(&psbt).is_err()); + + // Case 2: Global share present -> Valid + { + let mut psbt_global = psbt.clone(); + // Create a dummy share + let share_point = + PublicKey::from_secret_key(&secp, &SecretKey::from_slice(&[4u8; 32]).unwrap()); + let share = EcdhShareData::without_proof(scan_pub, share_point); + psbt_global.add_global_ecdh_share(&share).unwrap(); + + assert!(validate_ecdh_coverage(&psbt_global).is_ok()); + } + + // Case 3: Per-input shares for ALL inputs -> Valid + { + let mut psbt_inputs = psbt.clone(); + let share_point = + PublicKey::from_secret_key(&secp, &SecretKey::from_slice(&[4u8; 32]).unwrap()); + let share = EcdhShareData::without_proof(scan_pub, share_point); + + // Add to all inputs + for i in 0..psbt_inputs.num_inputs() { + psbt_inputs.add_input_ecdh_share(i, &share).unwrap(); + } + + assert!(validate_ecdh_coverage(&psbt_inputs).is_ok()); + } + + // Case 4: Per-input shares for SOME inputs -> Invalid + { + let mut psbt_partial = psbt.clone(); + let share_point = + PublicKey::from_secret_key(&secp, &SecretKey::from_slice(&[4u8; 32]).unwrap()); + let share = EcdhShareData::without_proof(scan_pub, share_point); + + // Add to only first input + psbt_partial.add_input_ecdh_share(0, &share).unwrap(); + + assert!(validate_ecdh_coverage(&psbt_partial).is_err()); + } + + // TODO: Case 5: Per-input shares for ALL inputs, but some are invalid -> Invalid + // TODO: Case 6: Global share present, invalid input shares -> Valid + } +} From bbcfb48fa6c3b8d1640aef686b99b6d0588774c7 Mon Sep 17 00:00:00 2001 From: macgyver13 <4712150+macgyver13@users.noreply.github.com> Date: Tue, 30 Dec 2025 19:39:14 -0500 Subject: [PATCH 02/16] psbt: Add additional psbt extension traits Remove Hrn dnssec partial implementation (HrnPsbtExt should not be provided by spdk) Migrate to DleqProof type --- spdk-core/src/psbt/core/extensions.rs | 536 ++++++++++++++++++++++++-- spdk-core/src/psbt/core/mod.rs | 4 +- spdk-core/src/psbt/core/types.rs | 85 +++- spdk-core/src/psbt/crypto/dleq.rs | 20 +- 4 files changed, 592 insertions(+), 53 deletions(-) diff --git a/spdk-core/src/psbt/core/extensions.rs b/spdk-core/src/psbt/core/extensions.rs index a452629..9ac550d 100644 --- a/spdk-core/src/psbt/core/extensions.rs +++ b/spdk-core/src/psbt/core/extensions.rs @@ -26,7 +26,7 @@ use super::{ error::{Error, Result}, - types::{EcdhShareData}, + types::EcdhShareData, SilentPaymentPsbt, }; use bitcoin::{OutPoint, Txid}; @@ -166,14 +166,11 @@ pub trait Bip375PsbtExt { /// * `input_index` - Index of the input fn get_input_partial_sigs(&self, input_index: usize) -> Vec<(Vec, Vec)>; - // ===== DNSSEC Proof ===== - - /// Set DNSSEC proof for an output + /// Get all scan keys from outputs with PSBT_OUT_SP_V0_INFO set /// - /// # Arguments - /// * `output_index` - Index of the output - /// * `proof` - The DNSSEC proof data - fn set_output_dnssec_proof(&mut self, output_index: usize, proof: Vec) -> Result<()>; + /// Iterates through all outputs and extracts scan keys from silent payment addresses. + /// This is used by signers to determine which scan keys need ECDH shares. + fn get_output_scan_keys(&self) -> Vec; } impl Bip375PsbtExt for Psbt { @@ -257,11 +254,13 @@ impl Bip375PsbtExt for Psbt { let output = self.outputs.get(output_index)?; if let Some(bytes) = &output.sp_v0_info { - if bytes.len() != 66 { return None }; + if bytes.len() != 66 { + return None; + }; let scan_key = PublicKey::from_slice(&bytes[..33]).ok(); let spend_key = PublicKey::from_slice(&bytes[33..]).ok(); if scan_key.is_some() && spend_key.is_some() { - return Some((scan_key.unwrap(), spend_key.unwrap())) + return Some((scan_key.unwrap(), spend_key.unwrap())); } } @@ -372,46 +371,43 @@ impl Bip375PsbtExt for Psbt { } } - fn set_output_dnssec_proof(&mut self, output_index: usize, proof: Vec) -> Result<()> { - const PSBT_OUT_DNSSEC_PROOF: u8 = 0x35; - - let output = self - .outputs - .get_mut(output_index) - .ok_or(Error::InvalidOutputIndex(output_index))?; - - let key = Key { - type_value: PSBT_OUT_DNSSEC_PROOF, - key: vec![], - }; - output.unknowns.insert(key, proof); - Ok(()) + fn get_output_scan_keys(&self) -> Vec { + let mut scan_keys = Vec::new(); + for output_idx in 0..self.outputs.len() { + if let Some(sp_info) = self.get_output_sp_info_v0(output_idx) { + scan_keys.push(sp_info.0); + } + } + scan_keys } } // Private helper functions for DLEQ proof management -fn get_global_dleq_proof(psbt: &Psbt, scan_key: &PublicKey) -> Option<[u8; 64]> { +fn get_global_dleq_proof(psbt: &Psbt, scan_key: &PublicKey) -> Option { let scan_key_compressed = CompressedPublicKey::try_from(bitcoin::PublicKey::new(*scan_key)).ok()?; psbt.global .sp_dleq_proofs .get(&scan_key_compressed) - .map(|proof| *proof.as_bytes()) + .map(|proof| *proof) } -fn add_global_dleq_proof(psbt: &mut Psbt, scan_key: &PublicKey, proof: [u8; 64]) -> Result<()> { +fn add_global_dleq_proof(psbt: &mut Psbt, scan_key: &PublicKey, proof: DleqProof) -> Result<()> { let scan_key_compressed = CompressedPublicKey::try_from(bitcoin::PublicKey::new(*scan_key)) .map_err(|_| Error::InvalidPublicKey)?; - let dleq_proof = DleqProof::new(proof); psbt.global .sp_dleq_proofs - .insert(scan_key_compressed, dleq_proof); + .insert(scan_key_compressed, proof); Ok(()) } -fn get_input_dleq_proof(psbt: &Psbt, input_index: usize, scan_key: &PublicKey) -> Option<[u8; 64]> { +fn get_input_dleq_proof( + psbt: &Psbt, + input_index: usize, + scan_key: &PublicKey, +) -> Option { let input = psbt.inputs.get(input_index)?; let scan_key_compressed = CompressedPublicKey::try_from(bitcoin::PublicKey::new(*scan_key)).ok()?; @@ -419,14 +415,14 @@ fn get_input_dleq_proof(psbt: &Psbt, input_index: usize, scan_key: &PublicKey) - input .sp_dleq_proofs .get(&scan_key_compressed) - .map(|proof| *proof.as_bytes()) + .map(|proof| *proof) } fn add_input_dleq_proof( psbt: &mut Psbt, input_index: usize, scan_key: &PublicKey, - proof: [u8; 64], + proof: DleqProof, ) -> Result<()> { let input = psbt .inputs @@ -435,9 +431,8 @@ fn add_input_dleq_proof( let scan_key_compressed = CompressedPublicKey::try_from(bitcoin::PublicKey::new(*scan_key)) .map_err(|_| Error::InvalidPublicKey)?; - let dleq_proof = DleqProof::new(proof); - input.sp_dleq_proofs.insert(scan_key_compressed, dleq_proof); + input.sp_dleq_proofs.insert(scan_key_compressed, proof); Ok(()) } @@ -513,7 +508,19 @@ pub fn get_input_pubkey(psbt: &SilentPaymentPsbt, input_idx: usize) -> Result Result Result Result Result<(PublicKey, PublicKey)> { + // Use the extension trait method via SilentPaymentPsbt wrapper + let sp_info = psbt.get_output_sp_info_v0(output_idx).ok_or_else(|| { + Error::MissingField(format!("Output {} missing PSBT_OUT_SP_V0_INFO", output_idx)) + })?; + Ok((sp_info.0, sp_info.1)) +} /// Get silent payment keys (scan_key, spend_key) from output SP_V0_INFO field +// ============================================================================ +// Display Extension Traits +// ============================================================================ +// +// The following traits provide methods for extracting and serializing PSBT fields +// for display purposes. These are used by GUI and analysis tools to inspect PSBT contents. + +/// Extension trait for accessing psbt_v2::v2::Global fields for display +/// +/// This trait provides convenient methods to access all standard PSBT v2 global fields +/// in a serialized format suitable for display or further processing. +pub trait GlobalFieldsExt { + /// Iterator over all standard global fields as (field_type, key_data, value_data) tuples + /// + /// Returns fields in the following order: + /// - PSBT_GLOBAL_XPUB (0x01) - Multiple entries possible + /// - PSBT_GLOBAL_TX_VERSION (0x02) + /// - PSBT_GLOBAL_FALLBACK_LOCKTIME (0x03) - If present + /// - PSBT_GLOBAL_INPUT_COUNT (0x04) + /// - PSBT_GLOBAL_OUTPUT_COUNT (0x05) + /// - PSBT_GLOBAL_TX_MODIFIABLE (0x06) + /// - PSBT_GLOBAL_SP_ECDH_SHARE (0x07) - Multiple entries possible (BIP-375) + /// - PSBT_GLOBAL_SP_DLEQ (0x08) - Multiple entries possible (BIP-375) + /// - PSBT_GLOBAL_VERSION (0xFB) + /// - PSBT_GLOBAL_PROPRIETARY (0xFC) - Multiple entries possible + /// - Unknown fields from the unknowns map + fn iter_global_fields(&self) -> Vec<(u8, Vec, Vec)>; +} + +impl GlobalFieldsExt for psbt_v2::v2::Global { + fn iter_global_fields(&self) -> Vec<(u8, Vec, Vec)> { + let mut fields = Vec::new(); + + // PSBT_GLOBAL_XPUB = 0x01 - Can have multiple entries + for (xpub, key_source) in &self.xpubs { + let field_type = 0x01; + // Key is the serialized xpub + let key_data = xpub.to_string().as_bytes().to_vec(); + // Value is the key source (fingerprint + derivation path) + let mut value_data = Vec::new(); + // Fingerprint is 4 bytes + value_data.extend_from_slice(&key_source.0.to_bytes()); + // Derivation path - each ChildNumber is 4 bytes (u32) + for child in &key_source.1 { + value_data.extend_from_slice(&u32::from(*child).to_le_bytes()); + } + fields.push((field_type, key_data, value_data)); + } + + // PSBT_GLOBAL_TX_VERSION = 0x02 - Always present + { + let field_type = 0x02; + let key_data = vec![]; + let value_data = self.tx_version.0.to_le_bytes().to_vec(); + fields.push((field_type, key_data, value_data)); + } + + // PSBT_GLOBAL_FALLBACK_LOCKTIME = 0x03 - Optional + if let Some(lock_time) = self.fallback_lock_time { + let field_type = 0x03; + let key_data = vec![]; + let value_data = lock_time.to_consensus_u32().to_le_bytes().to_vec(); + fields.push((field_type, key_data, value_data)); + } + + // PSBT_GLOBAL_INPUT_COUNT = 0x04 - Always present + { + let field_type = 0x04; + let key_data = vec![]; + // Serialize as VarInt (compact size) + let mut value_data = vec![]; + let count = self.input_count as u64; + if count < 0xFD { + value_data.push(count as u8); + } else if count <= 0xFFFF { + value_data.push(0xFD); + value_data.extend_from_slice(&(count as u16).to_le_bytes()); + } else if count <= 0xFFFF_FFFF { + value_data.push(0xFE); + value_data.extend_from_slice(&(count as u32).to_le_bytes()); + } else { + value_data.push(0xFF); + value_data.extend_from_slice(&count.to_le_bytes()); + } + fields.push((field_type, key_data, value_data)); + } + + // PSBT_GLOBAL_OUTPUT_COUNT = 0x05 - Always present + { + let field_type = 0x05; + let key_data = vec![]; + // Serialize as VarInt (compact size) + let mut value_data = vec![]; + let count = self.output_count as u64; + if count < 0xFD { + value_data.push(count as u8); + } else if count <= 0xFFFF { + value_data.push(0xFD); + value_data.extend_from_slice(&(count as u16).to_le_bytes()); + } else if count <= 0xFFFF_FFFF { + value_data.push(0xFE); + value_data.extend_from_slice(&(count as u32).to_le_bytes()); + } else { + value_data.push(0xFF); + value_data.extend_from_slice(&count.to_le_bytes()); + } + fields.push((field_type, key_data, value_data)); + } + + // PSBT_GLOBAL_TX_MODIFIABLE = 0x06 - Always present + { + let field_type = 0x06; + let key_data = vec![]; + let value_data = vec![self.tx_modifiable_flags]; + fields.push((field_type, key_data, value_data)); + } + + // PSBT_GLOBAL_SP_ECDH_SHARE = 0x07 - BIP-375, can have multiple entries + for (scan_key, ecdh_share) in &self.sp_ecdh_shares { + let field_type = 0x07; + fields.push(( + field_type, + scan_key.to_bytes().to_vec(), + ecdh_share.to_bytes().to_vec(), + )); + } + + // PSBT_GLOBAL_SP_DLEQ = 0x08 - BIP-375, can have multiple entries + for (scan_key, dleq_proof) in &self.sp_dleq_proofs { + let field_type = 0x08; + fields.push(( + field_type, + scan_key.to_bytes().to_vec(), + dleq_proof.as_bytes().to_vec(), + )); + } + + // PSBT_GLOBAL_VERSION = 0xFB - Always present + { + let field_type = 0xFB; + let key_data = vec![]; + let value_data = self.version.to_u32().to_le_bytes().to_vec(); + fields.push((field_type, key_data, value_data)); + } + + // PSBT_GLOBAL_PROPRIETARY = 0xFC - Can have multiple entries + for (prop_key, value) in &self.proprietaries { + use bitcoin::consensus::Encodable; + let field_type = 0xFC; + // Key data is the proprietary key structure + let mut key_data = vec![]; + let _ = prop_key.consensus_encode(&mut key_data); + fields.push((field_type, key_data, value.clone())); + } + + // Unknown fields from the unknowns map + for (key, value) in &self.unknowns { + fields.push((key.type_value, key.key.clone(), value.clone())); + } + + fields + } +} + +/// Extension trait for accessing psbt_v2::v2::Input fields for display +/// +/// This trait provides convenient methods to access all standard PSBT v2 input fields +/// in a serialized format suitable for display or further processing. +pub trait InputFieldsExt { + /// Iterator over all standard input fields as (field_type, key_data, value_data) tuples + fn iter_input_fields(&self) -> Vec<(u8, Vec, Vec)>; +} + +impl InputFieldsExt for psbt_v2::v2::Input { + fn iter_input_fields(&self) -> Vec<(u8, Vec, Vec)> { + let mut fields = Vec::new(); + + // PSBT_IN_NON_WITNESS_UTXO (0x00) - Optional + if let Some(ref tx) = self.non_witness_utxo { + use bitcoin::consensus::Encodable; + let field_type = 0x00; + let key_data = vec![]; + let mut value_data = vec![]; + let _ = tx.consensus_encode(&mut value_data); + fields.push((field_type, key_data, value_data)); + } + + // PSBT_IN_WITNESS_UTXO (0x01) - Optional + if let Some(ref utxo) = self.witness_utxo { + use bitcoin::consensus::Encodable; + let field_type = 0x01; + let key_data = vec![]; + let mut value_data = vec![]; + let _ = utxo.consensus_encode(&mut value_data); + fields.push((field_type, key_data, value_data)); + } + + // PSBT_IN_PARTIAL_SIG (0x02) - Multiple entries possible + for (pubkey, sig) in &self.partial_sigs { + let field_type = 0x02; + let key_data = pubkey.inner.serialize().to_vec(); + let value_data = sig.to_vec(); + fields.push((field_type, key_data, value_data)); + } + + // PSBT_IN_SIGHASH_TYPE (0x03) - Optional + if let Some(sighash_type) = self.sighash_type { + let field_type = 0x03; + let key_data = vec![]; + let value_data = (sighash_type.to_u32()).to_le_bytes().to_vec(); + fields.push((field_type, key_data, value_data)); + } + + // PSBT_IN_REDEEM_SCRIPT (0x04) - Optional + if let Some(ref script) = self.redeem_script { + let field_type = 0x04; + let key_data = vec![]; + let value_data = script.to_bytes(); + fields.push((field_type, key_data, value_data)); + } + + // PSBT_IN_WITNESS_SCRIPT (0x05) - Optional + if let Some(ref script) = self.witness_script { + let field_type = 0x05; + let key_data = vec![]; + let value_data = script.to_bytes(); + fields.push((field_type, key_data, value_data)); + } + + // PSBT_IN_BIP32_DERIVATION (0x06) - Multiple entries possible + for (pubkey, key_source) in &self.bip32_derivations { + let field_type = 0x06; + let key_data = pubkey.serialize().to_vec(); + let mut value_data = Vec::new(); + value_data.extend_from_slice(&key_source.0.to_bytes()); + for child in &key_source.1 { + value_data.extend_from_slice(&u32::from(*child).to_le_bytes()); + } + fields.push((field_type, key_data, value_data)); + } + + // PSBT_IN_FINAL_SCRIPTSIG (0x07) - Optional + if let Some(ref script) = self.final_script_sig { + let field_type = 0x07; + let key_data = vec![]; + let value_data = script.to_bytes(); + fields.push((field_type, key_data, value_data)); + } + + // PSBT_IN_FINAL_SCRIPTWITNESS (0x08) - Optional + if let Some(ref witness) = self.final_script_witness { + use bitcoin::consensus::Encodable; + let field_type = 0x08; + let key_data = vec![]; + let mut value_data = vec![]; + let _ = witness.consensus_encode(&mut value_data); + fields.push((field_type, key_data, value_data)); + } + + // PSBT_IN_PREVIOUS_TXID (0x0e) - Always present + { + use bitcoin::consensus::Encodable; + let field_type = 0x0e; + let key_data = vec![]; + let mut value_data = vec![]; + let _ = self.previous_txid.consensus_encode(&mut value_data); + fields.push((field_type, key_data, value_data)); + } + + // PSBT_IN_OUTPUT_INDEX (0x0f) - Always present + { + let field_type = 0x0f; + let key_data = vec![]; + let value_data = self.spent_output_index.to_le_bytes().to_vec(); + fields.push((field_type, key_data, value_data)); + } + + // PSBT_IN_SEQUENCE (0x10) - Optional + if let Some(sequence) = self.sequence { + let field_type = 0x10; + let key_data = vec![]; + let value_data = sequence.to_consensus_u32().to_le_bytes().to_vec(); + fields.push((field_type, key_data, value_data)); + } + + // PSBT_IN_TAP_BIP32_DERIVATION (0x16) - Multiple entries possible + for (xonly_pubkey, (leaf_hashes, key_source)) in &self.tap_key_origins { + let field_type = 0x16; + let key_data = xonly_pubkey.serialize().to_vec(); + let mut value_data = Vec::new(); + + // Encode leaf_hashes (compact size + hashes) + value_data.push(leaf_hashes.len() as u8); + for leaf_hash in leaf_hashes { + value_data.extend_from_slice(leaf_hash.as_ref()); + } + + // Encode key_source (fingerprint + derivation path) + value_data.extend_from_slice(&key_source.0.to_bytes()); + for child in &key_source.1 { + value_data.extend_from_slice(&u32::from(*child).to_le_bytes()); + } + + fields.push((field_type, key_data, value_data)); + } + + // PSBT_IN_SP_ECDH_SHARE (0x1d) - BIP-375, multiple entries possible + for (scan_key, ecdh_share) in &self.sp_ecdh_shares { + let field_type = 0x1d; + fields.push(( + field_type, + scan_key.to_bytes().to_vec(), + ecdh_share.to_bytes().to_vec(), + )); + } + + // PSBT_IN_SP_DLEQ (0x1e) - BIP-375, multiple entries possible + for (scan_key, dleq_proof) in &self.sp_dleq_proofs { + let field_type = 0x1e; + fields.push(( + field_type, + scan_key.to_bytes().to_vec(), + dleq_proof.as_bytes().to_vec(), + )); + } + + // PSBT_IN_PROPRIETARY (0xFC) - Multiple entries possible + for (prop_key, value) in &self.proprietaries { + use bitcoin::consensus::Encodable; + let field_type = 0xFC; + let mut key_data = vec![]; + let _ = prop_key.consensus_encode(&mut key_data); + fields.push((field_type, key_data, value.clone())); + } + + // Unknown fields + for (key, value) in &self.unknowns { + fields.push((key.type_value, key.key.clone(), value.clone())); + } + + fields + } +} + +/// Extension trait for accessing psbt_v2::v2::Output fields for display +/// +/// This trait provides convenient methods to access all standard PSBT v2 output fields +/// in a serialized format suitable for display or further processing. +pub trait OutputFieldsExt { + /// Iterator over all standard output fields as (field_type, key_data, value_data) tuples + fn iter_output_fields(&self) -> Vec<(u8, Vec, Vec)>; +} + +impl OutputFieldsExt for psbt_v2::v2::Output { + fn iter_output_fields(&self) -> Vec<(u8, Vec, Vec)> { + let mut fields = Vec::new(); + + // PSBT_OUT_REDEEM_SCRIPT (0x00) - Optional + if let Some(ref script) = self.redeem_script { + let field_type = 0x00; + let key_data = vec![]; + let value_data = script.to_bytes(); + fields.push((field_type, key_data, value_data)); + } + + // PSBT_OUT_WITNESS_SCRIPT (0x01) - Optional + if let Some(ref script) = self.witness_script { + let field_type = 0x01; + let key_data = vec![]; + let value_data = script.to_bytes(); + fields.push((field_type, key_data, value_data)); + } + + // PSBT_OUT_BIP32_DERIVATION (0x02) - Multiple entries possible + for (pubkey, key_source) in &self.bip32_derivations { + let field_type = 0x02; + let key_data = pubkey.serialize().to_vec(); + let mut value_data = Vec::new(); + value_data.extend_from_slice(&key_source.0.to_bytes()); + for child in &key_source.1 { + value_data.extend_from_slice(&u32::from(*child).to_le_bytes()); + } + fields.push((field_type, key_data, value_data)); + } + + // PSBT_OUT_AMOUNT (0x03) - Always present + { + let field_type = 0x03; + let key_data = vec![]; + let value_data = self.amount.to_sat().to_le_bytes().to_vec(); + fields.push((field_type, key_data, value_data)); + } + + // PSBT_OUT_SCRIPT (0x04) - Always present + { + let field_type = 0x04; + let key_data = vec![]; + let value_data = self.script_pubkey.to_bytes(); + fields.push((field_type, key_data, value_data)); + } + + // PSBT_OUT_SP_V0_INFO (0x09) - BIP-375, optional + if let Some(ref sp_info) = self.sp_v0_info { + let field_type = 0x09; + let key_data = vec![]; + fields.push((field_type, key_data, sp_info.clone())); + } + + // PSBT_OUT_SP_V0_LABEL (0x0a) - BIP-375, optional + if let Some(label) = self.sp_v0_label { + let field_type = 0x0a; + let key_data = vec![]; + let value_data = label.to_le_bytes().to_vec(); + fields.push((field_type, key_data, value_data)); + } + + // PSBT_OUT_PROPRIETARY (0xFC) - Multiple entries possible + for (prop_key, value) in &self.proprietaries { + use bitcoin::consensus::Encodable; + let field_type = 0xFC; + let mut key_data = vec![]; + let _ = prop_key.consensus_encode(&mut key_data); + fields.push((field_type, key_data, value.clone())); + } + + // Unknown fields + for (key, value) in &self.unknowns { + fields.push((key.type_value, key.key.clone(), value.clone())); + } + + fields + } +} + #[cfg(test)] mod tests { use super::*; @@ -594,7 +1045,7 @@ mod tests { let secp = Secp256k1::new(); let scan_key = PublicKey::from_secret_key(&secp, &SecretKey::from_slice(&[1u8; 32]).unwrap()); - let proof = [0x42u8; 64]; + let proof = DleqProof([0x42u8; 64]); // Add proof add_global_dleq_proof(&mut psbt, &scan_key, proof).unwrap(); @@ -615,14 +1066,19 @@ mod tests { let spend_key = PublicKey::from_secret_key(&secp, &SecretKey::from_slice(&[2u8; 32]).unwrap()); - let address = SilentPaymentAddress::new(scan_key, spend_key, silentpayments::Network::Regtest, 0).unwrap(); + let address = + SilentPaymentAddress::new(scan_key, spend_key, silentpayments::Network::Regtest, 0) + .unwrap(); // Set address psbt.set_output_sp_info_v0(0, &address).unwrap(); // Retrieve address let retrieved = psbt.get_output_sp_info_v0(0); - assert_eq!(retrieved.map(|res| (res.0, res.1)), Some((address.get_scan_key(), address.get_spend_key()))); + assert_eq!( + retrieved.map(|res| (res.0, res.1)), + Some((address.get_scan_key(), address.get_spend_key())) + ); } #[test] diff --git a/spdk-core/src/psbt/core/mod.rs b/spdk-core/src/psbt/core/mod.rs index 61cca85..909be84 100644 --- a/spdk-core/src/psbt/core/mod.rs +++ b/spdk-core/src/psbt/core/mod.rs @@ -16,7 +16,8 @@ pub mod types; pub use error::{Error, Result}; pub use extensions::{ get_input_bip32_pubkeys, get_input_outpoint, get_input_outpoint_bytes, get_input_pubkey, - get_input_txid, get_input_vout, Bip375PsbtExt, + get_input_txid, get_input_vout, Bip375PsbtExt, GlobalFieldsExt, InputFieldsExt, + OutputFieldsExt, }; pub use shares::{aggregate_ecdh_shares, AggregatedShare, AggregatedShares}; pub use types::{EcdhShareData, PsbtInput, PsbtOutput}; @@ -25,3 +26,4 @@ pub use types::{EcdhShareData, PsbtInput, PsbtOutput}; /// /// Use the `Bip375PsbtExt` trait to access BIP-375 specific functionality. pub type SilentPaymentPsbt = psbt_v2::v2::Psbt; +pub type PsbtKey = psbt_v2::raw::Key; \ No newline at end of file diff --git a/spdk-core/src/psbt/core/types.rs b/spdk-core/src/psbt/core/types.rs index 82ab713..33cadf2 100644 --- a/spdk-core/src/psbt/core/types.rs +++ b/spdk-core/src/psbt/core/types.rs @@ -3,6 +3,7 @@ //! Core types for silent payments in PSBTs. use bitcoin::{Amount, OutPoint, ScriptBuf, Sequence, TxOut}; +use psbt_v2::v2::dleq::DleqProof; use secp256k1::{PublicKey, SecretKey}; use silentpayments::SilentPaymentAddress; @@ -10,6 +11,74 @@ use silentpayments::SilentPaymentAddress; // Core BIP-352/BIP-375 Protocol Types // ============================================================================ +/// A silent payment address for PSBT (BIP-375). +/// +/// Contains a scan public key and spend public key, with an optional label. +/// This type is used when storing silent payment addresses in PSBT outputs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct SilentPaymentOutputInfo { + /// Scan public key (33 bytes compressed). + pub scan_key: PublicKey, + /// Spend public key (33 bytes compressed). + pub spend_key: PublicKey, + /// Optional label for change outputs. + pub label: Option, +} + +impl SilentPaymentOutputInfo { + /// Creates a new silent payment address. + pub fn new(scan_key: PublicKey, spend_key: PublicKey, label: Option) -> Self { + Self { + scan_key, + spend_key, + label, + } + } + + /// Creates an address without a label. + pub fn without_label(scan_key: PublicKey, spend_key: PublicKey) -> Self { + Self::new(scan_key, spend_key, None) + } + + /// Serializes to bytes (scan_key || spend_key). + /// + /// Format: 66 bytes - scan_key (33) || spend_key (33) + /// Note: Label is stored separately in PSBT + pub fn to_bytes(&self) -> Vec { + let mut bytes = Vec::with_capacity(66); + bytes.extend_from_slice(&self.scan_key.serialize()); + bytes.extend_from_slice(&self.spend_key.serialize()); + bytes + } + + /// Deserializes from bytes. + /// + /// # Errors + /// + /// Returns an error if: + /// - The byte length is not 66 + /// - The public keys are invalid + pub fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() != 66 { + return Err(super::Error::InvalidAddress(format!( + "Invalid length: expected 66 bytes, got {}", + bytes.len() + ))); + } + + let scan_key = PublicKey::from_slice(&bytes[0..33]) + .map_err(|e| super::Error::InvalidAddress(e.to_string()))?; + let spend_key: PublicKey = PublicKey::from_slice(&bytes[33..66]) + .map_err(|e| super::Error::InvalidAddress(e.to_string()))?; + + Ok(Self { + scan_key, + spend_key, + label: None, + }) + } +} + /// ECDH share for a silent payment output #[derive(Debug, Clone, PartialEq, Eq)] pub struct EcdhShareData { @@ -18,12 +87,12 @@ pub struct EcdhShareData { /// ECDH share value (33 bytes compressed public key) pub share: PublicKey, /// Optional DLEQ proof (64 bytes) - pub dleq_proof: Option<[u8; 64]>, + pub dleq_proof: Option, } impl EcdhShareData { /// Create a new ECDH share - pub fn new(scan_key: PublicKey, share: PublicKey, dleq_proof: Option<[u8; 64]>) -> Self { + pub fn new(scan_key: PublicKey, share: PublicKey, dleq_proof: Option) -> Self { Self { scan_key, share, @@ -132,8 +201,16 @@ impl PsbtOutput { } /// Create a silent payment output - pub fn silent_payment(amount: Amount, address: SilentPaymentAddress, label: Option) -> Self { - Self::SilentPayment { amount, address, label } + pub fn silent_payment( + amount: Amount, + address: SilentPaymentAddress, + label: Option, + ) -> Self { + Self::SilentPayment { + amount, + address, + label, + } } /// Check if this is a silent payment output diff --git a/spdk-core/src/psbt/crypto/dleq.rs b/spdk-core/src/psbt/crypto/dleq.rs index e8205af..eb59bf6 100644 --- a/spdk-core/src/psbt/crypto/dleq.rs +++ b/spdk-core/src/psbt/crypto/dleq.rs @@ -5,6 +5,7 @@ use super::error::{CryptoError, Result}; use bitcoin::hashes::{sha256, Hash, HashEngine}; +use psbt_v2::v2::dleq::DleqProof; use secp256k1::{PublicKey, Scalar, Secp256k1, SecretKey}; // Tagged hash tags for BIP-374 @@ -78,7 +79,7 @@ pub fn dleq_generate_proof( b: &PublicKey, r: &[u8; 32], m: Option<&[u8; 32]>, -) -> Result<[u8; 64]> { +) -> Result { // Compute A = a*G and C = a*B let a_point = PublicKey::from_secret_key(secp, a); let a_scalar: Scalar = (*a).into(); @@ -131,9 +132,12 @@ pub fn dleq_generate_proof( let s = Scalar::from(s_key); // Construct proof: e || s - let mut proof = [0u8; 64]; - proof[0..32].copy_from_slice(&e.to_be_bytes()); - proof[32..64].copy_from_slice(&s.to_be_bytes()); + let mut proof_bytes = [0u8; 64]; + proof_bytes[0..32].copy_from_slice(&e.to_be_bytes()); + proof_bytes[32..64].copy_from_slice(&s.to_be_bytes()); + + // Verify the proof before returning + let proof = DleqProof(proof_bytes); // Verify the proof before returning if !dleq_verify_proof(secp, &a_point, b, &c_point, &proof, m)? { @@ -161,14 +165,14 @@ pub fn dleq_verify_proof( a: &PublicKey, b: &PublicKey, c: &PublicKey, - proof: &[u8; 64], + proof: &DleqProof, m: Option<&[u8; 32]>, ) -> Result { // Parse proof: e || s let mut e_bytes = [0u8; 32]; let mut s_bytes = [0u8; 32]; - e_bytes.copy_from_slice(&proof[0..32]); - s_bytes.copy_from_slice(&proof[32..64]); + e_bytes.copy_from_slice(&proof.0[0..32]); + s_bytes.copy_from_slice(&proof.0[32..64]); let e = Scalar::from_be_bytes(e_bytes).map_err(|_| CryptoError::DleqVerificationFailed)?; let s = Scalar::from_be_bytes(s_bytes).map_err(|_| CryptoError::DleqVerificationFailed)?; @@ -277,7 +281,7 @@ mod tests { let mut proof = dleq_generate_proof(&secp, &a, &b, &rand_aux, None).unwrap(); // Corrupt the proof by flipping a bit - proof[0] ^= 1; + proof.0[0] ^= 1; // Verification should fail let valid = dleq_verify_proof(&secp, &a_pub, &b, &c, &proof, None).unwrap(); From cc98833330e3fd520610b0cc54c13c848826df79 Mon Sep 17 00:00:00 2001 From: macgyver13 <4712150+macgyver13@users.noreply.github.com> Date: Wed, 31 Dec 2025 10:49:21 -0500 Subject: [PATCH 03/16] psbt: Expand psbt taproot handling --- spdk-core/src/psbt/crypto/bip352.rs | 35 +++++++++++ spdk-core/src/psbt/mod.rs | 9 ++- spdk-core/src/psbt/roles/input_finalizer.rs | 38 +++++++----- spdk-core/src/psbt/roles/signer.rs | 68 ++++++++++++++++++--- spdk-core/src/psbt/roles/validation.rs | 50 ++++++++++++--- 5 files changed, 161 insertions(+), 39 deletions(-) diff --git a/spdk-core/src/psbt/crypto/bip352.rs b/spdk-core/src/psbt/crypto/bip352.rs index ec00083..6b6fd2c 100644 --- a/spdk-core/src/psbt/crypto/bip352.rs +++ b/spdk-core/src/psbt/crypto/bip352.rs @@ -120,11 +120,46 @@ pub fn pubkey_to_p2wpkh_script(pubkey: &PublicKey) -> ScriptBuf { /// Convert public key to P2TR (Taproot) script /// /// Returns: OP_1 <32-byte-xonly-pubkey> +#[deprecated(since = "0.24.0", note = "use tweaked_key_to_p2tr_script instead")] pub fn pubkey_to_p2tr_script(pubkey: &PublicKey) -> ScriptBuf { let xonly = pubkey.x_only_public_key().0; ScriptBuf::new_p2tr_tweaked(xonly.dangerous_assume_tweaked()) } +/// Convert an UNTWEAKED internal key to P2TR script (BIP-86 standard) +/// +/// Applies BIP-341 taproot tweak: Q = P + hash_TapTweak(P) * G +/// Use this for regular BIP-86 taproot addresses where the internal key +/// needs to be tweaked according to BIP-341. +/// +/// For keypath-only spends (no script tree), the tweak is computed as: +/// t = hash_TapTweak(P || 0x00) where 0x00 represents empty merkle root +/// +/// Returns: OP_1 <32-byte-tweaked-xonly-pubkey> +pub fn internal_key_to_p2tr_script(internal_key: &PublicKey) -> Result { + let secp = Secp256k1::new(); + let (xonly, _parity) = internal_key.x_only_public_key(); + + // Apply BIP-341 taproot tweak (no script tree) + let (tweaked, _parity) = xonly.tap_tweak(&secp, None); + + Ok(ScriptBuf::new_p2tr_tweaked(tweaked)) +} + +/// Convert an ALREADY-TWEAKED output key to P2TR script +/// +/// Use this for Silent Payment outputs where the pubkey is already +/// tweaked via BIP-352 derivation: output_pubkey = spend_key + t_k * G +/// +/// The key has already been modified by the Silent Payment protocol, +/// so no additional BIP-341 taproot tweak should be applied. +/// +/// Returns: OP_1 <32-byte-xonly-pubkey> +pub fn tweaked_key_to_p2tr_script(tweaked_output_key: &PublicKey) -> ScriptBuf { + let xonly = tweaked_output_key.x_only_public_key().0; + ScriptBuf::new_p2tr_tweaked(xonly.dangerous_assume_tweaked()) +} + /// Detect script type from ScriptBuf /// /// Returns a human-readable string identifying the script type. diff --git a/spdk-core/src/psbt/mod.rs b/spdk-core/src/psbt/mod.rs index f5e661f..a41b3cf 100644 --- a/spdk-core/src/psbt/mod.rs +++ b/spdk-core/src/psbt/mod.rs @@ -15,9 +15,8 @@ pub mod roles; // Re-export commonly used types from core pub use core::{ - aggregate_ecdh_shares, get_input_bip32_pubkeys, get_input_outpoint, - get_input_outpoint_bytes, get_input_pubkey, get_input_txid, get_input_vout, - AggregatedShare, AggregatedShares, Bip375PsbtExt, EcdhShareData, - Error, PsbtInput, PsbtOutput, Result, SilentPaymentPsbt, + aggregate_ecdh_shares, get_input_bip32_pubkeys, get_input_outpoint, get_input_outpoint_bytes, + get_input_pubkey, get_input_txid, get_input_vout, AggregatedShare, AggregatedShares, + Bip375PsbtExt, EcdhShareData, Error, GlobalFieldsExt, InputFieldsExt, OutputFieldsExt, + PsbtInput, PsbtKey, PsbtOutput, Result, SilentPaymentPsbt, }; - diff --git a/spdk-core/src/psbt/roles/input_finalizer.rs b/spdk-core/src/psbt/roles/input_finalizer.rs index 503055d..f365ae7 100644 --- a/spdk-core/src/psbt/roles/input_finalizer.rs +++ b/spdk-core/src/psbt/roles/input_finalizer.rs @@ -5,6 +5,7 @@ use crate::psbt::core::{aggregate_ecdh_shares, Bip375PsbtExt, Error, Result, SilentPaymentPsbt}; use crate::psbt::crypto::{ apply_label_to_spend_key, derive_silent_payment_output_pubkey, pubkey_to_p2tr_script, + tweaked_key_to_p2tr_script, }; use secp256k1::{PublicKey, Secp256k1, SecretKey}; use std::collections::HashMap; @@ -49,18 +50,15 @@ pub fn finalize_inputs( if let Some(privkeys) = scan_privkeys { if let Some(scan_privkey) = privkeys.get(&scan_key) { spend_key_to_use = - apply_label_to_spend_key(secp, &spend_key, scan_privkey, label) - .map_err(|e| { - Error::Other(format!("Failed to apply label tweak: {}", e)) - })?; + apply_label_to_spend_key(secp, &spend_key, scan_privkey, label).map_err( + |e| Error::Other(format!("Failed to apply label tweak: {}", e)), + )?; } } } // Get or initialize the output index for this scan key - let k = *scan_key_output_indices - .get(&scan_key) - .unwrap_or(&0); + let k = *scan_key_output_indices.get(&scan_key).unwrap_or(&0); // Derive the output public key using BIP-352 let ecdh_secret = aggregated_share.serialize(); @@ -72,8 +70,11 @@ pub fn finalize_inputs( ) .map_err(|e| Error::Other(format!("Output derivation failed: {}", e)))?; - // Create P2TR output script - let output_script = pubkey_to_p2tr_script(&output_pubkey); + // Create P2TR output script from already-tweaked Silent Payment output key + // The output_pubkey is already tweaked via BIP-352 derivation, so we use + // tweaked_key_to_p2tr_script (no additional BIP-341 tweak needed) + + let output_script = tweaked_key_to_p2tr_script(&output_pubkey); // Add output script to PSBT psbt.outputs[output_idx].script_pubkey = output_script; @@ -92,12 +93,14 @@ pub fn finalize_inputs( #[cfg(test)] mod tests { use super::*; - use crate::psbt::roles::{constructor::add_outputs, creator::create_psbt, signer::add_ecdh_shares_full}; use crate::psbt::core::{PsbtInput, PsbtOutput}; + use crate::psbt::roles::{ + constructor::add_outputs, creator::create_psbt, signer::add_ecdh_shares_full, + }; use bitcoin::hashes::Hash; use bitcoin::{Amount, OutPoint, ScriptBuf, Sequence, TxOut, Txid}; use secp256k1::SecretKey; - use silentpayments::{SilentPaymentAddress, Network as SpNetwork}; + use silentpayments::{Network as SpNetwork, SilentPaymentAddress}; #[test] fn test_finalize_inputs_basic() { @@ -112,13 +115,14 @@ mod tests { let spend_privkey = SecretKey::from_slice(&[20u8; 32]).unwrap(); let spend_key = PublicKey::from_secret_key(&secp, &spend_privkey); - let sp_address = SilentPaymentAddress::new(scan_key, spend_key, SpNetwork::Regtest, 0).unwrap(); + let sp_address = + SilentPaymentAddress::new(scan_key, spend_key, SpNetwork::Regtest, 0).unwrap(); // Add output let outputs = vec![PsbtOutput::silent_payment( Amount::from_sat(50000), sp_address, - None + None, )]; add_outputs(&mut psbt, &outputs).unwrap(); @@ -181,7 +185,8 @@ mod tests { let spend_privkey = SecretKey::from_slice(&[20u8; 32]).unwrap(); let spend_key = PublicKey::from_secret_key(&secp, &spend_privkey); - let sp_address = SilentPaymentAddress::new(scan_key, spend_key, SpNetwork::Regtest, 0).unwrap(); + let sp_address = + SilentPaymentAddress::new(scan_key, spend_key, SpNetwork::Regtest, 0).unwrap(); // Add output let outputs = vec![PsbtOutput::silent_payment( @@ -232,13 +237,14 @@ mod tests { let spend_privkey = SecretKey::from_slice(&[20u8; 32]).unwrap(); let spend_key = PublicKey::from_secret_key(&secp, &spend_privkey); - let sp_address = SilentPaymentAddress::new(scan_key, spend_key, SpNetwork::Regtest, 0).unwrap(); + let sp_address = + SilentPaymentAddress::new(scan_key, spend_key, SpNetwork::Regtest, 0).unwrap(); // Add output let outputs = vec![PsbtOutput::silent_payment( Amount::from_sat(50000), sp_address, - None + None, )]; add_outputs(&mut psbt, &outputs).unwrap(); diff --git a/spdk-core/src/psbt/roles/signer.rs b/spdk-core/src/psbt/roles/signer.rs index f86f3cb..e0d61ca 100644 --- a/spdk-core/src/psbt/roles/signer.rs +++ b/spdk-core/src/psbt/roles/signer.rs @@ -7,11 +7,14 @@ //! - **P2TR SP inputs**: Use [`sign_sp_inputs()`] with tweaked key + Schnorr → `tap_key_sig` //! - **Mixed transactions**: Call both functions as needed for different input types -use crate::psbt::core::{Bip375PsbtExt, EcdhShareData, Error, PsbtInput, Result, SilentPaymentPsbt}; +use crate::psbt::core::{ + Bip375PsbtExt, EcdhShareData, Error, PsbtInput, Result, SilentPaymentPsbt, +}; use crate::psbt::crypto::{ - apply_tweak_to_privkey, compute_ecdh_share, dleq_generate_proof, sign_p2pkh_input, - sign_p2tr_input, sign_p2wpkh_input, + apply_tweak_to_privkey, compute_ecdh_share, dleq_generate_proof, pubkey_to_p2wpkh_script, + sign_p2pkh_input, sign_p2tr_input, sign_p2wpkh_input, }; +use bitcoin::key::TapTweak; use bitcoin::ScriptBuf; use secp256k1::{PublicKey, Secp256k1, SecretKey}; use std::collections::HashSet; @@ -84,10 +87,10 @@ pub fn add_ecdh_shares_partial( *base_privkey }; - // For P2TR inputs, the public key is x-only (implicitly even Y). - // If our private key produces an odd Y point, we must negate it - // to match the on-chain public key for DLEQ verification. - if input.witness_utxo.script_pubkey.is_p2tr() { + // For P2TR Silent Payment inputs ONLY, check parity and negate if needed. + // Regular P2TR inputs (BIP-86) use the internal key for ECDH, not the tweaked key. + // The BIP-341 tweak is only for scriptPubKey generation, not for ECDH computation. + if psbt.get_input_sp_tweak(input_idx).is_some() { let keypair = secp256k1::Keypair::from_secret_key(secp, &privkey); let (_, parity) = keypair.x_only_public_key(); if parity == secp256k1::Parity::Odd { @@ -320,11 +323,60 @@ fn extract_tx_for_signing(psbt: &SilentPaymentPsbt) -> Result, + psbt: &SilentPaymentPsbt, + public_key: &PublicKey, +) -> Vec { + let mut signable = Vec::new(); + let bitcoin_pubkey = bitcoin::PublicKey::new(*public_key); + + for (idx, input) in psbt.inputs.iter().enumerate() { + if input.partial_sigs.contains_key(&bitcoin_pubkey) || input.tap_key_sig.is_some() { + continue; + } + + if let Some(witness_utxo) = &input.witness_utxo { + if witness_utxo.script_pubkey.is_p2wpkh() { + let expected_script = pubkey_to_p2wpkh_script(public_key); + if witness_utxo.script_pubkey == expected_script { + signable.push(idx); + } + } else if witness_utxo.script_pubkey.is_p2tr() { + let (xonly, _) = public_key.x_only_public_key(); + let tweaked_pubkey = xonly.dangerous_assume_tweaked(); + + use bitcoin::ScriptBuf; + let expected_script = ScriptBuf::new_p2tr_tweaked(tweaked_pubkey); + if witness_utxo.script_pubkey == expected_script { + signable.push(idx); + } + } + } + } + + signable +} + +pub fn get_unsigned_inputs(psbt: &SilentPaymentPsbt) -> Vec { + psbt.inputs + .iter() + .enumerate() + .filter_map(|(idx, input)| { + if input.partial_sigs.is_empty() && input.tap_key_sig.is_none() { + Some(idx) + } else { + None + } + }) + .collect() +} + #[cfg(test)] mod tests { use super::*; - use crate::psbt::roles::{constructor::add_inputs, creator::create_psbt}; use crate::psbt::core::PsbtInput; + use crate::psbt::roles::{constructor::add_inputs, creator::create_psbt}; use bitcoin::{hashes::Hash, Amount, OutPoint, ScriptBuf, Sequence, TxOut, Txid}; use secp256k1::SecretKey; diff --git a/spdk-core/src/psbt/roles/validation.rs b/spdk-core/src/psbt/roles/validation.rs index c2c9b28..8f441f3 100644 --- a/spdk-core/src/psbt/roles/validation.rs +++ b/spdk-core/src/psbt/roles/validation.rs @@ -4,7 +4,7 @@ use crate::psbt::core::{Bip375PsbtExt, Error, Result, SilentPaymentPsbt}; use crate::psbt::crypto::dleq_verify_proof; -use secp256k1::{PublicKey, Secp256k1}; +use secp256k1::{PublicKey, Scalar, Secp256k1, SecretKey}; use std::collections::HashSet; /// Validation level for PSBT checks @@ -262,10 +262,10 @@ fn validate_output_scripts( psbt: &SilentPaymentPsbt, ) -> Result<()> { use crate::psbt::core::{ - aggregate_ecdh_shares, get_input_bip32_pubkeys, get_input_outpoint_bytes + aggregate_ecdh_shares, get_input_bip32_pubkeys, get_input_outpoint_bytes, }; use crate::psbt::crypto::{ - compute_input_hash, derive_silent_payment_output_pubkey, pubkey_to_p2tr_script, + compute_input_hash, derive_silent_payment_output_pubkey, tweaked_key_to_p2tr_script, }; use std::collections::HashMap; @@ -350,7 +350,7 @@ fn validate_output_scripts( derive_silent_payment_output_pubkey(secp, &spend_key, &shared_secret_bytes, k) .map_err(|e| Error::Other(format!("Failed to derive output pubkey: {}", e)))?; - let expected_script = pubkey_to_p2tr_script(&expected_pubkey); + let expected_script = tweaked_key_to_p2tr_script(&expected_pubkey); if output.script_pubkey != expected_script { return Err(Error::Other(format!( @@ -396,7 +396,32 @@ fn validate_dleq_proofs(secp: &Secp256k1, psbt: &SilentPaymentPs } if let Some(proof) = share.dleq_proof { - let input_pubkey = get_input_pubkey(psbt, input_idx)?; + let mut input_pubkey = get_input_pubkey(psbt, input_idx)?; + + // If this is a Silent Payment input, derive the tweaked public key + if let Some(tweak) = psbt.get_input_sp_tweak(input_idx) { + let tweak_scalar = Scalar::from_be_bytes(tweak) + .map_err(|_| Error::Other("Invalid SP tweak scalar".to_string()))?; + let tweak_key = SecretKey::from_slice(&tweak_scalar.to_be_bytes())?; + let tweak_point = PublicKey::from_secret_key(secp, &tweak_key); + + input_pubkey = input_pubkey.combine(&tweak_point)?; // A' = A + tweak*G + + // Handle parity for P2TR Silent Payment inputs ONLY + // Regular P2TR inputs use the internal key for ECDH, not the tweaked key + let input = psbt + .inputs + .get(input_idx) + .ok_or(Error::InvalidInputIndex(input_idx))?; + if let Some(ref witness_utxo) = input.witness_utxo { + if witness_utxo.script_pubkey.is_p2tr() { + let (_, parity) = input_pubkey.x_only_public_key(); + if parity == secp256k1::Parity::Odd { + input_pubkey = input_pubkey.negate(secp); + } + } + } + } let is_valid = dleq_verify_proof( secp, @@ -458,16 +483,16 @@ pub fn validate_ready_for_extraction(psbt: &SilentPaymentPsbt) -> Result<()> { #[cfg(test)] mod tests { use super::*; + use crate::psbt::core::{PsbtInput, PsbtOutput}; + use crate::psbt::crypto::pubkey_to_p2wpkh_script; use crate::psbt::roles::{ constructor::{add_inputs, add_outputs}, creator::create_psbt, }; - use crate::psbt::core::{PsbtInput, PsbtOutput}; - use crate::psbt::crypto::pubkey_to_p2wpkh_script; use bitcoin::hashes::Hash; use bitcoin::{Amount, OutPoint, Sequence, TxOut, Txid}; use secp256k1::SecretKey; - use silentpayments::{SilentPaymentAddress, Network as SpNetwork}; + use silentpayments::{Network as SpNetwork, SilentPaymentAddress}; #[test] fn test_validate_psbt_version() { @@ -528,10 +553,15 @@ mod tests { let spend_priv = SecretKey::from_slice(&[3u8; 32]).unwrap(); let spend_pub = PublicKey::from_secret_key(&secp, &spend_priv); - let sp_addr = SilentPaymentAddress::new(scan_pub, spend_pub, SpNetwork::Regtest, 0).unwrap(); + let sp_addr = + SilentPaymentAddress::new(scan_pub, spend_pub, SpNetwork::Regtest, 0).unwrap(); // Add SP output - let outputs = vec![PsbtOutput::silent_payment(Amount::from_sat(10000), sp_addr, None)]; + let outputs = vec![PsbtOutput::silent_payment( + Amount::from_sat(10000), + sp_addr, + None, + )]; add_outputs(&mut psbt, &outputs).unwrap(); // Add dummy inputs so we have something to check against From 3515c55f21887fc9379ecef02074c044b5d1896e Mon Sep 17 00:00:00 2001 From: macgyver13 <4712150+macgyver13@users.noreply.github.com> Date: Wed, 31 Dec 2025 10:54:39 -0500 Subject: [PATCH 04/16] psbt: formatting cleanup --- spdk-core/src/psbt/helpers/wallet/types.rs | 5 +++-- spdk-core/src/psbt/roles/constructor.rs | 6 +++++- spdk-core/src/psbt/roles/extractor.rs | 8 +++++--- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/spdk-core/src/psbt/helpers/wallet/types.rs b/spdk-core/src/psbt/helpers/wallet/types.rs index 85ab37c..3db1509 100644 --- a/spdk-core/src/psbt/helpers/wallet/types.rs +++ b/spdk-core/src/psbt/helpers/wallet/types.rs @@ -211,8 +211,9 @@ impl VirtualWallet { let (final_pubkey, tweak) = if has_sp_tweak { // Generate a deterministic tweak for this UTXO let tweak = Self::generate_demo_tweak(idx); - let tweaked_privkey = crate::psbt::crypto::apply_tweak_to_privkey(&privkey, &tweak) - .expect("Valid tweak"); + let tweaked_privkey = + crate::psbt::crypto::apply_tweak_to_privkey(&privkey, &tweak) + .expect("Valid tweak"); let tweaked_pubkey = PublicKey::from_secret_key(&secp, &tweaked_privkey); (tweaked_pubkey, Some(tweak)) } else { diff --git a/spdk-core/src/psbt/roles/constructor.rs b/spdk-core/src/psbt/roles/constructor.rs index 2ea8ef7..701f1b2 100644 --- a/spdk-core/src/psbt/roles/constructor.rs +++ b/spdk-core/src/psbt/roles/constructor.rs @@ -62,7 +62,11 @@ pub fn add_outputs(psbt: &mut SilentPaymentPsbt, outputs: &[PsbtOutput]) -> Resu psbt_output.amount = psbt_v2::bitcoin::Amount::from_sat(txout.value.to_sat()); psbt_output.script_pubkey = txout.script_pubkey.clone(); } - PsbtOutput::SilentPayment { amount, address, label } => { + PsbtOutput::SilentPayment { + amount, + address, + label, + } => { let psbt_output = &mut psbt.outputs[i]; // Convert between potentially different bitcoin::Amount types psbt_output.amount = psbt_v2::bitcoin::Amount::from_sat(amount.to_sat()); diff --git a/spdk-core/src/psbt/roles/extractor.rs b/spdk-core/src/psbt/roles/extractor.rs index df5c779..2b20926 100644 --- a/spdk-core/src/psbt/roles/extractor.rs +++ b/spdk-core/src/psbt/roles/extractor.rs @@ -144,14 +144,14 @@ fn extract_output(psbt: &SilentPaymentPsbt, output_idx: usize) -> Result #[cfg(test)] mod tests { use super::*; + use crate::psbt::core::{PsbtInput, PsbtOutput}; + use crate::psbt::crypto::pubkey_to_p2wpkh_script; use crate::psbt::roles::{ constructor::{add_inputs, add_outputs}, creator::create_psbt, input_finalizer::finalize_inputs, signer::{add_ecdh_shares_full, sign_inputs}, }; - use crate::psbt::core::{PsbtInput, PsbtOutput}; - use crate::psbt::crypto::pubkey_to_p2wpkh_script; use bitcoin::{hashes::Hash, Amount, OutPoint, ScriptBuf, Sequence, TxOut, Txid}; use secp256k1::{PublicKey, Secp256k1, SecretKey}; use silentpayments::SilentPaymentAddress; @@ -233,7 +233,9 @@ mod tests { let spend_privkey = SecretKey::from_slice(&[20u8; 32]).unwrap(); let spend_key = PublicKey::from_secret_key(&secp, &spend_privkey); - let sp_address = SilentPaymentAddress::new(scan_key, spend_key, silentpayments::Network::Regtest, 0).unwrap(); + let sp_address = + SilentPaymentAddress::new(scan_key, spend_key, silentpayments::Network::Regtest, 0) + .unwrap(); // Create inputs with private keys let privkey1 = SecretKey::from_slice(&[1u8; 32]).unwrap(); From 96410d25003e28bbe52a98d0eefab94f326389c8 Mon Sep 17 00:00:00 2001 From: macgyver13 <4712150+macgyver13@users.noreply.github.com> Date: Wed, 31 Dec 2025 12:42:35 -0500 Subject: [PATCH 05/16] psbt: Remove deprecated pubkey_to_p2tr_script Remove helper module providing SimpleWallet, Utxo, TranscationConfig, VirtualWallet --- spdk-core/src/psbt/core/mod.rs | 2 +- spdk-core/src/psbt/crypto/bip352.rs | 23 +++++++-------------- spdk-core/src/psbt/mod.rs | 1 - spdk-core/src/psbt/roles/input_finalizer.rs | 3 +-- 4 files changed, 9 insertions(+), 20 deletions(-) diff --git a/spdk-core/src/psbt/core/mod.rs b/spdk-core/src/psbt/core/mod.rs index 909be84..0a80081 100644 --- a/spdk-core/src/psbt/core/mod.rs +++ b/spdk-core/src/psbt/core/mod.rs @@ -26,4 +26,4 @@ pub use types::{EcdhShareData, PsbtInput, PsbtOutput}; /// /// Use the `Bip375PsbtExt` trait to access BIP-375 specific functionality. pub type SilentPaymentPsbt = psbt_v2::v2::Psbt; -pub type PsbtKey = psbt_v2::raw::Key; \ No newline at end of file +pub type PsbtKey = psbt_v2::raw::Key; diff --git a/spdk-core/src/psbt/crypto/bip352.rs b/spdk-core/src/psbt/crypto/bip352.rs index 6b6fd2c..702c257 100644 --- a/spdk-core/src/psbt/crypto/bip352.rs +++ b/spdk-core/src/psbt/crypto/bip352.rs @@ -117,15 +117,6 @@ pub fn pubkey_to_p2wpkh_script(pubkey: &PublicKey) -> ScriptBuf { ScriptBuf::new_p2wpkh(&pubkey_hash) } -/// Convert public key to P2TR (Taproot) script -/// -/// Returns: OP_1 <32-byte-xonly-pubkey> -#[deprecated(since = "0.24.0", note = "use tweaked_key_to_p2tr_script instead")] -pub fn pubkey_to_p2tr_script(pubkey: &PublicKey) -> ScriptBuf { - let xonly = pubkey.x_only_public_key().0; - ScriptBuf::new_p2tr_tweaked(xonly.dangerous_assume_tweaked()) -} - /// Convert an UNTWEAKED internal key to P2TR script (BIP-86 standard) /// /// Applies BIP-341 taproot tweak: Q = P + hash_TapTweak(P) * G @@ -136,14 +127,14 @@ pub fn pubkey_to_p2tr_script(pubkey: &PublicKey) -> ScriptBuf { /// t = hash_TapTweak(P || 0x00) where 0x00 represents empty merkle root /// /// Returns: OP_1 <32-byte-tweaked-xonly-pubkey> -pub fn internal_key_to_p2tr_script(internal_key: &PublicKey) -> Result { +pub fn internal_key_to_p2tr_script(internal_key: &PublicKey) -> ScriptBuf { let secp = Secp256k1::new(); let (xonly, _parity) = internal_key.x_only_public_key(); // Apply BIP-341 taproot tweak (no script tree) let (tweaked, _parity) = xonly.tap_tweak(&secp, None); - Ok(ScriptBuf::new_p2tr_tweaked(tweaked)) + ScriptBuf::new_p2tr_tweaked(tweaked) } /// Convert an ALREADY-TWEAKED output key to P2TR script @@ -316,13 +307,13 @@ mod tests { println!("Derived x-only: {}", xonly_hex); println!( - "Expected x-only: ae19fbee2730a1a952d7d2598cc703fddf3b972b25148b1ed1a79ae8739d5e07" + "Expected x-only: 2ef9f0e19f3c275d84d98c44912fec626bac45e442af47d02d9b9652ff9a9f0a" ); // This should match the test vector assert_eq!( xonly_hex, - "ae19fbee2730a1a952d7d2598cc703fddf3b972b25148b1ed1a79ae8739d5e07" + "2ef9f0e19f3c275d84d98c44912fec626bac45e442af47d02d9b9652ff9a9f0a" ); } @@ -332,7 +323,7 @@ mod tests { let privkey = SecretKey::from_slice(&[1u8; 32]).unwrap(); let pubkey = PublicKey::from_secret_key(&secp, &privkey); - let script = pubkey_to_p2tr_script(&pubkey); + let script = tweaked_key_to_p2tr_script(&pubkey); // P2TR scripts are 34 bytes: OP_1 (0x51) + PUSH_32 (0x20) + 32-byte x-only key assert_eq!(script.len(), 34); @@ -350,14 +341,14 @@ mod tests { #[test] fn test_p2tr_script_compatibility() { - // Verify that pubkey_to_p2tr_script produces identical results + // Verify that tweaked_key_to_p2tr_script produces identical results // to the manual construction previously used in validation.rs and input_finalizer.rs let secp = Secp256k1::new(); let privkey = SecretKey::from_slice(&[42u8; 32]).unwrap(); let pubkey = PublicKey::from_secret_key(&secp, &privkey); // New method - let new_script = pubkey_to_p2tr_script(&pubkey); + let new_script = tweaked_key_to_p2tr_script(&pubkey); // Old method (manual construction) let (xonly, _parity) = pubkey.x_only_public_key(); diff --git a/spdk-core/src/psbt/mod.rs b/spdk-core/src/psbt/mod.rs index a41b3cf..90fd1d3 100644 --- a/spdk-core/src/psbt/mod.rs +++ b/spdk-core/src/psbt/mod.rs @@ -9,7 +9,6 @@ pub mod core; pub mod crypto; -pub mod helpers; pub mod io; pub mod roles; diff --git a/spdk-core/src/psbt/roles/input_finalizer.rs b/spdk-core/src/psbt/roles/input_finalizer.rs index f365ae7..ac0356b 100644 --- a/spdk-core/src/psbt/roles/input_finalizer.rs +++ b/spdk-core/src/psbt/roles/input_finalizer.rs @@ -4,8 +4,7 @@ use crate::psbt::core::{aggregate_ecdh_shares, Bip375PsbtExt, Error, Result, SilentPaymentPsbt}; use crate::psbt::crypto::{ - apply_label_to_spend_key, derive_silent_payment_output_pubkey, pubkey_to_p2tr_script, - tweaked_key_to_p2tr_script, + apply_label_to_spend_key, derive_silent_payment_output_pubkey, tweaked_key_to_p2tr_script, }; use secp256k1::{PublicKey, Secp256k1, SecretKey}; use std::collections::HashMap; From 5c3b6f7415119acd9340e2afd76cb6413c9778f2 Mon Sep 17 00:00:00 2001 From: macgyver13 <4712150+macgyver13@users.noreply.github.com> Date: Thu, 1 Jan 2026 18:45:49 -0500 Subject: [PATCH 06/16] dleq: POC to use rust-dleq library with native or standalone features Switch to rust-dleq native implementation by default Add documentation for switching between native and standalone crypto for dleq --- Cargo.toml | 26 ++-- justfile | 63 ++++++++ nightly-version | 1 + spdk-core/Cargo.toml | 18 ++- spdk-core/examples/dleq_example.rs | 114 +++++++++++++++ spdk-core/src/lib.rs | 1 + spdk-core/src/psbt/core/types.rs | 68 --------- spdk-core/src/psbt/crypto/dleq.rs | 224 +++++++---------------------- spdk-core/src/psbt/mod.rs | 6 + 9 files changed, 264 insertions(+), 257 deletions(-) create mode 100644 justfile create mode 100644 nightly-version create mode 100644 spdk-core/examples/dleq_example.rs diff --git a/Cargo.toml b/Cargo.toml index 31b85d4..499b26d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,31 +10,35 @@ backend-blindbit-v1 = { path = "backend-blindbit-v1"} # Core dependencies - shared across crates anyhow = "1.0" +async-trait = "0.1" +base64 = "0.22" +bdk_coin_select = "0.4.0" bech32 = "0.9" +# bdk_coin_select = "0.4.0" bimap = "0.6" -serde = { version = "1.0.188", features = ["derive"] } -serde_json = "1.0.107" bitcoin = { version = "0.32.8", features = ["serde", "rand", "base64"] } bitcoin_hashes = "0.13.0" -rayon = "1.10.0" +dnssec-prover = "0.1" futures = "0.3" -async-trait = "0.1" -log = "0.4" hex = { version = "0.4.3", features = ["serde"] } -bdk_coin_select = "0.4.0" +hmac = "0.12" +log = "0.4" reqwest = { version = "0.12.4", features = [ "json", "rustls-tls", "gzip", ], default-features = false } -secp256k1 = { version = "0.29.0", features = ["rand"] } psbt-v2 = { git = "https://github.com/tcharding/rust-psbt.git", branch = "master", features = ["silent-payments"] } -thiserror = "1.0" -base64 = "0.22" +rayon = "1.10.0" +rust-dleq = { git = "https://github.com/macgyver13/rust-dleq.git", branch = "master", default-features = false } +secp256k1 = { version = "0.29.0", features = ["rand"] } +serde = { version = "1.0.188", features = ["derive"] } +serde_json = "1.0.107" sha2 = "0.10" -hmac = "0.12" -dnssec-prover = "0.1" tempfile = "3.8" +thiserror = "1.0" +tokio = { version = "1.48.0", features = ["rt"], default-features = false } + [workspace.package] repository = "https://github.com/cygnet3/spdk" diff --git a/justfile b/justfile new file mode 100644 index 0000000..cc70e9b --- /dev/null +++ b/justfile @@ -0,0 +1,63 @@ +# Once just v1.39.0 is widely deployed, simplify with the `read` function. +NIGHTLY_VERSION := trim(read(justfile_directory() / "nightly-version")) + +_default: + @just --list + +# Install rbmt (Rust Bitcoin Maintainer Tools). +@_install-rbmt: + cargo install --quiet --git https://github.com/rust-bitcoin/rust-bitcoin-maintainer-tools.git --rev $(cat {{justfile_directory()}}/rbmt-version) cargo-rbmt + +# Check spdk-core. +[group('spdk-core')] +check: + cargo check -p spdk-core + +# Build spdk-core. +[group('spdk-core')] +build: + cargo build -p spdk-core + +# Test spdk-core. +[group('spdk-core')] +test: + cargo test -p spdk-core + +# Lint spdk-core. +[group('spdk-core')] +lint: + cargo +{{NIGHTLY_VERSION}} clippy -p spdk-core + +# Run cargo fmt +fmt: + cargo +{{NIGHTLY_VERSION}} fmt --all + +# Run dleq example (default) +run-dleq: + cargo run --example dleq_example + +# Run dleq example (standalone) +run-dleq-standalone: + cargo run -p spdk-core --example dleq_example --no-default-features --features dleq-standalone + +# Update the recent and minimal lock files using rbmt. +[group('tools')] +@update-lock-files: _install-rbmt + rustup run {{NIGHTLY_VERSION}} cargo rbmt lock + +# Run CI tasks with rbmt. +[group('ci')] +@ci task toolchain="stable" lock="recent": _install-rbmt + RBMT_LOG_LEVEL=quiet rustup run {{toolchain}} cargo rbmt --lock-file {{lock}} {{task}} + +# Test crate. +[group('ci')] +ci-test: (ci "test stable") + +# Lint crate. +[group('ci')] +ci-lint: (ci "lint" NIGHTLY_VERSION) + +# Bitcoin core integration tests. +[group('ci')] +ci-integration: (ci "integration") diff --git a/nightly-version b/nightly-version new file mode 100644 index 0000000..4daf9df --- /dev/null +++ b/nightly-version @@ -0,0 +1 @@ +nightly-2025-01-21 diff --git a/spdk-core/Cargo.toml b/spdk-core/Cargo.toml index 49dc292..343f87a 100644 --- a/spdk-core/Cargo.toml +++ b/spdk-core/Cargo.toml @@ -6,18 +6,32 @@ repository.workspace = true [lib] crate-type = ["lib", "staticlib", "cdylib"] + + [features] + # Default: both sync and async APIs available, with parallelization + default = ["dleq-native"] + +# DLEQ proof implementation options (mutually exclusive) +# Native: Direct FFI to libsecp256k1 (default, uses vendored secp256k1) +dleq-native = ["rust-dleq/native"] +# Standalone: Pure Rust + rust-secp256k1 (portable) +dleq-standalone = ["rust-dleq/standalone"] [dependencies] anyhow.workspace = true async-trait.workspace = true bitcoin.workspace = true +dnssec-prover.workspace = true futures.workspace = true +hex.workspace = true +hmac.workspace = true serde.workspace = true +serde_json.workspace = true silentpayments.workspace = true psbt-v2.workspace = true +rust-dleq.workspace = true secp256k1.workspace = true thiserror.workspace = true base64.workspace = true sha2.workspace = true -hmac.workspace = true -dnssec-prover.workspace = true \ No newline at end of file +tempfile.workspace = true \ No newline at end of file diff --git a/spdk-core/examples/dleq_example.rs b/spdk-core/examples/dleq_example.rs new file mode 100644 index 0000000..cc11ada --- /dev/null +++ b/spdk-core/examples/dleq_example.rs @@ -0,0 +1,114 @@ +//! Example: Using DLEQ proofs with rust-dleq integration +//! +//! This example demonstrates how to use DLEQ proofs in SPDK with rust-dleq. +//! The same code works with both `dleq-standalone` and `dleq-native` features. +//! +//! Run with standalone (default): +//! cargo run --example dleq_example +//! +//! Run with native: +//! cargo run --example dleq_example --no-default-features --features dleq-native,async,parallel + +use secp256k1::{PublicKey, Secp256k1, SecretKey}; +use spdk_core::psbt::crypto::dleq::{dleq_generate_proof, dleq_verify_proof}; +use spdk_core::psbt::{from_psbt_v2_proof, to_psbt_v2_proof}; + +fn main() { + println!("DLEQ Proof Example with rust-dleq Integration\n"); + + let secp = Secp256k1::new(); + + // Party A: Generate a keypair + let secret_a = SecretKey::from_slice(&[0x01; 32]).expect("valid secret key"); + let pubkey_a = PublicKey::from_secret_key(&secp, &secret_a); + println!("Party A public key: {}", pubkey_a); + + // Party B: Generate a public key (scan key in silent payments context) + let secret_b = SecretKey::from_slice(&[0x02; 32]).expect("valid secret key"); + let pubkey_b = PublicKey::from_secret_key(&secp, &secret_b); + println!("Party B public key (scan key): {}", pubkey_b); + + // Compute ECDH share: C = a * B + let ecdh_share = pubkey_b + .mul_tweak(&secp, &secret_a.into()) + .expect("valid ECDH computation"); + println!("ECDH share: {}\n", ecdh_share); + + // Generate DLEQ proof + println!("Generating DLEQ proof..."); + let aux_randomness = [0x42; 32]; // In practice, use secure randomness + let message = Some([0xAB; 32]); // Optional message to bind to proof + + let proof = dleq_generate_proof( + &secp, + &secret_a, + &pubkey_b, + &aux_randomness, + message.as_ref(), + ) + .expect("proof generation successful"); + + println!("✓ Proof generated successfully"); + println!(" Proof bytes (first 16): {:02x?}...\n", &proof.0[..16]); + + // Verify the DLEQ proof + println!("Verifying DLEQ proof..."); + let is_valid = dleq_verify_proof( + &secp, + &pubkey_a, + &pubkey_b, + &ecdh_share, + &proof, + message.as_ref(), + ) + .expect("verification executed"); + + if is_valid { + println!("✓ Proof is VALID"); + println!(" The prover knows the discrete log relationship:"); + println!(" log_G(A) = log_B(C)\n"); + } else { + println!("✗ Proof is INVALID"); + } + + // Demonstrate proof conversion between types + println!("Demonstrating type conversion..."); + let rust_dleq_proof = from_psbt_v2_proof(&proof); + println!(" Converted psbt-v2 proof to rust-dleq proof"); + + let converted_back = to_psbt_v2_proof(&rust_dleq_proof); + println!(" Converted back to psbt-v2 proof"); + + assert_eq!(proof.0, converted_back.0); + println!("✓ Round-trip conversion successful\n"); + + // Test with invalid proof + println!("Testing with corrupted proof..."); + let mut corrupted_proof = proof; + corrupted_proof.0[0] ^= 0xFF; // Flip bits + + let is_valid_corrupted = dleq_verify_proof( + &secp, + &pubkey_a, + &pubkey_b, + &ecdh_share, + &corrupted_proof, + message.as_ref(), + ) + .expect("verification executed"); + + if !is_valid_corrupted { + println!("✓ Corrupted proof correctly rejected\n"); + } else { + println!("✗ Corrupted proof incorrectly accepted\n"); + } + + // Feature detection + #[cfg(all(feature = "dleq-standalone", not(feature = "dleq-native")))] + println!("Using: dleq-standalone feature (Pure Rust implementation)"); + + #[cfg(all(feature = "dleq-native", not(feature = "dleq-standalone")))] + println!("Using: dleq-native feature (Native FFI to libsecp256k1)"); + + println!("\n✓ Example completed successfully!"); +} diff --git a/spdk-core/src/lib.rs b/spdk-core/src/lib.rs index 3b7392c..69e762b 100644 --- a/spdk-core/src/lib.rs +++ b/spdk-core/src/lib.rs @@ -1,3 +1,4 @@ pub mod chain; pub mod constants; +pub mod psbt; pub mod updater; diff --git a/spdk-core/src/psbt/core/types.rs b/spdk-core/src/psbt/core/types.rs index 33cadf2..f335488 100644 --- a/spdk-core/src/psbt/core/types.rs +++ b/spdk-core/src/psbt/core/types.rs @@ -11,74 +11,6 @@ use silentpayments::SilentPaymentAddress; // Core BIP-352/BIP-375 Protocol Types // ============================================================================ -/// A silent payment address for PSBT (BIP-375). -/// -/// Contains a scan public key and spend public key, with an optional label. -/// This type is used when storing silent payment addresses in PSBT outputs. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct SilentPaymentOutputInfo { - /// Scan public key (33 bytes compressed). - pub scan_key: PublicKey, - /// Spend public key (33 bytes compressed). - pub spend_key: PublicKey, - /// Optional label for change outputs. - pub label: Option, -} - -impl SilentPaymentOutputInfo { - /// Creates a new silent payment address. - pub fn new(scan_key: PublicKey, spend_key: PublicKey, label: Option) -> Self { - Self { - scan_key, - spend_key, - label, - } - } - - /// Creates an address without a label. - pub fn without_label(scan_key: PublicKey, spend_key: PublicKey) -> Self { - Self::new(scan_key, spend_key, None) - } - - /// Serializes to bytes (scan_key || spend_key). - /// - /// Format: 66 bytes - scan_key (33) || spend_key (33) - /// Note: Label is stored separately in PSBT - pub fn to_bytes(&self) -> Vec { - let mut bytes = Vec::with_capacity(66); - bytes.extend_from_slice(&self.scan_key.serialize()); - bytes.extend_from_slice(&self.spend_key.serialize()); - bytes - } - - /// Deserializes from bytes. - /// - /// # Errors - /// - /// Returns an error if: - /// - The byte length is not 66 - /// - The public keys are invalid - pub fn from_bytes(bytes: &[u8]) -> Result { - if bytes.len() != 66 { - return Err(super::Error::InvalidAddress(format!( - "Invalid length: expected 66 bytes, got {}", - bytes.len() - ))); - } - - let scan_key = PublicKey::from_slice(&bytes[0..33]) - .map_err(|e| super::Error::InvalidAddress(e.to_string()))?; - let spend_key: PublicKey = PublicKey::from_slice(&bytes[33..66]) - .map_err(|e| super::Error::InvalidAddress(e.to_string()))?; - - Ok(Self { - scan_key, - spend_key, - label: None, - }) - } -} - /// ECDH share for a silent payment output #[derive(Debug, Clone, PartialEq, Eq)] pub struct EcdhShareData { diff --git a/spdk-core/src/psbt/crypto/dleq.rs b/spdk-core/src/psbt/crypto/dleq.rs index eb59bf6..9d2b344 100644 --- a/spdk-core/src/psbt/crypto/dleq.rs +++ b/spdk-core/src/psbt/crypto/dleq.rs @@ -1,215 +1,91 @@ //! BIP-374 DLEQ (Discrete Log Equality) Proofs //! -//! Implements DLEQ proof generation and verification for secp256k1. -//! Based on BIP-374 specification. +//! This module provides DLEQ proof generation and verification using rust-dleq. +//! The rust-dleq library can be used with either: +//! - `dleq-standalone` feature: Pure Rust implementation (default) +//! - `dleq-native` feature: Direct FFI to libsecp256k1 +//! +//! Note: We provide conversion between rust-dleq::DleqProof and psbt_v2::v2::dleq::DleqProof +//! since psbt-v2 defines its own DleqProof type. use super::error::{CryptoError, Result}; -use bitcoin::hashes::{sha256, Hash, HashEngine}; -use psbt_v2::v2::dleq::DleqProof; -use secp256k1::{PublicKey, Scalar, Secp256k1, SecretKey}; +use secp256k1::{PublicKey, Secp256k1, SecretKey}; -// Tagged hash tags for BIP-374 -const DLEQ_TAG_AUX: &str = "BIP0374/aux"; -const DLEQ_TAG_NONCE: &str = "BIP0374/nonce"; -const DLEQ_TAG_CHALLENGE: &str = "BIP0374/challenge"; +// Re-export rust-dleq types for convenience +pub use rust_dleq::{DleqError, DleqProof as RustDleqProof}; -/// Compute a tagged hash as defined in BIP-340 -fn tagged_hash(tag: &str, data: &[u8]) -> [u8; 32] { - let tag_hash = sha256::Hash::hash(tag.as_bytes()); - let mut engine = sha256::Hash::engine(); - engine.input(tag_hash.as_byte_array()); - engine.input(tag_hash.as_byte_array()); - engine.input(data); - sha256::Hash::from_engine(engine).to_byte_array() -} +// Import psbt-v2's DleqProof type under an alias +use psbt_v2::v2::dleq::DleqProof as PsbtV2DleqProof; -/// XOR two 32-byte arrays -fn xor_bytes(lhs: &[u8; 32], rhs: &[u8; 32]) -> [u8; 32] { - let mut result = [0u8; 32]; - for i in 0..32 { - result[i] = lhs[i] ^ rhs[i]; - } - result -} +// ============================================================================ +// Type Conversion +// ============================================================================ -/// Compute DLEQ challenge value -/// -/// e = H_challenge(A || B || C || G || R1 || R2 || m) -fn dleq_challenge( - a: &PublicKey, - b: &PublicKey, - c: &PublicKey, - g: &PublicKey, - r1: &PublicKey, - r2: &PublicKey, - m: Option<&[u8; 32]>, -) -> Scalar { - let mut data = Vec::with_capacity(6 * 33 + if m.is_some() { 32 } else { 0 }); - data.extend_from_slice(&a.serialize()); - data.extend_from_slice(&b.serialize()); - data.extend_from_slice(&c.serialize()); - data.extend_from_slice(&g.serialize()); - data.extend_from_slice(&r1.serialize()); - data.extend_from_slice(&r2.serialize()); - if let Some(msg) = m { - data.extend_from_slice(msg); - } +/// Convert rust-dleq proof to psbt-v2 proof format +pub fn to_psbt_v2_proof(proof: &RustDleqProof) -> PsbtV2DleqProof { + PsbtV2DleqProof(*proof.as_bytes()) +} - let hash = tagged_hash(DLEQ_TAG_CHALLENGE, &data); - Scalar::from_be_bytes(hash).expect("Valid scalar from hash") +/// Convert psbt-v2 proof to rust-dleq format +pub fn from_psbt_v2_proof(proof: &PsbtV2DleqProof) -> RustDleqProof { + RustDleqProof(proof.0) } -/// Generate a DLEQ proof +// ============================================================================ +// DLEQ Proof Generation and Verification +// ============================================================================ + +/// Generate a DLEQ proof using rust-dleq /// /// Proves that log_G(A) = log_B(C), i.e., A = a*G and C = a*B for some secret a. +/// Returns proof in psbt-v2 format for compatibility. /// /// # Arguments /// * `secp` - Secp256k1 context /// * `a` - Secret scalar (private key) /// * `b` - Public key B /// * `r` - 32 bytes of randomness for aux randomization -/// * `g` - Generator point (default: secp256k1 generator G) /// * `m` - Optional 32-byte message to include in proof /// /// # Returns -/// 64-byte proof: e (32 bytes) || s (32 bytes) +/// PsbtV2DleqProof (64-byte proof: e || s) pub fn dleq_generate_proof( secp: &Secp256k1, a: &SecretKey, b: &PublicKey, r: &[u8; 32], m: Option<&[u8; 32]>, -) -> Result { - // Compute A = a*G and C = a*B - let a_point = PublicKey::from_secret_key(secp, a); - let a_scalar: Scalar = (*a).into(); - let c_point = b.mul_tweak(secp, &a_scalar)?; - - // Get generator G - let g_point = PublicKey::from_secret_key( - secp, - &SecretKey::from_slice(&[ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 1, - ]) - .unwrap(), - ); - - // Compute t = a XOR H_aux(r) - let aux_hash = tagged_hash(DLEQ_TAG_AUX, r); - let a_bytes = a.secret_bytes(); - let t = xor_bytes(&a_bytes, &aux_hash); - - // Compute nonce: k = H_nonce(t || A || C || m) mod n - let mut nonce_data = Vec::with_capacity(32 + 33 + 33 + if m.is_some() { 32 } else { 0 }); - nonce_data.extend_from_slice(&t); - nonce_data.extend_from_slice(&a_point.serialize()); - nonce_data.extend_from_slice(&c_point.serialize()); - if let Some(msg) = m { - nonce_data.extend_from_slice(msg); - } - - let nonce_hash = tagged_hash(DLEQ_TAG_NONCE, &nonce_data); - let k = Scalar::from_be_bytes(nonce_hash) - .map_err(|_| CryptoError::DleqGenerationFailed("Invalid nonce scalar".to_string()))?; - - // Check if k is zero by trying to convert to SecretKey - let k_key = SecretKey::from_slice(&k.to_be_bytes())?; +) -> Result { + let proof = rust_dleq::generate_dleq_proof(secp, a, b, r, m) + .map_err(|e| CryptoError::DleqGenerationFailed(format!("rust-dleq error: {:?}", e)))?; - // Compute R1 = k*G and R2 = k*B - let r1 = PublicKey::from_secret_key(secp, &k_key); - let r2 = b.mul_tweak(secp, &k)?; - - // Compute challenge e = H_challenge(A, B, C, G, R1, R2, m) - let e = dleq_challenge(&a_point, b, &c_point, &g_point, &r1, &r2, m); - - // Compute s = k + e*a (mod n) - // We need to do scalar arithmetic. Since `Scalar` doesn't support arithmetic directly, - // we convert to SecretKey for operations - let e_key = SecretKey::from_slice(&e.to_be_bytes())?; - let ea = e_key.mul_tweak(&a_scalar)?; - let s_key = k_key.add_tweak(&ea.into())?; - let s = Scalar::from(s_key); - - // Construct proof: e || s - let mut proof_bytes = [0u8; 64]; - proof_bytes[0..32].copy_from_slice(&e.to_be_bytes()); - proof_bytes[32..64].copy_from_slice(&s.to_be_bytes()); - - // Verify the proof before returning - let proof = DleqProof(proof_bytes); - - // Verify the proof before returning - if !dleq_verify_proof(secp, &a_point, b, &c_point, &proof, m)? { - return Err(CryptoError::DleqGenerationFailed( - "Self-verification failed".to_string(), - )); - } - - Ok(proof) + Ok(to_psbt_v2_proof(&proof)) } -/// Verify a DLEQ proof +/// Verify a DLEQ proof using rust-dleq /// /// Verifies that log_G(A) = log_B(C). +/// Accepts proof in psbt-v2 format for compatibility. /// /// # Arguments /// * `secp` - Secp256k1 context /// * `a` - Public key A = a*G /// * `b` - Public key B /// * `c` - Public key C = a*B -/// * `proof` - 64-byte proof +/// * `proof` - 64-byte proof in psbt-v2 format /// * `m` - Optional 32-byte message pub fn dleq_verify_proof( secp: &Secp256k1, a: &PublicKey, b: &PublicKey, c: &PublicKey, - proof: &DleqProof, + proof: &PsbtV2DleqProof, m: Option<&[u8; 32]>, ) -> Result { - // Parse proof: e || s - let mut e_bytes = [0u8; 32]; - let mut s_bytes = [0u8; 32]; - e_bytes.copy_from_slice(&proof.0[0..32]); - s_bytes.copy_from_slice(&proof.0[32..64]); - - let e = Scalar::from_be_bytes(e_bytes).map_err(|_| CryptoError::DleqVerificationFailed)?; - let s = Scalar::from_be_bytes(s_bytes).map_err(|_| CryptoError::DleqVerificationFailed)?; - - // Get generator G - let g_point = PublicKey::from_secret_key( - secp, - &SecretKey::from_slice(&[ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 1, - ]) - .unwrap(), - ); + let rust_dleq_proof = from_psbt_v2_proof(proof); - // Compute R1 = s*G - e*A - let s_key = SecretKey::from_slice(&s.to_be_bytes())?; - let s_g = PublicKey::from_secret_key(secp, &s_key); - - let e_key = SecretKey::from_slice(&e.to_be_bytes())?; - let e_a = a.mul_tweak(secp, &e_key.into())?; - - let r1 = s_g - .combine(&e_a.negate(secp)) - .map_err(|_| CryptoError::DleqVerificationFailed)?; - - // Compute R2 = s*B - e*C - let s_b = b.mul_tweak(secp, &s)?; - let e_c = c.mul_tweak(secp, &e)?; - - let r2 = s_b - .combine(&e_c.negate(secp)) - .map_err(|_| CryptoError::DleqVerificationFailed)?; - - // Verify challenge - let e_prime = dleq_challenge(a, b, c, &g_point, &r1, &r2, m); - - Ok(e == e_prime) + rust_dleq::verify_dleq_proof(secp, a, b, c, &rust_dleq_proof, m) + .map_err(|_e| CryptoError::DleqVerificationFailed) } #[cfg(test)] @@ -217,18 +93,14 @@ mod tests { use super::*; #[test] - fn test_tagged_hash() { - let data = b"test data"; - let hash = tagged_hash(DLEQ_TAG_AUX, data); - assert_eq!(hash.len(), 32); - } - - #[test] - fn test_xor_bytes() { - let a = [0xFFu8; 32]; - let b = [0xAAu8; 32]; - let result = xor_bytes(&a, &b); - assert_eq!(result, [0x55u8; 32]); + fn test_proof_conversion() { + let proof_bytes = [0x42u8; 64]; + let rust_dleq_proof = RustDleqProof(proof_bytes); + let psbt_v2_proof = to_psbt_v2_proof(&rust_dleq_proof); + let converted_back = from_psbt_v2_proof(&psbt_v2_proof); + + assert_eq!(rust_dleq_proof, converted_back); + assert_eq!(psbt_v2_proof.0, proof_bytes); } #[test] diff --git a/spdk-core/src/psbt/mod.rs b/spdk-core/src/psbt/mod.rs index 90fd1d3..479c525 100644 --- a/spdk-core/src/psbt/mod.rs +++ b/spdk-core/src/psbt/mod.rs @@ -19,3 +19,9 @@ pub use core::{ Bip375PsbtExt, EcdhShareData, Error, GlobalFieldsExt, InputFieldsExt, OutputFieldsExt, PsbtInput, PsbtKey, PsbtOutput, Result, SilentPaymentPsbt, }; + +// Re-export DleqProof from psbt_v2 (used in EcdhShareData) +pub use psbt_v2::v2::dleq::DleqProof; + +// Re-export rust-dleq types and conversion functions from crypto module +pub use crypto::dleq::{from_psbt_v2_proof, to_psbt_v2_proof, DleqError, RustDleqProof}; From 32613f800b9777f3d362a84ce056ce5cbaf131d8 Mon Sep 17 00:00:00 2001 From: macgyver13 <4712150+macgyver13@users.noreply.github.com> Date: Thu, 8 Jan 2026 09:05:19 -0500 Subject: [PATCH 07/16] psbt: Use less confusing name for sp_v0_info - get_output_sp_info --- spdk-core/src/psbt/core/extensions.rs | 16 ++++++++-------- spdk-core/src/psbt/roles/constructor.rs | 2 +- spdk-core/src/psbt/roles/input_finalizer.rs | 2 +- spdk-core/src/psbt/roles/validation.rs | 8 ++++---- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/spdk-core/src/psbt/core/extensions.rs b/spdk-core/src/psbt/core/extensions.rs index 9ac550d..1436c19 100644 --- a/spdk-core/src/psbt/core/extensions.rs +++ b/spdk-core/src/psbt/core/extensions.rs @@ -88,14 +88,14 @@ pub trait Bip375PsbtExt { /// /// # Arguments /// * `output_index` - Index of the output - fn get_output_sp_info_v0(&self, output_index: usize) -> Option<(PublicKey, PublicKey)>; + fn get_output_sp_info(&self, output_index: usize) -> Option<(PublicKey, PublicKey)>; /// Set silent payment v0 keys for an output /// /// # Arguments /// * `output_index` - Index of the output /// * `address` - The silent payment address - fn set_output_sp_info_v0( + fn set_output_sp_info( &mut self, output_index: usize, address: &SilentPaymentAddress, @@ -250,7 +250,7 @@ impl Bip375PsbtExt for Psbt { Ok(()) } - fn get_output_sp_info_v0(&self, output_index: usize) -> Option<(PublicKey, PublicKey)> { + fn get_output_sp_info(&self, output_index: usize) -> Option<(PublicKey, PublicKey)> { let output = self.outputs.get(output_index)?; if let Some(bytes) = &output.sp_v0_info { @@ -267,7 +267,7 @@ impl Bip375PsbtExt for Psbt { None } - fn set_output_sp_info_v0( + fn set_output_sp_info( &mut self, output_index: usize, address: &SilentPaymentAddress, @@ -374,7 +374,7 @@ impl Bip375PsbtExt for Psbt { fn get_output_scan_keys(&self) -> Vec { let mut scan_keys = Vec::new(); for output_idx in 0..self.outputs.len() { - if let Some(sp_info) = self.get_output_sp_info_v0(output_idx) { + if let Some(sp_info) = self.get_output_sp_info(output_idx) { scan_keys.push(sp_info.0); } } @@ -561,7 +561,7 @@ pub fn get_output_sp_keys( output_idx: usize, ) -> Result<(PublicKey, PublicKey)> { // Use the extension trait method via SilentPaymentPsbt wrapper - let sp_info = psbt.get_output_sp_info_v0(output_idx).ok_or_else(|| { + let sp_info = psbt.get_output_sp_info(output_idx).ok_or_else(|| { Error::MissingField(format!("Output {} missing PSBT_OUT_SP_V0_INFO", output_idx)) })?; Ok((sp_info.0, sp_info.1)) @@ -1071,10 +1071,10 @@ mod tests { .unwrap(); // Set address - psbt.set_output_sp_info_v0(0, &address).unwrap(); + psbt.set_output_sp_info(0, &address).unwrap(); // Retrieve address - let retrieved = psbt.get_output_sp_info_v0(0); + let retrieved = psbt.get_output_sp_info(0); assert_eq!( retrieved.map(|res| (res.0, res.1)), Some((address.get_scan_key(), address.get_spend_key())) diff --git a/spdk-core/src/psbt/roles/constructor.rs b/spdk-core/src/psbt/roles/constructor.rs index 701f1b2..1003e08 100644 --- a/spdk-core/src/psbt/roles/constructor.rs +++ b/spdk-core/src/psbt/roles/constructor.rs @@ -74,7 +74,7 @@ pub fn add_outputs(psbt: &mut SilentPaymentPsbt, outputs: &[PsbtOutput]) -> Resu // which is computed during finalization // Set BIP-375 fields - psbt.set_output_sp_info_v0(i, address)?; + psbt.set_output_sp_info(i, address)?; if let Some(label) = label { psbt.set_output_sp_label(i, *label)?; diff --git a/spdk-core/src/psbt/roles/input_finalizer.rs b/spdk-core/src/psbt/roles/input_finalizer.rs index ac0356b..d1852a2 100644 --- a/spdk-core/src/psbt/roles/input_finalizer.rs +++ b/spdk-core/src/psbt/roles/input_finalizer.rs @@ -24,7 +24,7 @@ pub fn finalize_inputs( // Process each output for output_idx in 0..psbt.num_outputs() { // Check if this is a silent payment output - let (scan_key, spend_key) = match psbt.get_output_sp_info_v0(output_idx) { + let (scan_key, spend_key) = match psbt.get_output_sp_info(output_idx) { Some(keys) => keys, None => continue, // Not a silent payment output, skip }; diff --git a/spdk-core/src/psbt/roles/validation.rs b/spdk-core/src/psbt/roles/validation.rs index 8f441f3..7bc9e17 100644 --- a/spdk-core/src/psbt/roles/validation.rs +++ b/spdk-core/src/psbt/roles/validation.rs @@ -34,7 +34,7 @@ pub fn validate_psbt( validate_sp_tweak_fields(psbt, false)?; // Check if this PSBT has silent payment outputs - let has_sp_outputs = (0..psbt.num_outputs()).any(|i| psbt.get_output_sp_info_v0(i).is_some()); + let has_sp_outputs = (0..psbt.num_outputs()).any(|i| psbt.get_output_sp_info(i).is_some()); if has_sp_outputs { // Rule 6: Segwit version restrictions (must be v0 or v1 for silent payments) @@ -98,7 +98,7 @@ fn validate_output_fields(psbt: &SilentPaymentPsbt) -> Result<()> { // Amount is mandatory in struct // Check if this is a silent payment output - let has_sp_address = psbt.get_output_sp_info_v0(i).is_some(); + let has_sp_address = psbt.get_output_sp_info(i).is_some(); let has_script = !output.script_pubkey.is_empty(); // Output must have either a script OR a silent payment address @@ -216,7 +216,7 @@ fn validate_ecdh_coverage(psbt: &SilentPaymentPsbt) -> Result<()> { // Collect all scan keys from outputs let mut scan_keys = HashSet::new(); for i in 0..psbt.num_outputs() { - if let Some((scan_key, _spend_key)) = psbt.get_output_sp_info_v0(i) { + if let Some((scan_key, _spend_key)) = psbt.get_output_sp_info(i) { scan_keys.insert(scan_key); } } @@ -331,7 +331,7 @@ fn validate_output_scripts( continue; } - let (scan_key, spend_key) = match psbt.get_output_sp_info_v0(output_idx) { + let (scan_key, spend_key) = match psbt.get_output_sp_info(output_idx) { Some(keys) => keys, None => continue, }; From 6e7201e577f9519d66965a7f8274af803cb740d0 Mon Sep 17 00:00:00 2001 From: macgyver13 <4712150+macgyver13@users.noreply.github.com> Date: Thu, 8 Jan 2026 10:17:59 -0500 Subject: [PATCH 08/16] psbt: remove address labeling from input_finalizer - rely on principle that constructor computes the labeled address correctly and stores in SP_V0_INFO --- spdk-core/src/psbt/core/extensions.rs | 21 ++++++++------- spdk-core/src/psbt/core/types.rs | 8 ++++-- spdk-core/src/psbt/roles/extractor.rs | 2 +- spdk-core/src/psbt/roles/input_finalizer.rs | 30 +++++---------------- 4 files changed, 25 insertions(+), 36 deletions(-) diff --git a/spdk-core/src/psbt/core/extensions.rs b/spdk-core/src/psbt/core/extensions.rs index 1436c19..8dacabd 100644 --- a/spdk-core/src/psbt/core/extensions.rs +++ b/spdk-core/src/psbt/core/extensions.rs @@ -105,15 +105,22 @@ pub trait Bip375PsbtExt { /// /// Field type: PSBT_OUT_SP_V0_LABEL (0x0a) /// + /// **Important:** This field is metadata only. When present, the spend key in + /// PSBT_OUT_SP_V0_INFO is already labeled. The label value is not used for + /// cryptographic derivation during finalization. + /// /// # Arguments /// * `output_index` - Index of the output fn get_output_sp_label(&self, output_index: usize) -> Option; /// Set silent payment label for an output /// + /// **Important:** When setting this field, ensure that the spend key in + /// PSBT_OUT_SP_V0_INFO is already labeled. This field is metadata only. + /// /// # Arguments /// * `output_index` - Index of the output - /// * `label` - The label value + /// * `label` - The label value (0 = change, 1+ = labeled receiving addresses) fn set_output_sp_label(&mut self, output_index: usize, label: u32) -> Result<()>; // ===== Silent Payment Spending ===== @@ -259,8 +266,8 @@ impl Bip375PsbtExt for Psbt { }; let scan_key = PublicKey::from_slice(&bytes[..33]).ok(); let spend_key = PublicKey::from_slice(&bytes[33..]).ok(); - if scan_key.is_some() && spend_key.is_some() { - return Some((scan_key.unwrap(), spend_key.unwrap())); + if let (Some(scan_key), Some(spend_key)) = (scan_key, spend_key) { + return Some((scan_key, spend_key)); } } @@ -389,7 +396,7 @@ fn get_global_dleq_proof(psbt: &Psbt, scan_key: &PublicKey) -> Option psbt.global .sp_dleq_proofs .get(&scan_key_compressed) - .map(|proof| *proof) + .copied() } fn add_global_dleq_proof(psbt: &mut Psbt, scan_key: &PublicKey, proof: DleqProof) -> Result<()> { @@ -412,10 +419,7 @@ fn get_input_dleq_proof( let scan_key_compressed = CompressedPublicKey::try_from(bitcoin::PublicKey::new(*scan_key)).ok()?; - input - .sp_dleq_proofs - .get(&scan_key_compressed) - .map(|proof| *proof) + input.sp_dleq_proofs.get(&scan_key_compressed).copied() } fn add_input_dleq_proof( @@ -566,7 +570,6 @@ pub fn get_output_sp_keys( })?; Ok((sp_info.0, sp_info.1)) } -/// Get silent payment keys (scan_key, spend_key) from output SP_V0_INFO field // ============================================================================ // Display Extension Traits diff --git a/spdk-core/src/psbt/core/types.rs b/spdk-core/src/psbt/core/types.rs index f335488..cbe3309 100644 --- a/spdk-core/src/psbt/core/types.rs +++ b/spdk-core/src/psbt/core/types.rs @@ -116,9 +116,13 @@ pub enum PsbtOutput { SilentPayment { /// Amount to send amount: Amount, - /// Silent payment address + /// Silent payment address (if label is present, this is the already-labeled address) address: SilentPaymentAddress, - /// Optional label (useful for detecting change outputs) + /// Optional label metadata (useful for detecting change outputs) + /// + /// When present, the address field contains the final labeled address. + /// The label value is informational only and is not used for derivation. + /// Conventional values: 0 = change, 1+ = labeled receiving addresses. label: Option, }, } diff --git a/spdk-core/src/psbt/roles/extractor.rs b/spdk-core/src/psbt/roles/extractor.rs index 2b20926..b8fb350 100644 --- a/spdk-core/src/psbt/roles/extractor.rs +++ b/spdk-core/src/psbt/roles/extractor.rs @@ -277,7 +277,7 @@ mod tests { add_ecdh_shares_full(&secp, &mut psbt, &inputs, &[scan_key], false).unwrap(); // Finalize inputs (compute output scripts) - finalize_inputs(&secp, &mut psbt, None).unwrap(); + finalize_inputs(&secp, &mut psbt).unwrap(); // Sign inputs sign_inputs(&secp, &mut psbt, &inputs).unwrap(); diff --git a/spdk-core/src/psbt/roles/input_finalizer.rs b/spdk-core/src/psbt/roles/input_finalizer.rs index d1852a2..982a32d 100644 --- a/spdk-core/src/psbt/roles/input_finalizer.rs +++ b/spdk-core/src/psbt/roles/input_finalizer.rs @@ -3,17 +3,14 @@ //! Aggregates ECDH shares and computes final output scripts for silent payments. use crate::psbt::core::{aggregate_ecdh_shares, Bip375PsbtExt, Error, Result, SilentPaymentPsbt}; -use crate::psbt::crypto::{ - apply_label_to_spend_key, derive_silent_payment_output_pubkey, tweaked_key_to_p2tr_script, -}; -use secp256k1::{PublicKey, Secp256k1, SecretKey}; +use crate::psbt::crypto::{derive_silent_payment_output_pubkey, tweaked_key_to_p2tr_script}; +use secp256k1::{PublicKey, Secp256k1}; use std::collections::HashMap; /// Finalize inputs by computing output scripts from ECDH shares pub fn finalize_inputs( secp: &Secp256k1, psbt: &mut SilentPaymentPsbt, - scan_privkeys: Option<&HashMap>, ) -> Result<()> { // Aggregate ECDH shares by scan key (detects global vs per-input automatically) let aggregated_shares = aggregate_ecdh_shares(psbt)?; @@ -41,21 +38,6 @@ pub fn finalize_inputs( let aggregated_share = aggregated.aggregated_share; - // Check for label and apply if present and we have scan private key - let mut spend_key_to_use = spend_key.clone(); - - if let Some(label) = psbt.get_output_sp_label(output_idx) { - // If we have the scan private key, apply the label tweak to spend key - if let Some(privkeys) = scan_privkeys { - if let Some(scan_privkey) = privkeys.get(&scan_key) { - spend_key_to_use = - apply_label_to_spend_key(secp, &spend_key, scan_privkey, label).map_err( - |e| Error::Other(format!("Failed to apply label tweak: {}", e)), - )?; - } - } - } - // Get or initialize the output index for this scan key let k = *scan_key_output_indices.get(&scan_key).unwrap_or(&0); @@ -63,7 +45,7 @@ pub fn finalize_inputs( let ecdh_secret = aggregated_share.serialize(); let output_pubkey = derive_silent_payment_output_pubkey( secp, - &spend_key_to_use, // Use labeled spend key if label was applied + &spend_key, &ecdh_secret, k, // Use per-scan-key index ) @@ -160,7 +142,7 @@ mod tests { add_ecdh_shares_full(&secp, &mut psbt, &inputs, &[scan_key], false).unwrap(); // Finalize inputs (compute output scripts) - finalize_inputs(&secp, &mut psbt, None).unwrap(); + finalize_inputs(&secp, &mut psbt).unwrap(); // Verify output script was added let script = &psbt.outputs[0].script_pubkey; @@ -212,7 +194,7 @@ mod tests { add_ecdh_shares_partial(&secp, &mut psbt, &inputs, &[scan_key], &[0], false).unwrap(); // Finalize should fail due to incomplete coverage - let result = finalize_inputs(&secp, &mut psbt, None); + let result = finalize_inputs(&secp, &mut psbt); assert!(result.is_err()); assert!(matches!(result, Err(Error::IncompleteEcdhCoverage(0)))); } @@ -276,7 +258,7 @@ mod tests { add_ecdh_shares_full(&secp, &mut psbt, &inputs, &[scan_key], false).unwrap(); // Finalize inputs (compute output scripts) - finalize_inputs(&secp, &mut psbt, None).unwrap(); + finalize_inputs(&secp, &mut psbt).unwrap(); // Verify tx_modifiable_flags is cleared after finalization assert_eq!( From f3939aaed0c21bdc1cd9185d0ccabf45c44d1513 Mon Sep 17 00:00:00 2001 From: macgyver13 <4712150+macgyver13@users.noreply.github.com> Date: Tue, 13 Jan 2026 16:44:53 -0500 Subject: [PATCH 09/16] dleq: map silent payments terms to dleq arguments --- spdk-core/examples/dleq_example.rs | 37 +++++++++++++++--------------- spdk-core/src/psbt/crypto/dleq.rs | 26 +++++++++++---------- 2 files changed, 33 insertions(+), 30 deletions(-) diff --git a/spdk-core/examples/dleq_example.rs b/spdk-core/examples/dleq_example.rs index cc11ada..6be1ffa 100644 --- a/spdk-core/examples/dleq_example.rs +++ b/spdk-core/examples/dleq_example.rs @@ -18,19 +18,19 @@ fn main() { let secp = Secp256k1::new(); - // Party A: Generate a keypair - let secret_a = SecretKey::from_slice(&[0x01; 32]).expect("valid secret key"); - let pubkey_a = PublicKey::from_secret_key(&secp, &secret_a); - println!("Party A public key: {}", pubkey_a); + // Sender A: Generate a keypair + let sender_input_secret = SecretKey::from_slice(&[0x01; 32]).expect("valid secret key"); + let sender_input_public = PublicKey::from_secret_key(&secp, &sender_input_secret); + println!("Sender A public key: {}", sender_input_public); - // Party B: Generate a public key (scan key in silent payments context) - let secret_b = SecretKey::from_slice(&[0x02; 32]).expect("valid secret key"); - let pubkey_b = PublicKey::from_secret_key(&secp, &secret_b); - println!("Party B public key (scan key): {}", pubkey_b); + // Receiver B: Generate a public key (scan key in silent payments context) + let receiver_scan_secret = SecretKey::from_slice(&[0x02; 32]).expect("valid secret key"); + let receiver_scan_public = PublicKey::from_secret_key(&secp, &receiver_scan_secret); + println!("Receiver B scan public key: {}", receiver_scan_public); // Compute ECDH share: C = a * B - let ecdh_share = pubkey_b - .mul_tweak(&secp, &secret_a.into()) + let ecdh_share = receiver_scan_public + .mul_tweak(&secp, &sender_input_secret.into()) .expect("valid ECDH computation"); println!("ECDH share: {}\n", ecdh_share); @@ -41,22 +41,23 @@ fn main() { let proof = dleq_generate_proof( &secp, - &secret_a, - &pubkey_b, + &sender_input_secret, + &receiver_scan_public, &aux_randomness, message.as_ref(), ) .expect("proof generation successful"); println!("✓ Proof generated successfully"); - println!(" Proof bytes (first 16): {:02x?}...\n", &proof.0[..16]); + let hex_proof: String = proof.0.iter().map(|b| format!("{:02x}", b)).collect(); + println!(" Proof bytes (hex): {}\n", hex_proof); // Verify the DLEQ proof println!("Verifying DLEQ proof..."); let is_valid = dleq_verify_proof( &secp, - &pubkey_a, - &pubkey_b, + &sender_input_public, + &receiver_scan_public, &ecdh_share, &proof, message.as_ref(), @@ -66,7 +67,7 @@ fn main() { if is_valid { println!("✓ Proof is VALID"); println!(" The prover knows the discrete log relationship:"); - println!(" log_G(A) = log_B(C)\n"); + println!(" log_G(sender_input_public) = log_B(ecdh_share)\n"); } else { println!("✗ Proof is INVALID"); } @@ -89,8 +90,8 @@ fn main() { let is_valid_corrupted = dleq_verify_proof( &secp, - &pubkey_a, - &pubkey_b, + &sender_input_public, + &receiver_scan_public, &ecdh_share, &corrupted_proof, message.as_ref(), diff --git a/spdk-core/src/psbt/crypto/dleq.rs b/spdk-core/src/psbt/crypto/dleq.rs index 9d2b344..388b1ed 100644 --- a/spdk-core/src/psbt/crypto/dleq.rs +++ b/spdk-core/src/psbt/crypto/dleq.rs @@ -38,12 +38,13 @@ pub fn from_psbt_v2_proof(proof: &PsbtV2DleqProof) -> RustDleqProof { /// Generate a DLEQ proof using rust-dleq /// /// Proves that log_G(A) = log_B(C), i.e., A = a*G and C = a*B for some secret a. +/// ex. log_G(input_key*G) = log_B(input_key*scan_key) /// Returns proof in psbt-v2 format for compatibility. /// /// # Arguments /// * `secp` - Secp256k1 context -/// * `a` - Secret scalar (private key) -/// * `b` - Public key B +/// * `input_key` - input sum (private key) +/// * `scan_key` - receiver scan key (public key) /// * `r` - 32 bytes of randomness for aux randomization /// * `m` - Optional 32-byte message to include in proof /// @@ -51,12 +52,12 @@ pub fn from_psbt_v2_proof(proof: &PsbtV2DleqProof) -> RustDleqProof { /// PsbtV2DleqProof (64-byte proof: e || s) pub fn dleq_generate_proof( secp: &Secp256k1, - a: &SecretKey, - b: &PublicKey, + input_key: &SecretKey, + scan_key: &PublicKey, r: &[u8; 32], m: Option<&[u8; 32]>, ) -> Result { - let proof = rust_dleq::generate_dleq_proof(secp, a, b, r, m) + let proof = rust_dleq::generate_dleq_proof(secp, input_key, scan_key, r, m) .map_err(|e| CryptoError::DleqGenerationFailed(format!("rust-dleq error: {:?}", e)))?; Ok(to_psbt_v2_proof(&proof)) @@ -65,26 +66,27 @@ pub fn dleq_generate_proof( /// Verify a DLEQ proof using rust-dleq /// /// Verifies that log_G(A) = log_B(C). +/// ex. log_G(input_public) = log_B(ecdh_share) /// Accepts proof in psbt-v2 format for compatibility. /// /// # Arguments /// * `secp` - Secp256k1 context -/// * `a` - Public key A = a*G -/// * `b` - Public key B -/// * `c` - Public key C = a*B +/// * `input_public` - input sum (public key) = a*G +/// * `scan_key` - receiver scan key (public key) +/// * `ecdh_share` - ECDH share (public key) C = a*B /// * `proof` - 64-byte proof in psbt-v2 format /// * `m` - Optional 32-byte message pub fn dleq_verify_proof( secp: &Secp256k1, - a: &PublicKey, - b: &PublicKey, - c: &PublicKey, + input_public: &PublicKey, + scan_key: &PublicKey, + ecdh_share: &PublicKey, proof: &PsbtV2DleqProof, m: Option<&[u8; 32]>, ) -> Result { let rust_dleq_proof = from_psbt_v2_proof(proof); - rust_dleq::verify_dleq_proof(secp, a, b, c, &rust_dleq_proof, m) + rust_dleq::verify_dleq_proof(secp, input_public, scan_key, ecdh_share, &rust_dleq_proof, m) .map_err(|_e| CryptoError::DleqVerificationFailed) } From 2e2d7f3d54429f3aa43fa6f7fccf3657595b61cf Mon Sep 17 00:00:00 2001 From: macgyver13 <4712150+macgyver13@users.noreply.github.com> Date: Wed, 28 Jan 2026 14:39:11 -0500 Subject: [PATCH 10/16] psbt: Handle ecdh coverage for multiple inputs properly Add PSBT state check Add is_input_eligible --- spdk-core/src/psbt/core/error.rs | 3 + spdk-core/src/psbt/crypto/bip352.rs | 50 ++++++++++++ spdk-core/src/psbt/crypto/dleq.rs | 11 ++- spdk-core/src/psbt/roles/validation.rs | 106 +++++++++++++++++-------- 4 files changed, 135 insertions(+), 35 deletions(-) diff --git a/spdk-core/src/psbt/core/error.rs b/spdk-core/src/psbt/core/error.rs index b92a220..3b448b6 100644 --- a/spdk-core/src/psbt/core/error.rs +++ b/spdk-core/src/psbt/core/error.rs @@ -56,6 +56,9 @@ pub enum Error { #[error("Invalid public key (must be compressed)")] InvalidPublicKey, + #[error("Invalid PSBT state: {0}")] + InvalidPsbtState(String), + #[error( "Cannot add standard field type {0} via generic accessor - use specific method instead" )] diff --git a/spdk-core/src/psbt/crypto/bip352.rs b/spdk-core/src/psbt/crypto/bip352.rs index 702c257..9b94acd 100644 --- a/spdk-core/src/psbt/crypto/bip352.rs +++ b/spdk-core/src/psbt/crypto/bip352.rs @@ -6,6 +6,7 @@ use super::error::{CryptoError, Result}; use bitcoin::hashes::{sha256, Hash, HashEngine}; use bitcoin::key::TapTweak; use bitcoin::ScriptBuf; +use psbt_v2::v2::Input; use secp256k1::{PublicKey, Scalar, Secp256k1, SecretKey}; /// Compute label tweak for a silent payment address @@ -173,6 +174,55 @@ pub fn script_type_string(script: &ScriptBuf) -> &'static str { } } +/// TODO: Very basic implmentation for testing - replace with spdk solution +/// Check if an input is eligible for silent payments (BIP-352) +pub fn is_input_eligible(input: &Input) -> bool { + // Check if input has witness_utxo + let witness_utxo = match &input.witness_utxo { + Some(utxo) => utxo, + None => return false, + }; + + let script = &witness_utxo.script_pubkey; + + // P2WPKH (SegWit v0) - eligible + if script.is_p2wpkh() { + return true; + } + + // P2TR (Taproot, SegWit v1) - eligible unless internal key is NUMS point (BIP-352) + if script.is_p2tr() { + const NUMS_POINT: [u8; 32] = [ + 0x50, 0x92, 0x9b, 0x74, 0xc1, 0xa0, 0x49, 0x54, + 0xb7, 0x8b, 0x4b, 0x60, 0x35, 0xe9, 0x7a, 0x5e, + 0x07, 0x8a, 0x5a, 0x0f, 0x28, 0xec, 0x96, 0xd5, + 0x47, 0xbf, 0xee, 0x9a, 0xce, 0x80, 0x3a, 0xc0, + ]; + if let Some(internal_key) = &input.tap_internal_key { + if internal_key.serialize() == NUMS_POINT { + return false; + } + } + return true; + } + + // P2PKH (legacy) - eligible + if script.is_p2pkh() { + return true; + } + + // P2SH - only eligible if it's P2SH-P2WPKH + if script.is_p2sh() { + if let Some(redeem_script) = &input.redeem_script { + return redeem_script.is_p2wpkh(); + } + return false; + } + + // All other types are ineligible (multisig, etc.) + false +} + /// Compute ECDH shared secret /// /// ecdh_secret = privkey * pubkey diff --git a/spdk-core/src/psbt/crypto/dleq.rs b/spdk-core/src/psbt/crypto/dleq.rs index 388b1ed..418db7e 100644 --- a/spdk-core/src/psbt/crypto/dleq.rs +++ b/spdk-core/src/psbt/crypto/dleq.rs @@ -86,8 +86,15 @@ pub fn dleq_verify_proof( ) -> Result { let rust_dleq_proof = from_psbt_v2_proof(proof); - rust_dleq::verify_dleq_proof(secp, input_public, scan_key, ecdh_share, &rust_dleq_proof, m) - .map_err(|_e| CryptoError::DleqVerificationFailed) + rust_dleq::verify_dleq_proof( + secp, + input_public, + scan_key, + ecdh_share, + &rust_dleq_proof, + m, + ) + .map_err(|_e| CryptoError::DleqVerificationFailed) } #[cfg(test)] diff --git a/spdk-core/src/psbt/roles/validation.rs b/spdk-core/src/psbt/roles/validation.rs index 7bc9e17..ddf9893 100644 --- a/spdk-core/src/psbt/roles/validation.rs +++ b/spdk-core/src/psbt/roles/validation.rs @@ -3,6 +3,7 @@ //! Validates PSBTs according to BIP-375 rules. use crate::psbt::core::{Bip375PsbtExt, Error, Result, SilentPaymentPsbt}; +use crate::psbt::crypto::bip352::is_input_eligible; use crate::psbt::crypto::dleq_verify_proof; use secp256k1::{PublicKey, Scalar, Secp256k1, SecretKey}; use std::collections::HashSet; @@ -26,6 +27,7 @@ pub fn validate_psbt( ) -> Result<()> { // Basic validations validate_psbt_version(psbt)?; + validate_psbt_state(psbt)?; validate_input_fields(psbt)?; validate_output_fields(psbt)?; @@ -68,6 +70,22 @@ fn validate_psbt_version(psbt: &SilentPaymentPsbt) -> Result<()> { actual: psbt.global.version.into(), }); } + + Ok(()) +} + +/// Verify script_pubkey presence and tx_modifiable_flags consistency +fn validate_psbt_state(psbt: &SilentPaymentPsbt) -> Result<()> { + + let output_maps = &psbt.outputs; + for output_map in output_maps { + if !output_map.script_pubkey.is_empty() && psbt.global.tx_modifiable_flags != 0 { + return Err(Error::InvalidPsbtState( + "tx_modifiable flag is modifiable with script_pubkey present".to_string(), + )); + } + } + Ok(()) } @@ -79,14 +97,6 @@ fn validate_input_fields(psbt: &SilentPaymentPsbt) -> Result<()> { if input.sequence.is_none() { return Err(Error::MissingField(format!("Input {} missing sequence", i))); } - - // SegWit inputs require WITNESS_UTXO - if input.witness_utxo.is_none() { - return Err(Error::MissingField(format!( - "Input {} missing witness_utxo", - i - ))); - } } Ok(()) @@ -210,10 +220,14 @@ fn validate_sighash_types(psbt: &SilentPaymentPsbt) -> Result<()> { } /// Validate ECDH coverage for silent payment outputs +/// +/// Per BIP-375: +/// - Each scan key in SP outputs must have corresponding ECDH share(s) +/// - Complete coverage (all eligible inputs) required when output scripts are computed fn validate_ecdh_coverage(psbt: &SilentPaymentPsbt) -> Result<()> { let num_inputs = psbt.num_inputs(); - // Collect all scan keys from outputs + // Collect all unique scan keys from SP outputs let mut scan_keys = HashSet::new(); for i in 0..psbt.num_outputs() { if let Some((scan_key, _spend_key)) = psbt.get_output_sp_info(i) { @@ -221,34 +235,60 @@ fn validate_ecdh_coverage(psbt: &SilentPaymentPsbt) -> Result<()> { } } - // For each scan key, verify coverage - for scan_key in scan_keys { - // Check for global share first + // For EACH scan key, verify ECDH coverage + for scan_key in &scan_keys { + // Check global shares for this specific scan key let global_shares = psbt.get_global_ecdh_shares(); - let has_global = global_shares.iter().any(|s| s.scan_key == scan_key); + let has_global_ecdh = global_shares.iter().any(|s| &s.scan_key == scan_key); - if has_global { - // Rule: If global share exists, there MUST NOT be any per-input shares for this scan key - for i in 0..num_inputs { - let shares = psbt.get_input_ecdh_shares(i); - if shares.iter().any(|s| s.scan_key == scan_key) { - return Err(Error::InvalidFieldData(format!( - "Scan key {} has both global ECDH share and per-input share at input {}", - scan_key, i - ))); - } + // Check per-input shares for this specific scan key + let has_input_ecdh = (0..num_inputs).any(|i| { + psbt.get_input_ecdh_shares(i) + .iter() + .any(|s| &s.scan_key == scan_key) + }); + + // Check if any outputs with this scan key have PSBT_OUT_SCRIPT set + let has_computed_outputs = (0..psbt.num_outputs()).any(|i| { + let output = &psbt.outputs[i]; + let has_script = !output.script_pubkey.is_empty(); + if let Some((sk, _)) = psbt.get_output_sp_info(i) { + has_script && &sk == scan_key + } else { + false } + }); + + // Only require ECDH shares when output scripts have been computed + if !has_computed_outputs { continue; } - // If no global share, MUST have per-input shares for ALL inputs - for i in 0..num_inputs { - let shares = psbt.get_input_ecdh_shares(i); - if !shares.iter().any(|s| s.scan_key == scan_key) { - return Err(Error::Other(format!( - "Scan key {} missing ECDH share for input {} (and no global share found)", - scan_key, i - ))); + // Must have at least one ECDH share for this scan key when scripts are computed + if !has_global_ecdh && !has_input_ecdh { + return Err(Error::Other( + "Silent payment output present but no ECDH share for scan key".to_string(), + )); + } + + // If using per-input shares (not global), require complete ECDH coverage + // of all eligible inputs + if has_input_ecdh && !has_global_ecdh { + for i in 0..num_inputs { + if let Some(input) = psbt.inputs.get(i) { + if is_input_eligible(input) { + let has_share_for_key = psbt + .get_input_ecdh_shares(i) + .iter() + .any(|s| &s.scan_key == scan_key); + if !has_share_for_key { + return Err(Error::Other(format!( + "Output script set but eligible input {} missing ECDH share for scan key", + i + ))); + } + } + } } } } @@ -619,7 +659,7 @@ mod tests { assert!(validate_ecdh_coverage(&psbt_inputs).is_ok()); } - // Case 4: Per-input shares for SOME inputs -> Invalid + // Case 4: Per-input shares for SOME inputs -> Valid { let mut psbt_partial = psbt.clone(); let share_point = @@ -629,7 +669,7 @@ mod tests { // Add to only first input psbt_partial.add_input_ecdh_share(0, &share).unwrap(); - assert!(validate_ecdh_coverage(&psbt_partial).is_err()); + assert!(validate_ecdh_coverage(&psbt_partial).is_ok()); } // TODO: Case 5: Per-input shares for ALL inputs, but some are invalid -> Invalid From a89f13ce29f1feabddc81f73b32c5d50832558b7 Mon Sep 17 00:00:00 2001 From: macgyver13 <4712150+macgyver13@users.noreply.github.com> Date: Sat, 14 Feb 2026 11:24:16 -0500 Subject: [PATCH 11/16] psbt: Add provisional support for BIP376: PSBT_IN_SP_TWEAK and BIP376 PSBT_IN_SP_SPEND_BIP32_DERIVATION simplify ecdh share coverage --- spdk-core/src/psbt/core/extensions.rs | 182 ++++++++++++++++++++----- spdk-core/src/psbt/roles/signer.rs | 134 ++++-------------- spdk-core/src/psbt/roles/updater.rs | 104 ++++++++++++++ spdk-core/src/psbt/roles/validation.rs | 95 +++---------- 4 files changed, 299 insertions(+), 216 deletions(-) diff --git a/spdk-core/src/psbt/core/extensions.rs b/spdk-core/src/psbt/core/extensions.rs index 8dacabd..4a7bfdd 100644 --- a/spdk-core/src/psbt/core/extensions.rs +++ b/spdk-core/src/psbt/core/extensions.rs @@ -39,7 +39,8 @@ use silentpayments::secp256k1::PublicKey; use silentpayments::SilentPaymentAddress; pub const PSBT_OUT_DNSSEC_PROOF: u8 = 0x35; -pub const PSBT_IN_SP_TWEAK: u8 = 0x1f; +pub const PSBT_IN_SP_SPEND_BIP32_DERIVATION: u8 = 0x1f; +pub const PSBT_IN_SP_TWEAK: u8 = 0x20; /// Extension trait for BIP-375 silent payment fields on PSBT v2 /// /// This trait adds methods to access and modify BIP-375 specific fields: @@ -123,7 +124,7 @@ pub trait Bip375PsbtExt { /// * `label` - The label value (0 = change, 1+ = labeled receiving addresses) fn set_output_sp_label(&mut self, output_index: usize, label: u32) -> Result<()>; - // ===== Silent Payment Spending ===== + // ===== Silent Payment Spend Key Derivation (BIP-376) ===== /// Get silent payment tweak for an input /// @@ -159,6 +160,57 @@ pub trait Bip375PsbtExt { /// * `input_index` - Index of the input fn remove_input_sp_tweak(&mut self, input_index: usize) -> Result<()>; + /// Get silent payment spend key BIP32 derivation for an input + /// + /// Returns the 33-byte spend public key and its BIP32 derivation path. + /// This is used by hardware wallets to identify inputs they control when + /// spending Silent Payment outputs. + /// + /// Field type: PSBT_IN_SP_SPEND_BIP32_DERIVATION + /// + /// # Arguments + /// * `input_index` - Index of the input + /// + /// # Returns + /// * `Some((spend_pubkey, fingerprint, path))` - The spend key and derivation info + /// * `None` - If the field is not present + fn get_input_sp_spend_bip32_derivation( + &self, + input_index: usize, + ) -> Option<(PublicKey, [u8; 4], Vec)>; + + /// Set silent payment spend key BIP32 derivation for an input + /// + /// Used by the UPDATER role when spending Silent Payment outputs. + /// The spend key is the 33-byte compressed public key that, when tweaked + /// with PSBT_IN_SP_TWEAK, produces the key locking the output. + /// + /// Field type: PSBT_IN_SP_SPEND_BIP32_DERIVATION + /// + /// # Arguments + /// * `input_index` - Index of the input + /// * `spend_pubkey` - The 33-byte spend public key + /// * `fingerprint` - Master key fingerprint (4 bytes) + /// * `path` - BIP32 derivation path (e.g., [352', 0', 0', 1, index]) + fn set_input_sp_spend_bip32_derivation( + &mut self, + input_index: usize, + spend_pubkey: &PublicKey, + fingerprint: [u8; 4], + path: Vec, + ) -> Result<()>; + + /// Remove silent payment spend key BIP32 derivation from an input + /// + /// This is typically called after transaction extraction to clean up the PSBT. + /// Prevents accidental re-use of tweaks and keeps PSBTs cleaner. + /// + /// Field type: PSBT_IN_SP_SPEND_BIP32_DERIVATION + /// + /// # Arguments + /// * `input_index` - Index of the input + fn remove_input_sp_spend_bip32_derivation(&mut self, input_index: usize) -> Result<()>; + // ===== Convenience Methods ===== /// Get the number of inputs @@ -339,6 +391,7 @@ impl Bip375PsbtExt for Psbt { key: vec![], }; + //FIXME: migrate to dedicated field in psbt_v2 once available instead of using unknowns input.unknowns.insert(key, tweak.to_vec()); Ok(()) } @@ -358,6 +411,87 @@ impl Bip375PsbtExt for Psbt { Ok(()) } + fn get_input_sp_spend_bip32_derivation( + &self, + input_index: usize, + ) -> Option<(PublicKey, [u8; 4], Vec)> { + let input = self.inputs.get(input_index)?; + + for (key, value) in &input.unknowns { + if key.type_value == PSBT_IN_SP_SPEND_BIP32_DERIVATION && key.key.len() == 33 { + // Key data is 33-byte spend public key + let spend_pubkey = PublicKey::from_slice(&key.key).ok()?; + + // Value is fingerprint (4 bytes) + path (4 bytes per element) + if value.len() < 4 || (value.len() - 4) % 4 != 0 { + return None; + } + + let mut fingerprint = [0u8; 4]; + fingerprint.copy_from_slice(&value[0..4]); + + let path: Vec = value[4..] + .chunks(4) + .map(|chunk| u32::from_le_bytes(chunk.try_into().expect("chunk is 4 bytes"))) + .collect(); + + return Some((spend_pubkey, fingerprint, path)); + } + } + None + } + + fn set_input_sp_spend_bip32_derivation( + &mut self, + input_index: usize, + spend_pubkey: &PublicKey, + fingerprint: [u8; 4], + path: Vec, + ) -> Result<()> { + let input = self + .inputs + .get_mut(input_index) + .ok_or(Error::InvalidInputIndex(input_index))?; + + // Key: type 0x1f with 33-byte spend pubkey as key data + let key = Key { + type_value: PSBT_IN_SP_SPEND_BIP32_DERIVATION, + key: spend_pubkey.serialize().to_vec(), + }; + + // Value: fingerprint (4 bytes) + path (4 bytes per element, little-endian) + let mut value = Vec::with_capacity(4 + path.len() * 4); + value.extend_from_slice(&fingerprint); + for child in &path { + value.extend_from_slice(&child.to_le_bytes()); + } + + //FIXME: migrate to dedicated field in psbt_v2 once available instead of using unknowns + input.unknowns.insert(key, value); + Ok(()) + } + + fn remove_input_sp_spend_bip32_derivation(&mut self, input_index: usize) -> Result<()> { + let input = self + .inputs + .get_mut(input_index) + .ok_or(Error::InvalidInputIndex(input_index))?; + + // Find and remove the key with type 0x1f (any key data) + let keys_to_remove: Vec = input + .unknowns + .keys() + .filter(|k| k.type_value == PSBT_IN_SP_SPEND_BIP32_DERIVATION) + .cloned() + .collect(); + + for key in keys_to_remove { + input.unknowns.remove(&key); + } + + Ok(()) + } + fn num_inputs(&self) -> usize { self.inputs.len() } @@ -503,19 +637,25 @@ pub fn get_input_bip32_pubkeys(psbt: &SilentPaymentPsbt, input_idx: usize) -> Ve /// Get input public key from PSBT fields with fallback priority /// /// Tries multiple sources in this order: -/// 1. BIP32 derivation field (highest priority) -/// 2. Taproot internal key (for Taproot inputs) -/// 3. Partial signature field +/// 1. SP spend BIP32 derivation (for Silent Payment inputs, highest priority) +/// 2. Taproot BIP32 derivation (tap_internal_key for P2TR) +/// 3. Standard BIP32 derivation field (for non-Taproot) +/// 4. Partial signature field pub fn get_input_pubkey(psbt: &SilentPaymentPsbt, input_idx: usize) -> Result { let input = psbt .inputs .get(input_idx) .ok_or_else(|| Error::InvalidInputIndex(input_idx))?; - // Method 1: Extract from Taproot BIP32 derivation (tap_key_origins for P2TR) - if !input.tap_key_origins.is_empty() { + // Method 1: Extract from SP spend BIP32 derivation (for Silent Payment inputs) + if let Some((spend_pubkey, _, _)) = psbt.get_input_sp_spend_bip32_derivation(input_idx) { + return Ok(spend_pubkey); + } + + // Method 2: Extract from Taproot BIP32 derivation (tap_internal_key for P2TR) + if !input.tap_internal_key.is_none() { // Return the first key, converting x-only to full pubkey (even Y) - if let Some(xonly_key) = input.tap_key_origins.keys().next() { + if let Some(xonly_key) = input.tap_internal_key { let mut pubkey_bytes = vec![0x02]; pubkey_bytes.extend_from_slice(&xonly_key.serialize()); if let Ok(pubkey) = PublicKey::from_slice(&pubkey_bytes) { @@ -524,7 +664,7 @@ pub fn get_input_pubkey(psbt: &SilentPaymentPsbt, input_idx: usize) -> Result Result secp256k1::XOnlyPublicKey - let x_only = tap_key; - - // Convert x-only to full pubkey (assumes even y - prefix 0x02) - let mut pubkey_bytes = vec![0x02]; - pubkey_bytes.extend_from_slice(&x_only.serialize()); - if let Ok(pubkey) = PublicKey::from_slice(&pubkey_bytes) { - return Ok(pubkey); - } - } - - // Method 4: Extract from partial signature field - if !input.partial_sigs.is_empty() { - if let Some(key) = input.partial_sigs.keys().next() { - return Ok(key.inner); - } - } - Err(Error::Other(format!( - "Input {} missing public key (no BIP32 derivation, Taproot key, or partial signature found)", + "Input {} missing public key (no SP spend, BIP32 derivation, or tap key origin found)", input_idx ))) } diff --git a/spdk-core/src/psbt/roles/signer.rs b/spdk-core/src/psbt/roles/signer.rs index e0d61ca..dd34531 100644 --- a/spdk-core/src/psbt/roles/signer.rs +++ b/spdk-core/src/psbt/roles/signer.rs @@ -3,9 +3,9 @@ //! Adds ECDH shares and signatures to the PSBT. //! //! This module handles both regular P2WPKH signing and Silent Payment P2TR signing: -//! - **P2WPKH inputs**: Use [`sign_inputs()`] with ECDSA signatures → `partial_sigs` -//! - **P2TR SP inputs**: Use [`sign_sp_inputs()`] with tweaked key + Schnorr → `tap_key_sig` -//! - **Mixed transactions**: Call both functions as needed for different input types +//! - **P2PKH inputs**: Signs with ECDSA (legacy) → `partial_sigs` +//! - **P2WPKH inputs**: Signs with ECDSA (SegWit v0) → `partial_sigs` +//! - **P2TR inputs**: Signs with Schnorr (Taproot v1) → `tap_key_sig`, with optional SP tweak use crate::psbt::core::{ Bip375PsbtExt, EcdhShareData, Error, PsbtInput, Result, SilentPaymentPsbt, @@ -78,34 +78,14 @@ pub fn add_ecdh_shares_partial( ))); }; - // Check for Silent Payment tweak in PSBT and apply if present - // This ensures DLEQ proofs match the on-chain tweaked public key - let mut privkey = if let Some(tweak) = psbt.get_input_sp_tweak(input_idx) { - apply_tweak_to_privkey(base_privkey, &tweak) - .map_err(|e| Error::Other(format!("Tweak application failed: {}", e)))? - } else { - *base_privkey - }; - - // For P2TR Silent Payment inputs ONLY, check parity and negate if needed. - // Regular P2TR inputs (BIP-86) use the internal key for ECDH, not the tweaked key. - // The BIP-341 tweak is only for scriptPubKey generation, not for ECDH computation. - if psbt.get_input_sp_tweak(input_idx).is_some() { - let keypair = secp256k1::Keypair::from_secret_key(secp, &privkey); - let (_, parity) = keypair.x_only_public_key(); - if parity == secp256k1::Parity::Odd { - privkey = privkey.negate(); - } - } - for scan_key in scan_keys { - let share_point = compute_ecdh_share(secp, &privkey, scan_key) + let share_point = compute_ecdh_share(secp, base_privkey, scan_key) .map_err(|e| Error::Other(format!("ECDH computation failed: {}", e)))?; let dleq_proof = if include_dleq { let rand_aux = [input_idx as u8; 32]; Some( - dleq_generate_proof(secp, &privkey, scan_key, &rand_aux, None) + dleq_generate_proof(secp, &base_privkey, scan_key, &rand_aux, None) .map_err(|e| Error::Other(format!("DLEQ generation failed: {}", e)))?, ) } else { @@ -132,7 +112,6 @@ pub fn sign_inputs( inputs: &[PsbtInput], ) -> Result<()> { let tx = extract_tx_for_signing(psbt)?; - let mut prevouts: Option> = None; // Lazy loaded for P2TR for (input_idx, input) in inputs.iter().enumerate() { let Some(ref privkey) = input.private_key else { @@ -180,76 +159,23 @@ pub fn sign_inputs( .partial_sigs .insert(bitcoin_pubkey, sig); } else if input.witness_utxo.script_pubkey.is_p2tr() { - // Load prevouts if not already loaded (needed for Taproot sighash) - if prevouts.is_none() { - prevouts = Some( - psbt.inputs - .iter() - .enumerate() - .map(|(idx, input)| { - input.witness_utxo.clone().ok_or(Error::Other(format!( - "Input {} missing witness_utxo (required for P2TR)", - idx - ))) - }) - .collect::>>()?, - ); - } - - // Check for SP tweak - let tweak = psbt.get_input_sp_tweak(input_idx); - let signing_key = if let Some(tweak) = tweak { - apply_tweak_to_privkey(privkey, &tweak) - .map_err(|e| Error::Other(format!("Tweak application failed: {}", e)))? - } else { - *privkey - }; - - // Sign with BIP-340 Schnorr - let signature = sign_p2tr_input( - secp, - &tx, - input_idx, - prevouts.as_ref().unwrap(), - &signing_key, - ) - .map_err(|e| Error::Other(format!("Schnorr signing failed: {}", e)))?; - - // Add tap_key_sig to PSBT - psbt.inputs[input_idx].tap_key_sig = Some(signature); + sign_p2tr_with_optional_tweak(secp, psbt, &tx, input_idx, privkey)?; } } Ok(()) } -/// Sign Silent Payment P2TR inputs using tweaked private keys -/// -/// For each input with `PSBT_IN_SP_TWEAK` (0x1f): -/// 1. Apply tweak: `tweaked_privkey = spend_privkey + tweak` -/// 2. Sign with BIP-340 Schnorr signature -/// 3. Add `tap_key_sig` to PSBT input -/// -/// The tweak is derived from BIP-352 output derivation during wallet scanning. -/// This allows spending silent payment outputs without revealing the connection -/// between the scan key and spend key. -/// -/// # Arguments -/// * `secp` - Secp256k1 context -/// * `psbt` - PSBT to sign -/// * `spend_privkey` - The spend private key (before tweaking) -/// * `input_indices` - Indices of inputs to sign -/// -/// # Witness Format -/// Creates P2TR key-path witness: `[]` (65 bytes: 64-byte sig + sighash byte) +/// Sign a P2TR input, applying SP tweak if present. /// -/// See also: [`sign_inputs()`] for P2WPKH signing -pub fn sign_sp_inputs( +/// Builds prevouts from the PSBT, checks for `PSBT_IN_SP_TWEAK`, and signs with +/// BIP-340 Schnorr. If a tweak is present, it is applied to the private key before signing. +fn sign_p2tr_with_optional_tweak( secp: &Secp256k1, psbt: &mut SilentPaymentPsbt, - spend_privkey: &SecretKey, - input_indices: &[usize], + tx: &bitcoin::Transaction, + input_idx: usize, + privkey: &SecretKey, ) -> Result<()> { - // Build prevouts array for Taproot sighash let prevouts: Vec = psbt .inputs .iter() @@ -258,31 +184,25 @@ pub fn sign_sp_inputs( input .witness_utxo .clone() - .ok_or(Error::Other(format!("Input {} missing witness_utxo", idx))) + .ok_or(Error::Other(format!( + "Input {} missing witness_utxo (required for P2TR)", + idx + ))) }) .collect::>>()?; - // Build unsigned transaction for signing - let tx = extract_tx_for_signing(psbt)?; - - // Sign each input with tweak - for &input_idx in input_indices { - let Some(tweak) = psbt.get_input_sp_tweak(input_idx) else { - continue; // Not a silent payment input, skip - }; + let tweak = psbt.get_input_sp_tweak(input_idx); + let signing_key = if let Some(tweak) = tweak { + apply_tweak_to_privkey(privkey, &tweak) + .map_err(|e| Error::Other(format!("Tweak application failed: {}", e)))? + } else { + *privkey + }; - // Apply tweak to spend key: tweaked_privkey = spend_privkey + tweak - let tweaked_privkey = apply_tweak_to_privkey(spend_privkey, &tweak) - .map_err(|e| Error::Other(format!("Tweak application failed: {}", e)))?; - - // Sign with BIP-340 Schnorr - let signature = sign_p2tr_input(secp, &tx, input_idx, &prevouts, &tweaked_privkey) - .map_err(|e| Error::Other(format!("Schnorr signing failed: {}", e)))?; - - // Add tap_key_sig to PSBT - psbt.inputs[input_idx].tap_key_sig = Some(signature); - } + let signature = sign_p2tr_input(secp, tx, input_idx, &prevouts, &signing_key) + .map_err(|e| Error::Other(format!("Schnorr signing failed: {}", e)))?; + psbt.inputs[input_idx].tap_key_sig = Some(signature); Ok(()) } diff --git a/spdk-core/src/psbt/roles/updater.rs b/spdk-core/src/psbt/roles/updater.rs index f558a53..a74a30b 100644 --- a/spdk-core/src/psbt/roles/updater.rs +++ b/spdk-core/src/psbt/roles/updater.rs @@ -4,6 +4,8 @@ use crate::psbt::core::{Error, Result, SilentPaymentPsbt}; use bitcoin::bip32::{ChildNumber, DerivationPath, Fingerprint}; +use bitcoin::key::XOnlyPublicKey; +use bitcoin::taproot::TapLeafHash; use bitcoin::EcdsaSighashType; use psbt_v2::PsbtSighashType; @@ -75,6 +77,87 @@ pub fn add_output_bip32_derivation( Ok(()) } +/// Add Taproot BIP32 derivation information for an input (PSBT_IN_TAP_BIP32_DERIVATION) +/// +/// Use this for P2TR inputs +/// The leaf_hashes parameter specifies which tap leaves this key is used in; +/// pass an empty vec for key-path spending. +pub fn add_input_tap_bip32_derivation( + psbt: &mut SilentPaymentPsbt, + input_index: usize, + xonly_pubkey: &XOnlyPublicKey, + leaf_hashes: Vec, + derivation: &Bip32Derivation, +) -> Result<()> { + let input = psbt + .inputs + .get_mut(input_index) + .ok_or(Error::InvalidInputIndex(input_index))?; + + let fingerprint = Fingerprint::from(derivation.master_fingerprint); + let path: DerivationPath = derivation + .path + .iter() + .map(|&i| ChildNumber::from(i)) + .collect(); + + input + .tap_key_origins + .insert(*xonly_pubkey, (leaf_hashes, (fingerprint, path))); + + Ok(()) +} + +/// Add Taproot BIP32 derivation information for an output (PSBT_OUT_TAP_BIP32_DERIVATION) +/// +/// Use this for P2TR outputs. +pub fn add_output_tap_bip32_derivation( + psbt: &mut SilentPaymentPsbt, + output_index: usize, + xonly_pubkey: &XOnlyPublicKey, + leaf_hashes: Vec, + derivation: &Bip32Derivation, +) -> Result<()> { + let output = psbt + .outputs + .get_mut(output_index) + .ok_or(Error::InvalidOutputIndex(output_index))?; + + let fingerprint = Fingerprint::from(derivation.master_fingerprint); + let path: DerivationPath = derivation + .path + .iter() + .map(|&i| ChildNumber::from(i)) + .collect(); + + output + .tap_key_origins + .insert(*xonly_pubkey, (leaf_hashes, (fingerprint, path))); + + Ok(()) +} + +/// Add Silent Payment spend BIP32 derivation for an input (PSBT_IN_SP_SPEND_BIP32_DERIVATION) +/// +/// Use this for Silent Payment inputs (those with PSBT_IN_SP_TWEAK). +/// The spend key is the untweaked key that, when combined with the SP tweak, +/// produces the key locking the output. +pub fn add_input_sp_spend_bip32_derivation( + psbt: &mut SilentPaymentPsbt, + input_index: usize, + spend_pubkey: &secp256k1::PublicKey, + derivation: &Bip32Derivation, +) -> Result<()> { + use crate::psbt::Bip375PsbtExt; + + psbt.set_input_sp_spend_bip32_derivation( + input_index, + spend_pubkey, + derivation.master_fingerprint, + derivation.path.clone(), + ) +} + /// Add sighash type for an input pub fn add_input_sighash_type( psbt: &mut SilentPaymentPsbt, @@ -118,6 +201,27 @@ mod tests { assert_eq!(path.len(), 1); } + #[test] + fn test_add_input_tap_bip32_derivation() { + let mut psbt = create_psbt(1, 1); + let secp = Secp256k1::new(); + let privkey = SecretKey::from_slice(&[1u8; 32]).unwrap(); + let pubkey = secp256k1::PublicKey::from_secret_key(&secp, &privkey); + let (xonly, _parity) = pubkey.x_only_public_key(); + + let derivation = Bip32Derivation::new([0xAA, 0xBB, 0xCC, 0xDD], vec![0x80000056]); // m/86' + + add_input_tap_bip32_derivation(&mut psbt, 0, &xonly, vec![], &derivation).unwrap(); + + let input = &psbt.inputs[0]; + assert!(input.tap_key_origins.contains_key(&xonly)); + + let (leaf_hashes, (fp, path)) = input.tap_key_origins.get(&xonly).unwrap(); + assert!(leaf_hashes.is_empty()); + assert_eq!(fp.as_bytes(), &[0xAA, 0xBB, 0xCC, 0xDD]); + assert_eq!(path.len(), 1); + } + #[test] fn test_add_sighash_type() { let mut psbt = create_psbt(1, 1); diff --git a/spdk-core/src/psbt/roles/validation.rs b/spdk-core/src/psbt/roles/validation.rs index ddf9893..62fa5c9 100644 --- a/spdk-core/src/psbt/roles/validation.rs +++ b/spdk-core/src/psbt/roles/validation.rs @@ -5,7 +5,7 @@ use crate::psbt::core::{Bip375PsbtExt, Error, Result, SilentPaymentPsbt}; use crate::psbt::crypto::bip352::is_input_eligible; use crate::psbt::crypto::dleq_verify_proof; -use secp256k1::{PublicKey, Scalar, Secp256k1, SecretKey}; +use secp256k1::{PublicKey, Secp256k1}; use std::collections::HashSet; /// Validation level for PSBT checks @@ -31,17 +31,16 @@ pub fn validate_psbt( validate_input_fields(psbt)?; validate_output_fields(psbt)?; - // TODO: is this correct? - // Validate SP tweak fields (if any inputs have them) - validate_sp_tweak_fields(psbt, false)?; + // Validate SP spend fields BIP-376 (if any inputs have them) + validate_sp_spend_fields(psbt)?; // Check if this PSBT has silent payment outputs let has_sp_outputs = (0..psbt.num_outputs()).any(|i| psbt.get_output_sp_info(i).is_some()); if has_sp_outputs { - // Rule 6: Segwit version restrictions (must be v0 or v1 for silent payments) + // Segwit version restrictions (must be v0 or v1 for silent payments) validate_segwit_versions(psbt)?; - // Rule 7: SIGHASH_ALL requirement (only SIGHASH_ALL allowed with silent payments) + // SIGHASH_ALL requirement (only SIGHASH_ALL allowed with silent payments) validate_sighash_types(psbt)?; } @@ -76,7 +75,6 @@ fn validate_psbt_version(psbt: &SilentPaymentPsbt) -> Result<()> { /// Verify script_pubkey presence and tx_modifiable_flags consistency fn validate_psbt_state(psbt: &SilentPaymentPsbt) -> Result<()> { - let output_maps = &psbt.outputs; for output_map in output_maps { if !output_map.script_pubkey.is_empty() && psbt.global.tx_modifiable_flags != 0 { @@ -119,7 +117,7 @@ fn validate_output_fields(psbt: &SilentPaymentPsbt) -> Result<()> { ))); } - // Rule 3: PSBT_OUT_SP_V0_LABEL requires SP address + // PSBT_OUT_SP_V0_LABEL requires SP address let has_sp_label = psbt.get_output_sp_label(i).is_some(); if has_sp_label && !has_sp_address { @@ -133,40 +131,17 @@ fn validate_output_fields(psbt: &SilentPaymentPsbt) -> Result<()> { Ok(()) } -/// Validate SP tweak fields on inputs +/// Validate SP spend fields on inputs (BIP-376) /// -/// Checks that inputs with `PSBT_IN_SP_TWEAK` (0x1f) are properly configured: -/// - Tweak must be exactly 32 bytes -/// - Input must have `witness_utxo` that is P2TR (SegWit v1) -/// - For pre-extraction validation: Input must have `tap_key_sig` -fn validate_sp_tweak_fields(psbt: &SilentPaymentPsbt, require_signatures: bool) -> Result<()> { - for (input_idx, input) in psbt.inputs.iter().enumerate() { +/// Checks that inputs with `PSBT_IN_SP_TWEAK` are properly configured: +/// - PSBT_IN_SP_SPEND_BIP32_DERIVATION must be present +fn validate_sp_spend_fields(psbt: &SilentPaymentPsbt) -> Result<()> { + for input_idx in 0..psbt.num_inputs() { // Check if this input has an SP tweak - if let Some(tweak) = psbt.get_input_sp_tweak(input_idx) { - // Tweak must be 32 bytes (already enforced by return type, but document it) - assert_eq!(tweak.len(), 32, "SP tweak must be 32 bytes"); - - // Input must have witness_utxo - let witness_utxo = input.witness_utxo.as_ref().ok_or_else(|| { - Error::Other(format!( - "Input {} has PSBT_IN_SP_TWEAK but missing witness_utxo", - input_idx - )) - })?; - - // witness_utxo must be P2TR (SegWit v1) - let script = &witness_utxo.script_pubkey; - if !script.is_p2tr() { - return Err(Error::InvalidFieldData(format!( - "Input {} has PSBT_IN_SP_TWEAK but witness_utxo is not P2TR (SegWit v1)", - input_idx - ))); - } - - // If requiring signatures (pre-extraction), verify tap_key_sig exists - if require_signatures && input.tap_key_sig.is_none() { - return Err(Error::Other(format!( - "Input {} has PSBT_IN_SP_TWEAK but missing tap_key_sig (not yet signed)", + if psbt.get_input_sp_tweak(input_idx).is_some() { + if psbt.get_input_sp_spend_bip32_derivation(input_idx).is_none() { + return Err(Error::MissingField(format!( + "Input {} has SP tweak but missing SP spend BIP32 derivation", input_idx ))); } @@ -176,7 +151,7 @@ fn validate_sp_tweak_fields(psbt: &SilentPaymentPsbt, require_signatures: bool) Ok(()) } -/// Validate segwit version restrictions (Rule 6) +/// Validate segwit version restrictions fn validate_segwit_versions(psbt: &SilentPaymentPsbt) -> Result<()> { for (i, input) in psbt.inputs.iter().enumerate() { if let Some(witness_utxo) = &input.witness_utxo { @@ -200,7 +175,7 @@ fn validate_segwit_versions(psbt: &SilentPaymentPsbt) -> Result<()> { Ok(()) } -/// Validate SIGHASH_ALL requirement (Rule 7) +/// Validate SIGHASH_ALL requirement fn validate_sighash_types(psbt: &SilentPaymentPsbt) -> Result<()> { for (i, input) in psbt.inputs.iter().enumerate() { if let Some(sighash_type) = input.sighash_type { @@ -413,10 +388,6 @@ fn validate_dleq_proofs(secp: &Secp256k1, psbt: &SilentPaymentPs let global_shares = psbt.get_global_ecdh_shares(); for share in global_shares { if share.dleq_proof.is_none() { - // Global shares MUST have DLEQ proofs? - // BIP-375 says DLEQ proof is required. - // But my EcdhShare struct has Option. - // If it's missing, it's invalid. return Err(Error::Other( "Global ECDH share missing DLEQ proof".to_string(), )); @@ -436,32 +407,7 @@ fn validate_dleq_proofs(secp: &Secp256k1, psbt: &SilentPaymentPs } if let Some(proof) = share.dleq_proof { - let mut input_pubkey = get_input_pubkey(psbt, input_idx)?; - - // If this is a Silent Payment input, derive the tweaked public key - if let Some(tweak) = psbt.get_input_sp_tweak(input_idx) { - let tweak_scalar = Scalar::from_be_bytes(tweak) - .map_err(|_| Error::Other("Invalid SP tweak scalar".to_string()))?; - let tweak_key = SecretKey::from_slice(&tweak_scalar.to_be_bytes())?; - let tweak_point = PublicKey::from_secret_key(secp, &tweak_key); - - input_pubkey = input_pubkey.combine(&tweak_point)?; // A' = A + tweak*G - - // Handle parity for P2TR Silent Payment inputs ONLY - // Regular P2TR inputs use the internal key for ECDH, not the tweaked key - let input = psbt - .inputs - .get(input_idx) - .ok_or(Error::InvalidInputIndex(input_idx))?; - if let Some(ref witness_utxo) = input.witness_utxo { - if witness_utxo.script_pubkey.is_p2tr() { - let (_, parity) = input_pubkey.x_only_public_key(); - if parity == secp256k1::Parity::Odd { - input_pubkey = input_pubkey.negate(secp); - } - } - } - } + let input_pubkey = get_input_pubkey(psbt, input_idx)?; let is_valid = dleq_verify_proof( secp, @@ -487,12 +433,7 @@ fn validate_dleq_proofs(secp: &Secp256k1, psbt: &SilentPaymentPs /// /// Checks that all inputs are signed (either P2WPKH with `partial_sigs` or P2TR with `tap_key_sig`) /// and all outputs have scripts. -/// -/// For SP tweak inputs, validates that they have required signatures. pub fn validate_ready_for_extraction(psbt: &SilentPaymentPsbt) -> Result<()> { - // Validate SP tweak fields with signature requirement - validate_sp_tweak_fields(psbt, true)?; - for input_idx in 0..psbt.num_inputs() { let input = &psbt.inputs[input_idx]; From 291a24aba83aa12002d7a8be1a59db711b236611 Mon Sep 17 00:00:00 2001 From: macgyver13 <4712150+macgyver13@users.noreply.github.com> Date: Wed, 18 Feb 2026 18:05:19 -0500 Subject: [PATCH 12/16] psbt: Extract shared secret computation into a reusable compute_shared_secrets helper in bip352.rs for finalize_inputs and validate_output_scripts Also validate full ECDH coverage across all inputs before processing outputs --- spdk-core/src/psbt/core/shares.rs | 7 -- spdk-core/src/psbt/crypto/bip352.rs | 52 ++++++++++++++ spdk-core/src/psbt/roles/input_finalizer.rs | 76 ++++++++++++++------- spdk-core/src/psbt/roles/validation.rs | 53 ++++---------- 4 files changed, 117 insertions(+), 71 deletions(-) diff --git a/spdk-core/src/psbt/core/shares.rs b/spdk-core/src/psbt/core/shares.rs index c9bde08..39216f1 100644 --- a/spdk-core/src/psbt/core/shares.rs +++ b/spdk-core/src/psbt/core/shares.rs @@ -94,13 +94,6 @@ impl AggregatedShares { /// # Errors /// * If no inputs exist in the PSBT /// * If elliptic curve operations fail during aggregation -/// -/// # Example -/// ```rust,ignore -/// let aggregated = aggregate_ecdh_shares(&psbt)?; -/// let share = aggregated.get_share_point(&scan_key) -/// .ok_or_else(|| Error::Other("Missing share".to_string()))?; -/// ``` pub fn aggregate_ecdh_shares(psbt: &SilentPaymentPsbt) -> Result { let num_inputs = psbt.num_inputs(); if num_inputs == 0 { diff --git a/spdk-core/src/psbt/crypto/bip352.rs b/spdk-core/src/psbt/crypto/bip352.rs index 9b94acd..2017f6e 100644 --- a/spdk-core/src/psbt/crypto/bip352.rs +++ b/spdk-core/src/psbt/crypto/bip352.rs @@ -8,6 +8,7 @@ use bitcoin::key::TapTweak; use bitcoin::ScriptBuf; use psbt_v2::v2::Input; use secp256k1::{PublicKey, Scalar, Secp256k1, SecretKey}; +use std::collections::HashMap; /// Compute label tweak for a silent payment address /// @@ -50,6 +51,57 @@ pub fn compute_input_hash(smallest_outpoint: &[u8], summed_pubkey: &PublicKey) - .map_err(|_| CryptoError::Other("Failed to create scalar from input hash".to_string())) } +/// Compute BIP-352 shared secrets from aggregated ECDH shares. +/// +/// For each scan key, computes: shared_secret = input_hash * aggregated_share +/// where input_hash = hash_BIP0352/Inputs(smallest_outpoint || sum_of_pubkeys) +/// +/// If `input_pubkeys` is empty or doesn't cover all outpoints, falls back to +/// returning raw aggregated shares (for backwards compatibility with tests +/// that don't set BIP32 derivations). +pub fn compute_shared_secrets( + secp: &Secp256k1, + aggregated_shares: &[(PublicKey, PublicKey)], // (scan_key, aggregated_share) + outpoints: &[Vec], + input_pubkeys: &[PublicKey], +) -> Result> { + let input_hash = + if !input_pubkeys.is_empty() && input_pubkeys.len() == outpoints.len() { + let mut summed_pubkey = input_pubkeys[0]; + for pubkey in &input_pubkeys[1..] { + summed_pubkey = summed_pubkey.combine(pubkey).map_err(|e| { + CryptoError::Other(format!("Failed to sum input pubkeys: {}", e)) + })?; + } + + let smallest_outpoint = outpoints + .iter() + .min() + .ok_or_else(|| CryptoError::Other("No outpoints provided".to_string()))?; + + Some(compute_input_hash(smallest_outpoint, &summed_pubkey)?) + } else { + None + }; + + let mut shared_secrets = HashMap::new(); + for (scan_key, aggregated_share) in aggregated_shares { + let shared_secret = if let Some(ref ih) = input_hash { + aggregated_share.mul_tweak(secp, ih).map_err(|e| { + CryptoError::Other(format!( + "Failed to multiply ECDH share by input_hash: {}", + e + )) + })? + } else { + *aggregated_share + }; + shared_secrets.insert(*scan_key, shared_secret); + } + + Ok(shared_secrets) +} + /// Compute shared secret tweak for output derivation /// /// BIP 352: hash_BIP0352/SharedSecret(ecdh_secret || ser₃₂(k)) diff --git a/spdk-core/src/psbt/roles/input_finalizer.rs b/spdk-core/src/psbt/roles/input_finalizer.rs index 982a32d..69ebc74 100644 --- a/spdk-core/src/psbt/roles/input_finalizer.rs +++ b/spdk-core/src/psbt/roles/input_finalizer.rs @@ -2,12 +2,21 @@ //! //! Aggregates ECDH shares and computes final output scripts for silent payments. -use crate::psbt::core::{aggregate_ecdh_shares, Bip375PsbtExt, Error, Result, SilentPaymentPsbt}; -use crate::psbt::crypto::{derive_silent_payment_output_pubkey, tweaked_key_to_p2tr_script}; +use crate::psbt::core::{ + aggregate_ecdh_shares, get_input_bip32_pubkeys, get_input_outpoint_bytes, Bip375PsbtExt, + Error, Result, SilentPaymentPsbt, +}; +use crate::psbt::crypto::{ + compute_shared_secrets, derive_silent_payment_output_pubkey, tweaked_key_to_p2tr_script, +}; use secp256k1::{PublicKey, Secp256k1}; use std::collections::HashMap; -/// Finalize inputs by computing output scripts from ECDH shares +/// Finalize inputs by computing output scripts from ECDH shares. +/// +/// Per BIP 352, the shared secret for output derivation is: +/// shared_secret = input_hash * aggregated_ecdh_share +/// where input_hash = hash_BIP0352/Inputs(smallest_outpoint || sum_of_pubkeys) pub fn finalize_inputs( secp: &Secp256k1, psbt: &mut SilentPaymentPsbt, @@ -15,6 +24,40 @@ pub fn finalize_inputs( // Aggregate ECDH shares by scan key (detects global vs per-input automatically) let aggregated_shares = aggregate_ecdh_shares(psbt)?; + // Verify all inputs contributed shares (unless global) + for (scan_key, aggregated) in aggregated_shares.iter() { + if !aggregated.is_global && aggregated.num_inputs != psbt.num_inputs() { + let output_idx = (0..psbt.num_outputs()) + .find(|&i| { + psbt.get_output_sp_info(i) + .map(|(sk, _)| sk == *scan_key) + .unwrap_or(false) + }) + .unwrap_or(0); + return Err(Error::IncompleteEcdhCoverage(output_idx)); + } + } + + // Extract outpoints and BIP32 pubkeys from PSBT + let mut outpoints: Vec> = Vec::new(); + let mut input_pubkeys: Vec = Vec::new(); + for input_idx in 0..psbt.num_inputs() { + outpoints.push(get_input_outpoint_bytes(psbt, input_idx)?); + let bip32_pubkeys = get_input_bip32_pubkeys(psbt, input_idx); + if !bip32_pubkeys.is_empty() { + input_pubkeys.push(bip32_pubkeys[0]); + } + } + + // Build (scan_key, aggregated_share) pairs for BIP-352 computation + let share_pairs: Vec<(PublicKey, PublicKey)> = aggregated_shares + .iter() + .map(|(sk, agg)| (*sk, agg.aggregated_share)) + .collect(); + + let shared_secrets = compute_shared_secrets(secp, &share_pairs, &outpoints, &input_pubkeys) + .map_err(|e| Error::Other(format!("Shared secret computation failed: {}", e)))?; + // Track output index per scan key (for BIP 352 k parameter) let mut scan_key_output_indices: HashMap = HashMap::new(); @@ -23,49 +66,34 @@ pub fn finalize_inputs( // Check if this is a silent payment output let (scan_key, spend_key) = match psbt.get_output_sp_info(output_idx) { Some(keys) => keys, - None => continue, // Not a silent payment output, skip + None => continue, }; - // Get the aggregated share for this scan key - let aggregated = aggregated_shares + let shared_secret = shared_secrets .get(&scan_key) .ok_or(Error::IncompleteEcdhCoverage(output_idx))?; - // Verify all inputs contributed shares (unless it's a global share) - if !aggregated.is_global && aggregated.num_inputs != psbt.num_inputs() { - return Err(Error::IncompleteEcdhCoverage(output_idx)); - } - - let aggregated_share = aggregated.aggregated_share; - // Get or initialize the output index for this scan key let k = *scan_key_output_indices.get(&scan_key).unwrap_or(&0); // Derive the output public key using BIP-352 - let ecdh_secret = aggregated_share.serialize(); + let shared_secret_bytes = shared_secret.serialize(); let output_pubkey = derive_silent_payment_output_pubkey( secp, &spend_key, - &ecdh_secret, - k, // Use per-scan-key index + &shared_secret_bytes, + k, ) .map_err(|e| Error::Other(format!("Output derivation failed: {}", e)))?; - // Create P2TR output script from already-tweaked Silent Payment output key - // The output_pubkey is already tweaked via BIP-352 derivation, so we use - // tweaked_key_to_p2tr_script (no additional BIP-341 tweak needed) - let output_script = tweaked_key_to_p2tr_script(&output_pubkey); - // Add output script to PSBT psbt.outputs[output_idx].script_pubkey = output_script; - // Increment the output index for this scan key scan_key_output_indices.insert(scan_key, k + 1); } - // BIP-370: Clear tx_modifiable_flags after finalizing outputs - // Once output scripts are computed, the transaction structure is locked + // Clear tx_modifiable_flags after finalizing outputs psbt.global.tx_modifiable_flags = 0x00; Ok(()) diff --git a/spdk-core/src/psbt/roles/validation.rs b/spdk-core/src/psbt/roles/validation.rs index 62fa5c9..50af6b9 100644 --- a/spdk-core/src/psbt/roles/validation.rs +++ b/spdk-core/src/psbt/roles/validation.rs @@ -280,18 +280,16 @@ fn validate_output_scripts( aggregate_ecdh_shares, get_input_bip32_pubkeys, get_input_outpoint_bytes, }; use crate::psbt::crypto::{ - compute_input_hash, derive_silent_payment_output_pubkey, tweaked_key_to_p2tr_script, + compute_shared_secrets, derive_silent_payment_output_pubkey, tweaked_key_to_p2tr_script, }; use std::collections::HashMap; - // First, collect outpoints and public keys for input_hash computation + // Extract outpoints and BIP32 pubkeys from PSBT let mut outpoints: Vec> = Vec::new(); let mut input_pubkeys: Vec = Vec::new(); for input_idx in 0..psbt.num_inputs() { - let outpoint = get_input_outpoint_bytes(psbt, input_idx)?; - outpoints.push(outpoint); - + outpoints.push(get_input_outpoint_bytes(psbt, input_idx)?); let bip32_pubkeys = get_input_bip32_pubkeys(psbt, input_idx); if bip32_pubkeys.is_empty() { eprintln!( @@ -307,36 +305,16 @@ fn validate_output_scripts( return Ok(()); } - let mut summed_pubkey = input_pubkeys[0]; - for pubkey in &input_pubkeys[1..] { - summed_pubkey = summed_pubkey - .combine(pubkey) - .map_err(|e| Error::Other(format!("Failed to sum input pubkeys: {}", e)))?; - } + let aggregated_shares = aggregate_ecdh_shares(psbt)?; - let smallest_outpoint = outpoints + let share_pairs: Vec<(PublicKey, PublicKey)> = aggregated_shares .iter() - .min() - .ok_or_else(|| Error::Other("No inputs found".to_string()))?; + .map(|(sk, agg)| (*sk, agg.aggregated_share)) + .collect(); - let aggregated_shares = aggregate_ecdh_shares(psbt)?; - - let input_hash = compute_input_hash(smallest_outpoint, &summed_pubkey) - .map_err(|e| Error::Other(format!("Failed to compute input hash: {}", e)))?; - - let mut shared_secrets: HashMap = HashMap::new(); - for (scan_key, aggregated_share_data) in aggregated_shares.iter() { - let shared_secret = aggregated_share_data - .aggregated_share - .mul_tweak(secp, &input_hash) - .map_err(|e| { - Error::Other(format!( - "Failed to multiply ECDH share by input_hash: {}", - e - )) - })?; - shared_secrets.insert(*scan_key, shared_secret); - } + let shared_secrets = + compute_shared_secrets(secp, &share_pairs, &outpoints, &input_pubkeys) + .map_err(|e| Error::Other(format!("Shared secret computation failed: {}", e)))?; let mut scan_key_output_indices: HashMap = HashMap::new(); @@ -570,10 +548,7 @@ mod tests { ]; add_inputs(&mut psbt, &inputs).unwrap(); - // Case 1: No shares at all -> Invalid - assert!(validate_ecdh_coverage(&psbt).is_err()); - - // Case 2: Global share present -> Valid + // Case 1: Global share present -> Valid { let mut psbt_global = psbt.clone(); // Create a dummy share @@ -585,7 +560,7 @@ mod tests { assert!(validate_ecdh_coverage(&psbt_global).is_ok()); } - // Case 3: Per-input shares for ALL inputs -> Valid + // Case 2: Per-input shares for ALL inputs -> Valid { let mut psbt_inputs = psbt.clone(); let share_point = @@ -600,7 +575,7 @@ mod tests { assert!(validate_ecdh_coverage(&psbt_inputs).is_ok()); } - // Case 4: Per-input shares for SOME inputs -> Valid + // Case 3: Per-input shares for SOME inputs -> Valid { let mut psbt_partial = psbt.clone(); let share_point = @@ -613,7 +588,5 @@ mod tests { assert!(validate_ecdh_coverage(&psbt_partial).is_ok()); } - // TODO: Case 5: Per-input shares for ALL inputs, but some are invalid -> Invalid - // TODO: Case 6: Global share present, invalid input shares -> Valid } } From 21b3e2fc285f2777e2f50c45349bb8ae380f64a6 Mon Sep 17 00:00:00 2001 From: macgyver13 <4712150+macgyver13@users.noreply.github.com> Date: Wed, 18 Feb 2026 18:24:18 -0500 Subject: [PATCH 13/16] psbt: Allow tx_modifiable_flags and output scripts for non silent payment outputs --- spdk-core/src/psbt/roles/validation.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/spdk-core/src/psbt/roles/validation.rs b/spdk-core/src/psbt/roles/validation.rs index 50af6b9..12fa4ed 100644 --- a/spdk-core/src/psbt/roles/validation.rs +++ b/spdk-core/src/psbt/roles/validation.rs @@ -74,12 +74,19 @@ fn validate_psbt_version(psbt: &SilentPaymentPsbt) -> Result<()> { } /// Verify script_pubkey presence and tx_modifiable_flags consistency +/// +/// Per BIP 375, the Signer clears tx_modifiable_flags when it computes +/// PSBT_OUT_SCRIPT for SP outputs. Regular (non-SP) outputs may have +/// script_pubkey set at construction time while flags are still modifiable. fn validate_psbt_state(psbt: &SilentPaymentPsbt) -> Result<()> { - let output_maps = &psbt.outputs; - for output_map in output_maps { - if !output_map.script_pubkey.is_empty() && psbt.global.tx_modifiable_flags != 0 { + for (i, output) in psbt.outputs.iter().enumerate() { + let is_sp_output = psbt.get_output_sp_info(i).is_some(); + if is_sp_output + && !output.script_pubkey.is_empty() + && psbt.global.tx_modifiable_flags != 0 + { return Err(Error::InvalidPsbtState( - "tx_modifiable flag is modifiable with script_pubkey present".to_string(), + "SP output has script_pubkey set while tx_modifiable_flags is non-zero".to_string(), )); } } From c4bfd4fed008b9718f2f987e044d1fa9a40d21e3 Mon Sep 17 00:00:00 2001 From: macgyver13 <4712150+macgyver13@users.noreply.github.com> Date: Thu, 19 Feb 2026 11:48:05 -0500 Subject: [PATCH 14/16] psbt: Add missing global DLEQ validation --- spdk-core/src/psbt/roles/validation.rs | 34 ++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/spdk-core/src/psbt/roles/validation.rs b/spdk-core/src/psbt/roles/validation.rs index 12fa4ed..8e9b622 100644 --- a/spdk-core/src/psbt/roles/validation.rs +++ b/spdk-core/src/psbt/roles/validation.rs @@ -377,6 +377,40 @@ fn validate_dleq_proofs(secp: &Secp256k1, psbt: &SilentPaymentPs "Global ECDH share missing DLEQ proof".to_string(), )); } + + if let Some(proof) = share.dleq_proof { + // Aggregate all input public keys for global proof verification + let mut input_pubkeys = Vec::new(); + for input_idx in 0..psbt.num_inputs() { + let pubkey = get_input_pubkey(psbt, input_idx)?; + input_pubkeys.push(pubkey); + } + if input_pubkeys.is_empty() { + return Err(Error::Other( + "No input public keys for global DLEQ verification".to_string(), + )); + } + let mut aggregate_key = input_pubkeys[0]; + for pubkey in &input_pubkeys[1..] { + aggregate_key = aggregate_key.combine(pubkey).map_err(|e| { + Error::Other(format!("Failed to aggregate input pubkeys: {}", e)) + })?; + } + let is_valid = dleq_verify_proof( + secp, + &aggregate_key, + &share.scan_key, + &share.share, + &proof, + None, + ) + .map_err(|e| Error::Other(format!("Global DLEQ verification error: {}", e)))?; + if !is_valid { + return Err(Error::Other( + "Global DLEQ proof verification failed".to_string(), + )); + } + } } // Validate per-input ECDH shares From 33968419055855d0a597adada7e4727396f44547 Mon Sep 17 00:00:00 2001 From: macgyver13 <4712150+macgyver13@users.noreply.github.com> Date: Tue, 24 Feb 2026 12:04:10 -0500 Subject: [PATCH 15/16] psbt: core extension simplification requires pairs() from rust-psbt --- spdk-core/src/psbt/core/extensions.rs | 421 ++------------------------ 1 file changed, 24 insertions(+), 397 deletions(-) diff --git a/spdk-core/src/psbt/core/extensions.rs b/spdk-core/src/psbt/core/extensions.rs index 4a7bfdd..275b989 100644 --- a/spdk-core/src/psbt/core/extensions.rs +++ b/spdk-core/src/psbt/core/extensions.rs @@ -696,430 +696,57 @@ pub fn get_output_sp_keys( // The following traits provide methods for extracting and serializing PSBT fields // for display purposes. These are used by GUI and analysis tools to inspect PSBT contents. -/// Extension trait for accessing psbt_v2::v2::Global fields for display +/// Extension trait for iterating all PSBT global map fields as raw (type, key, value) tuples. /// -/// This trait provides convenient methods to access all standard PSBT v2 global fields -/// in a serialized format suitable for display or further processing. +/// Delegates to psbt_v2's internal serialization path, so all fields — including unknowns +/// and any future additions to psbt_v2 — are returned automatically in serialization order. pub trait GlobalFieldsExt { - /// Iterator over all standard global fields as (field_type, key_data, value_data) tuples - /// - /// Returns fields in the following order: - /// - PSBT_GLOBAL_XPUB (0x01) - Multiple entries possible - /// - PSBT_GLOBAL_TX_VERSION (0x02) - /// - PSBT_GLOBAL_FALLBACK_LOCKTIME (0x03) - If present - /// - PSBT_GLOBAL_INPUT_COUNT (0x04) - /// - PSBT_GLOBAL_OUTPUT_COUNT (0x05) - /// - PSBT_GLOBAL_TX_MODIFIABLE (0x06) - /// - PSBT_GLOBAL_SP_ECDH_SHARE (0x07) - Multiple entries possible (BIP-375) - /// - PSBT_GLOBAL_SP_DLEQ (0x08) - Multiple entries possible (BIP-375) - /// - PSBT_GLOBAL_VERSION (0xFB) - /// - PSBT_GLOBAL_PROPRIETARY (0xFC) - Multiple entries possible - /// - Unknown fields from the unknowns map + /// Returns all global map fields as (field_type, key_data, value_data) tuples. fn iter_global_fields(&self) -> Vec<(u8, Vec, Vec)>; } impl GlobalFieldsExt for psbt_v2::v2::Global { fn iter_global_fields(&self) -> Vec<(u8, Vec, Vec)> { - let mut fields = Vec::new(); - - // PSBT_GLOBAL_XPUB = 0x01 - Can have multiple entries - for (xpub, key_source) in &self.xpubs { - let field_type = 0x01; - // Key is the serialized xpub - let key_data = xpub.to_string().as_bytes().to_vec(); - // Value is the key source (fingerprint + derivation path) - let mut value_data = Vec::new(); - // Fingerprint is 4 bytes - value_data.extend_from_slice(&key_source.0.to_bytes()); - // Derivation path - each ChildNumber is 4 bytes (u32) - for child in &key_source.1 { - value_data.extend_from_slice(&u32::from(*child).to_le_bytes()); - } - fields.push((field_type, key_data, value_data)); - } - - // PSBT_GLOBAL_TX_VERSION = 0x02 - Always present - { - let field_type = 0x02; - let key_data = vec![]; - let value_data = self.tx_version.0.to_le_bytes().to_vec(); - fields.push((field_type, key_data, value_data)); - } - - // PSBT_GLOBAL_FALLBACK_LOCKTIME = 0x03 - Optional - if let Some(lock_time) = self.fallback_lock_time { - let field_type = 0x03; - let key_data = vec![]; - let value_data = lock_time.to_consensus_u32().to_le_bytes().to_vec(); - fields.push((field_type, key_data, value_data)); - } - - // PSBT_GLOBAL_INPUT_COUNT = 0x04 - Always present - { - let field_type = 0x04; - let key_data = vec![]; - // Serialize as VarInt (compact size) - let mut value_data = vec![]; - let count = self.input_count as u64; - if count < 0xFD { - value_data.push(count as u8); - } else if count <= 0xFFFF { - value_data.push(0xFD); - value_data.extend_from_slice(&(count as u16).to_le_bytes()); - } else if count <= 0xFFFF_FFFF { - value_data.push(0xFE); - value_data.extend_from_slice(&(count as u32).to_le_bytes()); - } else { - value_data.push(0xFF); - value_data.extend_from_slice(&count.to_le_bytes()); - } - fields.push((field_type, key_data, value_data)); - } - - // PSBT_GLOBAL_OUTPUT_COUNT = 0x05 - Always present - { - let field_type = 0x05; - let key_data = vec![]; - // Serialize as VarInt (compact size) - let mut value_data = vec![]; - let count = self.output_count as u64; - if count < 0xFD { - value_data.push(count as u8); - } else if count <= 0xFFFF { - value_data.push(0xFD); - value_data.extend_from_slice(&(count as u16).to_le_bytes()); - } else if count <= 0xFFFF_FFFF { - value_data.push(0xFE); - value_data.extend_from_slice(&(count as u32).to_le_bytes()); - } else { - value_data.push(0xFF); - value_data.extend_from_slice(&count.to_le_bytes()); - } - fields.push((field_type, key_data, value_data)); - } - - // PSBT_GLOBAL_TX_MODIFIABLE = 0x06 - Always present - { - let field_type = 0x06; - let key_data = vec![]; - let value_data = vec![self.tx_modifiable_flags]; - fields.push((field_type, key_data, value_data)); - } - - // PSBT_GLOBAL_SP_ECDH_SHARE = 0x07 - BIP-375, can have multiple entries - for (scan_key, ecdh_share) in &self.sp_ecdh_shares { - let field_type = 0x07; - fields.push(( - field_type, - scan_key.to_bytes().to_vec(), - ecdh_share.to_bytes().to_vec(), - )); - } - - // PSBT_GLOBAL_SP_DLEQ = 0x08 - BIP-375, can have multiple entries - for (scan_key, dleq_proof) in &self.sp_dleq_proofs { - let field_type = 0x08; - fields.push(( - field_type, - scan_key.to_bytes().to_vec(), - dleq_proof.as_bytes().to_vec(), - )); - } - - // PSBT_GLOBAL_VERSION = 0xFB - Always present - { - let field_type = 0xFB; - let key_data = vec![]; - let value_data = self.version.to_u32().to_le_bytes().to_vec(); - fields.push((field_type, key_data, value_data)); - } - - // PSBT_GLOBAL_PROPRIETARY = 0xFC - Can have multiple entries - for (prop_key, value) in &self.proprietaries { - use bitcoin::consensus::Encodable; - let field_type = 0xFC; - // Key data is the proprietary key structure - let mut key_data = vec![]; - let _ = prop_key.consensus_encode(&mut key_data); - fields.push((field_type, key_data, value.clone())); - } - - // Unknown fields from the unknowns map - for (key, value) in &self.unknowns { - fields.push((key.type_value, key.key.clone(), value.clone())); - } - - fields + self.pairs() + .into_iter() + .map(|pair| (pair.key.type_value, pair.key.key, pair.value)) + .collect() } } -/// Extension trait for accessing psbt_v2::v2::Input fields for display +/// Extension trait for iterating all PSBT input map fields as raw (type, key, value) tuples. /// -/// This trait provides convenient methods to access all standard PSBT v2 input fields -/// in a serialized format suitable for display or further processing. +/// Delegates to psbt_v2's internal serialization path, so all fields — including unknowns +/// and any future additions to psbt_v2 — are returned automatically in serialization order. pub trait InputFieldsExt { - /// Iterator over all standard input fields as (field_type, key_data, value_data) tuples + /// Returns all input map fields as (field_type, key_data, value_data) tuples. fn iter_input_fields(&self) -> Vec<(u8, Vec, Vec)>; } impl InputFieldsExt for psbt_v2::v2::Input { fn iter_input_fields(&self) -> Vec<(u8, Vec, Vec)> { - let mut fields = Vec::new(); - - // PSBT_IN_NON_WITNESS_UTXO (0x00) - Optional - if let Some(ref tx) = self.non_witness_utxo { - use bitcoin::consensus::Encodable; - let field_type = 0x00; - let key_data = vec![]; - let mut value_data = vec![]; - let _ = tx.consensus_encode(&mut value_data); - fields.push((field_type, key_data, value_data)); - } - - // PSBT_IN_WITNESS_UTXO (0x01) - Optional - if let Some(ref utxo) = self.witness_utxo { - use bitcoin::consensus::Encodable; - let field_type = 0x01; - let key_data = vec![]; - let mut value_data = vec![]; - let _ = utxo.consensus_encode(&mut value_data); - fields.push((field_type, key_data, value_data)); - } - - // PSBT_IN_PARTIAL_SIG (0x02) - Multiple entries possible - for (pubkey, sig) in &self.partial_sigs { - let field_type = 0x02; - let key_data = pubkey.inner.serialize().to_vec(); - let value_data = sig.to_vec(); - fields.push((field_type, key_data, value_data)); - } - - // PSBT_IN_SIGHASH_TYPE (0x03) - Optional - if let Some(sighash_type) = self.sighash_type { - let field_type = 0x03; - let key_data = vec![]; - let value_data = (sighash_type.to_u32()).to_le_bytes().to_vec(); - fields.push((field_type, key_data, value_data)); - } - - // PSBT_IN_REDEEM_SCRIPT (0x04) - Optional - if let Some(ref script) = self.redeem_script { - let field_type = 0x04; - let key_data = vec![]; - let value_data = script.to_bytes(); - fields.push((field_type, key_data, value_data)); - } - - // PSBT_IN_WITNESS_SCRIPT (0x05) - Optional - if let Some(ref script) = self.witness_script { - let field_type = 0x05; - let key_data = vec![]; - let value_data = script.to_bytes(); - fields.push((field_type, key_data, value_data)); - } - - // PSBT_IN_BIP32_DERIVATION (0x06) - Multiple entries possible - for (pubkey, key_source) in &self.bip32_derivations { - let field_type = 0x06; - let key_data = pubkey.serialize().to_vec(); - let mut value_data = Vec::new(); - value_data.extend_from_slice(&key_source.0.to_bytes()); - for child in &key_source.1 { - value_data.extend_from_slice(&u32::from(*child).to_le_bytes()); - } - fields.push((field_type, key_data, value_data)); - } - - // PSBT_IN_FINAL_SCRIPTSIG (0x07) - Optional - if let Some(ref script) = self.final_script_sig { - let field_type = 0x07; - let key_data = vec![]; - let value_data = script.to_bytes(); - fields.push((field_type, key_data, value_data)); - } - - // PSBT_IN_FINAL_SCRIPTWITNESS (0x08) - Optional - if let Some(ref witness) = self.final_script_witness { - use bitcoin::consensus::Encodable; - let field_type = 0x08; - let key_data = vec![]; - let mut value_data = vec![]; - let _ = witness.consensus_encode(&mut value_data); - fields.push((field_type, key_data, value_data)); - } - - // PSBT_IN_PREVIOUS_TXID (0x0e) - Always present - { - use bitcoin::consensus::Encodable; - let field_type = 0x0e; - let key_data = vec![]; - let mut value_data = vec![]; - let _ = self.previous_txid.consensus_encode(&mut value_data); - fields.push((field_type, key_data, value_data)); - } - - // PSBT_IN_OUTPUT_INDEX (0x0f) - Always present - { - let field_type = 0x0f; - let key_data = vec![]; - let value_data = self.spent_output_index.to_le_bytes().to_vec(); - fields.push((field_type, key_data, value_data)); - } - - // PSBT_IN_SEQUENCE (0x10) - Optional - if let Some(sequence) = self.sequence { - let field_type = 0x10; - let key_data = vec![]; - let value_data = sequence.to_consensus_u32().to_le_bytes().to_vec(); - fields.push((field_type, key_data, value_data)); - } - - // PSBT_IN_TAP_BIP32_DERIVATION (0x16) - Multiple entries possible - for (xonly_pubkey, (leaf_hashes, key_source)) in &self.tap_key_origins { - let field_type = 0x16; - let key_data = xonly_pubkey.serialize().to_vec(); - let mut value_data = Vec::new(); - - // Encode leaf_hashes (compact size + hashes) - value_data.push(leaf_hashes.len() as u8); - for leaf_hash in leaf_hashes { - value_data.extend_from_slice(leaf_hash.as_ref()); - } - - // Encode key_source (fingerprint + derivation path) - value_data.extend_from_slice(&key_source.0.to_bytes()); - for child in &key_source.1 { - value_data.extend_from_slice(&u32::from(*child).to_le_bytes()); - } - - fields.push((field_type, key_data, value_data)); - } - - // PSBT_IN_SP_ECDH_SHARE (0x1d) - BIP-375, multiple entries possible - for (scan_key, ecdh_share) in &self.sp_ecdh_shares { - let field_type = 0x1d; - fields.push(( - field_type, - scan_key.to_bytes().to_vec(), - ecdh_share.to_bytes().to_vec(), - )); - } - - // PSBT_IN_SP_DLEQ (0x1e) - BIP-375, multiple entries possible - for (scan_key, dleq_proof) in &self.sp_dleq_proofs { - let field_type = 0x1e; - fields.push(( - field_type, - scan_key.to_bytes().to_vec(), - dleq_proof.as_bytes().to_vec(), - )); - } - - // PSBT_IN_PROPRIETARY (0xFC) - Multiple entries possible - for (prop_key, value) in &self.proprietaries { - use bitcoin::consensus::Encodable; - let field_type = 0xFC; - let mut key_data = vec![]; - let _ = prop_key.consensus_encode(&mut key_data); - fields.push((field_type, key_data, value.clone())); - } - - // Unknown fields - for (key, value) in &self.unknowns { - fields.push((key.type_value, key.key.clone(), value.clone())); - } - - fields + self.pairs() + .into_iter() + .map(|pair| (pair.key.type_value, pair.key.key, pair.value)) + .collect() } } -/// Extension trait for accessing psbt_v2::v2::Output fields for display +/// Extension trait for iterating all PSBT output map fields as raw (type, key, value) tuples. /// -/// This trait provides convenient methods to access all standard PSBT v2 output fields -/// in a serialized format suitable for display or further processing. +/// Delegates to psbt_v2's internal serialization path, so all fields — including unknowns +/// and any future additions to psbt_v2 — are returned automatically in serialization order. pub trait OutputFieldsExt { - /// Iterator over all standard output fields as (field_type, key_data, value_data) tuples + /// Returns all output map fields as (field_type, key_data, value_data) tuples. fn iter_output_fields(&self) -> Vec<(u8, Vec, Vec)>; } impl OutputFieldsExt for psbt_v2::v2::Output { fn iter_output_fields(&self) -> Vec<(u8, Vec, Vec)> { - let mut fields = Vec::new(); - - // PSBT_OUT_REDEEM_SCRIPT (0x00) - Optional - if let Some(ref script) = self.redeem_script { - let field_type = 0x00; - let key_data = vec![]; - let value_data = script.to_bytes(); - fields.push((field_type, key_data, value_data)); - } - - // PSBT_OUT_WITNESS_SCRIPT (0x01) - Optional - if let Some(ref script) = self.witness_script { - let field_type = 0x01; - let key_data = vec![]; - let value_data = script.to_bytes(); - fields.push((field_type, key_data, value_data)); - } - - // PSBT_OUT_BIP32_DERIVATION (0x02) - Multiple entries possible - for (pubkey, key_source) in &self.bip32_derivations { - let field_type = 0x02; - let key_data = pubkey.serialize().to_vec(); - let mut value_data = Vec::new(); - value_data.extend_from_slice(&key_source.0.to_bytes()); - for child in &key_source.1 { - value_data.extend_from_slice(&u32::from(*child).to_le_bytes()); - } - fields.push((field_type, key_data, value_data)); - } - - // PSBT_OUT_AMOUNT (0x03) - Always present - { - let field_type = 0x03; - let key_data = vec![]; - let value_data = self.amount.to_sat().to_le_bytes().to_vec(); - fields.push((field_type, key_data, value_data)); - } - - // PSBT_OUT_SCRIPT (0x04) - Always present - { - let field_type = 0x04; - let key_data = vec![]; - let value_data = self.script_pubkey.to_bytes(); - fields.push((field_type, key_data, value_data)); - } - - // PSBT_OUT_SP_V0_INFO (0x09) - BIP-375, optional - if let Some(ref sp_info) = self.sp_v0_info { - let field_type = 0x09; - let key_data = vec![]; - fields.push((field_type, key_data, sp_info.clone())); - } - - // PSBT_OUT_SP_V0_LABEL (0x0a) - BIP-375, optional - if let Some(label) = self.sp_v0_label { - let field_type = 0x0a; - let key_data = vec![]; - let value_data = label.to_le_bytes().to_vec(); - fields.push((field_type, key_data, value_data)); - } - - // PSBT_OUT_PROPRIETARY (0xFC) - Multiple entries possible - for (prop_key, value) in &self.proprietaries { - use bitcoin::consensus::Encodable; - let field_type = 0xFC; - let mut key_data = vec![]; - let _ = prop_key.consensus_encode(&mut key_data); - fields.push((field_type, key_data, value.clone())); - } - - // Unknown fields - for (key, value) in &self.unknowns { - fields.push((key.type_value, key.key.clone(), value.clone())); - } - - fields + self.pairs() + .into_iter() + .map(|pair| (pair.key.type_value, pair.key.key, pair.value)) + .collect() } } From 762b897cc1fb34b7302d9584c75a18b1d7dbf368 Mon Sep 17 00:00:00 2001 From: macgyver13 <4712150+macgyver13@users.noreply.github.com> Date: Thu, 26 Feb 2026 15:15:19 -0500 Subject: [PATCH 16/16] psbt: switch to pairs branch --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 499b26d..50e0b81 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ reqwest = { version = "0.12.4", features = [ "rustls-tls", "gzip", ], default-features = false } -psbt-v2 = { git = "https://github.com/tcharding/rust-psbt.git", branch = "master", features = ["silent-payments"] } +psbt-v2 = { git = "https://github.com/macgyver13/rust-psbt.git", branch = "add-pairs-v2-map", features = ["silent-payments"] } rayon = "1.10.0" rust-dleq = { git = "https://github.com/macgyver13/rust-dleq.git", branch = "master", default-features = false } secp256k1 = { version = "0.29.0", features = ["rand"] }