diff --git a/Cargo.toml b/Cargo.toml index aef5f78..50e0b81 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,24 +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 } +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"] } +serde = { version = "1.0.188", features = ["derive"] } +serde_json = "1.0.107" +sha2 = "0.10" +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 e6f4e83..343f87a 100644 --- a/spdk-core/Cargo.toml +++ b/spdk-core/Cargo.toml @@ -6,11 +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 +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..6be1ffa --- /dev/null +++ b/spdk-core/examples/dleq_example.rs @@ -0,0 +1,115 @@ +//! 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(); + + // 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); + + // 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 = receiver_scan_public + .mul_tweak(&secp, &sender_input_secret.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, + &sender_input_secret, + &receiver_scan_public, + &aux_randomness, + message.as_ref(), + ) + .expect("proof generation successful"); + + println!("✓ Proof generated successfully"); + 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, + &sender_input_public, + &receiver_scan_public, + &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(sender_input_public) = log_B(ecdh_share)\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, + &sender_input_public, + &receiver_scan_public, + &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/error.rs b/spdk-core/src/psbt/core/error.rs new file mode 100644 index 0000000..3b448b6 --- /dev/null +++ b/spdk-core/src/psbt/core/error.rs @@ -0,0 +1,81 @@ +//! 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("Invalid PSBT state: {0}")] + InvalidPsbtState(String), + + #[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..275b989 --- /dev/null +++ b/spdk-core/src/psbt/core/extensions.rs @@ -0,0 +1,846 @@ +//! 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_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: +/// - 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(&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( + &mut self, + output_index: usize, + address: &SilentPaymentAddress, + ) -> Result<()>; + + /// Get silent payment label for an output + /// + /// 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 (0 = change, 1+ = labeled receiving addresses) + fn set_output_sp_label(&mut self, output_index: usize, label: u32) -> Result<()>; + + // ===== Silent Payment Spend Key Derivation (BIP-376) ===== + + /// 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<()>; + + /// 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 + 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)>; + + /// Get all scan keys from outputs with PSBT_OUT_SP_V0_INFO set + /// + /// 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 { + 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(&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 let (Some(scan_key), Some(spend_key)) = (scan_key, spend_key) { + return Some((scan_key, spend_key)); + } + } + + None + } + + fn set_output_sp_info( + &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![], + }; + + //FIXME: migrate to dedicated field in psbt_v2 once available instead of using unknowns + 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 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() + } + + 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 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(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 { + let scan_key_compressed = + CompressedPublicKey::try_from(bitcoin::PublicKey::new(*scan_key)).ok()?; + psbt.global + .sp_dleq_proofs + .get(&scan_key_compressed) + .copied() +} + +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)?; + + psbt.global + .sp_dleq_proofs + .insert(scan_key_compressed, proof); + + Ok(()) +} + +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()?; + + input.sp_dleq_proofs.get(&scan_key_compressed).copied() +} + +fn add_input_dleq_proof( + psbt: &mut Psbt, + input_index: usize, + scan_key: &PublicKey, + proof: DleqProof, +) -> 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)?; + + input.sp_dleq_proofs.insert(scan_key_compressed, 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. 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 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_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) { + return Ok(pubkey); + } + } + } + + // Method 3: Extract from BIP32 derivation field (for non-Taproot) + if !input.bip32_derivations.is_empty() { + // Return the first key + if let Some(key) = input.bip32_derivations.keys().next() { + return Ok(*key); + } + } + + Err(Error::Other(format!( + "Input {} missing public key (no SP spend, BIP32 derivation, or tap key origin found)", + input_idx + ))) +} + +pub fn get_output_sp_keys( + psbt: &SilentPaymentPsbt, + output_idx: usize, +) -> Result<(PublicKey, PublicKey)> { + // Use the extension trait method via SilentPaymentPsbt wrapper + 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)) +} + +// ============================================================================ +// 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 iterating all PSBT global map fields as raw (type, key, value) tuples. +/// +/// 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 { + /// 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)> { + self.pairs() + .into_iter() + .map(|pair| (pair.key.type_value, pair.key.key, pair.value)) + .collect() + } +} + +/// Extension trait for iterating all PSBT input map fields as raw (type, key, value) tuples. +/// +/// 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 { + /// 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)> { + self.pairs() + .into_iter() + .map(|pair| (pair.key.type_value, pair.key.key, pair.value)) + .collect() + } +} + +/// Extension trait for iterating all PSBT output map fields as raw (type, key, value) tuples. +/// +/// 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 { + /// 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)> { + self.pairs() + .into_iter() + .map(|pair| (pair.key.type_value, pair.key.key, pair.value)) + .collect() + } +} + +#[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 = DleqProof([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(0, &address).unwrap(); + + // Retrieve address + 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())) + ); + } + + #[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..0a80081 --- /dev/null +++ b/spdk-core/src/psbt/core/mod.rs @@ -0,0 +1,29 @@ +//! 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, GlobalFieldsExt, InputFieldsExt, + OutputFieldsExt, +}; +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; +pub type PsbtKey = psbt_v2::raw::Key; diff --git a/spdk-core/src/psbt/core/shares.rs b/spdk-core/src/psbt/core/shares.rs new file mode 100644 index 0000000..39216f1 --- /dev/null +++ b/spdk-core/src/psbt/core/shares.rs @@ -0,0 +1,210 @@ +//! 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 +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..cbe3309 --- /dev/null +++ b/spdk-core/src/psbt/core/types.rs @@ -0,0 +1,201 @@ +//! BIP-375 Type Definitions +//! +//! 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; + +// ============================================================================ +// 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, +} + +impl EcdhShareData { + /// Create a new ECDH share + pub fn new(scan_key: PublicKey, share: PublicKey, dleq_proof: Option) -> 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 (if label is present, this is the already-labeled address) + address: SilentPaymentAddress, + /// 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, + }, +} + +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..2017f6e --- /dev/null +++ b/spdk-core/src/psbt/crypto/bip352.rs @@ -0,0 +1,466 @@ +//! 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 psbt_v2::v2::Input; +use secp256k1::{PublicKey, Scalar, Secp256k1, SecretKey}; +use std::collections::HashMap; + +/// 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 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)) +/// 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 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) -> 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); + + 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. +/// 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" + } +} + +/// 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 +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: 2ef9f0e19f3c275d84d98c44912fec626bac45e442af47d02d9b9652ff9a9f0a" + ); + + // This should match the test vector + assert_eq!( + xonly_hex, + "2ef9f0e19f3c275d84d98c44912fec626bac45e442af47d02d9b9652ff9a9f0a" + ); + } + + #[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 = 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); + 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 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 = tweaked_key_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..418db7e --- /dev/null +++ b/spdk-core/src/psbt/crypto/dleq.rs @@ -0,0 +1,171 @@ +//! BIP-374 DLEQ (Discrete Log Equality) Proofs +//! +//! 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 secp256k1::{PublicKey, Secp256k1, SecretKey}; + +// Re-export rust-dleq types for convenience +pub use rust_dleq::{DleqError, DleqProof as RustDleqProof}; + +// Import psbt-v2's DleqProof type under an alias +use psbt_v2::v2::dleq::DleqProof as PsbtV2DleqProof; + +// ============================================================================ +// Type Conversion +// ============================================================================ + +/// Convert rust-dleq proof to psbt-v2 proof format +pub fn to_psbt_v2_proof(proof: &RustDleqProof) -> PsbtV2DleqProof { + PsbtV2DleqProof(*proof.as_bytes()) +} + +/// Convert psbt-v2 proof to rust-dleq format +pub fn from_psbt_v2_proof(proof: &PsbtV2DleqProof) -> RustDleqProof { + RustDleqProof(proof.0) +} + +// ============================================================================ +// 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. +/// ex. log_G(input_key*G) = log_B(input_key*scan_key) +/// Returns proof in psbt-v2 format for compatibility. +/// +/// # Arguments +/// * `secp` - Secp256k1 context +/// * `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 +/// +/// # Returns +/// PsbtV2DleqProof (64-byte proof: e || s) +pub fn dleq_generate_proof( + secp: &Secp256k1, + input_key: &SecretKey, + scan_key: &PublicKey, + r: &[u8; 32], + m: Option<&[u8; 32]>, +) -> Result { + 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)) +} + +/// 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 +/// * `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, + 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, + input_public, + scan_key, + ecdh_share, + &rust_dleq_proof, + m, + ) + .map_err(|_e| CryptoError::DleqVerificationFailed) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + 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] + 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[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..3db1509 --- /dev/null +++ b/spdk-core/src/psbt/helpers/wallet/types.rs @@ -0,0 +1,612 @@ +//! 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..479c525 --- /dev/null +++ b/spdk-core/src/psbt/mod.rs @@ -0,0 +1,27 @@ +//! 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 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, 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}; diff --git a/spdk-core/src/psbt/roles/constructor.rs b/spdk-core/src/psbt/roles/constructor.rs new file mode 100644 index 0000000..1003e08 --- /dev/null +++ b/spdk-core/src/psbt/roles/constructor.rs @@ -0,0 +1,181 @@ +//! 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(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..b8fb350 --- /dev/null +++ b/spdk-core/src/psbt/roles/extractor.rs @@ -0,0 +1,328 @@ +//! 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::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 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).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..69ebc74 --- /dev/null +++ b/spdk-core/src/psbt/roles/input_finalizer.rs @@ -0,0 +1,297 @@ +//! PSBT Input Finalizer Role +//! +//! Aggregates ECDH shares and computes final output scripts for silent payments. + +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. +/// +/// 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, +) -> Result<()> { + // 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(); + + // 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(output_idx) { + Some(keys) => keys, + None => continue, + }; + + let shared_secret = shared_secrets + .get(&scan_key) + .ok_or(Error::IncompleteEcdhCoverage(output_idx))?; + + // 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 shared_secret_bytes = shared_secret.serialize(); + let output_pubkey = derive_silent_payment_output_pubkey( + secp, + &spend_key, + &shared_secret_bytes, + k, + ) + .map_err(|e| Error::Other(format!("Output derivation failed: {}", e)))?; + + let output_script = tweaked_key_to_p2tr_script(&output_pubkey); + + psbt.outputs[output_idx].script_pubkey = output_script; + + scan_key_output_indices.insert(scan_key, k + 1); + } + + // Clear tx_modifiable_flags after finalizing outputs + psbt.global.tx_modifiable_flags = 0x00; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + 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::{Network as SpNetwork, SilentPaymentAddress}; + + #[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).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); + 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).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..dd34531 --- /dev/null +++ b/spdk-core/src/psbt/roles/signer.rs @@ -0,0 +1,390 @@ +//! PSBT Signer Role +//! +//! Adds ECDH shares and signatures to the PSBT. +//! +//! This module handles both regular P2WPKH signing and Silent Payment P2TR signing: +//! - **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, +}; +use crate::psbt::crypto::{ + 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; + +/// 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 + ))); + }; + + for scan_key in scan_keys { + 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, &base_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)?; + + 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() { + sign_p2tr_with_optional_tweak(secp, psbt, &tx, input_idx, privkey)?; + } + } + Ok(()) +} + +/// Sign a P2TR input, applying SP tweak if present. +/// +/// 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, + tx: &bitcoin::Transaction, + input_idx: usize, + privkey: &SecretKey, +) -> Result<()> { + let prevouts: Vec = 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::>>()?; + + 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 + }; + + 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(()) +} + +/// 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, + }) +} + +pub fn get_signable_inputs( + _secp: &Secp256k1, + 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::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; + + #[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..a74a30b --- /dev/null +++ b/spdk-core/src/psbt/roles/updater.rs @@ -0,0 +1,235 @@ +//! 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::key::XOnlyPublicKey; +use bitcoin::taproot::TapLeafHash; +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 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, + 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_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); + + 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..8e9b622 --- /dev/null +++ b/spdk-core/src/psbt/roles/validation.rs @@ -0,0 +1,633 @@ +//! PSBT Validation Functions +//! +//! 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, 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_psbt_state(psbt)?; + validate_input_fields(psbt)?; + validate_output_fields(psbt)?; + + // 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 { + // Segwit version restrictions (must be v0 or v1 for silent payments) + validate_segwit_versions(psbt)?; + // 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(()) +} + +/// 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<()> { + 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( + "SP output has script_pubkey set while tx_modifiable_flags is non-zero".to_string(), + )); + } + } + + 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))); + } + } + + 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(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 + ))); + } + + // 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 spend fields on inputs (BIP-376) +/// +/// 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 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 + ))); + } + } + } + + Ok(()) +} + +/// 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 { + 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 +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 +/// +/// 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 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) { + scan_keys.insert(scan_key); + } + } + + // 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_ecdh = global_shares.iter().any(|s| &s.scan_key == scan_key); + + // 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; + } + + // 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 + ))); + } + } + } + } + } + } + + 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_shared_secrets, derive_silent_payment_output_pubkey, tweaked_key_to_p2tr_script, + }; + use std::collections::HashMap; + + // 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() { + 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 aggregated_shares = aggregate_ecdh_shares(psbt)?; + + 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)))?; + + 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(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 = tweaked_key_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() { + return Err(Error::Other( + "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 + 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. +pub fn validate_ready_for_extraction(psbt: &SilentPaymentPsbt) -> Result<()> { + 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::core::{PsbtInput, PsbtOutput}; + use crate::psbt::crypto::pubkey_to_p2wpkh_script; + use crate::psbt::roles::{ + constructor::{add_inputs, add_outputs}, + creator::create_psbt, + }; + use bitcoin::hashes::Hash; + use bitcoin::{Amount, OutPoint, Sequence, TxOut, Txid}; + use secp256k1::SecretKey; + use silentpayments::{Network as SpNetwork, SilentPaymentAddress}; + + #[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: 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 2: 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 3: Per-input shares for SOME inputs -> Valid + { + 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_ok()); + } + + } +}