diff --git a/Cargo.toml b/Cargo.toml index 7594b50..6d919ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ rand = ["std", "bitcoin/rand-std"] serde = ["dep:actual-serde", "bitcoin/serde"] base64 = ["bitcoin/base64"] miniscript = ["dep:miniscript", "miniscript?/no-std"] +silent-payments = [] # Silent Payments support [dependencies] bitcoin = { version = "0.32.6", default-features = false } diff --git a/src/consts.rs b/src/consts.rs index 8d7939d..c1c7dc4 100644 --- a/src/consts.rs +++ b/src/consts.rs @@ -21,6 +21,12 @@ pub(crate) const PSBT_GLOBAL_INPUT_COUNT: u8 = 0x04; pub(crate) const PSBT_GLOBAL_OUTPUT_COUNT: u8 = 0x05; /// Type: Transaction Modifiable Flags PSBT_GLOBAL_TX_MODIFIABLE = 0x06 pub(crate) const PSBT_GLOBAL_TX_MODIFIABLE: u8 = 0x06; +#[cfg(feature = "silent-payments")] +/// Type: Silent Payment ECDH Share PSBT_GLOBAL_SP_ECDH_SHARE = 0x07 +pub(crate) const PSBT_GLOBAL_SP_ECDH_SHARE: u8 = 0x07; +#[cfg(feature = "silent-payments")] +/// Type: Silent Payment DLEQ PSBT_GLOBAL_SP_DLEQ = 0x08 +pub(crate) const PSBT_GLOBAL_SP_DLEQ: u8 = 0x08; /// Type: Version Number PSBT_GLOBAL_VERSION = 0xFB pub(crate) const PSBT_GLOBAL_VERSION: u8 = 0xFB; /// Type: Proprietary Use Type PSBT_GLOBAL_PROPRIETARY = 0xFC @@ -77,6 +83,12 @@ pub(crate) const PSBT_IN_TAP_BIP32_DERIVATION: u8 = 0x16; pub(crate) const PSBT_IN_TAP_INTERNAL_KEY: u8 = 0x17; /// Type: Taproot Merkle Root PSBT_IN_TAP_MERKLE_ROOT = 0x18 pub(crate) const PSBT_IN_TAP_MERKLE_ROOT: u8 = 0x18; +#[cfg(feature = "silent-payments")] +/// Type: Silent Payment ECDH Share PSBT_IN_SP_ECDH_SHARE = 0x1D +pub(crate) const PSBT_IN_SP_ECDH_SHARE: u8 = 0x1D; +#[cfg(feature = "silent-payments")] +/// Type: Silent Payment DLEQ Proof PSBT_IN_SP_DLEQ = 0x1E +pub(crate) const PSBT_IN_SP_DLEQ: u8 = 0x1E; /// Type: Proprietary Use Type PSBT_IN_PROPRIETARY = 0xFC pub(crate) const PSBT_IN_PROPRIETARY: u8 = 0xFC; @@ -96,6 +108,12 @@ pub(crate) const PSBT_OUT_TAP_INTERNAL_KEY: u8 = 0x05; pub(crate) const PSBT_OUT_TAP_TREE: u8 = 0x06; /// Type: Taproot Key BIP 32 Derivation Path PSBT_OUT_TAP_BIP32_DERIVATION = 0x07 pub(crate) const PSBT_OUT_TAP_BIP32_DERIVATION: u8 = 0x07; +#[cfg(feature = "silent-payments")] +/// Type: Silent Payment v0 Info PSBT_OUT_SP_V0_INFO = 0x09 +pub(crate) const PSBT_OUT_SP_V0_INFO: u8 = 0x09; +#[cfg(feature = "silent-payments")] +/// Type: Silent Payment v0 Label PSBT_OUT_SP_V0_LABEL = 0x0A +pub(crate) const PSBT_OUT_SP_V0_LABEL: u8 = 0x0A; /// Type: Proprietary Use Type PSBT_IN_PROPRIETARY = 0xFC pub(crate) const PSBT_OUT_PROPRIETARY: u8 = 0xFC; @@ -109,6 +127,10 @@ pub(crate) fn psbt_global_key_type_value_to_str(v: u8) -> &'static str { PSBT_GLOBAL_INPUT_COUNT => "PSBT_GLOBAL_INPUT_COUNT", PSBT_GLOBAL_OUTPUT_COUNT => "PSBT_GLOBAL_OUTPUT_COUNT", PSBT_GLOBAL_TX_MODIFIABLE => "PSBT_GLOBAL_TX_MODIFIABLE", + #[cfg(feature = "silent-payments")] + PSBT_GLOBAL_SP_ECDH_SHARE => "PSBT_GLOBAL_SP_ECDH_SHARE", + #[cfg(feature = "silent-payments")] + PSBT_GLOBAL_SP_DLEQ => "PSBT_GLOBAL_SP_DLEQ", PSBT_GLOBAL_VERSION => "PSBT_GLOBAL_VERSION", PSBT_GLOBAL_PROPRIETARY => "PSBT_GLOBAL_PROPRIETARY", _ => "unknown PSBT_GLOBAL_ key type value", @@ -143,6 +165,10 @@ pub(crate) fn psbt_in_key_type_value_to_str(v: u8) -> &'static str { PSBT_IN_TAP_BIP32_DERIVATION => "PSBT_IN_TAP_BIP32_DERIVATION", PSBT_IN_TAP_INTERNAL_KEY => "PSBT_IN_TAP_INTERNAL_KEY", PSBT_IN_TAP_MERKLE_ROOT => "PSBT_IN_TAP_MERKLE_ROOT", + #[cfg(feature = "silent-payments")] + PSBT_IN_SP_ECDH_SHARE => "PSBT_IN_SP_ECDH_SHARE", + #[cfg(feature = "silent-payments")] + PSBT_IN_SP_DLEQ => "PSBT_IN_SP_DLEQ", PSBT_IN_PROPRIETARY => "PSBT_IN_PROPRIETARY", _ => "unknown PSBT_IN_ key type value", } @@ -159,6 +185,10 @@ pub(crate) fn psbt_out_key_type_value_to_str(v: u8) -> &'static str { PSBT_OUT_TAP_INTERNAL_KEY => "PSBT_OUT_TAP_INTERNAL_KEY", PSBT_OUT_TAP_TREE => "PSBT_OUT_TAP_TREE", PSBT_OUT_TAP_BIP32_DERIVATION => "PSBT_OUT_TAP_BIP32_DERIVATION", + #[cfg(feature = "silent-payments")] + PSBT_OUT_SP_V0_INFO => "PSBT_OUT_SP_V0_INFO", + #[cfg(feature = "silent-payments")] + PSBT_OUT_SP_V0_LABEL => "PSBT_OUT_SP_V0_LABEL", PSBT_OUT_PROPRIETARY => "PSBT_OUT_PROPRIETARY", _ => "unknown PSBT_OUT_ key type value", } diff --git a/src/macros.rs b/src/macros.rs index 74806d4..6e5cc88 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -128,3 +128,42 @@ macro_rules! v2_impl_psbt_hash_serialize { } }; } + +/// Macro for inserting BIP-375 silent payment fields with CompressedPublicKey keys. +#[cfg(feature = "silent-payments")] +macro_rules! v2_impl_psbt_insert_sp_pair { + // For CompressedPublicKey values (ECDH shares) + ($map:expr, $raw_key:expr, $raw_value:expr, compressed_pubkey) => {{ + if $raw_key.key.is_empty() { + return Err(InsertPairError::InvalidKeyDataEmpty($raw_key).into()); + } + let scan_key = bitcoin::CompressedPublicKey::from_slice(&$raw_key.key) + .map_err(|_| InsertPairError::KeyWrongLength($raw_key.key.len(), 33))?; + let value = bitcoin::CompressedPublicKey::from_slice(&$raw_value) + .map_err(|_| InsertPairError::ValueWrongLength($raw_value.len(), 33))?; + match $map.entry(scan_key) { + $crate::prelude::btree_map::Entry::Vacant(empty_key) => { + empty_key.insert(value); + } + $crate::prelude::btree_map::Entry::Occupied(_) => + return Err(InsertPairError::DuplicateKey($raw_key).into()), + } + }}; + // For DleqProof values (DLEQ proofs) + ($map:expr, $raw_key:expr, $raw_value:expr, dleq_proof) => {{ + if $raw_key.key.is_empty() { + return Err(InsertPairError::InvalidKeyDataEmpty($raw_key).into()); + } + let scan_key = bitcoin::CompressedPublicKey::from_slice(&$raw_key.key) + .map_err(|_| InsertPairError::KeyWrongLength($raw_key.key.len(), 33))?; + let value = $crate::v2::dleq::DleqProof::try_from($raw_value.as_slice()) + .map_err(|_| InsertPairError::ValueWrongLength($raw_value.len(), 64))?; + match $map.entry(scan_key) { + $crate::prelude::btree_map::Entry::Vacant(empty_key) => { + empty_key.insert(value); + } + $crate::prelude::btree_map::Entry::Occupied(_) => + return Err(InsertPairError::DuplicateKey($raw_key).into()), + } + }}; +} diff --git a/src/serialize.rs b/src/serialize.rs index 8c4bfc2..63a7620 100644 --- a/src/serialize.rs +++ b/src/serialize.rs @@ -413,6 +413,27 @@ pub enum Error { LockTime(absolute::ConversionError), /// Unsupported PSBT version. UnsupportedVersion(version::UnsupportedVersionError), + /// Invalid scan key for BIP-375 silent payments (expected 33 bytes). + InvalidScanKey { + /// The length that was provided. + got: usize, + /// The expected length. + expected: usize, + }, + /// Invalid ECDH share for BIP-375 silent payments (expected 33 bytes). + InvalidEcdhShare { + /// The length that was provided. + got: usize, + /// The expected length. + expected: usize, + }, + /// Invalid DLEQ proof for BIP-375 silent payments (expected 64 bytes). + InvalidDleqProof { + /// The length that was provided. + got: usize, + /// The expected length. + expected: usize, + }, } impl fmt::Display for Error { @@ -440,6 +461,15 @@ impl fmt::Display for Error { f.write_str("data not consumed entirely when explicitly deserializing"), LockTime(ref e) => write_err!(f, "parsed locktime invalid"; e), UnsupportedVersion(ref e) => write_err!(f, "unsupported version"; e), + InvalidScanKey { got, expected } => { + write!(f, "invalid scan key: got {} bytes, expected {}", got, expected) + } + InvalidEcdhShare { got, expected } => { + write!(f, "invalid ECDH share: got {} bytes, expected {}", got, expected) + } + InvalidDleqProof { got, expected } => { + write!(f, "invalid DLEQ proof: got {} bytes, expected {}", got, expected) + } } } } @@ -467,7 +497,10 @@ impl std::error::Error for Error { | InvalidLeafVersion | Taproot(_) | TapTree(_) - | PartialDataConsumption => None, + | PartialDataConsumption + | InvalidScanKey { .. } + | InvalidEcdhShare { .. } + | InvalidDleqProof { .. } => None, } } } diff --git a/src/v2/dleq.rs b/src/v2/dleq.rs new file mode 100644 index 0000000..36b03e1 --- /dev/null +++ b/src/v2/dleq.rs @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: CC0-1.0 + +//! BIP-375: Support for silent payments in PSBTs. +//! +//! This module provides type-safe wrapper for BIP-374 dleq proof field. + +use core::fmt; + +use crate::prelude::*; +use crate::serialize::{Deserialize, Serialize}; + +/// A 64-byte DLEQ proof (BIP-374). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct DleqProof(pub [u8; 64]); + +#[cfg(feature = "serde")] +impl actual_serde::Serialize for DleqProof { + fn serialize(&self, serializer: S) -> Result + where + S: actual_serde::Serializer, + { + if serializer.is_human_readable() { + serializer.serialize_str(&bitcoin::hex::DisplayHex::to_lower_hex_string(&self.0[..])) + } else { + serializer.serialize_bytes(&self.0[..]) + } + } +} + +#[cfg(feature = "serde")] +impl<'de> actual_serde::Deserialize<'de> for DleqProof { + fn deserialize(deserializer: D) -> Result + where + D: actual_serde::Deserializer<'de>, + { + if deserializer.is_human_readable() { + struct HexVisitor; + impl actual_serde::de::Visitor<'_> for HexVisitor { + type Value = DleqProof; + + fn expecting(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { + f.write_str("a 64-byte hex string") + } + + fn visit_str(self, s: &str) -> Result + where + E: actual_serde::de::Error, + { + use bitcoin::hex::FromHex; + let vec = Vec::::from_hex(s).map_err(E::custom)?; + DleqProof::try_from(vec).map_err(|e| { + E::custom(format!("expected {} bytes, got {}", e.expected, e.got)) + }) + } + } + deserializer.deserialize_str(HexVisitor) + } else { + struct BytesVisitor; + impl actual_serde::de::Visitor<'_> for BytesVisitor { + type Value = DleqProof; + + fn expecting(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { + f.write_str("64 bytes") + } + + fn visit_bytes(self, v: &[u8]) -> Result + where + E: actual_serde::de::Error, + { + DleqProof::try_from(v).map_err(|e| { + E::custom(format!("expected {} bytes, got {}", e.expected, e.got)) + }) + } + } + deserializer.deserialize_bytes(BytesVisitor) + } + } +} + +impl DleqProof { + /// Creates a new [`DleqProof`] from a 64-byte array. + pub fn new(bytes: [u8; 64]) -> Self { DleqProof(bytes) } + + /// Returns the inner 64-byte array. + pub fn as_bytes(&self) -> &[u8; 64] { &self.0 } +} + +impl From<[u8; 64]> for DleqProof { + fn from(bytes: [u8; 64]) -> Self { DleqProof(bytes) } +} + +impl AsRef<[u8]> for DleqProof { + fn as_ref(&self) -> &[u8] { &self.0 } +} + +impl TryFrom<&[u8]> for DleqProof { + type Error = InvalidLengthError; + + fn try_from(slice: &[u8]) -> Result { + <[u8; 64]>::try_from(slice) + .map(DleqProof) + .map_err(|_| InvalidLengthError { got: slice.len(), expected: 64 }) + } +} + +impl TryFrom> for DleqProof { + type Error = InvalidLengthError; + + fn try_from(v: Vec) -> Result { Self::try_from(v.as_slice()) } +} + +impl Serialize for DleqProof { + fn serialize(&self) -> Vec { self.0.to_vec() } +} + +impl Deserialize for DleqProof { + fn deserialize(bytes: &[u8]) -> Result { + DleqProof::try_from(bytes).map_err(|e| crate::serialize::Error::InvalidDleqProof { + got: e.got, + expected: e.expected, + }) + } +} + +/// Error returned when a byte array has an invalid length for a dleq proof. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct InvalidLengthError { + /// The length that was provided. + pub got: usize, + /// The expected length. + pub expected: usize, +} + +impl fmt::Display for InvalidLengthError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "invalid length for BIP-375 type: got {}, expected {}", self.got, self.expected) + } +} + +#[cfg(feature = "std")] +impl std::error::Error for InvalidLengthError {} diff --git a/src/v2/map/global.rs b/src/v2/map/global.rs index 078e079..e1e2d24 100644 --- a/src/v2/map/global.rs +++ b/src/v2/map/global.rs @@ -6,6 +6,8 @@ use core::fmt; use bitcoin::bip32::{ChildNumber, DerivationPath, Fingerprint, KeySource, Xpub}; use bitcoin::consensus::{encode as consensus, Decodable}; use bitcoin::locktime::absolute; +#[cfg(feature = "silent-payments")] +use bitcoin::CompressedPublicKey; use bitcoin::{bip32, transaction, VarInt}; use crate::consts::{ @@ -13,10 +15,14 @@ use crate::consts::{ PSBT_GLOBAL_PROPRIETARY, PSBT_GLOBAL_TX_MODIFIABLE, PSBT_GLOBAL_TX_VERSION, PSBT_GLOBAL_UNSIGNED_TX, PSBT_GLOBAL_VERSION, PSBT_GLOBAL_XPUB, }; +#[cfg(feature = "silent-payments")] +use crate::consts::{PSBT_GLOBAL_SP_DLEQ, PSBT_GLOBAL_SP_ECDH_SHARE}; use crate::error::{write_err, InconsistentKeySourcesError}; use crate::io::{BufRead, Cursor, Read}; use crate::prelude::*; use crate::serialize::Serialize; +#[cfg(feature = "silent-payments")] +use crate::v2::dleq::DleqProof; use crate::v2::map::Map; use crate::version::Version; use crate::{consts, raw, serialize, V2}; @@ -56,6 +62,16 @@ pub struct Global { /// A map from xpub to the used key fingerprint and derivation path as defined by BIP 32. pub xpubs: BTreeMap, + /// BIP-375: Map from scan public key to ECDH share (33 bytes each). + #[cfg(feature = "silent-payments")] + #[cfg_attr(feature = "serde", serde(with = "crate::serde_utils::btreemap_as_seq"))] + pub sp_ecdh_shares: BTreeMap, + + /// BIP-375: Map from scan public key to DLEQ proof (64 bytes each). + #[cfg(feature = "silent-payments")] + #[cfg_attr(feature = "serde", serde(with = "crate::serde_utils::btreemap_as_seq"))] + pub sp_dleq_proofs: BTreeMap, + /// Global proprietary key-value pairs. #[cfg_attr(feature = "serde", serde(with = "crate::serde_utils::btreemap_as_seq_byte_values"))] pub proprietaries: BTreeMap>, @@ -76,6 +92,10 @@ impl Global { input_count: 0, output_count: 0, xpubs: Default::default(), + #[cfg(feature = "silent-payments")] + sp_ecdh_shares: Default::default(), + #[cfg(feature = "silent-payments")] + sp_dleq_proofs: Default::default(), proprietaries: Default::default(), unknowns: Default::default(), } @@ -131,6 +151,11 @@ impl Global { let mut input_count: Option = None; let mut output_count: Option = None; let mut xpubs: BTreeMap = Default::default(); + #[cfg(feature = "silent-payments")] + let mut sp_ecdh_shares: BTreeMap = + Default::default(); + #[cfg(feature = "silent-payments")] + let mut sp_dleq_proofs: BTreeMap = Default::default(); let mut proprietaries: BTreeMap> = Default::default(); let mut unknowns: BTreeMap> = Default::default(); @@ -158,7 +183,7 @@ impl Global { } else { return Err(InsertPairError::InvalidKeyDataNotEmpty(pair.key)); }, - PSBT_GLOBAL_TX_VERSION => + PSBT_GLOBAL_TX_VERSION => { if pair.key.key.is_empty() { if tx_version.is_none() { let vlen: usize = pair.value.len(); @@ -173,7 +198,8 @@ impl Global { } } else { return Err(InsertPairError::InvalidKeyDataNotEmpty(pair.key)); - }, + } + } PSBT_GLOBAL_FALLBACK_LOCKTIME => if pair.key.key.is_empty() { if fallback_lock_time.is_none() { @@ -189,7 +215,7 @@ impl Global { } else { return Err(InsertPairError::InvalidKeyDataNotEmpty(pair.key)); }, - PSBT_GLOBAL_INPUT_COUNT => + PSBT_GLOBAL_INPUT_COUNT => { if pair.key.key.is_empty() { if output_count.is_none() { // TODO: Do we need to check the length for a VarInt? @@ -202,8 +228,9 @@ impl Global { } } else { return Err(InsertPairError::InvalidKeyDataNotEmpty(pair.key)); - }, - PSBT_GLOBAL_OUTPUT_COUNT => + } + } + PSBT_GLOBAL_OUTPUT_COUNT => { if pair.key.key.is_empty() { if output_count.is_none() { // TODO: Do we need to check the length for a VarInt? @@ -216,7 +243,8 @@ impl Global { } } else { return Err(InsertPairError::InvalidKeyDataNotEmpty(pair.key)); - }, + } + } PSBT_GLOBAL_TX_MODIFIABLE => if pair.key.key.is_empty() { if tx_modifiable_flags.is_none() { @@ -232,7 +260,7 @@ impl Global { } else { return Err(InsertPairError::InvalidKeyDataNotEmpty(pair.key)); }, - PSBT_GLOBAL_XPUB => + PSBT_GLOBAL_XPUB => { if !pair.key.key.is_empty() { let xpub = Xpub::decode(&pair.key.key)?; if pair.value.is_empty() { @@ -267,7 +295,8 @@ impl Global { } } else { return Err(InsertPairError::InvalidKeyDataEmpty(pair.key)); - }, + } + } // TODO: Remove clone by implementing TryFrom for reference. PSBT_GLOBAL_PROPRIETARY => if !pair.key.key.is_empty() { @@ -284,6 +313,19 @@ impl Global { } else { return Err(InsertPairError::InvalidKeyDataEmpty(pair.key)); }, + #[cfg(feature = "silent-payments")] + PSBT_GLOBAL_SP_ECDH_SHARE => { + v2_impl_psbt_insert_sp_pair!( + sp_ecdh_shares, + pair.key, + pair.value, + compressed_pubkey + ); + } + #[cfg(feature = "silent-payments")] + PSBT_GLOBAL_SP_DLEQ => { + v2_impl_psbt_insert_sp_pair!(sp_dleq_proofs, pair.key, pair.value, dleq_proof); + } v if v == PSBT_GLOBAL_UNSIGNED_TX => return Err(InsertPairError::ExcludedKey { key_type_value: v }), _ => match unknowns.entry(pair.key) { @@ -321,6 +363,15 @@ impl Global { let output_count = usize::try_from(output_count.ok_or(DecodeError::MissingOutputCount)?) .map_err(|_| DecodeError::OutputCountOverflow(output_count.expect("is some")))?; + #[cfg(feature = "silent-payments")] + { + let has_ecdh = !sp_ecdh_shares.is_empty(); + let has_dleq = !sp_dleq_proofs.is_empty(); + if has_ecdh != has_dleq { + return Err(DecodeError::FieldMismatch); + } + } + Ok(Global { tx_version, fallback_lock_time, @@ -328,6 +379,10 @@ impl Global { output_count, tx_modifiable_flags, version, + #[cfg(feature = "silent-payments")] + sp_ecdh_shares, + #[cfg(feature = "silent-payments")] + sp_dleq_proofs, xpubs, proprietaries, unknowns, @@ -389,6 +444,10 @@ impl Global { } } + #[cfg(feature = "silent-payments")] + v2_combine_map!(sp_ecdh_shares, self, other); + #[cfg(feature = "silent-payments")] + v2_combine_map!(sp_dleq_proofs, self, other); v2_combine_map!(proprietaries, self, other); v2_combine_map!(unknowns, self, other); @@ -445,6 +504,28 @@ impl Map for Global { }); } + #[cfg(feature = "silent-payments")] + for (scan_key, ecdh_share) in &self.sp_ecdh_shares { + rv.push(raw::Pair { + key: raw::Key { + type_value: PSBT_GLOBAL_SP_ECDH_SHARE, + key: scan_key.to_bytes().to_vec(), + }, + value: ecdh_share.to_bytes().to_vec(), + }); + } + + #[cfg(feature = "silent-payments")] + for (scan_key, dleq_proof) in &self.sp_dleq_proofs { + rv.push(raw::Pair { + key: raw::Key { + type_value: PSBT_GLOBAL_SP_DLEQ, + key: scan_key.to_bytes().to_vec(), + }, + value: dleq_proof.as_bytes().to_vec(), + }); + } + for (key, value) in self.proprietaries.iter() { rv.push(raw::Pair { key: key.to_key(), value: value.clone() }); } @@ -477,6 +558,8 @@ pub enum DecodeError { MissingOutputCount, /// Output count overflows word size for current architecture. OutputCountOverflow(u64), + /// ECDH shares and DLEQ proofs must both be present or both absent. + FieldMismatch, } impl fmt::Display for DecodeError { @@ -487,14 +570,20 @@ impl fmt::Display for DecodeError { InsertPair(ref e) => write_err!(f, "error inserting a pair"; e), DeserPair(ref e) => write_err!(f, "error deserializing a pair"; e), MissingVersion => write!(f, "serialized PSBT is missing the version number"), - MissingTxVersion => - write!(f, "serialized PSBT is missing the transaction version number"), + MissingTxVersion => { + write!(f, "serialized PSBT is missing the transaction version number") + } MissingInputCount => write!(f, "serialized PSBT is missing the input count"), - InputCountOverflow(count) => - write!(f, "input count overflows word size for current architecture: {}", count), + InputCountOverflow(count) => { + write!(f, "input count overflows word size for current architecture: {}", count) + } MissingOutputCount => write!(f, "serialized PSBT is missing the output count"), - OutputCountOverflow(count) => - write!(f, "output count overflows word size for current architecture: {}", count), + OutputCountOverflow(count) => { + write!(f, "output count overflows word size for current architecture: {}", count) + } + FieldMismatch => { + write!(f, "ECDH shares and DLEQ proofs must both be present or both absent") + } } } } @@ -512,7 +601,8 @@ impl std::error::Error for DecodeError { | MissingInputCount | InputCountOverflow(_) | MissingOutputCount - | OutputCountOverflow(_) => None, + | OutputCountOverflow(_) + | FieldMismatch => None, } } } @@ -554,6 +644,8 @@ pub enum InsertPairError { /// Key type value we found. key_type_value: u8, }, + /// Key was not the correct length (got, expected). + KeyWrongLength(usize, usize), } impl fmt::Display for InsertPairError { @@ -566,12 +658,15 @@ impl fmt::Display for InsertPairError { InvalidKeyDataNotEmpty(ref key) => write!(f, "key should not contain data: {}", key), Deser(ref e) => write_err!(f, "error deserializing raw value"; e), Consensus(ref e) => write_err!(f, "error consensus deserializing type"; e), - ValueWrongLength(got, want) => - write!(f, "value (keyvalue pair) wrong length (got, want) {} {}", got, want), - WrongVersion(v) => - write!(f, "PSBT_GLOBAL_VERSION: PSBT v2 expects the version to be 2, found: {}", v), - XpubInvalidFingerprint => - write!(f, "PSBT_GLOBAL_XPUB: derivation path must be a list of 32 byte varints"), + ValueWrongLength(got, want) => { + write!(f, "value (keyvalue pair) wrong length (got, want) {} {}", got, want) + } + WrongVersion(v) => { + write!(f, "PSBT_GLOBAL_VERSION: PSBT v2 expects the version to be 2, found: {}", v) + } + XpubInvalidFingerprint => { + write!(f, "PSBT_GLOBAL_XPUB: derivation path must be a list of 32 byte varints") + } XpubInvalidPath(len) => write!( f, "PSBT_GLOBAL_XPUB: derivation path must be a list of 32 byte varints: {}", @@ -589,6 +684,9 @@ impl fmt::Display for InsertPairError { "found a keypair type that is explicitly excluded: {}", consts::psbt_global_key_type_value_to_str(key_type_value) ), + KeyWrongLength(got, expected) => { + write!(f, "key wrong length (got: {}, expected: {})", got, expected) + } } } } @@ -611,7 +709,8 @@ impl std::error::Error for InsertPairError { | XpubInvalidPath(_) | DuplicateXpub(_) | InvalidProprietaryKey - | ExcludedKey { .. } => None, + | ExcludedKey { .. } + | KeyWrongLength(..) => None, } } } @@ -655,12 +754,15 @@ impl fmt::Display for CombineError { use CombineError::*; match *self { - VersionMismatch { ref this, ref that } => - write!(f, "combine two PSBTs with different versions: {:?} {:?}", this, that), - TxVersionMismatch { ref this, ref that } => - write!(f, "combine two PSBTs with different tx versions: {:?} {:?}", this, that), - InconsistentKeySources(ref e) => - write_err!(f, "combine with inconsistent key sources"; e), + VersionMismatch { ref this, ref that } => { + write!(f, "combine two PSBTs with different versions: {:?} {:?}", this, that) + } + TxVersionMismatch { ref this, ref that } => { + write!(f, "combine two PSBTs with different tx versions: {:?} {:?}", this, that) + } + InconsistentKeySources(ref e) => { + write_err!(f, "combine with inconsistent key sources"; e) + } } } } diff --git a/src/v2/map/input.rs b/src/v2/map/input.rs index 91dedbb..14e205a 100644 --- a/src/v2/map/input.rs +++ b/src/v2/map/input.rs @@ -11,6 +11,8 @@ use bitcoin::key::{PublicKey, XOnlyPublicKey}; use bitcoin::locktime::absolute; use bitcoin::sighash::{EcdsaSighashType, NonStandardSighashTypeError, TapSighashType}; use bitcoin::taproot::{ControlBlock, LeafVersion, TapLeafHash, TapNodeHash}; +#[cfg(feature = "silent-payments")] +use bitcoin::CompressedPublicKey; use bitcoin::{ ecdsa, hashes, secp256k1, taproot, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, Witness, @@ -25,10 +27,14 @@ use crate::consts::{ PSBT_IN_TAP_INTERNAL_KEY, PSBT_IN_TAP_KEY_SIG, PSBT_IN_TAP_LEAF_SCRIPT, PSBT_IN_TAP_MERKLE_ROOT, PSBT_IN_TAP_SCRIPT_SIG, PSBT_IN_WITNESS_SCRIPT, PSBT_IN_WITNESS_UTXO, }; +#[cfg(feature = "silent-payments")] +use crate::consts::{PSBT_IN_SP_DLEQ, PSBT_IN_SP_ECDH_SHARE}; use crate::error::{write_err, FundingUtxoError}; use crate::prelude::*; use crate::serialize::{Deserialize, Serialize}; use crate::sighash_type::{InvalidSighashTypeError, PsbtSighashType}; +#[cfg(feature = "silent-payments")] +use crate::v2::dleq::DleqProof; use crate::v2::map::Map; use crate::{raw, serialize}; @@ -115,6 +121,17 @@ pub struct Input { pub tap_internal_key: Option, /// Taproot Merkle root. pub tap_merkle_root: Option, + + /// BIP-375: Map from scan public key to per-input ECDH share (33 bytes each). + #[cfg(feature = "silent-payments")] + #[cfg_attr(feature = "serde", serde(with = "crate::serde_utils::btreemap_as_seq"))] + pub sp_ecdh_shares: BTreeMap, + + /// BIP-375: Map from scan public key to per-input DLEQ proof (64 bytes each). + #[cfg(feature = "silent-payments")] + #[cfg_attr(feature = "serde", serde(with = "crate::serde_utils::btreemap_as_seq"))] + pub sp_dleq_proofs: BTreeMap, + /// Proprietary key-value pairs for this input. #[cfg_attr(feature = "serde", serde(with = "crate::serde_utils::btreemap_as_seq_byte_values"))] pub proprietaries: BTreeMap>, @@ -151,6 +168,10 @@ impl Input { tap_key_origins: BTreeMap::new(), tap_internal_key: None, tap_merkle_root: None, + #[cfg(feature = "silent-payments")] + sp_ecdh_shares: BTreeMap::new(), + #[cfg(feature = "silent-payments")] + sp_dleq_proofs: BTreeMap::new(), proprietaries: BTreeMap::new(), unknowns: BTreeMap::new(), } @@ -226,6 +247,10 @@ impl Input { tap_key_origins: BTreeMap::new(), tap_internal_key: None, tap_merkle_root: None, + #[cfg(feature = "silent-payments")] + sp_ecdh_shares: BTreeMap::new(), + #[cfg(feature = "silent-payments")] + sp_dleq_proofs: BTreeMap::new(), proprietaries: BTreeMap::new(), unknowns: BTreeMap::new(), }; @@ -387,6 +412,16 @@ impl Input { if rv.spent_output_index == u32::MAX { return Err(DecodeError::MissingSpentOutputIndex); } + + #[cfg(feature = "silent-payments")] + { + let has_ecdh = !rv.sp_ecdh_shares.is_empty(); + let has_dleq = !rv.sp_dleq_proofs.is_empty(); + if has_ecdh != has_dleq { + return Err(DecodeError::FieldMismatch); + } + } + Ok(rv) } @@ -525,6 +560,19 @@ impl Input { self.tap_merkle_root <= |< raw_value: TapNodeHash> } } + #[cfg(feature = "silent-payments")] + PSBT_IN_SP_ECDH_SHARE => { + v2_impl_psbt_insert_sp_pair!( + self.sp_ecdh_shares, + raw_key, + raw_value, + compressed_pubkey + ); + } + #[cfg(feature = "silent-payments")] + PSBT_IN_SP_DLEQ => { + v2_impl_psbt_insert_sp_pair!(self.sp_dleq_proofs, raw_key, raw_value, dleq_proof); + } PSBT_IN_PROPRIETARY => { let key = raw::ProprietaryKey::try_from(raw_key.clone())?; match self.proprietaries.entry(key) { @@ -593,6 +641,10 @@ impl Input { v2_combine_map!(tap_key_origins, self, other); v2_combine_option!(tap_internal_key, self, other); v2_combine_option!(tap_merkle_root, self, other); + #[cfg(feature = "silent-payments")] + v2_combine_map!(sp_ecdh_shares, self, other); + #[cfg(feature = "silent-payments")] + v2_combine_map!(sp_dleq_proofs, self, other); v2_combine_map!(proprietaries, self, other); v2_combine_map!(unknowns, self, other); @@ -699,6 +751,26 @@ impl Map for Input { v2_impl_psbt_get_pair! { rv.push(self.tap_merkle_root, PSBT_IN_TAP_MERKLE_ROOT) } + + #[cfg(feature = "silent-payments")] + for (scan_key, ecdh_share) in &self.sp_ecdh_shares { + rv.push(raw::Pair { + key: raw::Key { + type_value: PSBT_IN_SP_ECDH_SHARE, + key: scan_key.to_bytes().to_vec(), + }, + value: ecdh_share.to_bytes().to_vec(), + }); + } + + #[cfg(feature = "silent-payments")] + for (scan_key, dleq_proof) in &self.sp_dleq_proofs { + rv.push(raw::Pair { + key: raw::Key { type_value: PSBT_IN_SP_DLEQ, key: scan_key.to_bytes().to_vec() }, + value: dleq_proof.as_bytes().to_vec(), + }); + } + for (key, value) in self.proprietaries.iter() { rv.push(raw::Pair { key: key.to_key(), value: value.clone() }); } @@ -796,6 +868,8 @@ pub enum DecodeError { MissingPreviousTxid, /// Input must contain a spent output index. MissingSpentOutputIndex, + /// BIP-375: ECDH shares and DLEQ proofs must both be present or both absent. + FieldMismatch, } impl fmt::Display for DecodeError { @@ -807,6 +881,9 @@ impl fmt::Display for DecodeError { DeserPair(ref e) => write_err!(f, "error decoding pair"; e), MissingPreviousTxid => write!(f, "input must contain a previous txid"), MissingSpentOutputIndex => write!(f, "input must contain a spent output index"), + FieldMismatch => { + write!(f, "ECDH shares and DLEQ proofs must both be present or both absent") + } } } } @@ -820,6 +897,7 @@ impl std::error::Error for DecodeError { InsertPair(ref e) => Some(e), DeserPair(ref e) => Some(e), MissingPreviousTxid | MissingSpentOutputIndex => None, + FieldMismatch => None, } } } @@ -841,6 +919,10 @@ pub enum InsertPairError { InvalidKeyDataNotEmpty(raw::Key), /// The pre-image must hash to the correponding psbt hash HashPreimage(HashPreimageError), + /// Key was not the correct length (got, expected). + KeyWrongLength(usize, usize), + /// Value was not the correct length (got, expected). + ValueWrongLength(usize, usize), } impl fmt::Display for InsertPairError { @@ -853,6 +935,12 @@ impl fmt::Display for InsertPairError { InvalidKeyDataEmpty(ref key) => write!(f, "key should contain data: {}", key), InvalidKeyDataNotEmpty(ref key) => write!(f, "key should not contain data: {}", key), HashPreimage(ref e) => write_err!(f, "invalid hash preimage"; e), + KeyWrongLength(got, expected) => { + write!(f, "key wrong length (got: {}, expected: {})", got, expected) + } + ValueWrongLength(got, expected) => { + write!(f, "value wrong length (got: {}, expected: {})", got, expected) + } } } } @@ -865,7 +953,11 @@ impl std::error::Error for InsertPairError { match *self { Deser(ref e) => Some(e), HashPreimage(ref e) => Some(e), - DuplicateKey(_) | InvalidKeyDataEmpty(_) | InvalidKeyDataNotEmpty(_) => None, + DuplicateKey(_) + | InvalidKeyDataEmpty(_) + | InvalidKeyDataNotEmpty(_) + | KeyWrongLength(..) + | ValueWrongLength(..) => None, } } } @@ -975,8 +1067,9 @@ impl fmt::Display for CombineError { use CombineError::*; match *self { - PreviousTxidMismatch { ref this, ref that } => - write!(f, "combine two PSBTs with different previous txids: {:?} {:?}", this, that), + PreviousTxidMismatch { ref this, ref that } => { + write!(f, "combine two PSBTs with different previous txids: {:?} {:?}", this, that) + } SpentOutputIndexMismatch { ref this, ref that } => write!( f, "combine two PSBTs with different spent output indecies: {:?} {:?}", diff --git a/src/v2/map/output.rs b/src/v2/map/output.rs index 60745cf..4b15fb3 100644 --- a/src/v2/map/output.rs +++ b/src/v2/map/output.rs @@ -14,6 +14,8 @@ use crate::consts::{ PSBT_OUT_SCRIPT, PSBT_OUT_TAP_BIP32_DERIVATION, PSBT_OUT_TAP_INTERNAL_KEY, PSBT_OUT_TAP_TREE, PSBT_OUT_WITNESS_SCRIPT, }; +#[cfg(feature = "silent-payments")] +use crate::consts::{PSBT_OUT_SP_V0_INFO, PSBT_OUT_SP_V0_LABEL}; use crate::error::write_err; use crate::prelude::*; use crate::serialize::{Deserialize, Serialize}; @@ -47,6 +49,15 @@ pub struct Output { /// Map of tap root x only keys to origin info and leaf hashes contained in it. #[cfg_attr(feature = "serde", serde(with = "crate::serde_utils::btreemap_as_seq"))] pub tap_key_origins: BTreeMap, KeySource)>, + + /// BIP-375: Silent payment v0 address info (66 bytes: scan_key || spend_key). + #[cfg(feature = "silent-payments")] + pub sp_v0_info: Option>, + + /// BIP-375: Silent payment v0 label (4-byte little-endian u32). + #[cfg(feature = "silent-payments")] + pub sp_v0_label: Option, + /// Proprietary key-value pairs for this output. #[cfg_attr(feature = "serde", serde(with = "crate::serde_utils::btreemap_as_seq_byte_values"))] pub proprietaries: BTreeMap>, @@ -67,6 +78,10 @@ impl Output { tap_internal_key: None, tap_tree: None, tap_key_origins: BTreeMap::new(), + #[cfg(feature = "silent-payments")] + sp_v0_info: None, + #[cfg(feature = "silent-payments")] + sp_v0_label: None, proprietaries: BTreeMap::new(), unknowns: BTreeMap::new(), } @@ -107,9 +122,17 @@ impl Output { if rv.amount == Amount::ZERO { return Err(DecodeError::MissingValue); } + // BIP-375 allows outputs to be missing scriptPubkey + #[cfg(not(feature = "silent-payments"))] if rv.script_pubkey == ScriptBuf::default() { return Err(DecodeError::MissingScriptPubkey); } + + #[cfg(feature = "silent-payments")] + if rv.sp_v0_label.is_some() && rv.sp_v0_info.is_none() { + return Err(DecodeError::LabelWithoutInfo); + } + Ok(rv) } @@ -172,6 +195,34 @@ impl Output { self.tap_key_origins <= |< raw_value: (Vec, KeySource)> } } + #[cfg(feature = "silent-payments")] + PSBT_OUT_SP_V0_INFO => { + if self.sp_v0_info.is_some() { + return Err(InsertPairError::DuplicateKey(raw_key)); + } + if !raw_key.key.is_empty() { + return Err(InsertPairError::InvalidKeyDataNotEmpty(raw_key)); + } + if raw_value.len() != 66 { + return Err(InsertPairError::ValueWrongLength(raw_value.len(), 66)); + } + self.sp_v0_info = Some(raw_value); + } + #[cfg(feature = "silent-payments")] + PSBT_OUT_SP_V0_LABEL => { + if self.sp_v0_label.is_some() { + return Err(InsertPairError::DuplicateKey(raw_key)); + } + if !raw_key.key.is_empty() { + return Err(InsertPairError::InvalidKeyDataNotEmpty(raw_key)); + } + if raw_value.len() != 4 { + return Err(InsertPairError::ValueWrongLength(raw_value.len(), 4)); + } + let label = + u32::from_le_bytes([raw_value[0], raw_value[1], raw_value[2], raw_value[3]]); + self.sp_v0_label = Some(label); + } // Note, PSBT v2 does not exclude any keys from the input map. _ => match self.unknowns.entry(raw_key) { btree_map::Entry::Vacant(empty_key) => { @@ -204,6 +255,10 @@ impl Output { v2_combine_option!(tap_internal_key, self, other); v2_combine_option!(tap_tree, self, other); v2_combine_map!(tap_key_origins, self, other); + #[cfg(feature = "silent-payments")] + v2_combine_option!(sp_v0_info, self, other); + #[cfg(feature = "silent-payments")] + v2_combine_option!(sp_v0_label, self, other); v2_combine_map!(proprietaries, self, other); v2_combine_map!(unknowns, self, other); @@ -249,6 +304,22 @@ impl Map for Output { rv.push_map(self.tap_key_origins, PSBT_OUT_TAP_BIP32_DERIVATION) } + #[cfg(feature = "silent-payments")] + if let Some(sp_info) = &self.sp_v0_info { + rv.push(raw::Pair { + key: raw::Key { type_value: PSBT_OUT_SP_V0_INFO, key: vec![] }, + value: sp_info.clone(), + }); + } + + #[cfg(feature = "silent-payments")] + if let Some(label) = self.sp_v0_label { + rv.push(raw::Pair { + key: raw::Key { type_value: PSBT_OUT_SP_V0_LABEL, key: vec![] }, + value: label.to_le_bytes().to_vec(), + }); + } + for (key, value) in self.proprietaries.iter() { rv.push(raw::Pair { key: key.to_key(), value: value.clone() }); } @@ -285,6 +356,8 @@ pub enum DecodeError { MissingValue, /// Encoded output is missing a script pubkey. MissingScriptPubkey, + /// Encoded output is missing a sp_v0_info. + LabelWithoutInfo, } impl fmt::Display for DecodeError { @@ -296,6 +369,7 @@ impl fmt::Display for DecodeError { DeserPair(ref e) => write_err!(f, "error deserializing a pair"; e), MissingValue => write!(f, "encoded output is missing a value"), MissingScriptPubkey => write!(f, "encoded output is missing a script pubkey"), + LabelWithoutInfo => write!(f, "output has a sp_v0_label without a sp_v0_info"), } } } @@ -308,7 +382,7 @@ impl std::error::Error for DecodeError { match *self { InsertPair(ref e) => Some(e), DeserPair(ref e) => Some(e), - MissingValue | MissingScriptPubkey => None, + MissingValue | MissingScriptPubkey | LabelWithoutInfo => None, } } } @@ -328,6 +402,8 @@ pub enum InsertPairError { InvalidKeyDataEmpty(raw::Key), /// Key should not contain data. InvalidKeyDataNotEmpty(raw::Key), + /// Value was not the correct length (got, expected). + ValueWrongLength(usize, usize), } impl fmt::Display for InsertPairError { @@ -339,6 +415,9 @@ impl fmt::Display for InsertPairError { Deser(ref e) => write_err!(f, "error deserializing raw value"; e), InvalidKeyDataEmpty(ref key) => write!(f, "key should contain data: {}", key), InvalidKeyDataNotEmpty(ref key) => write!(f, "key should not contain data: {}", key), + ValueWrongLength(got, expected) => { + write!(f, "value wrong length (got: {}, expected: {})", got, expected) + } } } } @@ -350,7 +429,10 @@ impl std::error::Error for InsertPairError { match *self { Deser(ref e) => Some(e), - DuplicateKey(_) | InvalidKeyDataEmpty(_) | InvalidKeyDataNotEmpty(_) => None, + DuplicateKey(_) + | InvalidKeyDataEmpty(_) + | InvalidKeyDataNotEmpty(_) + | ValueWrongLength(..) => None, } } } @@ -384,10 +466,12 @@ impl fmt::Display for CombineError { use CombineError::*; match *self { - AmountMismatch { ref this, ref that } => - write!(f, "combine two PSBTs with different amounts: {} {}", this, that), - ScriptPubkeyMismatch { ref this, ref that } => - write!(f, "combine two PSBTs with different script_pubkeys: {:x} {:x}", this, that), + AmountMismatch { ref this, ref that } => { + write!(f, "combine two PSBTs with different amounts: {} {}", this, that) + } + ScriptPubkeyMismatch { ref this, ref that } => { + write!(f, "combine two PSBTs with different script_pubkeys: {:x} {:x}", this, that) + } } } } diff --git a/src/v2/mod.rs b/src/v2/mod.rs index 73a7208..fd49bab 100644 --- a/src/v2/mod.rs +++ b/src/v2/mod.rs @@ -23,6 +23,8 @@ //! [BIP-174]: //! [BIP-370]: +#[cfg(feature = "silent-payments")] +pub mod dleq; mod error; mod extract; mod map; @@ -63,6 +65,8 @@ pub use self::{ }; #[cfg(feature = "base64")] pub use self::display_from_str::ParsePsbtError; +#[cfg(feature = "silent-payments")] +pub use self::dleq::{DleqProof, InvalidLengthError}; #[cfg(feature = "miniscript")] pub use self::miniscript::{ FinalizeError, FinalizeInputError, Finalizer, InputError, InterpreterCheckError, diff --git a/tests/bip370-parse-invalid.rs b/tests/bip370-parse-invalid.rs index e535604..a143a1f 100644 --- a/tests/bip370-parse-invalid.rs +++ b/tests/bip370-parse-invalid.rs @@ -124,10 +124,15 @@ fn bip370_invalid() { util::assert_invalid_v2(hex, base64); // Case: PSBTv2 missing PSBT_OUT_SCRIPT. - let hex = "70736274ff0102040200000001030400000000010401010105010201fb0402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc8010f0400000000011004feffffff00220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000103080008af2f0000000000220202e36fbff53dd534070cf8fd396614680f357a9b85db7340bf1cfa745d2ad7b34018f69d873e54000080010000800000008001000000640000000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300"; - let base64 = "cHNidP8BAgQCAAAAAQMEAAAAAAEEAQEBBQECAfsEAgAAAAABAFICAAAAAcGqJW4hS5ahgi+T3kK/87Xz/40FGTBuNRXXUVpegFsSAAAAAAD/////ARjGmjsAAAAAFgAUsKOvFEIIQSaTyn0WaFK1LbCu8G4AAAAAAQEfGMaaOwAAAAAWABSwo68UQghBJpPKfRZoUrUtsK7wbgEOIAsK2SFBnByHGXNdctxzn56p4GONH+TB7vD5lECEgV/IAQ8EAAAAAAEQBP7///8AIgIC1gH4SEamdV93a+AOPZ3o+xCsyTX7g8RfsBYtTK1at5IY9p2HPlQAAIABAACAAAAAgAAAAAAqAAAAAQMIAAivLwAAAAAAIgIC42+/9T3VNAcM+P05ZhRoDzV6m4Xbc0C/HPp0XSrXs0AY9p2HPlQAAIABAACAAAAAgAEAAABkAAAAAQMIi73rCwAAAAABBBYAFE3Rk6yWSlasG54cyoRU/i9HT4UTAA=="; - util::assert_invalid_v0(hex, base64); - util::assert_invalid_v2(hex, base64); + // Note: With silentpayments enabled, outputs may omit script_pubkey for silent payments, + // so this test only applies to standard BIP-370 behavior. + #[cfg(not(feature = "silent-payments"))] + { + let hex = "70736274ff0102040200000001030400000000010401010105010201fb0402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc8010f0400000000011004feffffff00220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000103080008af2f0000000000220202e36fbff53dd534070cf8fd396614680f357a9b85db7340bf1cfa745d2ad7b34018f69d873e54000080010000800000008001000000640000000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300"; + let base64 = "cHNidP8BAgQCAAAAAQMEAAAAAAEEAQEBBQECAfsEAgAAAAABAFICAAAAAcGqJW4hS5ahgi+T3kK/87Xz/40FGTBuNRXXUVpegFsSAAAAAAD/////ARjGmjsAAAAAFgAUsKOvFEIIQSaTyn0WaFK1LbCu8G4AAAAAAQEfGMaaOwAAAAAWABSwo68UQghBJpPKfRZoUrUtsK7wbgEOIAsK2SFBnByHGXNdctxzn56p4GONH+TB7vD5lECEgV/IAQ8EAAAAAAEQBP7///8AIgIC1gH4SEamdV93a+AOPZ3o+xCsyTX7g8RfsBYtTK1at5IY9p2HPlQAAIABAACAAAAAgAAAAAAqAAAAAQMIAAivLwAAAAAAIgIC42+/9T3VNAcM+P05ZhRoDzV6m4Xbc0C/HPp0XSrXs0AY9p2HPlQAAIABAACAAAAAgAEAAABkAAAAAQMIi73rCwAAAAABBBYAFE3Rk6yWSlasG54cyoRU/i9HT4UTAA=="; + util::assert_invalid_v0(hex, base64); + util::assert_invalid_v2(hex, base64); + } // Case: PSBTv2 with PSBT_IN_REQUIRED_TIME_LOCKTIME less than 500000000. let hex = "70736274ff01020402000000010401010105010201fb0402000000000100520200000001c1aa256e214b96a1822f93de42bff3b5f3ff8d0519306e3515d7515a5e805b120000000000ffffffff0118c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e0000000001011f18c69a3b00000000160014b0a3af144208412693ca7d166852b52db0aef06e010e200b0ad921419c1c8719735d72dc739f9ea9e0638d1fe4c1eef0f9944084815fc8010f0400000000011104ff64cd1d00220202d601f84846a6755f776be00e3d9de8fb10acc935fb83c45fb0162d4cad5ab79218f69d873e540000800100008000000080000000002a0000000103080008af2f000000000104160014c430f64c4756da310dbd1a085572ef299926272c00220202e36fbff53dd534070cf8fd396614680f357a9b85db7340bf1cfa745d2ad7b34018f69d873e54000080010000800000008001000000640000000103088bbdeb0b0000000001041600144dd193ac964a56ac1b9e1cca8454fe2f474f851300"; diff --git a/tests/bip375-parse-invalid.rs b/tests/bip375-parse-invalid.rs new file mode 100644 index 0000000..553a39e --- /dev/null +++ b/tests/bip375-parse-invalid.rs @@ -0,0 +1,174 @@ +//! BIP-375 Silent Payments Parse Invalid Tests + +#![cfg(all(feature = "std", feature = "base64", feature = "silent-payments"))] + +mod util; + +use core::str::FromStr; + +use bitcoin::CompressedPublicKey; +use psbt_v2::v2::{Creator, DleqProof, Psbt}; + +/// Test: Global field mismatch - DLEQ proofs present but no ECDH shares +/// Expected error: DecodeError::FieldMismatch +#[test] +fn bip375_global_field_mismatch_dleq_only() { + // Approach 1: Programmatic + let mut psbt = Creator::new().psbt(); + let scan_key = CompressedPublicKey::from_slice(&[0x02u8; 33]).unwrap(); + let dleq_proof = DleqProof::new([0xAAu8; 64]); + psbt.global.sp_dleq_proofs.insert(scan_key, dleq_proof); + + let bytes = psbt.serialize(); + assert!(Psbt::deserialize(&bytes).is_err(), "should fail due to DLEQ without ECDH"); +} + +/// Test: Duplicate scan key in global ECDH shares +/// Expected error: InsertPairError::DuplicateKey +/// Note: This test demonstrates a limitation - BTreeMap prevents duplicates at construction, +/// so we must use raw hex to test the deserialization error path. +#[test] +fn bip375_global_duplicate_scan_key_ecdh() { + // Raw hex with duplicate ECDH entries for the same scan key + // This will be caught during deserialization when inserting the second occurrence + let hex = concat!( + "70736274ff", // magic + "01fb04", + "02000000", // version = 2 + "010204", + "02000000", // tx_version = 2 + "010401", + "00", // input_count = 0 + "010501", + "00", // output_count = 0 + "010601", + "00", // tx_modifiable = 0 + // First ECDH entry with scan_key = 0x02 repeated 33 times + "2207", + "020202020202020202020202020202020202020202020202020202020202020202", // ECDH key (33 bytes) + "21", + "040404040404040404040404040404040404040404040404040404040404040404", // ECDH value (33 bytes) + // Second ECDH entry with SAME scan_key but different value + "2207", + "020202020202020202020202020202020202020202020202020202020202020202", // ECDH key (33 bytes) - DUPLICATE! + "21", + "050505050505050505050505050505050505050505050505050505050505050505", // ECDH value (33 bytes, different) + // Matching DLEQ entries (required to avoid field mismatch error) + "2208", + "020202020202020202020202020202020202020202020202020202020202020202", // DLEQ key (33 bytes) + "40", // DLEQ value length (64 bytes) + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", // DLEQ value part 1 + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", // DLEQ value part 2 + "00", // global terminator + ); + + assert!(util::hex_psbt_v2(hex).is_err(), "should fail due to duplicate scan key in global"); +} + +/// Test: Per-input field mismatch - DLEQ proofs present but no ECDH shares +/// Expected error: DecodeError::FieldMismatch +#[test] +fn bip375_input_field_mismatch_dleq_only() { + // Minimal PSBTv2 with one input containing DLEQ proof but no ECDH share + let hex = concat!( + "70736274ff", // magic + "01fb04", + "02000000", // version = 2 + "010204", + "02000000", // tx_version = 2 + "010401", + "01", // input_count = 1 + "010501", + "00", // output_count = 0 + "010601", + "00", // tx_modifiable = 0 + "00", // global terminator + // Input 0 + "010e20", + "0000000000000000000000000000000000000000000000000000000000000000", // prev txid + "010f04", + "00000000", // output index + // DLEQ proof without matching ECDH share - MISMATCH! + "221e", + "020202020202020202020202020202020202020202020202020202020202020202", // DLEQ key (33 bytes) + "40", // DLEQ value length (64 bytes) + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", // DLEQ value part 1 + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", // DLEQ value part 2 + "00", // input terminator + ); + + assert!(util::hex_psbt_v2(hex).is_err(), "should fail: input has DLEQ proof but no ECDH share"); +} + +/// Test: Duplicate scan key in per-input ECDH shares +/// Expected error: InsertPairError::DuplicateKey +#[test] +fn bip375_input_duplicate_scan_key_ecdh() { + // Minimal PSBTv2 with one input containing duplicate ECDH shares for the same scan key + let hex = concat!( + "70736274ff", // magic + "01fb04", + "02000000", // version = 2 + "010204", + "02000000", // tx_version = 2 + "010401", + "01", // input_count = 1 + "010501", + "00", // output_count = 0 + "010601", + "00", // tx_modifiable = 0 + "00", // global terminator + // Input 0 + "010e20", + "0000000000000000000000000000000000000000000000000000000000000000", // prev txid + "010f04", + "00000000", // output index + // First per-input ECDH entry with scan_key = 0x02 repeated 33 times + "221d", + "020202020202020202020202020202020202020202020202020202020202020202", // ECDH key (33 bytes) + "21", + "040404040404040404040404040404040404040404040404040404040404040404", // ECDH value (33 bytes) + // Second per-input ECDH entry with SAME scan_key - DUPLICATE! + "221d", + "020202020202020202020202020202020202020202020202020202020202020202", // ECDH key (33 bytes) - DUPLICATE! + "21", + "050505050505050505050505050505050505050505050505050505050505050505", // ECDH value (33 bytes, different) + // Matching DLEQ entries (required to avoid field mismatch error) + "221e", + "020202020202020202020202020202020202020202020202020202020202020202", // DLEQ key (33 bytes) + "40", // DLEQ value length (64 bytes) + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", // DLEQ value part 1 + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", // DLEQ value part 2 + "00", // input terminator + ); + + assert!(util::hex_psbt_v2(hex).is_err(), "should fail due to duplicate scan key in input"); +} + +// ============================================================================= +// BIP-375 Test Vectors - Invalid Cases Serialization Constraints +// ============================================================================= + +/// Test: Missing DLEQ proof for ECDH share (per-input) +/// Expected error: DecodeError::FieldMismatch +#[test] +fn bip375_test_vector_missing_input_dleq_proof() { + let base64 = "cHNidP8B+wQCAAAAAQIEAgAAAAEEBAEAAAABBQQBAAAAAQYBAwABDiCrtYht20vGCALx8ZiisSkDZZzJ7nPgIx1FVehBiNyWQAEPBAAAAAABAR+ghgEAAAAAABYAFPja92rYA7DvqV1s/4ruCJHTrxyMARAE/v///yIGA9NX98BxjyR44/2PjMwnKd3YwMyusfArGBpvRNQ7n42NBAAAAAAiHQLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+CECVRZOeSbVDVKgn/mQZHpelcHbG/xophb7wtohOSf5i/8BAwQBAAAAAAEDCBhzAQAAAAAAAQQiUSAXu7qlEuAw+8o+5RT3sjgH4RGDGkdFRHCRNMn5v0a2xAEJQgLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+AJNUYNT9L0Y12nPaP9i7xBmm3CGJGsKZAP+V73kkhFEiwA="; + + assert!( + psbt_v2::v2::Psbt::from_str(base64).is_err(), + "should fail: input has ECDH share but no DLEQ proof" + ); +} + +/// Test: Global ECDH share without DLEQ proof +/// Expected error: DecodeError::FieldMismatch +#[test] +fn bip375_test_vector_global_ecdh_without_dleq() { + let base64 = "cHNidP8B+wQCAAAAAQIEAgAAAAEEBAEAAAABBQQBAAAAAQYBAyIHAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4IQJVFk55JtUNUqCf+ZBkel6Vwdsb/GimFvvC2iE5J/mL/wABDiD2W3/BmfoPsrzNsfoGEte1D2K0+PqV1WXEoB9MWC6SpAEPBAAAAAABAR+ghgEAAAAAABYAFPja92rYA7DvqV1s/4ruCJHTrxyMARAE/v///yIGA9NX98BxjyR44/2PjMwnKd3YwMyusfArGBpvRNQ7n42NBAAAAAABAwQBAAAAAAEDCBhzAQAAAAAAAQQiUSCBAAI55HC7UjfZ+r6wGeJ5j8RXLnnJOQtdHV42G7fgIAEJQgLQKf+W3iy894K+Q1nEhiDqkrzda+8DK5UVi5GhaT+0+AJNUYNT9L0Y12nPaP9i7xBmm3CGJGsKZAP+V73kkhFEiwA="; + + assert!( + psbt_v2::v2::Psbt::from_str(base64).is_err(), + "should fail: global ECDH share present but no DLEQ proof" + ); +} diff --git a/tests/bip375-parse-valid.rs b/tests/bip375-parse-valid.rs new file mode 100644 index 0000000..c6a27e4 --- /dev/null +++ b/tests/bip375-parse-valid.rs @@ -0,0 +1,95 @@ +//! BIP-375 Silent Payments Parse Valid Tests + +#![cfg(all(feature = "std", feature = "base64", feature = "silent-payments"))] + +mod util; + +use core::str::FromStr; + +use bitcoin::CompressedPublicKey; +use psbt_v2::v2::{Creator, DleqProof, Psbt}; + +/// Helper: Create a minimal valid PSBTv2 with BIP-375 fields populated correctly +fn valid_psbt_with_bip375_global_fields() -> Psbt { + let mut psbt = Creator::new().psbt(); + // Use real valid compressed public keys (from secp256k1 generator point and a modified one) + let scan_key = CompressedPublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(); + let ecdh_share = CompressedPublicKey::from_str( + "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5", + ) + .unwrap(); + let dleq_proof = DleqProof::new([0xAAu8; 64]); + + psbt.global.sp_ecdh_shares.insert(scan_key, ecdh_share); + psbt.global.sp_dleq_proofs.insert(scan_key, dleq_proof); + psbt +} + +/// Test: Valid PSBT with both ECDH and DLEQ should succeed (sanity check) +#[test] +fn bip375_global_fields_both_present_valid() { + let psbt = valid_psbt_with_bip375_global_fields(); + + let bytes = psbt.serialize(); + let result = Psbt::deserialize(&bytes); + assert!(result.is_ok(), "should succeed when both ECDH and DLEQ are present"); + + let roundtrip = result.unwrap(); + assert_eq!(roundtrip.global.sp_ecdh_shares.len(), 1); + assert_eq!(roundtrip.global.sp_dleq_proofs.len(), 1); +} + +// ============================================================================= +// BIP-375 Test Vectors - Valid Cases Serialization Constraints +// ============================================================================= + +/// Test: Single signer with global ECDH share +/// Source: BIP-375 test vectors +#[test] +fn bip375_test_vector_single_signer_global_shares_should_parse() { + let base64 = "cHNidP8B+wQCAAAAAQIEAgAAAAEEBAEAAAABBQQBAAAAAQYBAyIHAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4IQJVFk55JtUNUqCf+ZBkel6Vwdsb/GimFvvC2iE5J/mL/yIIAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4QMHWfzh4gr+BeRXqFYIdZNWzg1wfwFxVjOPYJJKhfZCMEEh9u5vdwQgMjC2cGXGyZgg8aQVOIhk/JEEorGZzfmAAAQ4gbprLPJXWyu5NSnIlAmnrjz0Fcu1PhPS3/DOpmr4+OgUBDwQAAAAAAQEfoIYBAAAAAAAWABT42vdq2AOw76ldbP+K7giR068cjAEQBP7///8iBgPTV/fAcY8keOP9j4zMJynd2MDMrrHwKxgab0TUO5+NjQQAAAAAAQMEAQAAAAABAwgYcwEAAAAAAAEEIlEgrhn77icwoalS19JZjMcD/d87lyslFIse0aea6HOdXgcBCUIC0Cn/lt4svPeCvkNZxIYg6pK83WvvAyuVFYuRoWk/tPgCTVGDU/S9GNdpz2j/Yu8QZptwhiRrCmQD/le95JIRRIsA"; + + assert!( + psbt_v2::v2::Psbt::from_str(base64).is_ok(), + "should parse: single signer global shares" + ); +} + +/// Test: Multi-party with per-input ECDH shares +/// Source: BIP-375 test vectors +#[test] +fn bip375_test_vector_per_input_shares_should_parse() { + let base64 = "cHNidP8B+wQCAAAAAQIEAgAAAAEEBAIAAAABBQQBAAAAAQYBAwABDiBc+IxEpEzVImNhvtEHUZRvn4cJUikR4HeBX19/B4WvaQEPBAAAAAABAR9QwwAAAAAAABYAFPja92rYA7DvqV1s/4ruCJHTrxyMARAE/v///yIGA9NX98BxjyR44/2PjMwnKd3YwMyusfArGBpvRNQ7n42NBAAAAAABAwQBAAAAIh0C0Cn/lt4svPeCvkNZxIYg6pK83WvvAyuVFYuRoWk/tPghAlUWTnkm1Q1SoJ/5kGR6XpXB2xv8aKYW+8LaITkn+Yv/Ih4C0Cn/lt4svPeCvkNZxIYg6pK83WvvAyuVFYuRoWk/tPhAwdZ/OHiCv4F5FeoVgh1k1bODXB/AXFWM49gkkqF9kIwQSH27m93BCAyMLZwZcbJmCDxpBU4iGT8kQSisZnN+YAABDiAT0u662aEJkUdBDIam/gsg7p23WdpBz9Tul5LNUBI3FQEPBAAAAAABAR9QwwAAAAAAABYAFEIccVrt+YOvDjtnb/fElNEFT826ARAE/v///yIGAo8dCC1gAfpLqJmkA9r5sdsBvpJcIlM69jDuZJOwvp95BAAAAAABAwQBAAAAIh0C0Cn/lt4svPeCvkNZxIYg6pK83WvvAyuVFYuRoWk/tPghA1voNBApxyR/ork41dS4Wzpp+9kxe62Fr2V5knHctCgBIh4C0Cn/lt4svPeCvkNZxIYg6pK83WvvAyuVFYuRoWk/tPhAwP4szFLfu5Jb0/9lS3qW2OwOAxpn0EG0+Zyw6BFJ4oeoK9PVG8D/czrOfTKrY9YSGGVFd4CPssf7BtK+Bv86tgABAwgYcwEAAAAAAAEEIlEgC9xqHau4dRdnjCtffc3/KXbcXRE1xN2T9mcj6++5jiUBCUIC0Cn/lt4svPeCvkNZxIYg6pK83WvvAyuVFYuRoWk/tPgCTVGDU/S9GNdpz2j/Yu8QZptwhiRrCmQD/le95JIRRIsA"; + + assert!( + psbt_v2::v2::Psbt::from_str(base64).is_ok(), + "should parse: multi party per input shares" + ); +} + +/// Test: Silent payment with change detection +/// Source: BIP-375 test vectors +#[test] +fn bip375_test_vector_output_with_change_should_parse() { + let base64 = "cHNidP8B+wQCAAAAAQIEAgAAAAEEBAEAAAABBQQCAAAAAQYBAwABDiAlbK6m2hWAb7hW7a50mI1EDHqxtcCGHsgR0ZCSdHudHAEPBAAAAAABAR+ghgEAAAAAABYAFPja92rYA7DvqV1s/4ruCJHTrxyMARAE/v///yIGA9NX98BxjyR44/2PjMwnKd3YwMyusfArGBpvRNQ7n42NBAAAAAABAwQBAAAAIh0C0Cn/lt4svPeCvkNZxIYg6pK83WvvAyuVFYuRoWk/tPghAlUWTnkm1Q1SoJ/5kGR6XpXB2xv8aKYW+8LaITkn+Yv/Ih4C0Cn/lt4svPeCvkNZxIYg6pK83WvvAyuVFYuRoWk/tPhAwdZ/OHiCv4F5FeoVgh1k1bODXB/AXFWM49gkkqF9kIwQSH27m93BCAyMLZwZcbJmCDxpBU4iGT8kQSisZnN+YAABAwhQwwAAAAAAAAEEIlEgVbkWS8N9yG9biTYWgqowiLWz+lPa20PpXxvRE5uxwDUBCUIC0Cn/lt4svPeCvkNZxIYg6pK83WvvAyuVFYuRoWk/tPgD9SRDSFIBZWa8RdH6alxaGGLN2TPxXIQ7QnI4IvUlMjcBCgQBAAAAAAEDCMivAAAAAAAAAQQWABTjwxDMKvOsbmLK5L0j4+5SueHJWSICA9NX98BxjyR44/2PjMwnKd3YwMyusfArGBpvRNQ7n42NDAAAAAAAAAAAAAAAAQA="; + + assert!( + psbt_v2::v2::Psbt::from_str(base64).is_ok(), + "should parse: silent payment with change detection" + ); +} + +/// Test: Multiple silent payment outputs to same scan key +/// Source: BIP-375 test vectors +#[test] +fn bip375_test_vector_multiple_outputs_same_key_should_parse() { + let base64 = "cHNidP8B+wQCAAAAAQIEAgAAAAEEBAEAAAABBQQCAAAAAQYBAyIHAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4IQJVFk55JtUNUqCf+ZBkel6Vwdsb/GimFvvC2iE5J/mL/yIIAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4QMHWfzh4gr+BeRXqFYIdZNWzg1wfwFxVjOPYJJKhfZCMEEh9u5vdwQgMjC2cGXGyZgg8aQVOIhk/JEEorGZzfmAAAQ4gLHvTL/FQccCuAyc4ZKFDbIpWITVp4RMtz46nPsjDMiIBDwQAAAAAAQEfoIYBAAAAAAAWABT42vdq2AOw76ldbP+K7giR068cjAEQBP7///8iBgPTV/fAcY8keOP9j4zMJynd2MDMrrHwKxgab0TUO5+NjQQAAAAAAQMEAQAAAAABAwhAnAAAAAAAAAEEIlEg+ytxOv1SuiRxgbmYQapPLIhVut99rOLrLjBV2hPuK4wBCUIC0Cn/lt4svPeCvkNZxIYg6pK83WvvAyuVFYuRoWk/tPgCTVGDU/S9GNdpz2j/Yu8QZptwhiRrCmQD/le95JIRRIsAAQMI2NYAAAAAAAABBCJRIFmgqeG9mJh0IBNVOGDtC0S3JvvFIOnFcbxGSV8kefYYAQlCAtAp/5beLLz3gr5DWcSGIOqSvN1r7wMrlRWLkaFpP7T4Ak1Rg1P0vRjXac9o/2LvEGabcIYkawpkA/5XveSSEUSLAA=="; + + assert!( + psbt_v2::v2::Psbt::from_str(base64).is_ok(), + "should parse: multiple outputs to same scan key" + ); +}