From b940e4c89779d0b324984989ca7adc1fecb8fc5a Mon Sep 17 00:00:00 2001 From: 42Pupusas Date: Fri, 31 Jul 2026 15:54:17 -0600 Subject: [PATCH 01/12] common: add silent payment boundary types (BIP-352) Derivation coordinates (SilentPaymentAccount), scan-only material with no path to b_spend (SilentPaymentScanMaterial), and the PSET proprietary-key metadata a wallet attaches for a signer (SilentPaymentInputMeta), behind a new silentpayments feature. Living in lwk_common lets wallet and signer share the boundary without depending on each other. --- lwk_common/Cargo.toml | 6 + lwk_common/src/lib.rs | 3 + lwk_common/src/silentpayments.rs | 411 +++++++++++++++++++++++++++++++ 3 files changed, 420 insertions(+) create mode 100644 lwk_common/src/silentpayments.rs diff --git a/lwk_common/Cargo.toml b/lwk_common/Cargo.toml index 7a696a555..d868f6430 100644 --- a/lwk_common/Cargo.toml +++ b/lwk_common/Cargo.toml @@ -24,6 +24,12 @@ serde_json.workspace = true default = ["amp0"] amp0 = [] sqlite = ["rusqlite"] +# Silent Payments (BIP-352) boundary types shared between `lwk_wollet` (the +# wallet/scan side) and `lwk_signer` (the signing side). Living here rather than in +# either crate is what lets a wallet depend on the shared types without depending on +# a signer, and vice versa — see `SilentPaymentScanMaterial`'s docs for why that +# split matters. +silentpayments = [] [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rusqlite = { version = "0.32", optional = true, features = ["bundled"] } diff --git a/lwk_common/src/lib.rs b/lwk_common/src/lib.rs index c0fede67e..40d0a619f 100644 --- a/lwk_common/src/lib.rs +++ b/lwk_common/src/lib.rs @@ -25,6 +25,9 @@ mod pset; mod qr; mod segwit; mod signer; +#[cfg(feature = "silentpayments")] +#[cfg_attr(docsrs, doc(cfg(feature = "silentpayments")))] +pub mod silentpayments; #[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))] pub mod sqlite; mod store; diff --git a/lwk_common/src/silentpayments.rs b/lwk_common/src/silentpayments.rs new file mode 100644 index 000000000..a7a72a446 --- /dev/null +++ b/lwk_common/src/silentpayments.rs @@ -0,0 +1,411 @@ +//! Shared Liquid silent-payment boundary types. + +use elements_miniscript::elements::bitcoin::bip32::{ChildNumber, DerivationPath}; +use elements_miniscript::elements::bitcoin::secp256k1::{PublicKey, Scalar, SecretKey}; +use elements_miniscript::elements::pset::raw::ProprietaryKey; +use elements_miniscript::elements::pset::Input as PsetInput; + +use crate::Signer; + +const PURPOSE: u32 = 352; + +const COIN_TYPE_LIQUID_MAINNET: u32 = 1776; + +const COIN_TYPE_LIQUID_TESTNET: u32 = 1; + +const HARDENED_THRESHOLD: u32 = 0x8000_0000; + +/// Errors for invalid hardened account coordinates. +#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)] +pub enum SilentPaymentAccountError { + /// The coin type has bit 31 set, so `coin_type'` is not a valid BIP-32 index. + #[error("silent payment coin type {0} cannot be hardened (must be < 2^31)")] + CoinTypeNotHardenable(u32), + + /// The account index has bit 31 set, so `account'` is not a valid BIP-32 index. + #[error("silent payment account {0} cannot be hardened (must be < 2^31)")] + AccountNotHardenable(u32), +} + +/// Silent-payment key derivation coordinates. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct SilentPaymentAccount { + coin_type: u32, + account: u32, +} + +impl SilentPaymentAccount { + /// The account for Liquid mainnet at index `account`. + pub fn liquid_mainnet(account: u32) -> Self { + SilentPaymentAccount { + coin_type: COIN_TYPE_LIQUID_MAINNET, + account, + } + } + + /// The account for Liquid testnet/regtest at index `account`. + pub fn liquid_testnet(account: u32) -> Self { + SilentPaymentAccount { + coin_type: COIN_TYPE_LIQUID_TESTNET, + account, + } + } + + /// Builds an account from a coin type and account index. + pub fn from_raw(coin_type: u32, account: u32) -> Result { + if coin_type >= HARDENED_THRESHOLD { + return Err(SilentPaymentAccountError::CoinTypeNotHardenable(coin_type)); + } + if account >= HARDENED_THRESHOLD { + return Err(SilentPaymentAccountError::AccountNotHardenable(account)); + } + Ok(SilentPaymentAccount { coin_type, account }) + } + + /// The SLIP-44 coin type this account uses. + pub fn coin_type(&self) -> u32 { + self.coin_type + } + + /// The account index. + pub fn account(&self) -> u32 { + self.account + } + + /// Scan-key path: `m/352'/'/'/1'/0`. + pub fn scan_path(&self) -> DerivationPath { + self.path_at(1) + } + + /// Spend-key path: `m/352'/'/'/0'/0`. + pub fn spend_path(&self) -> DerivationPath { + self.path_at(0) + } + + fn path_at(&self, change: u32) -> DerivationPath { + DerivationPath::from(vec![ + ChildNumber::from_hardened_idx(PURPOSE).expect("352 < 2^31"), + ChildNumber::from_hardened_idx(self.coin_type).expect("checked < 2^31 at construction"), + ChildNumber::from_hardened_idx(self.account).expect("checked < 2^31 at construction"), + ChildNumber::from_hardened_idx(change).expect("0 or 1"), + ChildNumber::from_normal_idx(0).expect("0 is always a valid normal index"), + ]) + } +} + +/// Scan-only material exported by a signer. +#[derive(Debug, Clone, Copy)] +pub struct SilentPaymentScanMaterial { + account: SilentPaymentAccount, + scan_seckey: SecretKey, + spend_pubkey: PublicKey, +} + +impl SilentPaymentScanMaterial { + /// Which BIP-352 account this material was derived for. + pub fn account(&self) -> SilentPaymentAccount { + self.account + } + + /// `b_scan` — the scan secret, for the ECDH shared secret and label tweaks. + pub fn scan_seckey(&self) -> SecretKey { + self.scan_seckey + } + + /// `B_spend = b_spend·G` — the public base point outputs are tweaked from. + pub fn spend_pubkey(&self) -> PublicKey { + self.spend_pubkey + } + + /// Scan public key. + pub fn scan_pubkey( + &self, + secp: &elements_miniscript::elements::bitcoin::secp256k1::Secp256k1, + ) -> PublicKey { + self.scan_seckey.public_key(secp) + } + /// Assemble scan material for `account`. + pub fn new( + account: SilentPaymentAccount, + scan_seckey: SecretKey, + spend_pubkey: PublicKey, + ) -> Self { + SilentPaymentScanMaterial { + account, + scan_seckey, + spend_pubkey, + } + } + + /// PSET metadata for an output's spend tweak. + pub fn input_meta(&self, spend_tweak: Scalar) -> SilentPaymentInputMeta { + SilentPaymentInputMeta { + account: self.account, + spend_tweak, + expected_spend_pubkey: self.spend_pubkey, + } + } +} + +/// Silent-payment PSET metadata. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SilentPaymentInputMeta { + account: SilentPaymentAccount, + spend_tweak: Scalar, + expected_spend_pubkey: PublicKey, +} + +/// Silent-payment operations offered by a signer. +pub trait SilentPaymentSigner: Signer { + /// Export scan material for `account`. + fn silent_payment_scan_material( + &self, + account: SilentPaymentAccount, + ) -> Result; +} + +/// Errors reading silent-payment PSET metadata. +#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)] +pub enum SilentPaymentPsetMetaError { + /// The proprietary key/value for silent payments was not present on this input. + #[error("input carries no silent payment metadata")] + Missing, + + /// The value was present but not the expected byte layout. + #[error("silent payment metadata is malformed")] + Malformed, + + /// The blob parsed, but named account coordinates that cannot be derived. + #[error("silent payment metadata names an underivable account: {0}")] + Account(#[from] SilentPaymentAccountError), +} + +impl SilentPaymentInputMeta { + /// Which account's `b_spend` this input's tweak is relative to. + pub fn account(&self) -> SilentPaymentAccount { + self.account + } + + /// The scalar that turns the account's `b_spend` into this output's spend key. + pub fn spend_tweak(&self) -> Scalar { + self.spend_tweak + } + + /// The `B_spend` the wallet says it derived this tweak from. + pub fn expected_spend_pubkey(&self) -> PublicKey { + self.expected_spend_pubkey + } + + /// Proprietary-key prefix for silent-payment metadata. + const PROPRIETARY_PREFIX: &'static [u8] = b"lwk_sp"; + + /// Proprietary-key subtype for this input metadata blob. + const SUBTYPE: u8 = 0x01; + + /// Encodes `coin_type || account || spend_tweak || expected_spend_pubkey`. + fn to_bytes(self) -> Vec { + let mut out = Vec::with_capacity(4 + 4 + 32 + 33); + out.extend_from_slice(&self.account.coin_type.to_le_bytes()); + out.extend_from_slice(&self.account.account.to_le_bytes()); + out.extend_from_slice(&self.spend_tweak.to_be_bytes()); + out.extend_from_slice(&self.expected_spend_pubkey.serialize()); + out + } + + fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() != 4 + 4 + 32 + 33 { + return Err(SilentPaymentPsetMetaError::Malformed); + } + let coin_type = u32::from_le_bytes(bytes[0..4].try_into().expect("checked len")); + let account = u32::from_le_bytes(bytes[4..8].try_into().expect("checked len")); + let spend_tweak = Scalar::from_be_bytes(bytes[8..40].try_into().expect("checked len")) + .map_err(|_| SilentPaymentPsetMetaError::Malformed)?; + let expected_spend_pubkey = PublicKey::from_slice(&bytes[40..73]) + .map_err(|_| SilentPaymentPsetMetaError::Malformed)?; + Ok(SilentPaymentInputMeta { + account: SilentPaymentAccount::from_raw(coin_type, account)?, + spend_tweak, + expected_spend_pubkey, + }) + } + + /// Builds the proprietary key without using the reserved `pset` prefix. + fn proprietary_key() -> ProprietaryKey { + ProprietaryKey { + prefix: Self::PROPRIETARY_PREFIX.to_vec(), + subtype: Self::SUBTYPE, + key: vec![], + } + } + + /// Attach this metadata to a PSET input. + /// + /// Overwrites any silent-payment metadata already present on the input. + pub fn attach(self, input: &mut PsetInput) { + input + .proprietary + .insert(Self::proprietary_key(), self.to_bytes()); + } + + /// Read silent-payment metadata back out of a PSET input, if present. + pub fn read(input: &PsetInput) -> Result { + let bytes = input + .proprietary + .get(&Self::proprietary_key()) + .ok_or(SilentPaymentPsetMetaError::Missing)?; + Self::from_bytes(bytes) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use elements_miniscript::elements::pset::PartiallySignedTransaction; + use std::str::FromStr; + + fn sk(byte: u8) -> SecretKey { + SecretKey::from_slice(&[byte; 32]).unwrap() + } + + #[test] + fn account_paths_follow_elip_convention() { + let account = SilentPaymentAccount::liquid_mainnet(0); + assert_eq!( + account.scan_path(), + DerivationPath::from_str("m/352'/1776'/0'/1'/0").unwrap() + ); + assert_eq!( + account.spend_path(), + DerivationPath::from_str("m/352'/1776'/0'/0'/0").unwrap() + ); + + let testnet = SilentPaymentAccount::liquid_testnet(3); + assert_eq!( + testnet.scan_path(), + DerivationPath::from_str("m/352'/1'/3'/1'/0").unwrap() + ); + assert_eq!( + testnet.spend_path(), + DerivationPath::from_str("m/352'/1'/3'/0'/0").unwrap() + ); + } + + #[test] + fn un_hardenable_account_coordinates_are_refused() { + for bad in [HARDENED_THRESHOLD, HARDENED_THRESHOLD + 1, u32::MAX] { + assert_eq!( + SilentPaymentAccount::from_raw(bad, 0), + Err(SilentPaymentAccountError::CoinTypeNotHardenable(bad)) + ); + assert_eq!( + SilentPaymentAccount::from_raw(1, bad), + Err(SilentPaymentAccountError::AccountNotHardenable(bad)) + ); + } + + let edge = SilentPaymentAccount::from_raw(HARDENED_THRESHOLD - 1, HARDENED_THRESHOLD - 1) + .expect("2^31 - 1 is a valid hardened index"); + let _ = edge.scan_path(); + let _ = edge.spend_path(); + } + + #[test] + fn a_crafted_pset_naming_an_underivable_account_errors_instead_of_panicking() { + let secp = elements_miniscript::elements::secp256k1_zkp::Secp256k1::new(); + let honest = SilentPaymentInputMeta { + account: SilentPaymentAccount::liquid_testnet(0), + spend_tweak: Scalar::from_be_bytes(sk(0x11).secret_bytes()).unwrap(), + expected_spend_pubkey: sk(0x22).public_key(&secp), + }; + let mut input = PsetInput::default(); + honest.attach(&mut input); + + let key = SilentPaymentInputMeta::proprietary_key(); + for (offset, expected) in [ + ( + 0, + SilentPaymentAccountError::CoinTypeNotHardenable(u32::MAX), + ), + (4, SilentPaymentAccountError::AccountNotHardenable(u32::MAX)), + ] { + let mut bytes = honest.to_bytes(); + bytes[offset..offset + 4].copy_from_slice(&u32::MAX.to_le_bytes()); + input.proprietary.insert(key.clone(), bytes); + + assert_eq!( + SilentPaymentInputMeta::read(&input), + Err(SilentPaymentPsetMetaError::Account(expected)), + "crafted coordinates at offset {offset} must be rejected, not derived" + ); + } + } + + #[test] + fn input_meta_roundtrips_through_a_pset_input() { + let secp = elements_miniscript::elements::secp256k1_zkp::Secp256k1::new(); + let meta = SilentPaymentInputMeta { + account: SilentPaymentAccount::liquid_mainnet(1), + spend_tweak: Scalar::from_be_bytes(sk(0x42).secret_bytes()).unwrap(), + expected_spend_pubkey: sk(0x24).public_key(&secp), + }; + + let mut input = PsetInput::default(); + assert_eq!( + SilentPaymentInputMeta::read(&input), + Err(SilentPaymentPsetMetaError::Missing) + ); + + meta.attach(&mut input); + assert_eq!(SilentPaymentInputMeta::read(&input), Ok(meta)); + } + + #[test] + fn metadata_survives_a_pset_serialization_roundtrip() { + use elements_miniscript::elements::encode::{deserialize, serialize}; + use elements_miniscript::elements::OutPoint; + + let secp = elements_miniscript::elements::secp256k1_zkp::Secp256k1::new(); + let meta = SilentPaymentInputMeta { + account: SilentPaymentAccount::liquid_testnet(2), + spend_tweak: Scalar::from_be_bytes(sk(0x42).secret_bytes()).unwrap(), + expected_spend_pubkey: sk(0x24).public_key(&secp), + }; + + let mut pset = PartiallySignedTransaction::new_v2(); + let mut input = PsetInput::from_prevout(OutPoint::default()); + meta.attach(&mut input); + pset.add_input(input); + + let bytes = serialize(&pset); + let decoded: PartiallySignedTransaction = + deserialize(&bytes).expect("a PSET carrying SP metadata must deserialize"); + + assert_eq!( + SilentPaymentInputMeta::read(&decoded.inputs()[0]), + Ok(meta), + "metadata must survive the round trip byte-for-byte" + ); + } + + #[test] + fn proprietary_key_uses_our_own_namespace() { + let key = SilentPaymentInputMeta::proprietary_key(); + assert_eq!(key.prefix, SilentPaymentInputMeta::PROPRIETARY_PREFIX); + assert!( + !key.is_pset_key(), + "must not claim the reserved `pset` namespace" + ); + } + + #[test] + fn malformed_metadata_is_reported_not_panicked() { + let mut input = PsetInput::default(); + input + .proprietary + .insert(SilentPaymentInputMeta::proprietary_key(), vec![0u8; 3]); + assert_eq!( + SilentPaymentInputMeta::read(&input), + Err(SilentPaymentPsetMetaError::Malformed) + ); + } +} From fcc52849106e6f99170d1b7dcf94afd604c93e7a Mon Sep 17 00:00:00 2001 From: 42Pupusas Date: Fri, 31 Jul 2026 15:55:26 -0600 Subject: [PATCH 02/12] signer: read the genesis hash from the PSET in sign_with_seckey It was hardcoded to all-zeros. Correct by accident today: this function only reaches BIP-143 v0 sighashes, which never commit to the genesis hash. The regression test pins both facts so a future taproot path cannot silently inherit the wrong constant. --- lwk_signer/src/software.rs | 80 +++++++++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/lwk_signer/src/software.rs b/lwk_signer/src/software.rs index 212ec8b4e..c8e2896d3 100644 --- a/lwk_signer/src/software.rs +++ b/lwk_signer/src/software.rs @@ -467,7 +467,8 @@ pub fn sign_with_seckey( let tx = pset.extract_tx()?; let mut sighash_cache = SighashCache::new(&tx); let mut signature_added = 0; - let genesis_hash = elements_miniscript::elements::BlockHash::all_zeros(); + // Read the genesis hash from the PSET for ELIP-101 compatibility. + let genesis_hash = get_genesis_hash(pset); let mut messages = vec![]; for i in 0..pset.inputs().len() { let msg = pset @@ -585,6 +586,83 @@ mod tests { // bitcoin-cli verifymessage "1BZ9j3F7m4H1RPyeDp5iFwpR31SB6zrs19" "Hwlg40qLYZXEj9AoA3oZpfJMJPxaXzBL0+siHAJRhTIvSFiwSdtCsqxqB7TxgWfhqIr/YnGE4nagWzPchFJElTo=" 'Hello, world!' } + /// ECDSA signing reads the genesis hash from the PSET. + #[test] + fn sign_with_seckey_uses_the_psets_genesis_hash() { + use elements_miniscript::elements::pset::{Input, Output, PsbtSighashType}; + use elements_miniscript::elements::{ + confidential::{Asset, Value}, + AssetId, OutPoint, Script, TxOut, Txid, + }; + + let secp = Secp256k1::new(); + let seckey = bitcoin::secp256k1::SecretKey::from_slice(&[0x42; 32]).unwrap(); + let pk = bitcoin::key::PublicKey::new(seckey.public_key(&secp)); + + let build = |network: lwk_common::Network| { + let wpkh = elements_miniscript::elements::WPubkeyHash::from_slice( + pk.wpubkey_hash().unwrap().as_byte_array(), + ) + .unwrap(); + let txout = TxOut { + asset: Asset::Explicit(AssetId::from_slice(&[0x42; 32]).unwrap()), + value: Value::Explicit(100_000), + nonce: Default::default(), + script_pubkey: Script::new_v0_wpkh(&wpkh), + witness: Default::default(), + }; + + let mut input = + Input::from_prevout(OutPoint::new(Txid::from_slice(&[0x99; 32]).unwrap(), 0)); + input.witness_utxo = Some(txout.clone()); + input.sighash_type = Some(PsbtSighashType::from_u32(EcdsaSighashType::All as u32)); + input + .bip32_derivation + .insert(pk, (Fingerprint::default(), DerivationPath::master())); + + let mut pset = PartiallySignedTransaction::new_v2(); + lwk_common::set_genesis_hash(&mut pset, &network); + pset.add_input(input); + pset.add_output(Output::from_txout(TxOut { + script_pubkey: Script::new(), + ..txout + })); + pset + }; + + let mut pset = build(lwk_common::Network::TestnetLiquid); + assert_eq!(sign_with_seckey(seckey, &mut pset).unwrap(), 1); + + let genesis_hash = get_genesis_hash(&pset); + assert_ne!( + genesis_hash, + elements_miniscript::elements::BlockHash::all_zeros(), + "fixture must carry a real genesis hash, or this proves nothing" + ); + let tx = pset.extract_tx().unwrap(); + let mut cache = SighashCache::new(&tx); + let msg = pset + .sighash_msg(0, &mut cache, None, genesis_hash) + .unwrap() + .to_secp_msg(); + + let stored = pset.inputs()[0].partial_sigs.get(&pk).unwrap().clone(); + let (_, der) = stored.split_last().unwrap(); + let sig = bitcoin::secp256k1::ecdsa::Signature::from_der(der).unwrap(); + secp.verify_ecdsa(&msg, &sig, &seckey.public_key(&secp)) + .expect("signature must verify under the PSET's own genesis hash"); + + // BIP-143 does not commit to the genesis hash. + let mut mainnet = build(lwk_common::Network::Liquid); + assert_eq!(sign_with_seckey(seckey, &mut mainnet).unwrap(), 1); + assert_eq!( + pset.inputs()[0].partial_sigs.get(&pk).unwrap(), + mainnet.inputs()[0].partial_sigs.get(&pk).unwrap(), + "BIP-143 v0 sighashes do not commit to the genesis hash; if these now differ, \ + a taproot signing path was added and needs its own genesis-hash coverage" + ); + } + #[test] fn test_bip85_mnemonic_derivation() { // Test with a known mnemonic From a2eb0e32432e4e5003802d8555cf6cd9e127fac3 Mon Sep 17 00:00:00 2001 From: 42Pupusas Date: Fri, 31 Jul 2026 15:55:49 -0600 Subject: [PATCH 03/12] signer: sign silent payment inputs in SwSigner A new silentpayments feature adds SilentPaymentSigner (scan-material export that never returns b_spend) and a silent-payment phase inside the ordinary Signer::sign: it reads untrusted SilentPaymentInputMeta off each PSET input, verifies the named account and that the tweaked key reproduces the Taproot output being spent, and only then signs. Hardware signers refuse loudly (no such protocol operation exists yet). --- lwk_signer/Cargo.toml | 2 + lwk_signer/src/lib.rs | 43 +++ lwk_signer/src/silentpayments.rs | 490 +++++++++++++++++++++++++++++++ lwk_signer/src/software.rs | 35 +++ 4 files changed, 570 insertions(+) create mode 100644 lwk_signer/src/silentpayments.rs diff --git a/lwk_signer/Cargo.toml b/lwk_signer/Cargo.toml index 447712231..e7890fe30 100644 --- a/lwk_signer/Cargo.toml +++ b/lwk_signer/Cargo.toml @@ -24,6 +24,8 @@ default = ["jade", "amp0"] jade = ["lwk_jade"] ledger = ["lwk_ledger"] amp0 = [] +# BIP-352 silent-payment signing support and its optional signer errors. +silentpayments = ["lwk_common/silentpayments"] [package.metadata.docs.rs] all-features = true diff --git a/lwk_signer/src/lib.rs b/lwk_signer/src/lib.rs index 76c38a4dd..7bda91478 100644 --- a/lwk_signer/src/lib.rs +++ b/lwk_signer/src/lib.rs @@ -8,7 +8,14 @@ mod software; pub use crate::software::{sign_with_seckey, NewError, SignError, SwSigner}; +#[cfg(feature = "silentpayments")] +mod silentpayments; + pub use bip39; +#[cfg(feature = "silentpayments")] +use lwk_common::silentpayments::{ + SilentPaymentAccount, SilentPaymentScanMaterial, SilentPaymentSigner, +}; use elements_miniscript::bitcoin::bip32::{self, DerivationPath, Fingerprint}; use elements_miniscript::bitcoin::sign_message::MessageSignature; @@ -33,6 +40,17 @@ pub enum SignerError { #[error(transparent)] Bip32Error(#[from] bip32::Error), + + /// A hardware signer was asked for a silent-payment operation. + /// + /// This is a protocol gap, not an oversight: BIP-352 spending needs the device + /// to combine its `b_spend` with a host-supplied tweak, and neither the Jade nor + /// the Ledger protocol exposes such an operation today. Refusing loudly is the + /// only honest answer — the alternative (deriving the key on the host) would + /// defeat the entire point of using a hardware signer. + #[cfg(feature = "silentpayments")] + #[error("This signer does not support silent payments")] + UnsupportedSilentPayments, } /// A signer that can be a software signer [`SwSigner`] or a [`lwk_jade::Jade`] @@ -83,6 +101,31 @@ impl Signer for AnySigner { } } +/// Dispatches silent-payment scan-material export to the only signer that supports it. +/// +/// Implemented on `AnySigner` rather than folded into [`Signer`] so signers with no +/// silent-payment support carry no dead state. Signing itself uses the single +/// [`Signer::sign`] operation; software signers recognize SP metadata there, while +/// hardware signers currently leave those unsupported inputs unsigned. +#[cfg(feature = "silentpayments")] +#[cfg_attr(docsrs, doc(cfg(feature = "silentpayments")))] +impl SilentPaymentSigner for AnySigner { + fn silent_payment_scan_material( + &self, + account: SilentPaymentAccount, + ) -> Result { + match self { + AnySigner::Software(s) => Ok(s.silent_payment_scan_material(account)?), + + #[cfg(feature = "jade")] + AnySigner::Jade(_, _) => Err(SignerError::UnsupportedSilentPayments), + + #[cfg(feature = "ledger")] + AnySigner::Ledger(_, _) => Err(SignerError::UnsupportedSilentPayments), + } + } +} + impl Signer for &AnySigner { type Error = SignerError; diff --git a/lwk_signer/src/silentpayments.rs b/lwk_signer/src/silentpayments.rs new file mode 100644 index 000000000..d289ca07c --- /dev/null +++ b/lwk_signer/src/silentpayments.rs @@ -0,0 +1,490 @@ +//! BIP-352 signing support for [`SwSigner`]. + +use elements_miniscript::elements::pset::PartiallySignedTransaction; +use elements_miniscript::elements::schnorr::TweakedPublicKey; +use elements_miniscript::elements::secp256k1_zkp::{ + Keypair, Message, Secp256k1, SecretKey as ZkpSecretKey, +}; +use elements_miniscript::elements::sighash::{Prevouts, SighashCache}; +use elements_miniscript::elements::{SchnorrSighashType, Script, TxOut}; +use lwk_common::get_genesis_hash; +use lwk_common::silentpayments::{ + SilentPaymentAccount, SilentPaymentInputMeta, SilentPaymentPsetMetaError, + SilentPaymentScanMaterial, SilentPaymentSigner, +}; + +use crate::software::{SignError, SwSigner}; + +impl SilentPaymentSigner for SwSigner { + fn silent_payment_scan_material( + &self, + account: SilentPaymentAccount, + ) -> Result { + let secp = Secp256k1::new(); + + let scan_seckey = self.derive_xprv(&account.scan_path())?.private_key; + + let b_spend = self.derive_xprv(&account.spend_path())?.private_key; + let spend_pubkey = b_spend.public_key(&secp); + + Ok(SilentPaymentScanMaterial::new( + account, + scan_seckey, + spend_pubkey, + )) + } +} + +impl SwSigner { + /// Sign the silent-payment inputs recognized by the ordinary signer entry point. + pub(crate) fn sign_silent_payment_inputs( + &self, + pset: &mut PartiallySignedTransaction, + ) -> Result { + SilentPaymentPsetSigner::new(self).sign(pset) + } +} + +/// Verifies and signs silent-payment inputs in a PSET. +struct SilentPaymentPsetSigner<'a> { + signer: &'a SwSigner, + secp: Secp256k1, +} + +impl<'a> SilentPaymentPsetSigner<'a> { + fn new(signer: &'a SwSigner) -> Self { + SilentPaymentPsetSigner { + signer, + secp: Secp256k1::new(), + } + } + + /// Signs verified silent-payment inputs and returns the number signed. + fn sign(&self, pset: &mut PartiallySignedTransaction) -> Result { + if !pset.inputs().iter().any(|i| { + !matches!( + SilentPaymentInputMeta::read(i), + Err(SilentPaymentPsetMetaError::Missing) + ) + }) { + return Ok(0); + } + + let prevouts = self.prevouts(pset)?; + let tx = pset.extract_tx()?; + let genesis_hash = get_genesis_hash(pset); + let mut sighash_cache = SighashCache::new(&tx); + + let mut signatures: Vec> = Vec::new(); + for (index, input) in pset.inputs().iter().enumerate() { + let meta = match SilentPaymentInputMeta::read(input) { + Ok(meta) => meta, + Err(SilentPaymentPsetMetaError::Missing) => { + signatures.push(None); + continue; + } + Err(e) => return Err(e.into()), + }; + + if input.tap_key_sig.is_some() { + signatures.push(None); + continue; + } + + let keypair = self.verified_keypair(&meta, &prevouts[index])?; + let hash_ty = input + .sighash_type + .and_then(|h| h.schnorr_hash_ty()) + .unwrap_or(SchnorrSighashType::Default); + let sighash = sighash_cache.taproot_key_spend_signature_hash( + index, + &Prevouts::All(&prevouts), + hash_ty, + genesis_hash, + )?; + let msg = Message::from_digest_slice(sighash.as_ref())?; + signatures.push(Some((keypair, msg, hash_ty))); + } + + let mut added = 0; + for (input, signature) in pset.inputs_mut().iter_mut().zip(signatures) { + let Some((keypair, msg, hash_ty)) = signature else { + continue; + }; + let sig = self.secp.sign_schnorr_no_aux_rand(&msg, &keypair); + input.tap_key_sig = + Some(elements_miniscript::elements::schnorr::SchnorrSig { sig, hash_ty }); + added += 1; + } + + Ok(added) + } + + /// Returns all witness prevouts required for Taproot sighashing. + fn prevouts(&self, pset: &PartiallySignedTransaction) -> Result, SignError> { + pset.inputs() + .iter() + .map(|i| i.witness_utxo.clone().ok_or(SignError::MissingWitnessUtxo)) + .collect() + } + + /// Verifies metadata and derives the temporary signing key. + fn verified_keypair( + &self, + meta: &SilentPaymentInputMeta, + prevout: &TxOut, + ) -> Result { + let b_spend = self + .signer + .derive_xprv(&meta.account().spend_path())? + .private_key; + if b_spend.public_key(&self.secp) != meta.expected_spend_pubkey() { + return Err(SignError::SilentPaymentSpendPubkeyMismatch); + } + + let d = b_spend + .add_tweak(&meta.spend_tweak()) + .map_err(|_| SignError::InvalidTweak)?; + let d_zkp = + ZkpSecretKey::from_slice(&d.secret_bytes()).map_err(|_| SignError::InvalidTweak)?; + let keypair = Keypair::from_secret_key(&self.secp, &d_zkp); + + let (x_only, _parity) = keypair.x_only_public_key(); + let expected_script = Script::new_v1_p2tr_tweaked(TweakedPublicKey::new(x_only)); + if prevout.script_pubkey != expected_script { + return Err(SignError::SilentPaymentOutputMismatch); + } + + Ok(keypair) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use elements_miniscript::bitcoin; + use elements_miniscript::elements::hashes::Hash; + use lwk_common::Signer; + + #[test] + fn scan_material_derivation() { + let signer = SwSigner::new(lwk_test_util::TEST_MNEMONIC, false).unwrap(); + let secp = Secp256k1::new(); + + let account = SilentPaymentAccount::liquid_testnet(0); + let material = signer.silent_payment_scan_material(account).unwrap(); + let b_scan = signer + .derive_xprv(&account.scan_path()) + .unwrap() + .private_key; + let b_spend = signer + .derive_xprv(&account.spend_path()) + .unwrap() + .private_key; + assert_eq!(material.scan_seckey(), b_scan); + assert_eq!(material.spend_pubkey(), b_spend.public_key(&secp)); + + let disjoint = [ + ( + "accounts differing by index", + SilentPaymentAccount::liquid_testnet(0), + SilentPaymentAccount::liquid_testnet(1), + ), + ( + "one index across mainnet and testnet", + SilentPaymentAccount::liquid_mainnet(0), + SilentPaymentAccount::liquid_testnet(0), + ), + ]; + for (why, left, right) in disjoint { + let a = signer.silent_payment_scan_material(left).unwrap(); + let b = signer.silent_payment_scan_material(right).unwrap(); + assert_ne!( + a.scan_seckey(), + b.scan_seckey(), + "{why} must not share a scan key" + ); + assert_ne!( + a.spend_pubkey(), + b.spend_pubkey(), + "{why} must not share a spend key" + ); + } + } + + /// Builds PSETs containing silent-payment metadata for signer tests. + struct SpPsetFixture { + signer: SwSigner, + account: SilentPaymentAccount, + tweak: bitcoin::secp256k1::Scalar, + } + + impl SpPsetFixture { + fn new() -> Self { + SpPsetFixture { + signer: SwSigner::new(lwk_test_util::TEST_MNEMONIC, false).unwrap(), + account: SilentPaymentAccount::liquid_testnet(0), + tweak: bitcoin::secp256k1::Scalar::from_be_bytes([0x37; 32]).unwrap(), + } + } + + fn spend_pubkey(&self) -> bitcoin::secp256k1::PublicKey { + self.signer + .silent_payment_scan_material(self.account) + .unwrap() + .spend_pubkey() + } + + /// The scriptPubKey of the output that `b_spend + tweak` actually controls: + /// a bare v1 P2TR of `x_only(B_spend + tweak·G)`, no script tree, no BIP-341 + /// taptweak — BIP-352's output convention. + fn output_script(&self, tweak: &bitcoin::secp256k1::Scalar) -> Script { + let secp = Secp256k1::new(); + let b_spend = self + .signer + .derive_xprv(&self.account.spend_path()) + .unwrap() + .private_key; + let d = b_spend.add_tweak(tweak).unwrap(); + let d_zkp = ZkpSecretKey::from_slice(&d.secret_bytes()).unwrap(); + let (x_only, _) = Keypair::from_secret_key(&secp, &d_zkp).x_only_public_key(); + Script::new_v1_p2tr_tweaked(TweakedPublicKey::new(x_only)) + } + + fn txout(&self, script_pubkey: Script) -> TxOut { + use elements_miniscript::elements::confidential::{Asset, Value}; + use elements_miniscript::elements::AssetId; + TxOut { + asset: Asset::Explicit(AssetId::from_slice(&[0x42; 32]).unwrap()), + value: Value::Explicit(100_000), + nonce: Default::default(), + script_pubkey, + witness: Default::default(), + } + } + + /// A PSET with one silent-payment input spending the output the metadata + /// describes. + fn pset(&self, meta: SilentPaymentInputMeta) -> PartiallySignedTransaction { + self.pset_spending(meta, self.output_script(&self.tweak)) + } + + /// As [`Self::pset`], but the coin actually being spent is `spent_script`. + fn pset_spending( + &self, + meta: SilentPaymentInputMeta, + spent_script: Script, + ) -> PartiallySignedTransaction { + use elements_miniscript::elements::pset::{Input, Output, PsbtSighashType}; + use elements_miniscript::elements::{OutPoint, Txid}; + + let outpoint = OutPoint::new(Txid::from_slice(&[0x99; 32]).unwrap(), 0); + let mut input = Input::from_prevout(outpoint); + input.witness_utxo = Some(self.txout(spent_script)); + input.sighash_type = Some(PsbtSighashType::from_u32(0)); + meta.attach(&mut input); + + let mut pset = PartiallySignedTransaction::new_v2(); + // A real genesis hash, as `TxBuilder` writes (ELIP-101). + lwk_common::set_genesis_hash(&mut pset, &lwk_common::Network::TestnetLiquid); + pset.add_input(input); + pset.add_output(Output::from_txout(self.txout(Script::new()))); + pset + } + + fn valid_meta(&self) -> SilentPaymentInputMeta { + self.meta_with(self.account, self.tweak, self.spend_pubkey()) + } + + /// Builds metadata from an account, tweak, and public spend key. + fn meta_with( + &self, + account: SilentPaymentAccount, + spend_tweak: bitcoin::secp256k1::Scalar, + spend_pubkey: bitcoin::secp256k1::PublicKey, + ) -> SilentPaymentInputMeta { + let dummy_scan = bitcoin::secp256k1::SecretKey::from_slice(&[0x11; 32]).unwrap(); + SilentPaymentScanMaterial::new(account, dummy_scan, spend_pubkey) + .input_meta(spend_tweak) + } + } + + #[test] + fn honest_metadata_is_signed_exactly_once() { + let f = SpPsetFixture::new(); + + let mut untouched = PartiallySignedTransaction::new_v2(); + assert_eq!( + f.signer.sign(&mut untouched).unwrap(), + 0, + "a PSET without silent payment metadata must be left alone" + ); + + let mut pset = f.pset(f.valid_meta()); + assert_eq!(f.signer.sign(&mut pset).unwrap(), 1); + let first = pset.inputs()[0].tap_key_sig; + assert!(first.is_some()); + + assert_eq!( + f.signer.sign(&mut pset).unwrap(), + 0, + "an already-signed input must not be re-signed" + ); + assert_eq!(pset.inputs()[0].tap_key_sig, first); + } + + /// Elements Taproot sighashes commit to the chain genesis hash (ELIP-101), which + /// the signer must read from the PSET rather than assume. + #[test] + fn signature_commits_to_the_psets_genesis_hash() { + use elements_miniscript::elements::sighash::Prevouts; + + let f = SpPsetFixture::new(); + + let mut liquid = f.pset(f.valid_meta()); + lwk_common::set_genesis_hash(&mut liquid, &lwk_common::Network::Liquid); + let mut testnet = f.pset(f.valid_meta()); + lwk_common::set_genesis_hash(&mut testnet, &lwk_common::Network::TestnetLiquid); + + assert_eq!(f.signer.sign(&mut liquid).unwrap(), 1); + assert_eq!(f.signer.sign(&mut testnet).unwrap(), 1); + + let liquid_sig = liquid.inputs()[0].tap_key_sig.unwrap().sig; + let testnet_sig = testnet.inputs()[0].tap_key_sig.unwrap().sig; + assert_ne!( + liquid_sig, testnet_sig, + "same transaction on two chains must not produce the same signature; \ + if it does, the genesis hash is not reaching the sighash" + ); + + let secp = Secp256k1::verification_only(); + let output_key = { + let b_spend = f + .signer + .derive_xprv(&f.account.spend_path()) + .unwrap() + .private_key; + let d = b_spend.add_tweak(&f.tweak).unwrap(); + let d_zkp = ZkpSecretKey::from_slice(&d.secret_bytes()).unwrap(); + Keypair::from_secret_key(&Secp256k1::new(), &d_zkp) + .x_only_public_key() + .0 + }; + let tx = liquid.clone().extract_tx().unwrap(); + let prevouts = [liquid.inputs()[0].witness_utxo.clone().unwrap()]; + let sighash = SighashCache::new(&tx) + .taproot_key_spend_signature_hash( + 0, + &Prevouts::All(&prevouts), + SchnorrSighashType::Default, + lwk_common::Network::Liquid.genesis_hash(), + ) + .unwrap(); + let msg = Message::from_digest_slice(sighash.as_ref()).unwrap(); + assert!( + secp.verify_schnorr(&liquid_sig, &msg, &output_key).is_ok(), + "signature must verify under the genesis hash the PSET actually carries" + ); + } + + enum Tampering { + WrongAccount, + WrongSpendPubkey, + WrongTweak, + ForeignSpentScript, + MissingWitnessUtxo, + MalformedMeta, + } + + impl Tampering { + fn apply(&self, f: &SpPsetFixture) -> (PartiallySignedTransaction, SignError) { + match self { + Tampering::WrongAccount => { + let meta = f.meta_with( + SilentPaymentAccount::liquid_testnet(7), + f.tweak, + f.spend_pubkey(), + ); + (f.pset(meta), SignError::SilentPaymentSpendPubkeyMismatch) + } + Tampering::WrongSpendPubkey => { + let stranger = bitcoin::secp256k1::SecretKey::from_slice(&[0x05; 32]) + .unwrap() + .public_key(&Secp256k1::new()); + let meta = f.meta_with(f.account, f.tweak, stranger); + (f.pset(meta), SignError::SilentPaymentSpendPubkeyMismatch) + } + Tampering::WrongTweak => { + let meta = f.meta_with( + f.account, + bitcoin::secp256k1::Scalar::from_be_bytes([0x51; 32]).unwrap(), + f.spend_pubkey(), + ); + // The coin spent is still the one the original tweak controls. + ( + f.pset_spending(meta, f.output_script(&f.tweak)), + SignError::SilentPaymentOutputMismatch, + ) + } + Tampering::ForeignSpentScript => ( + f.pset_spending(f.valid_meta(), Script::from(vec![0x00, 0x14, 0xAB])), + SignError::SilentPaymentOutputMismatch, + ), + Tampering::MissingWitnessUtxo => { + let mut pset = f.pset(f.valid_meta()); + pset.inputs_mut()[0].witness_utxo = None; + (pset, SignError::MissingWitnessUtxo) + } + Tampering::MalformedMeta => { + let mut pset = f.pset(f.valid_meta()); + let key = pset.inputs()[0] + .proprietary + .keys() + .next() + .expect("metadata was attached") + .clone(); + pset.inputs_mut()[0].proprietary.insert(key, vec![0xFF; 5]); + ( + pset, + SignError::SilentPaymentMeta( + lwk_common::silentpayments::SilentPaymentPsetMetaError::Malformed, + ), + ) + } + } + } + } + + #[test] + fn tampered_metadata_is_refused_and_left_unsigned() { + let f = SpPsetFixture::new(); + let cases = [ + Tampering::WrongAccount, + Tampering::WrongSpendPubkey, + Tampering::WrongTweak, + Tampering::ForeignSpentScript, + Tampering::MissingWitnessUtxo, + Tampering::MalformedMeta, + ]; + + for case in &cases { + let (mut pset, expected) = case.apply(&f); + let err = f + .signer + .sign(&mut pset) + .expect_err("tampered metadata must not be signed"); + + // By variant: SignError is not PartialEq, and the exact payload of the + // metadata error is pinned by its own tests in lwk_common. + assert_eq!( + std::mem::discriminant(&err), + std::mem::discriminant(&expected), + "expected {expected:?}, got {err:?}" + ); + assert!( + pset.inputs()[0].tap_key_sig.is_none(), + "a refused input must be left unsigned" + ); + } + } +} diff --git a/lwk_signer/src/software.rs b/lwk_signer/src/software.rs index c8e2896d3..c2ac96fc4 100644 --- a/lwk_signer/src/software.rs +++ b/lwk_signer/src/software.rs @@ -53,6 +53,36 @@ pub enum SignError { #[error("BIP85 derivation failed: {0}")] Bip85Derivation(String), + + /// Errors specific to silent-payment signing. + #[cfg(feature = "silentpayments")] + #[error("Invalid tweak: tweaked key is out of range (e.g. sums to zero)")] + InvalidTweak, + + #[cfg(feature = "silentpayments")] + #[error("Taproot key-spend sighash requires every input's witness_utxo")] + MissingWitnessUtxo, + + #[cfg(feature = "silentpayments")] + #[error(transparent)] + TaprootSighash(#[from] elements_miniscript::elements::sighash::Error), + + #[cfg(feature = "silentpayments")] + #[error(transparent)] + Secp256k1(#[from] elements_miniscript::bitcoin::secp256k1::Error), + + /// Invalid untrusted silent-payment PSET metadata. + #[cfg(feature = "silentpayments")] + #[error(transparent)] + SilentPaymentMeta(#[from] lwk_common::silentpayments::SilentPaymentPsetMetaError), + + #[cfg(feature = "silentpayments")] + #[error("Silent payment input names an account whose B_spend this signer does not derive")] + SilentPaymentSpendPubkeyMismatch, + + #[cfg(feature = "silentpayments")] + #[error("Silent payment tweak does not produce the Taproot output being spent")] + SilentPaymentOutputMismatch, } /// Possible errors when creating a new software signer [`SwSigner`] @@ -417,6 +447,11 @@ impl Signer for SwSigner { } } + #[cfg(feature = "silentpayments")] + { + signature_added += self.sign_silent_payment_inputs(pset)?; + } + Ok(signature_added) } From 932c4a34be8ab7b5f5d25a8447687ab83f2d2951 Mon Sep 17 00:00:00 2001 From: 42Pupusas Date: Fri, 31 Jul 2026 15:56:05 -0600 Subject: [PATCH 04/12] test-util: add deterministic Elements test data Shared fixtures for building deterministic transactions, outpoints and secret keys, used by the silent payment unit tests in lwk_wollet. --- lwk_test_util/src/elements_test_data.rs | 93 +++++++++++++++++++++++++ lwk_test_util/src/lib.rs | 2 + 2 files changed, 95 insertions(+) create mode 100644 lwk_test_util/src/elements_test_data.rs diff --git a/lwk_test_util/src/elements_test_data.rs b/lwk_test_util/src/elements_test_data.rs new file mode 100644 index 000000000..8ba670c57 --- /dev/null +++ b/lwk_test_util/src/elements_test_data.rs @@ -0,0 +1,93 @@ +use elements_miniscript::elements::bitcoin::hashes::{hash160, Hash as _}; +use elements_miniscript::elements::bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; +use elements_miniscript::elements::{ + OutPoint, Script, Sequence, TxIn, TxInWitness, Txid, WPubkeyHash, +}; + +pub struct ElementsTestData; + +impl ElementsTestData { + pub fn secret_key(byte: u8) -> SecretKey { + SecretKey::from_slice(&[byte; 32]).unwrap() + } + + pub fn public_key(byte: u8) -> PublicKey { + Self::secret_key(byte).public_key(&Secp256k1::new()) + } + + pub fn txid(byte: u8) -> Txid { + Txid::from_byte_array([byte; 32]) + } + + pub fn outpoint(txid_byte: u8, vout: u32) -> OutPoint { + OutPoint::new(Self::txid(txid_byte), vout) + } + + pub fn p2wpkh(secret_key: &SecretKey) -> Script { + Self::p2wpkh_of(&secret_key.public_key(&Secp256k1::new())) + } + + /// Builds a P2WPKH scriptPubKey for `pubkey`. + pub fn p2wpkh_of(pubkey: &PublicKey) -> Script { + let hash = hash160::Hash::hash(&pubkey.serialize()); + Script::new_v0_wpkh(&WPubkeyHash::from_byte_array(hash.to_byte_array())) + } + + /// Builds a P2WPKH witness containing `pubkey`. + pub fn p2wpkh_witness(pubkey: &PublicKey) -> Vec> { + vec![vec![0x30; 71], pubkey.serialize().to_vec()] + } + + /// Builds a transaction input with the supplied witness. + pub fn input(previous_output: OutPoint, script_witness: Vec>, is_pegin: bool) -> TxIn { + TxIn { + previous_output, + is_pegin, + script_sig: Script::new(), + sequence: Sequence::MAX, + asset_issuance: Default::default(), + witness: TxInWitness { + script_witness, + ..Default::default() + }, + } + } + + /// Builds a non-pegin P2WPKH input for `secret_key`. + pub fn p2wpkh_input(previous_output: OutPoint, secret_key: &SecretKey) -> TxIn { + let pubkey = secret_key.public_key(&Secp256k1::new()); + Self::input(previous_output, Self::p2wpkh_witness(&pubkey), false) + } +} + +#[cfg(test)] +mod tests { + use super::ElementsTestData; + + #[test] + fn deterministic_elements_values() { + assert_eq!( + ElementsTestData::outpoint(0x42, 7).txid, + ElementsTestData::txid(0x42) + ); + assert_eq!( + ElementsTestData::public_key(0x21), + ElementsTestData::secret_key(0x21) + .public_key(&elements_miniscript::elements::bitcoin::secp256k1::Secp256k1::new()) + ); + assert!(ElementsTestData::p2wpkh(&ElementsTestData::secret_key(0x21)).is_v0_p2wpkh()); + + let secret = ElementsTestData::secret_key(0x33); + assert_eq!( + ElementsTestData::p2wpkh(&secret), + ElementsTestData::p2wpkh_of(&ElementsTestData::public_key(0x33)) + ); + + let input = ElementsTestData::p2wpkh_input(ElementsTestData::outpoint(0x44, 0), &secret); + assert_eq!( + input.witness.script_witness[1], + ElementsTestData::public_key(0x33).serialize().to_vec() + ); + assert!(!input.is_pegin); + } +} diff --git a/lwk_test_util/src/lib.rs b/lwk_test_util/src/lib.rs index c0492ce95..1a47930f8 100644 --- a/lwk_test_util/src/lib.rs +++ b/lwk_test_util/src/lib.rs @@ -67,6 +67,7 @@ impl lwk_common::Store for PanicStore { mod amp2; mod auth; +mod elements_test_data; mod registry; mod test_env; mod waterfalls; @@ -74,6 +75,7 @@ pub use auth::{ AuthStack, AUTH_CLIENT_ID, AUTH_CLIENT_SECRET, AUTH_REALM, AUTH_SHORT_CLIENT_ID, AUTH_SHORT_CLIENT_SECRET, AUTH_USER_UUID, }; +pub use elements_test_data::ElementsTestData; pub use test_env::{TestEnv, TestEnvBuilder}; const DEFAULT_FEE_RATE: f32 = 100.0; From 63fbf82285874daf9b7d69798f5da1b470a8a410 Mon Sep 17 00:00:00 2001 From: 42Pupusas Date: Fri, 31 Jul 2026 15:58:11 -0600 Subject: [PATCH 05/12] wollet: declare the backend features test and example targets need tests/e2e.rs imports electrum/esplora/amp0 types at file scope and the list_transactions example uses the electrum client, so feature-off builds of --all-targets failed. required-features skips the targets instead; --test e2e -- --list under default features still shows every test. --- lwk_wollet/Cargo.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lwk_wollet/Cargo.toml b/lwk_wollet/Cargo.toml index 83ec8ce02..571db52de 100644 --- a/lwk_wollet/Cargo.toml +++ b/lwk_wollet/Cargo.toml @@ -149,12 +149,16 @@ amp0 = [ prices = ["reqwest"] registry = ["reqwest"] +# The e2e target requires the backend features it imports. [[test]] name = "e2e" path = "tests/e2e.rs" +required-features = ["electrum", "esplora", "amp0"] +# Uses `ElectrumClient` and `full_scan_with_electrum_client`, both gated on `electrum`. [[example]] name = "list_transactions" +required-features = ["electrum"] [package.metadata.docs.rs] all-features = true From 466a3553d4423cefc8a285b15feb8a9ceaf14646 Mon Sep 17 00:00:00 2001 From: 42Pupusas Date: Fri, 31 Jul 2026 16:01:58 -0600 Subject: [PATCH 06/12] wollet: add the silent payments module (BIP-352 per the Liquid ELIP) The full scan/derive/track stack behind a new silentpayments feature: bech32 lqsp addresses, sender/receiver output derivation with CT unblinding via the shared secret, input aggregation with BIP-352 eligibility, per-tx and per-block scanning (locally computed or tweak-server assisted), the cache entry and utxo views, and recipient resolution for the transaction builder. Pinned against the ELIP known-answer vectors. --- Cargo.lock | 1 + lwk_wollet/Cargo.toml | 6 + lwk_wollet/src/error.rs | 30 + lwk_wollet/src/lib.rs | 3 + lwk_wollet/src/silentpayments/address.rs | 156 +++++ lwk_wollet/src/silentpayments/block_tweaks.rs | 189 ++++++ lwk_wollet/src/silentpayments/cache_entry.rs | 150 +++++ lwk_wollet/src/silentpayments/inputs.rs | 454 +++++++++++++ lwk_wollet/src/silentpayments/mod.rs | 159 +++++ lwk_wollet/src/silentpayments/output.rs | 28 + lwk_wollet/src/silentpayments/receiver.rs | 160 +++++ lwk_wollet/src/silentpayments/recipient.rs | 136 ++++ .../src/silentpayments/scan_material.rs | 113 ++++ lwk_wollet/src/silentpayments/scanner.rs | 288 ++++++++ lwk_wollet/src/silentpayments/sender.rs | 83 +++ .../src/silentpayments/shared_secret.rs | 146 ++++ lwk_wollet/src/silentpayments/sync.rs | 157 +++++ lwk_wollet/src/silentpayments/tags.rs | 26 + lwk_wollet/src/silentpayments/test_fixture.rs | 150 +++++ lwk_wollet/src/silentpayments/tweak_server.rs | 82 +++ lwk_wollet/src/silentpayments/tx_inputs.rs | 629 ++++++++++++++++++ lwk_wollet/src/silentpayments/tx_scan.rs | 173 +++++ lwk_wollet/src/silentpayments/txout.rs | 76 +++ lwk_wollet/src/silentpayments/utxo.rs | 99 +++ 24 files changed, 3494 insertions(+) create mode 100644 lwk_wollet/src/silentpayments/address.rs create mode 100644 lwk_wollet/src/silentpayments/block_tweaks.rs create mode 100644 lwk_wollet/src/silentpayments/cache_entry.rs create mode 100644 lwk_wollet/src/silentpayments/inputs.rs create mode 100644 lwk_wollet/src/silentpayments/mod.rs create mode 100644 lwk_wollet/src/silentpayments/output.rs create mode 100644 lwk_wollet/src/silentpayments/receiver.rs create mode 100644 lwk_wollet/src/silentpayments/recipient.rs create mode 100644 lwk_wollet/src/silentpayments/scan_material.rs create mode 100644 lwk_wollet/src/silentpayments/scanner.rs create mode 100644 lwk_wollet/src/silentpayments/sender.rs create mode 100644 lwk_wollet/src/silentpayments/shared_secret.rs create mode 100644 lwk_wollet/src/silentpayments/sync.rs create mode 100644 lwk_wollet/src/silentpayments/tags.rs create mode 100644 lwk_wollet/src/silentpayments/test_fixture.rs create mode 100644 lwk_wollet/src/silentpayments/tweak_server.rs create mode 100644 lwk_wollet/src/silentpayments/tx_inputs.rs create mode 100644 lwk_wollet/src/silentpayments/tx_scan.rs create mode 100644 lwk_wollet/src/silentpayments/txout.rs create mode 100644 lwk_wollet/src/silentpayments/utxo.rs diff --git a/Cargo.lock b/Cargo.lock index 12539992a..386362352 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3026,6 +3026,7 @@ dependencies = [ "aes-gcm-siv", "age", "base64 0.21.7", + "bech32 0.11.0", "bip39", "bitcoincore-rpc", "cbc", diff --git a/lwk_wollet/Cargo.toml b/lwk_wollet/Cargo.toml index 571db52de..801d905f2 100644 --- a/lwk_wollet/Cargo.toml +++ b/lwk_wollet/Cargo.toml @@ -19,6 +19,7 @@ lwk_signer = { version = "0.18.1", features = [ ], optional = true } rand = "0.8" +bech32 = { version = "0.11", optional = true } serde = { workspace = true, features = ["derive"] } serde_json.workspace = true aes-gcm-siv = "0.11.0" @@ -118,6 +119,7 @@ default = [ "amp0", "prices", "registry", + "silentpayments", ] serial = ["lwk_jade/serial"] # this is a dev-dep feature esplora = ["reqwest", "age"] @@ -148,6 +150,10 @@ amp0 = [ ] prices = ["reqwest"] registry = ["reqwest"] +# Silent Payments (BIP-352 on Liquid, per the Liquid silent payments ELIP). +# The wallet feature scans, unblinds, and tracks outputs; signing remains in +# `lwk_signer`'s separate `silentpayments` feature. +silentpayments = ["bech32", "lwk_common/silentpayments"] # The e2e target requires the backend features it imports. [[test]] diff --git a/lwk_wollet/src/error.rs b/lwk_wollet/src/error.rs index 4741cac91..d922ad7a3 100644 --- a/lwk_wollet/src/error.rs +++ b/lwk_wollet/src/error.rs @@ -119,6 +119,36 @@ pub enum Error { body: Option, }, + #[cfg(feature = "silentpayments")] + #[error(transparent)] + SilentPaymentAddress(#[from] crate::silentpayments::SilentPaymentAddressError), + + #[cfg(feature = "silentpayments")] + #[error(transparent)] + SilentPaymentInput(#[from] crate::silentpayments::SilentPaymentInputError), + + /// A silent payment recipient was added, but the PSET was finalized without + /// supplying the input private keys needed to derive the output. + #[cfg(feature = "silentpayments")] + #[error("transaction has {0} silent payment recipient(s): finish with `TxBuilder::finish_silent_payment()` (a watch-only wallet cannot derive silent payment outputs, they require the input private keys)")] + SilentPaymentRequiresKeys(usize), + + /// A silent payment receive operation was attempted on a wallet built without + /// scan material. Detection needs `b_scan`, which a CT descriptor cannot express. + #[cfg(feature = "silentpayments")] + #[error("wallet has no silent payment scan material: build it with `WolletBuilder::with_silent_payment_material()`")] + MissingSilentPaymentKeys, + + /// A silent payment scan was requested from a backend that cannot discover them. + /// + /// Silent payment outputs match no descriptor-derived script, so a backend whose + /// only query is script history cannot find them however many addresses it scans. + /// This is reported rather than silently skipped: a scan that quietly finds nothing + /// is indistinguishable from having received no payments. + #[cfg(feature = "silentpayments")] + #[error("this backend cannot discover silent payments (it can only query descriptor-derived script history, which never matches a silent payment output); use a backend with `Capability::SilentPayments`, such as `EsploraClient`")] + SilentPaymentsUnsupportedByBackend, + #[error("Address must be explicit")] NotExplicitAddress, diff --git a/lwk_wollet/src/lib.rs b/lwk_wollet/src/lib.rs index f206398d2..422e802be 100644 --- a/lwk_wollet/src/lib.rs +++ b/lwk_wollet/src/lib.rs @@ -112,6 +112,9 @@ mod tx_details; mod pset_create; #[cfg(feature = "registry")] pub mod registry; +#[cfg(feature = "silentpayments")] +#[cfg_attr(docsrs, doc(cfg(feature = "silentpayments")))] +pub mod silentpayments; mod tx_builder; mod update; mod util; diff --git a/lwk_wollet/src/silentpayments/address.rs b/lwk_wollet/src/silentpayments/address.rs new file mode 100644 index 000000000..243acfdb3 --- /dev/null +++ b/lwk_wollet/src/silentpayments/address.rs @@ -0,0 +1,156 @@ +//! Bech32m silent-payment addresses. + +use bech32::primitives::decode::CheckedHrpstring; +use bech32::{Bech32m, Fe32, Hrp}; + +use crate::secp256k1::PublicKey; +use crate::Network; + +/// Receiver scan and spend public keys. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SilentPaymentAddress { + /// `B_scan` — used by the sender to compute the ECDH shared secret. + pub scan: PublicKey, + /// `B_spend` — base point the per-output spend key is tweaked from. + pub spend: PublicKey, +} + +impl SilentPaymentAddress { + /// Version 0 (`q`). + const VERSION: Fe32 = Fe32::Q; + + /// The expected payload size: two compressed secp256k1 public keys. + const PAYLOAD_LEN: usize = 66; + + /// Network-specific Liquid address HRP. + fn hrp(network: Network) -> Hrp { + match network { + Network::Liquid => Hrp::parse_unchecked("lqsp"), + _ => Hrp::parse_unchecked("tlqsp"), + } + } + + /// Encodes for `network`. + pub fn encode(&self, network: Network) -> String { + use bech32::primitives::iter::{ByteIterExt, Fe32IterExt}; + + let mut payload = Vec::with_capacity(Self::PAYLOAD_LEN); + payload.extend_from_slice(&self.scan.serialize()); + payload.extend_from_slice(&self.spend.serialize()); + + std::iter::once(Self::VERSION) + .chain(payload.into_iter().bytes_to_fes()) + .with_checksum::(&Self::hrp(network)) + .chars() + .collect() + } + + /// Parse a bech32m silent payment address, validating the HRP against `network`. + pub fn parse(s: &str, network: Network) -> Result { + use bech32::primitives::iter::Fe32IterExt; + + let checked = CheckedHrpstring::new::(s) + .map_err(|_| SilentPaymentAddressError::InvalidBech32m)?; + + if checked.hrp() != Self::hrp(network) { + return Err(SilentPaymentAddressError::WrongNetwork); + } + + let mut iter = checked.fe32_iter::>(); + let version = iter.next().ok_or(SilentPaymentAddressError::Truncated)?; + if version != Self::VERSION { + return Err(SilentPaymentAddressError::UnknownVersion); + } + + let bytes: Vec = iter.fes_to_bytes().collect(); + if bytes.len() != Self::PAYLOAD_LEN { + return Err(SilentPaymentAddressError::WrongPayloadLength(bytes.len())); + } + let scan = PublicKey::from_slice(&bytes[..33]) + .map_err(|_| SilentPaymentAddressError::InvalidPublicKey)?; + let spend = PublicKey::from_slice(&bytes[33..]) + .map_err(|_| SilentPaymentAddressError::InvalidPublicKey)?; + Ok(SilentPaymentAddress { scan, spend }) + } +} + +/// Errors parsing a [`SilentPaymentAddress`]. +#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)] +pub enum SilentPaymentAddressError { + /// Not a valid bech32m string. + #[error("not a valid bech32m string")] + InvalidBech32m, + + /// HRP does not match the expected network. + #[error("address HRP does not match network")] + WrongNetwork, + + /// Payload ended before the version/keys could be read. + #[error("address payload truncated")] + Truncated, + + /// Address version is not supported. + #[error("unsupported silent payment address version")] + UnknownVersion, + + /// Payload is not the expected 66 bytes (two compressed pubkeys). + #[error("expected 66-byte payload, got {0}")] + WrongPayloadLength(usize), + + /// A public key in the payload is not a valid point. + #[error("invalid public key in address payload")] + InvalidPublicKey, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::silentpayments::test_fixture::SilentPaymentTestData as Data; + use crate::silentpayments::SilentPaymentScan; + + #[test] + fn address_encodes_and_round_trips_per_network() { + let address = Data::material(0x11, 0x22).address(); + + for (network, hrp) in [ + (Network::Liquid, "lqsp1"), + (Network::TestnetLiquid, "tlqsp1"), + (Network::default_regtest(), "tlqsp1"), + ] { + let encoded = address.encode(network); + assert!( + encoded.starts_with(hrp), + "address {encoded} should start with {hrp}" + ); + assert_eq!( + SilentPaymentAddress::parse(&encoded, network).unwrap(), + address, + "address did not round-trip on {network:?}" + ); + } + + assert_eq!( + address.encode(Network::TestnetLiquid), + address.encode(Network::default_regtest()), + "testnet and regtest must share the tlqsp HRP" + ); + } + + #[test] + fn address_rejects_wrong_network_and_garbage() { + let address = Data::material(0x11, 0x22).address(); + let mainnet = address.encode(Network::Liquid); + + for network in [Network::TestnetLiquid, Network::default_regtest()] { + assert_eq!( + SilentPaymentAddress::parse(&mainnet, network), + Err(SilentPaymentAddressError::WrongNetwork), + "a mainnet address must not parse as {network:?}" + ); + } + + assert!(SilentPaymentAddress::parse("not an address", Network::Liquid).is_err()); + // Valid bech32m but wrong payload length (no key bytes). + assert!(SilentPaymentAddress::parse("lq1qqqqqq", Network::Liquid).is_err()); + } +} diff --git a/lwk_wollet/src/silentpayments/block_tweaks.rs b/lwk_wollet/src/silentpayments/block_tweaks.rs new file mode 100644 index 000000000..1d69f35cf --- /dev/null +++ b/lwk_wollet/src/silentpayments/block_tweaks.rs @@ -0,0 +1,189 @@ +//! Computes silent-payment tweaks for a block. + +use crate::elements::{Block, OutPoint, Script, Transaction, Txid}; +use crate::silentpayments::{PartialTweak, SilentPaymentTxInputs}; +use std::collections::HashMap; + +/// Computes silent-payment tweaks for a block view. +pub struct BlockTweaks<'a> { + block: &'a Block, +} + +impl<'a> BlockTweaks<'a> { + /// A tweak extractor over `block`. + pub fn new(block: &'a Block) -> Self { + BlockTweaks { block } + } + + /// Returns the external prevouts needed to classify block inputs. + pub fn required_prevouts(&self) -> Vec { + let local: HashMap = + self.block.txdata.iter().map(|tx| (tx.txid(), tx)).collect(); + + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::new(); + for tx in &self.block.txdata { + if tx.is_coinbase() { + continue; + } + for input in &tx.input { + if input.is_pegin() { + continue; + } + let op = input.previous_output; + if local.contains_key(&op.txid) { + continue; + } + if seen.insert(op) { + out.push(op); + } + } + } + out + } + + /// Computes partial tweaks for transactions with eligible inputs. + pub fn compute(&self, prevouts: &HashMap) -> Vec<(Txid, PartialTweak)> { + let local: HashMap = + self.block.txdata.iter().map(|tx| (tx.txid(), tx)).collect(); + + let mut out = Vec::new(); + for tx in &self.block.txdata { + if tx.is_coinbase() { + continue; + } + + let lookup = |op: &OutPoint| -> Option<&Script> { + if let Some(script) = prevouts.get(op) { + return Some(script); + } + local + .get(&op.txid) + .and_then(|prev| prev.output.get(op.vout as usize)) + .map(|txout| &txout.script_pubkey) + }; + + let inputs = SilentPaymentTxInputs::extract(tx, lookup); + if !inputs.is_eligible() { + continue; + } + let Ok(observed) = inputs.observed() else { + continue; + }; + out.push((tx.txid(), PartialTweak::from_observed(&observed))); + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::elements::{ + confidential::{Asset, Value}, + TxOut, + }; + use crate::util::EC; + use lwk_test_util::ElementsTestData as Data; + + /// Build a P2WPKH-spending transaction so its input is SP-eligible. + fn spending_tx(prev: OutPoint, secret: u8) -> Transaction { + let input = Data::p2wpkh_input(prev, &Data::secret_key(secret)); + Transaction { + version: 2, + lock_time: crate::elements::LockTime::ZERO, + input: vec![input], + output: vec![TxOut { + asset: Asset::Null, + value: Value::Explicit(1000), + nonce: crate::elements::confidential::Nonce::Null, + script_pubkey: Script::new(), + witness: Default::default(), + }], + } + } + + fn block_with(txs: Vec) -> Block { + Block { + // Reuse the crate's existing test header rather than a bespoke one: the + // header is irrelevant here, only txdata is read. + header: crate::update::default_blockheader(), + txdata: txs, + } + } + + /// Every prevout this block genuinely needs from the backend, and nothing else: + /// same-block outputs are already in hand, peg-in prevouts are Bitcoin txids a + /// Liquid backend cannot serve, the coinbase spends nothing, and a prevout wanted + /// twice is still one request. + #[test] + fn required_prevouts_asks_only_for_what_it_cannot_derive() { + let external = Data::outpoint(0x11, 0); + let funding = spending_tx(Data::outpoint(0x99, 0), 0x41); + let spends_local = spending_tx(OutPoint::new(funding.txid(), 0), 0x42); + let spends_external = spending_tx(external, 0x43); + let also_spends_external = spending_tx(external, 0x44); + + // A peg-in carries a Bitcoin outpoint; the coinbase carries a null one. + let bitcoin_op = Data::outpoint(0xbc, 0); + let mut pegin = spending_tx(bitcoin_op, 0x45); + pegin.input[0].is_pegin = true; + let mut coinbase = spending_tx(OutPoint::null(), 0x46); + coinbase.input[0].previous_output = OutPoint::null(); + + let block = block_with(vec![ + funding.clone(), + spends_local, + spends_external, + also_spends_external, + pegin, + coinbase, + ]); + let required = BlockTweaks::new(&block).required_prevouts(); + + assert!(required.contains(&external), "external prevout is needed"); + assert!( + !required.iter().any(|op| op.txid == funding.txid()), + "same-block prevout must not be fetched" + ); + assert!( + !required.contains(&bitcoin_op), + "a peg-in prevout is a Bitcoin txid and must never be fetched from a Liquid backend" + ); + assert!( + !required.contains(&OutPoint::null()), + "the coinbase spends nothing" + ); + assert_eq!( + required.iter().filter(|op| **op == external).count(), + 1, + "a prevout wanted twice is still one request" + ); + } + + /// Block-derived tweaks match direct input derivation. + #[test] + fn computed_tweak_matches_direct_derivation() { + let prev = Data::outpoint(0x11, 0); + let tx = spending_tx(prev, 0x41); + let block = block_with(vec![tx.clone()]); + + let mut prevouts = HashMap::new(); + prevouts.insert(prev, Data::p2wpkh(&Data::secret_key(0x41))); + + let tweaks = BlockTweaks::new(&block).compute(&prevouts); + assert_eq!(tweaks.len(), 1); + assert_eq!(tweaks[0].0, tx.txid()); + + let direct = PartialTweak::from_inputs(&[(prev, Data::secret_key(0x41).public_key(&EC))]) + .expect("direct tweak"); + assert_eq!(tweaks[0].1, direct, "block tweak must match direct tweak"); + + // Without the prevout script there is no eligible input, so the same + // transaction must yield no tweak rather than a wrong one. + assert!( + BlockTweaks::new(&block).compute(&HashMap::new()).is_empty(), + "unknown prevout must not produce a tweak" + ); + } +} diff --git a/lwk_wollet/src/silentpayments/cache_entry.rs b/lwk_wollet/src/silentpayments/cache_entry.rs new file mode 100644 index 000000000..813c9230e --- /dev/null +++ b/lwk_wollet/src/silentpayments/cache_entry.rs @@ -0,0 +1,150 @@ +//! Persisted wallet state for a discovered silent-payment output. + +use crate::elements::{OutPoint, Script}; +use crate::secp256k1::PublicKey; +use crate::silentpayments::{SpendTweak, CHANGE_LABEL}; +use crate::Chain; + +/// A discovered silent-payment output in wallet cache form. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SilentPaymentCacheEntry { + /// Where the output sits on chain. + pub outpoint: OutPoint, + + /// The output's scriptPubKey, `OP_1 `. + pub script_pubkey: Script, + + /// The output counter `k` the scan found this at. + pub k: u32, + + /// The BIP-352 label the payment was sent to, if any. + pub label: Option, + + /// `t_k (+ label_tweak_m)` — the scalar that turns a signer's `b_spend` into + /// this output's spend key. Not sufficient to spend by itself. + pub spend_tweak: SpendTweak, + + /// `BK_k`, the output's blinding pubkey, kept so a confidential address can be + /// rendered without re-deriving the shared secret. + pub blinding_pubkey: PublicKey, +} + +impl SilentPaymentCacheEntry { + /// Classifies change-labeled outputs as internal. + pub fn chain(&self) -> Chain { + if self.label == Some(CHANGE_LABEL) { + Chain::Internal + } else { + Chain::External + } + } + + /// Whether this output is the wallet's own silent-payment change. + pub fn is_change(&self) -> bool { + self.label == Some(CHANGE_LABEL) + } + + /// Checks `B_spend + spend_tweak·G == x_only(P_k)`. + pub fn verify(&self, spend_base: &PublicKey) -> bool { + let Some(expected) = self.spend_tweak.applied_to(spend_base) else { + return false; + }; + self.x_only_pubkey() == Some(expected.x_only_public_key().0) + } + + /// Extracts `x_only(P_k)` from the stored script. + pub fn x_only_pubkey(&self) -> Option { + let bytes = self.script_pubkey.as_bytes(); + // `OP_1 <32-byte push>`: 0x51 0x20 followed by the key. + if bytes.len() != 34 || bytes[0] != 0x51 || bytes[1] != 0x20 { + return None; + } + crate::elements::secp256k1_zkp::XOnlyPublicKey::from_slice(&bytes[2..]).ok() + } + + /// Weight of a key-path Taproot satisfaction. + pub fn max_weight_to_satisfy(&self) -> usize { + crate::silentpayments::SilentPaymentUtxo::MAX_WEIGHT_TO_SATISFY + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::silentpayments::test_fixture::SilentPaymentTestData as Data; + use crate::silentpayments::{ + SilentPaymentReceiver, SilentPaymentScanMaterial, SilentPaymentSender, + }; + + fn entry_and_material() -> (SilentPaymentCacheEntry, SilentPaymentScanMaterial) { + let m = Data::material(0x11, 0x22); + let inputs = [ + (Data::outpoint(1, 0), Data::secret_key(0xA1)), + (Data::outpoint(2, 1), Data::secret_key(0xA2)), + ]; + let sender = SilentPaymentSender::from_inputs(&inputs).unwrap(); + let observed = sender.inputs().observed(); + let (output, spend_tweak) = + SilentPaymentReceiver::new(m).derive_output_from_observed(&observed, 0); + + let entry = SilentPaymentCacheEntry { + outpoint: Data::outpoint(9, 0), + script_pubkey: output.script_pubkey(), + k: 0, + label: None, + spend_tweak, + blinding_pubkey: output.blinding_pubkey, + }; + (entry, m) + } + + #[test] + fn entry_verifies_only_against_its_own_material() { + let (entry, m) = entry_and_material(); + assert!( + entry.verify(&m.spend_pubkey()), + "the stored tweak must verify" + ); + + let stranger = Data::material(0x77, 0x88); + assert!( + !entry.verify(&stranger.spend_pubkey()), + "a different spend base must not verify this entry" + ); + + let mut corrupted = entry; + corrupted.spend_tweak = SpendTweak::from_be_bytes([0x5A; 32]).unwrap(); + assert!( + !corrupted.verify(&m.spend_pubkey()), + "a corrupted tweak must fail rather than be trusted" + ); + } + + #[test] + fn change_label_maps_to_the_internal_chain() { + let (mut entry, _) = entry_and_material(); + assert_eq!(entry.chain(), Chain::External); + assert!(!entry.is_change()); + + entry.label = Some(CHANGE_LABEL); + assert_eq!(entry.chain(), Chain::Internal); + assert!(entry.is_change()); + + entry.label = Some(7); + assert_eq!(entry.chain(), Chain::External); + assert!(!entry.is_change()); + } + + #[test] + fn entry_is_read_as_taproot_or_not_at_all() { + let (entry, _) = entry_and_material(); + assert_eq!( + entry.max_weight_to_satisfy(), + crate::silentpayments::SilentPaymentUtxo::MAX_WEIGHT_TO_SATISFY + ); + + let mut not_taproot = entry; + not_taproot.script_pubkey = Script::from(vec![0x00, 0x14, 0xAB, 0xCD]); + assert!(not_taproot.x_only_pubkey().is_none()); + } +} diff --git a/lwk_wollet/src/silentpayments/inputs.rs b/lwk_wollet/src/silentpayments/inputs.rs new file mode 100644 index 000000000..ec3cd26a6 --- /dev/null +++ b/lwk_wollet/src/silentpayments/inputs.rs @@ -0,0 +1,454 @@ +//! Silent-payment input aggregation. + +use crate::elements::OutPoint; +use crate::hashes::{Hash, HashEngine}; +use crate::secp256k1::{PublicKey, Scalar, SecretKey}; +use crate::silentpayments::tags::InputsHash; +use crate::util::EC; +use std::collections::HashMap; + +/// Computes BIP-352 input hashes. +pub(crate) struct InputHasher; + +impl InputHasher { + /// `input_hash = H_BIP0352/Inputs(outpoint_L || A)`. + /// + /// `outpoint_l` is the serialization of the lexicographically smallest input + /// outpoint; `a_sum_pubkey` is `A = a·G` (the sum of eligible input pubkeys). + pub(crate) fn hash(outpoint_l: &[u8], a_sum_pubkey: &PublicKey) -> Scalar { + let mut eng = InputsHash::engine(); + eng.input(outpoint_l); + eng.input(&a_sum_pubkey.serialize()); + let h = InputsHash::from_engine(eng); + // BIP-352 treats the hash directly as a scalar. + Scalar::from_be_bytes(h.to_byte_array()).expect("input hash within curve order") + } + + /// BIP-352's 36-byte form: `txid (32, internal) || vout (4, LE)`. + pub(crate) fn serialize_outpoint(outpoint: &crate::elements::OutPoint) -> Vec { + crate::elements::encode::serialize(outpoint) + } + + /// Hashes the smallest serialized outpoint. + pub(crate) fn hash_over<'a>( + outpoints: impl Iterator, + a_sum_pubkey: &PublicKey, + ) -> Scalar { + let outpoint_l = outpoints + .map(Self::serialize_outpoint) + .min() + .expect("caller checked inputs are non-empty"); + Self::hash(&outpoint_l, a_sum_pubkey) + } +} + +/// Eligible input key and spend type. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InputKey { + /// A key-path Taproot input: normalize to even-Y before summing. + Taproot(SecretKey), + /// A P2WPKH / P2SH-P2WPKH input: sum as-is. + Plain(SecretKey), +} + +impl InputKey { + /// Normalized secret key. + pub fn normalized(&self) -> SecretKey { + match self { + InputKey::Taproot(sk) => { + if sk.public_key(&EC).x_only_public_key().1 + == crate::elements::secp256k1_zkp::Parity::Odd + { + sk.negate() + } else { + *sk + } + } + InputKey::Plain(sk) => *sk, + } + } + + /// The public key an observer recovers for this input, i.e. `normalized()·G`. + pub fn public_key(&self) -> PublicKey { + self.normalized().public_key(&EC) + } +} + +/// Errors aggregating silent-payment inputs. +#[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Eq)] +pub enum SilentPaymentInputError { + /// No eligible inputs were provided. + #[error("no eligible silent payment inputs")] + NoInputs, + + /// A selected input should contribute to the sender sum, but its private key + /// was not supplied by the signer/provider. + #[error("missing private key for an eligible silent payment input")] + MissingKey, + + /// The summed private key is zero (the inputs cancel out); the payment is + /// undefined per BIP-352 and the transaction must be aborted. + #[error("eligible input keys sum to zero")] + SumIsZero, + + /// More outputs than BIP-352's `K_max` allows in one recipient group; the + /// receiver is forbidden to scan that far and could not spend them. + #[error("too many silent payment outputs in one transaction (limit is K_max = 2323)")] + TooManyOutputs, +} + +/// Sender-side aggregated input data. +#[derive(Debug, Clone, Copy)] +pub struct SilentPaymentInputs { + /// `a = Σ a_i` over eligible inputs. + pub a_sum: SecretKey, + /// `A = a·G`. + pub a_pubkey: PublicKey, + /// `input_hash = H_BIP0352/Inputs(outpoint_L || A)`. + pub input_hash: Scalar, +} + +impl SilentPaymentInputs { + /// Aggregate `(outpoint, private_key)` pairs into `a`, `A`, and `input_hash`. + pub fn aggregate( + inputs: &[(crate::elements::OutPoint, SecretKey)], + ) -> Result { + let tagged: Vec<_> = inputs + .iter() + .map(|(o, sk)| (*o, InputKey::Plain(*sk))) + .collect(); + Self::aggregate_keys(&tagged) + } + + /// Aggregates typed eligible inputs. + pub fn aggregate_keys( + inputs: &[(crate::elements::OutPoint, InputKey)], + ) -> Result { + Self::aggregate_with_extra_outpoints(inputs, &[]) + } + + /// Aggregates eligible inputs and keyless-input outpoints. + pub fn aggregate_with_extra_outpoints( + inputs: &[(crate::elements::OutPoint, InputKey)], + extra_outpoints: &[crate::elements::OutPoint], + ) -> Result { + let (first, rest) = inputs + .split_first() + .ok_or(SilentPaymentInputError::NoInputs)?; + + // `SecretKey` cannot represent an intermediate zero sum. + let mut a_sum = Some(first.1.normalized()); + for (_, key) in rest { + let next = key.normalized(); + a_sum = match a_sum { + Some(current) => current + .add_tweak(&Scalar::from_be_bytes(next.secret_bytes()).expect("scalar")) + .ok(), + None => Some(next), + }; + } + let a_sum = a_sum.ok_or(SilentPaymentInputError::SumIsZero)?; + let a_pubkey = a_sum.public_key(&EC); + + let input_hash = InputHasher::hash_over( + inputs.iter().map(|(o, _)| o).chain(extra_outpoints.iter()), + &a_pubkey, + ); + + Ok(SilentPaymentInputs { + a_sum, + a_pubkey, + input_hash, + }) + } + + /// The observer's view of these same inputs. + pub fn observed(&self) -> ObservedInputs { + ObservedInputs { + a_pubkey: self.a_pubkey, + input_hash: self.input_hash, + } + } +} + +/// Observer-side aggregated public input data. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ObservedInputs { + /// `A = Σ A_i`. + pub a_pubkey: PublicKey, + /// `input_hash = H_BIP0352/Inputs(outpoint_L || A)`. + pub input_hash: Scalar, +} + +impl ObservedInputs { + /// Aggregate `(outpoint, pubkey)` pairs into `A` and `input_hash`. + pub fn aggregate( + inputs: &[(crate::elements::OutPoint, PublicKey)], + ) -> Result { + Self::aggregate_with_extra_outpoints(inputs, &[]) + } + + /// Aggregates observed inputs and keyless-input outpoints. + pub fn aggregate_with_extra_outpoints( + inputs: &[(crate::elements::OutPoint, PublicKey)], + extra_outpoints: &[crate::elements::OutPoint], + ) -> Result { + let (first, rest) = inputs + .split_first() + .ok_or(SilentPaymentInputError::NoInputs)?; + + // An intermediate point at infinity is the additive identity. + let mut a_pubkey = Some(first.1); + for (_, pk) in rest { + a_pubkey = match a_pubkey { + Some(current) => current.combine(pk).ok(), + None => Some(*pk), + }; + } + let a_pubkey = a_pubkey.ok_or(SilentPaymentInputError::SumIsZero)?; + + let input_hash = InputHasher::hash_over( + inputs.iter().map(|(o, _)| o).chain(extra_outpoints.iter()), + &a_pubkey, + ); + + Ok(ObservedInputs { + a_pubkey, + input_hash, + }) + } +} + +/// Result of classifying one selected transaction input for silent payments. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InputKeyResult { + /// The input contributes this private key to the sender-side sum. + Eligible(InputKey), + /// The input belongs to the transaction but contributes no key, such as a peg-in. + Ineligible, + /// The input is eligible in principle, but its private key was not supplied. + Missing, +} + +/// Supplies silent-payment input classification and, where available, key material. +pub trait SilentPaymentInputProvider { + /// Classifies `outpoint` and supplies available key material. + fn input_key(&self, outpoint: &OutPoint) -> InputKeyResult; +} + +/// A [`SilentPaymentInputProvider`] backed by an in-memory map. +#[derive(Debug, Clone, Default)] +pub struct MapInputProvider { + keys: HashMap, +} + +impl MapInputProvider { + /// An empty provider. + pub fn new() -> Self { + Self::default() + } + + /// Register the key spending `outpoint`. + pub fn insert(mut self, outpoint: OutPoint, key: InputKey) -> Self { + self.keys.insert(outpoint, key); + self + } +} + +impl FromIterator<(OutPoint, InputKey)> for MapInputProvider { + fn from_iter>(iter: T) -> Self { + Self { + keys: iter.into_iter().collect(), + } + } +} + +impl SilentPaymentInputProvider for MapInputProvider { + fn input_key(&self, outpoint: &OutPoint) -> InputKeyResult { + self.keys + .get(outpoint) + .copied() + .map(InputKeyResult::Eligible) + .unwrap_or(InputKeyResult::Missing) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use lwk_test_util::ElementsTestData as Data; + + #[test] + fn map_provider_reports_known_and_unknown_inputs() { + let known = Data::outpoint(0x10, 0); + let unknown = Data::outpoint(0x99, 7); + + let provider = + MapInputProvider::new().insert(known, InputKey::Plain(Data::secret_key(0x31))); + + assert_eq!( + provider.input_key(&known), + InputKeyResult::Eligible(InputKey::Plain(Data::secret_key(0x31))) + ); + assert_eq!(provider.input_key(&unknown), InputKeyResult::Missing); + + // Taproot keys keep their tag so the even-Y normalization is not lost. + let provider: MapInputProvider = [(known, InputKey::Taproot(Data::secret_key(0x31)))] + .into_iter() + .collect(); + assert_eq!( + provider.input_key(&known), + InputKeyResult::Eligible(InputKey::Taproot(Data::secret_key(0x31))) + ); + } + + #[test] + fn input_aggregation_sender_and_observer_agree() { + let inputs_sk = [ + (Data::outpoint(0x30, 1), Data::secret_key(0x30)), + (Data::outpoint(0x10, 0), Data::secret_key(0x31)), // smallest txid → outpoint_L + (Data::outpoint(0x20, 7), Data::secret_key(0x32)), + ]; + let agg = SilentPaymentInputs::aggregate(&inputs_sk).unwrap(); + + let inputs_pk: Vec<_> = inputs_sk + .iter() + .map(|(o, s)| (*o, s.public_key(&EC))) + .collect(); + let obs = ObservedInputs::aggregate(&inputs_pk).unwrap(); + + assert_eq!( + agg.a_pubkey, obs.a_pubkey, + "A mismatch between sender and observer" + ); + assert_eq!( + agg.input_hash, obs.input_hash, + "input_hash mismatch between sender and observer" + ); + assert_eq!(agg.observed(), obs); + assert_eq!(agg.a_sum.public_key(&EC), agg.a_pubkey); + + let reversed: Vec<_> = inputs_sk.iter().rev().copied().collect(); + let shuffled = SilentPaymentInputs::aggregate(&reversed).unwrap(); + assert_eq!(shuffled.input_hash, agg.input_hash); + assert_eq!(shuffled.a_pubkey, agg.a_pubkey); + } + + /// `outpoint_L` uses serialized, not `OutPoint`, ordering. + #[test] + fn outpoint_l_uses_serialized_byte_order_not_outpoint_ord() { + let low_vout = Data::outpoint(0x10, 1); // serializes ...01000000 + let high_vout = Data::outpoint(0x10, 256); // serializes ...00010000 + + // The two orderings must genuinely disagree, or the test is vacuous. + assert!(low_vout < high_vout, "numerically vout 1 < vout 256"); + assert!( + InputHasher::serialize_outpoint(&high_vout) + < InputHasher::serialize_outpoint(&low_vout), + "as bytes, vout 256 sorts before vout 1" + ); + + let a = Data::secret_key(0x31); + let agg = SilentPaymentInputs::aggregate(&[(low_vout, a), (high_vout, a)]).unwrap(); + + let expected = + InputHasher::hash(&InputHasher::serialize_outpoint(&high_vout), &agg.a_pubkey); + assert_eq!( + agg.input_hash, expected, + "input_hash must use the byte-wise smallest outpoint" + ); + + // Not the hash the `OutPoint: Ord` minimum would produce. + let wrong = InputHasher::hash(&InputHasher::serialize_outpoint(&low_vout), &agg.a_pubkey); + assert_ne!(agg.input_hash, wrong); + + let obs = ObservedInputs::aggregate(&[ + (low_vout, a.public_key(&EC)), + (high_vout, a.public_key(&EC)), + ]) + .unwrap(); + assert_eq!(obs.input_hash, agg.input_hash); + } + + /// ELIP `test_taproot_even_y_negation`. + #[test] + fn taproot_input_keys_are_negated_to_even_y() { + use crate::elements::secp256k1_zkp::Parity; + + let odd = (1u8..=0xFF) + .map(Data::secret_key) + .find(|k| k.public_key(&EC).x_only_public_key().1 == Parity::Odd) + .expect("an odd-Y key exists"); + let even = (1u8..=0xFF) + .map(Data::secret_key) + .find(|k| k.public_key(&EC).x_only_public_key().1 == Parity::Even) + .expect("an even-Y key exists"); + + assert_eq!(InputKey::Taproot(odd).normalized(), odd.negate()); + assert_ne!( + InputKey::Taproot(odd).normalized(), + InputKey::Plain(odd).normalized() + ); + // Negation preserves the x-only key. + assert_eq!( + InputKey::Taproot(odd).public_key().x_only_public_key().0, + odd.public_key(&EC).x_only_public_key().0 + ); + + assert_eq!(InputKey::Taproot(even).normalized(), even); + assert_eq!( + InputKey::Taproot(even).normalized(), + InputKey::Plain(even).normalized() + ); + + let op = Data::outpoint(0x10, 0); + let agg = SilentPaymentInputs::aggregate_keys(&[(op, InputKey::Taproot(odd))]).unwrap(); + let obs = ObservedInputs::aggregate(&[(op, InputKey::Taproot(odd).public_key())]).unwrap(); + assert_eq!(agg.a_pubkey, obs.a_pubkey); + assert_eq!(agg.input_hash, obs.input_hash); + } + + /// ELIP `test_pegin_input_excluded_from_shared_secret`. + #[test] + fn pegin_outpoint_participates_in_outpoint_l_but_contributes_no_key() { + let eligible = [ + ( + Data::outpoint(0x61, 0), + InputKey::Plain(Data::secret_key(0x51)), + ), + ( + Data::outpoint(0x62, 1), + InputKey::Plain(Data::secret_key(0x52)), + ), + ]; + // Smaller than every eligible outpoint, so it decides outpoint_L. + let pegin = Data::outpoint(0x01, 0); + + let without = SilentPaymentInputs::aggregate_keys(&eligible).unwrap(); + let with = + SilentPaymentInputs::aggregate_with_extra_outpoints(&eligible, &[pegin]).unwrap(); + + assert_eq!(with.a_sum, without.a_sum); + assert_eq!(with.a_pubkey, without.a_pubkey); + + assert_ne!( + with.input_hash, without.input_hash, + "a peg-in outpoint smaller than every eligible one must change outpoint_L" + ); + let expected = InputHasher::hash(&InputHasher::serialize_outpoint(&pegin), &with.a_pubkey); + assert_eq!(with.input_hash, expected); + + let observed: Vec<_> = eligible.iter().map(|(o, k)| (*o, k.public_key())).collect(); + let obs = ObservedInputs::aggregate_with_extra_outpoints(&observed, &[pegin]).unwrap(); + assert_eq!(obs.input_hash, with.input_hash); + assert_eq!(obs.a_pubkey, with.a_pubkey); + } + + #[test] + fn input_aggregation_rejects_empty() { + assert!(matches!( + SilentPaymentInputs::aggregate(&[]), + Err(SilentPaymentInputError::NoInputs) + )); + } +} diff --git a/lwk_wollet/src/silentpayments/mod.rs b/lwk_wollet/src/silentpayments/mod.rs new file mode 100644 index 000000000..7d49adc7f --- /dev/null +++ b/lwk_wollet/src/silentpayments/mod.rs @@ -0,0 +1,159 @@ +//! Silent Payments (BIP-352) on Liquid, per the Liquid silent payments ELIP. +//! +//! This crate holds `b_scan`, the public `B_spend`, and per-output [`SpendTweak`]s: +//! enough to detect, unblind, and track a silent payment, never enough to sign one. +//! A tweak is verified publicly as `B_spend + spend_tweak·G == spend pubkey`. +//! +//! Silent-payment scripts are derived from transaction inputs, not wallet descriptors. + +pub mod address; +pub mod block_tweaks; +pub mod cache_entry; +pub mod inputs; +pub mod output; +pub mod receiver; +pub mod recipient; +pub mod scan_material; +pub mod scanner; +pub mod sender; +pub mod shared_secret; +pub mod sync; +mod tags; +#[cfg(test)] +pub(crate) mod test_fixture; +pub mod tweak_server; +pub mod tx_inputs; +pub mod tx_scan; +pub mod txout; +pub mod utxo; + +pub use address::{SilentPaymentAddress, SilentPaymentAddressError}; +pub use block_tweaks::BlockTweaks; +pub use cache_entry::SilentPaymentCacheEntry; +pub use inputs::{ + InputKey, InputKeyResult, MapInputProvider, ObservedInputs, SilentPaymentInputError, + SilentPaymentInputProvider, SilentPaymentInputs, +}; +pub use output::SilentPaymentOutput; +pub use receiver::{SilentPaymentReceiver, SpendTweak}; +pub use recipient::{ResolvedSilentPayment, SilentPaymentRecipient}; +pub use scan_material::{ + SilentPaymentAccount, SilentPaymentScan, SilentPaymentScanMaterial, CHANGE_LABEL, +}; +pub use scanner::{LabeledHit, SilentPaymentScanner}; +pub use sender::SilentPaymentSender; +pub use shared_secret::SharedSecret; +pub use sync::SilentPaymentSync; +pub use tweak_server::{PartialTweak, SilentPaymentTweakClient}; +pub use tx_inputs::{InputPubkeyRecovery, SilentPaymentTxInputs}; +pub use tx_scan::SilentPaymentTxScanner; +pub use txout::SpTxOutBuilder; +pub use utxo::SilentPaymentUtxo; + +#[cfg(test)] +mod tests { + use super::*; + use crate::silentpayments::test_fixture::SilentPaymentTestData as Data; + + /// Test-only: production wallet code never holds `b_spend`. + fn reconstruct_spend_key( + b_spend: &crate::secp256k1::SecretKey, + tweak: &SpendTweak, + ) -> crate::secp256k1::SecretKey { + b_spend + .add_tweak(tweak.as_scalar()) + .expect("test vectors are in range") + } + + /// The ELIP's known-answer vectors, pinned so an independent implementation can + /// confirm cross-implementation agreement. + #[test] + fn known_answer_vectors() { + use crate::elements::hashes::hex::DisplayHex; + + let b_spend = Data::secret_key(0x22); + let keys = Data::material(0x11, 0x22); + let inputs = [ + (Data::outpoint(0x10, 0), Data::secret_key(0x31)), + (Data::outpoint(0x20, 1), Data::secret_key(0x32)), + ]; + let sender = SilentPaymentSender::from_inputs(&inputs).unwrap(); + let agg = *sender.inputs(); + let receiver = SilentPaymentReceiver::new(keys); + + assert_eq!( + agg.a_pubkey.serialize().to_lower_hex_string(), + "031195a8046dcbb8e17034bca630065e7a0982e4e36f6f7e5a8d4554e4846fcd99", + "A = a·G" + ); + assert_eq!( + agg.input_hash.to_be_bytes().to_lower_hex_string(), + "d392922c00280a7e8d282182f5026f2fddbc74c1e1de18b4822128b2b77ec641", + "input_hash" + ); + + // (k, P_spend, BK, bk, spend_sk, scriptPubKey) + let expected: [(u32, &str, &str, &str, &str, &str); 2] = [ + ( + 0, + "02a29d9716417c964ca9e477343e71ffe730a4991a3eaad668eabec84e9feb7931", + "0344e1289497e6da66fde710d2f38de053fc07355e405524401d7d609df5a1a8cc", + "70ab8897b64bd21b427339ff4d014b883191ef6425862246c53bfc27a59aa3f0", + "f03c436d2cd67ae1fecf7d88a38aa3a03c0abea43feaf6da8eb71e2e3a866bda", + "5120a29d9716417c964ca9e477343e71ffe730a4991a3eaad668eabec84e9feb7931", + ), + ( + 1, + "0229d77654023af267dbe9cb7ff1956f947c816f203494381308387168fb010c92", + "03efdeda770ccdbe8bf466fba48bfd2b2c436ab0c04658fc6d6c277de5078129fa", + "945ba73a9804f62089c7d2ffdc079031031f0aebab372cec17ef9c110ebceb10", + "9eff3472230fc83ef5ea8f8c80401c4eecd595a048bd2482a107d3a49baa5a58", + "512029d77654023af267dbe9cb7ff1956f947c816f203494381308387168fb010c92", + ), + ]; + + for (k, p_spend, bk_pub, bk_sec, spend_sk_hex, script_hex) in expected { + let out = sender.derive_output(&keys.address(), k); + let (recv_out, spend_tweak) = receiver.derive_output_from_observed(&agg.observed(), k); + assert_eq!(out, recv_out); + + assert_eq!( + out.spend_pubkey.serialize().to_lower_hex_string(), + p_spend, + "P_spend k={k}" + ); + assert_eq!( + out.blinding_pubkey.serialize().to_lower_hex_string(), + bk_pub, + "BK k={k}" + ); + assert_eq!( + out.blinding_seckey.secret_bytes().to_lower_hex_string(), + bk_sec, + "bk k={k}" + ); + assert_eq!( + reconstruct_spend_key(&b_spend, &spend_tweak) + .secret_bytes() + .to_lower_hex_string(), + spend_sk_hex, + "b_spend + t_k k={k}" + ); + assert_eq!( + spend_tweak.applied_to(&keys.spend_pubkey()).unwrap(), + out.spend_pubkey, + "B_spend + t_k*G k={k}" + ); + assert_eq!( + out.script_pubkey().as_bytes().to_lower_hex_string(), + script_hex, + "scriptPubKey k={k}" + ); + } + + assert_eq!( + keys.address().encode(crate::Network::Liquid), + "lqsp1qqd8n2k7uklxq4aegau7vawtptkgxsja4kt99lpv6krctwpq8tpc65qjxd4lu4etruh9sngx3su9mtqp5fqzxz7re59y5nnez9p03ht3lyudcfhfe", + ); + } +} diff --git a/lwk_wollet/src/silentpayments/output.rs b/lwk_wollet/src/silentpayments/output.rs new file mode 100644 index 000000000..c760bab7f --- /dev/null +++ b/lwk_wollet/src/silentpayments/output.rs @@ -0,0 +1,28 @@ +//! Per-output silent payment key material. + +use crate::secp256k1::{PublicKey, SecretKey}; + +/// The per-output key material for output index `k`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SilentPaymentOutput { + /// `P_spend_k = B_spend + t_k·G`. + pub spend_pubkey: PublicKey, + /// `BK_k = bk_k·G`. + pub blinding_pubkey: PublicKey, + /// `bk_k`, recomputed by the receiver to unblind. + pub blinding_seckey: SecretKey, +} + +impl SilentPaymentOutput { + /// `P_spend_k` as an x-only key. + pub fn x_only_pubkey(&self) -> crate::elements::secp256k1_zkp::XOnlyPublicKey { + self.spend_pubkey.x_only_public_key().0 + } + + /// The `OP_1 ` scriptPubKey. + pub fn script_pubkey(&self) -> crate::elements::Script { + use crate::elements::schnorr::TweakedPublicKey; + let tweaked = TweakedPublicKey::new(self.x_only_pubkey()); + crate::elements::Script::new_v1_p2tr_tweaked(tweaked) + } +} diff --git a/lwk_wollet/src/silentpayments/receiver.rs b/lwk_wollet/src/silentpayments/receiver.rs new file mode 100644 index 000000000..8bb5d57bb --- /dev/null +++ b/lwk_wollet/src/silentpayments/receiver.rs @@ -0,0 +1,160 @@ +//! Recomputes silent-payment outputs and spend tweaks. + +use crate::secp256k1::{PublicKey, Scalar}; +use crate::silentpayments::inputs::InputHasher; +use crate::silentpayments::{ + ObservedInputs, SharedSecret, SilentPaymentOutput, SilentPaymentScan, SilentPaymentScanMaterial, +}; + +/// Scalar that tweaks `b_spend` into an output spend key. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SpendTweak(Scalar); + +impl SpendTweak { + /// Wrap a raw scalar as a spend tweak. + pub fn from_scalar(scalar: Scalar) -> Self { + SpendTweak(scalar) + } + + /// Underlying scalar. + pub fn as_scalar(&self) -> &Scalar { + &self.0 + } + + /// The 32 big-endian bytes, as persisted and as carried in PSET metadata. + pub fn to_be_bytes(self) -> [u8; 32] { + self.0.to_be_bytes() + } + + /// Rebuild from persisted bytes. + pub fn from_be_bytes(bytes: [u8; 32]) -> Option { + Scalar::from_be_bytes(bytes).ok().map(SpendTweak) + } + + /// Adds a label tweak. + pub fn add_label_tweak(self, label_tweak: &Scalar) -> Option { + let combined = crate::secp256k1::SecretKey::from_slice(&self.0.to_be_bytes()) + .ok()? + .add_tweak(label_tweak) + .ok()?; + Scalar::from_be_bytes(combined.secret_bytes()) + .ok() + .map(SpendTweak) + } + + /// Applies this tweak to a spend base. + pub fn applied_to(&self, spend_base: &PublicKey) -> Option { + spend_base.add_exp_tweak(&crate::util::EC, &self.0).ok() + } +} + +/// Recomputes outputs from scan material. +#[derive(Debug, Clone, Copy)] +pub struct SilentPaymentReceiver { + material: SilentPaymentScanMaterial, +} + +impl SilentPaymentReceiver { + /// Build a receiver from the wallet's scan-only material. + pub fn new(material: SilentPaymentScanMaterial) -> Self { + SilentPaymentReceiver { material } + } + + /// The scan-only material backing this receiver. + pub fn material(&self) -> &SilentPaymentScanMaterial { + &self.material + } + + /// Recomputes output `k` from aggregated inputs. + pub fn derive_output( + &self, + a_sum_pubkey: &PublicKey, + input_hash: &Scalar, + k: u32, + ) -> (SilentPaymentOutput, SpendTweak) { + let shared_secret = + SharedSecret::for_receiver(&self.material.scan_seckey(), a_sum_pubkey, input_hash); + self.derive_from_shared_secret(&shared_secret, k) + } + + /// Recompute the output for index `k` from an observer's aggregated inputs. + pub fn derive_output_from_observed( + &self, + observed: &ObservedInputs, + k: u32, + ) -> (SilentPaymentOutput, SpendTweak) { + self.derive_output(&observed.a_pubkey, &observed.input_hash, k) + } + + /// Recomputes output `k` from an aggregate pubkey and raw outpoint. + pub fn derive_output_from_raw( + &self, + a_sum_pubkey: &PublicKey, + outpoint_l: &[u8], + k: u32, + ) -> (SilentPaymentOutput, SpendTweak) { + let ih = InputHasher::hash(outpoint_l, a_sum_pubkey); + self.derive_output(a_sum_pubkey, &ih, k) + } + + /// Derives output `k` from a shared secret. + pub(crate) fn derive_from_shared_secret( + &self, + shared_secret: &SharedSecret, + k: u32, + ) -> (SilentPaymentOutput, SpendTweak) { + let out = shared_secret.derive_output(&self.material.spend_pubkey(), k); + (out, SpendTweak::from_scalar(shared_secret.spend_tweak(k))) + } + + /// The labeled spend base `B_m` for label `m`, computed by public point addition. + pub(crate) fn labeled_spend_base(&self, m: u32) -> PublicKey { + self.material.labeled_spend_base(m) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::silentpayments::test_fixture::SilentPaymentTestData as Data; + use crate::silentpayments::SilentPaymentSender; + + fn material() -> SilentPaymentScanMaterial { + Data::material(0x11, 0x22) + } + + /// Spend tweaks reproduce output keys from public data. + #[test] + fn spend_tweak_reproduces_the_output_key_from_public_data() { + let m = material(); + let inputs = [ + (Data::outpoint(1, 0), Data::secret_key(0xA1)), + (Data::outpoint(2, 1), Data::secret_key(0xA2)), + ]; + let sender = SilentPaymentSender::from_inputs(&inputs).unwrap(); + let receiver = SilentPaymentReceiver::new(m); + + for k in 0..3u32 { + let (out, tweak) = receiver.derive_output_from_observed(&sender.inputs().observed(), k); + assert_eq!( + tweak.applied_to(&m.spend_pubkey()).unwrap(), + out.spend_pubkey, + "B_spend + t_k*G must equal the output spend key at k={k}" + ); + assert_eq!( + SpendTweak::from_be_bytes(tweak.to_be_bytes()), + Some(tweak), + "a persisted tweak must come back unchanged" + ); + } + + let base_tweak = SpendTweak::from_scalar(Scalar::from_be_bytes([0x09; 32]).unwrap()); + let label = 7u32; + let combined = base_tweak.add_label_tweak(&m.label_tweak(label)).unwrap(); + assert_eq!( + combined.applied_to(&m.spend_pubkey()).unwrap(), + base_tweak.applied_to(&m.labeled_spend_base(label)).unwrap(), + "folding a label in must equal tweaking the labeled base" + ); + } +} diff --git a/lwk_wollet/src/silentpayments/recipient.rs b/lwk_wollet/src/silentpayments/recipient.rs new file mode 100644 index 000000000..9d85e844a --- /dev/null +++ b/lwk_wollet/src/silentpayments/recipient.rs @@ -0,0 +1,136 @@ +//! Pending silent-payment recipients and input-dependent resolution. + +use crate::elements::{AssetId, OutPoint}; +use crate::silentpayments::{InputKey, SilentPaymentAddress, SilentPaymentSender}; +use crate::{Error, Recipient}; +use std::collections::HashMap; + +/// A pending silent payment: who to pay, how much, and of which asset. +/// +/// Created by [`crate::TxBuilder::add_silent_payment_recipient()`]. +#[derive(Debug, Clone)] +pub struct SilentPaymentRecipient { + /// The receiver's reusable silent payment address. + pub address: SilentPaymentAddress, + + /// The amount to send, in satoshi. + pub satoshi: u64, + + /// The asset to send. + pub asset: AssetId, +} + +impl SilentPaymentRecipient { + /// Queue a payment of `satoshi` units of `asset` to `address`. + pub fn new(address: SilentPaymentAddress, satoshi: u64, asset: AssetId) -> Self { + Self { + address, + satoshi, + asset, + } + } + + /// Resolves recipients using the transaction's eligible input keys. + pub fn resolve_all( + recipients: &[SilentPaymentRecipient], + inputs: &[(OutPoint, InputKey)], + extra_outpoints: &[OutPoint], + ) -> Result, Error> { + if recipients.is_empty() { + return Ok(vec![]); + } + + let sender = SilentPaymentSender::from_input_keys(inputs, extra_outpoints)?; + + let mut next_index_by_scan: HashMap<_, u32> = HashMap::new(); + recipients + .iter() + .map(|r| { + let k = next_index_by_scan.entry(r.address.scan).or_insert(0); + let index = *k; + *k = (*k) + .checked_add(1) + .ok_or(Error::SilentPaymentRequiresKeys(0))?; + let k = index; + let out = sender + .try_derive_output(&r.address, k) + .ok_or(crate::silentpayments::SilentPaymentInputError::TooManyOutputs)?; + + Ok(ResolvedSilentPayment { + recipient: Recipient { + satoshi: r.satoshi, + script_pubkey: out.script_pubkey(), + blinding_pubkey: Some(out.blinding_pubkey), + asset: r.asset, + }, + output: out, + }) + }) + .collect() + } +} + +/// A resolved recipient and its derived silent-payment output. +#[derive(Debug, Clone)] +pub struct ResolvedSilentPayment { + /// The recipient as passed to the transaction builder. + pub recipient: Recipient, + + /// The derived output the recipient resolves to. + pub output: crate::silentpayments::SilentPaymentOutput, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::silentpayments::test_fixture::SilentPaymentTestData as Data; + use crate::silentpayments::SilentPaymentScan; + + /// Several silent payments in one transaction must land on DISTINCT outputs: + /// each recipient takes the next index `k`, per BIP-352. + #[test] + fn multiple_recipients_get_distinct_indices() { + let a = Data::material(0x11, 0x22); + let b = Data::material(0x33, 0x44); + let asset = AssetId::from_slice(&[0x42u8; 32]).unwrap(); + let inputs = [( + Data::outpoint(0x10, 0), + InputKey::Plain(Data::secret_key(0x31)), + )]; + + // Two payments to DIFFERENT addresses. + let pending = [ + SilentPaymentRecipient::new(a.address(), 1_000, asset), + SilentPaymentRecipient::new(b.address(), 2_000, asset), + ]; + let resolved = SilentPaymentRecipient::resolve_all(&pending, &inputs, &[]).unwrap(); + assert_ne!( + resolved[0].recipient.script_pubkey, + resolved[1].recipient.script_pubkey + ); + + // Two payments to the SAME address must also differ — this is the case that + // would silently collapse into one output if `k` were not incremented, + // burning the second payment. + let same = [ + SilentPaymentRecipient::new(a.address(), 1_000, asset), + SilentPaymentRecipient::new(a.address(), 2_000, asset), + ]; + let resolved = SilentPaymentRecipient::resolve_all(&same, &inputs, &[]).unwrap(); + assert_ne!( + resolved[0].recipient.script_pubkey, resolved[1].recipient.script_pubkey, + "two payments to one address must not collapse onto the same output" + ); + assert_eq!(resolved[0].recipient.satoshi, 1_000); + assert_eq!(resolved[1].recipient.satoshi, 2_000); + } + + /// No recipients → no inputs needed, and no error. Resolution must not demand + /// input keys from a transaction that has no silent payments in it. + #[test] + fn resolving_nothing_is_a_no_op() { + assert!(SilentPaymentRecipient::resolve_all(&[], &[], &[]) + .unwrap() + .is_empty()); + } +} diff --git a/lwk_wollet/src/silentpayments/scan_material.rs b/lwk_wollet/src/silentpayments/scan_material.rs new file mode 100644 index 000000000..be398346b --- /dev/null +++ b/lwk_wollet/src/silentpayments/scan_material.rs @@ -0,0 +1,113 @@ +//! Scan-only material and labeled silent-payment addresses. + +use crate::hashes::{Hash, HashEngine}; +use crate::secp256k1::Scalar; +use crate::silentpayments::tags::LabelHash; +use crate::silentpayments::SilentPaymentAddress; +use crate::util::EC; + +/// Scan-only silent-payment material. +pub use lwk_common::silentpayments::SilentPaymentScanMaterial; + +/// The account coordinates a wallet's silent payments belong to. +pub use lwk_common::silentpayments::SilentPaymentAccount; + +/// BIP-352 change label (`m = 0`). +pub const CHANGE_LABEL: u32 = 0; + +/// Scan-material address derivation. +pub trait SilentPaymentScan { + /// The (unlabeled) public address for this material. + fn address(&self) -> SilentPaymentAddress; + + /// Derives BIP-352's label tweak for `m`. + fn label_tweak(&self, m: u32) -> Scalar; + + /// Derives the labeled address for `m`. + fn labeled_address(&self, m: u32) -> SilentPaymentAddress; + + /// The labeled spend base `B_m = B_spend + label_tweak_m·G`. + fn labeled_spend_base(&self, m: u32) -> crate::secp256k1::PublicKey; +} + +impl SilentPaymentScan for SilentPaymentScanMaterial { + fn address(&self) -> SilentPaymentAddress { + SilentPaymentAddress { + scan: self.scan_seckey().public_key(&EC), + spend: self.spend_pubkey(), + } + } + + fn label_tweak(&self, m: u32) -> Scalar { + let mut eng = LabelHash::engine(); + eng.input(&self.scan_seckey().secret_bytes()); + eng.input(&m.to_be_bytes()); + let h = LabelHash::from_engine(eng); + Scalar::from_be_bytes(h.to_byte_array()).expect("label tweak within curve order") + } + + fn labeled_address(&self, m: u32) -> SilentPaymentAddress { + SilentPaymentAddress { + scan: self.scan_seckey().public_key(&EC), + spend: self.labeled_spend_base(m), + } + } + + fn labeled_spend_base(&self, m: u32) -> crate::secp256k1::PublicKey { + self.spend_pubkey() + .add_exp_tweak(&EC, &self.label_tweak(m)) + .expect("labeled spend key") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::silentpayments::test_fixture::SilentPaymentTestData as Data; + + /// Addresses derive from scan material without a spend secret. + #[test] + fn addresses_derive_from_public_material_only() { + let m = Data::material(0x11, 0x22); + let plain = m.address(); + assert_eq!(plain.scan, m.scan_seckey().public_key(&EC)); + assert_eq!(plain.spend, m.spend_pubkey()); + + for label in [CHANGE_LABEL, 7, 99] { + let labeled = m.labeled_address(label); + assert_eq!(labeled.scan, plain.scan, "scan key is never tweaked"); + assert_ne!(labeled.spend, plain.spend, "spend base is tweaked"); + assert_eq!( + labeled.spend, + plain + .spend + .add_exp_tweak(&EC, &m.label_tweak(label)) + .unwrap(), + "labeled base must be B_spend + label_tweak*G" + ); + } + } + + /// Labels commit to the scan key. + #[test] + fn label_tweak_commits_to_the_scan_key() { + let a = Data::material(0x11, 0x22); + let b = Data::material(0x33, 0x22); + assert_eq!(a.spend_pubkey(), b.spend_pubkey(), "same spend base"); + assert_ne!( + a.label_tweak(0).to_be_bytes(), + b.label_tweak(0).to_be_bytes(), + "different scan keys must give different label tweaks" + ); + } + + /// Scan material exposes no spend secret. + #[test] + fn scan_material_has_no_spend_secret() { + let m = Data::material(0x11, 0x22); + + let _: crate::secp256k1::PublicKey = m.spend_pubkey(); + + let _: crate::secp256k1::SecretKey = m.scan_seckey(); + } +} diff --git a/lwk_wollet/src/silentpayments/scanner.rs b/lwk_wollet/src/silentpayments/scanner.rs new file mode 100644 index 000000000..4c0f0e1af --- /dev/null +++ b/lwk_wollet/src/silentpayments/scanner.rs @@ -0,0 +1,288 @@ +//! Gap-limited silent-payment scanning. + +use crate::secp256k1::PublicKey; +use crate::silentpayments::{ + PartialTweak, SharedSecret, SilentPaymentOutput, SilentPaymentReceiver, SilentPaymentScan, + SilentPaymentScanMaterial, SpendTweak, +}; +use crate::util::EC; + +/// A silent-payment output found by a scan. +#[derive(Debug, Clone)] +pub struct LabeledHit { + /// Output counter `k`. + pub k: u32, + /// The label `m` the output was sent to, if any. `Some(0)` is change. + pub label: Option, + /// The recomputed SP output (spend pubkey + blinding keys). + pub output: SilentPaymentOutput, + /// Tweak needed to spend this output. + pub spend_tweak: SpendTweak, +} + +/// Scans transactions for silent-payment outputs. +#[derive(Debug, Clone)] +pub struct SilentPaymentScanner { + receiver: SilentPaymentReceiver, + labels: Vec, + gap_limit: u32, +} + +impl SilentPaymentScanner { + /// Default consecutive-miss limit. + pub const DEFAULT_GAP_LIMIT: u32 = 3; + + /// Exclusive BIP-352 output-count limit. + pub const K_MAX: u32 = 2323; + + /// A scanner for `material` that looks only for plain (unlabeled) outputs. + pub fn new(material: SilentPaymentScanMaterial) -> Self { + SilentPaymentScanner { + receiver: SilentPaymentReceiver::new(material), + labels: Vec::new(), + gap_limit: Self::DEFAULT_GAP_LIMIT, + } + } + + /// Also detect outputs sent to any of `labels` (e.g. + /// [`crate::silentpayments::CHANGE_LABEL`]). + pub fn with_labels(mut self, labels: impl IntoIterator) -> Self { + self.labels = labels.into_iter().collect(); + self + } + + /// Stop scanning after `gap_limit` consecutive misses. + pub fn with_gap_limit(mut self, gap_limit: u32) -> Self { + self.gap_limit = gap_limit; + self + } + + /// Scans a transaction's partial tweak for owned outputs. + pub fn scan( + &self, + partial_tweak: &PartialTweak, + output_scripts: &[crate::elements::Script], + ) -> Vec { + let material = self.receiver.material(); + let shared_secret = + SharedSecret::from_partial_tweak(&material.scan_seckey(), partial_tweak.as_pubkey()); + let labeled_bases = self.labeled_bases(); + + let mut found = Vec::new(); + let mut k = 0u32; + let mut misses = 0u32; + while misses < self.gap_limit && k < Self::K_MAX { + let t_k = shared_secret.spend_tweak(k); + let mut hit = false; + + let (plain, spend_tweak) = self.receiver.derive_from_shared_secret(&shared_secret, k); + if output_scripts.iter().any(|s| *s == plain.script_pubkey()) { + found.push(LabeledHit { + k, + label: None, + output: plain, + spend_tweak, + }); + hit = true; + } + + for (m, base) in &labeled_bases { + let spend_pubkey = base.add_exp_tweak(&EC, &t_k).expect("labeled P_k"); + let output = SilentPaymentOutput { + spend_pubkey, + blinding_pubkey: plain.blinding_pubkey, + blinding_seckey: plain.blinding_seckey, + }; + if output_scripts.iter().any(|s| *s == output.script_pubkey()) { + let Some(spend_tweak) = + SpendTweak::from_scalar(t_k).add_label_tweak(&material.label_tweak(*m)) + else { + continue; + }; + found.push(LabeledHit { + k, + label: Some(*m), + output, + spend_tweak, + }); + hit = true; + } + } + + if hit { + misses = 0; + } else { + misses += 1; + } + k += 1; + } + found + } + + /// Derives candidate output scripts for `0..count`. + pub fn candidate_scripts( + &self, + partial_tweak: &PartialTweak, + count: u32, + ) -> Vec { + let material = self.receiver.material(); + let shared_secret = + SharedSecret::from_partial_tweak(&material.scan_seckey(), partial_tweak.as_pubkey()); + let labeled_bases = self.labeled_bases(); + + let mut scripts = Vec::new(); + for k in 0..count.min(Self::K_MAX) { + let t_k = shared_secret.spend_tweak(k); + let (plain, _) = self.receiver.derive_from_shared_secret(&shared_secret, k); + scripts.push(plain.script_pubkey()); + + for (_, base) in &labeled_bases { + let spend_pubkey = base.add_exp_tweak(&EC, &t_k).expect("labeled P_k"); + let output = SilentPaymentOutput { + spend_pubkey, + blinding_pubkey: plain.blinding_pubkey, + blinding_seckey: plain.blinding_seckey, + }; + scripts.push(output.script_pubkey()); + } + } + scripts + } + + /// Computes labeled spend bases. + fn labeled_bases(&self) -> Vec<(u32, PublicKey)> { + self.labels + .iter() + .map(|&m| (m, self.receiver.labeled_spend_base(m))) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::silentpayments::test_fixture::SilentPaymentTestData as Data; + use crate::silentpayments::{SilentPaymentSender, SilentPaymentTweakClient, CHANGE_LABEL}; + + /// Verifies a scan hit using public spend material. + fn assert_hit_verifies(hit: &LabeledHit, m: &SilentPaymentScanMaterial) { + assert_eq!( + hit.spend_tweak.applied_to(&m.spend_pubkey()).unwrap(), + hit.output.spend_pubkey, + "tweak must reproduce the output spend key at k={}", + hit.k + ); + } + + #[test] + fn scan_finds_every_output_it_should_and_no_others() { + struct MockServer { + tweaks: Vec, + } + impl SilentPaymentTweakClient for MockServer { + type Error = (); + fn tweaks(&self, _height: u32) -> Result, ()> { + Ok(self.tweaks.clone()) + } + } + + let keys = Data::material(0x11, 0x22); + let other_label = 7u32; + + let change_addr = keys.labeled_address(CHANGE_LABEL); + assert_ne!(change_addr.spend, keys.address().spend); + assert_eq!(change_addr.scan, keys.address().scan); + + let inputs = [ + (Data::outpoint(0x10, 0), Data::secret_key(0x31)), + (Data::outpoint(0x20, 1), Data::secret_key(0x32)), + ]; + let sender = SilentPaymentSender::from_inputs(&inputs).unwrap(); + let agg = sender.inputs(); + + let plain0 = sender.derive_output(&keys.address(), 0); + let plain1 = sender.derive_output(&keys.address(), 1); + let change = sender.derive_output(&change_addr, 0); + let labeled = sender.derive_output(&keys.labeled_address(other_label), 0); + let noise = sender.derive_output(&Data::material(0xEE, 0xEF).address(), 0); + + let output_scripts = vec![ + noise.script_pubkey(), + plain0.script_pubkey(), + plain1.script_pubkey(), + change.script_pubkey(), + labeled.script_pubkey(), + crate::elements::Script::new(), // fee + ]; + + let server = MockServer { + tweaks: vec![PartialTweak::new(&agg.a_pubkey, &agg.input_hash)], + }; + let published = server.tweaks(1).unwrap(); + + let plain_scanner = SilentPaymentScanner::new(keys); + let mut hits = Vec::new(); + for t in &published { + hits.extend(plain_scanner.scan(t, &output_scripts)); + } + assert_eq!(hits.len(), 2, "should find both plain SP outputs"); + assert_eq!(hits.iter().map(|h| h.k).collect::>(), vec![0, 1]); + for hit in &hits { + assert_hit_verifies(hit, &keys); + assert_eq!(hit.label, None, "these are unlabeled outputs"); + } + + let labeled_hits = SilentPaymentScanner::new(keys) + .with_labels([CHANGE_LABEL, other_label]) + .scan(&published[0], &output_scripts); + let found_labels: Vec<_> = labeled_hits.iter().filter_map(|h| h.label).collect(); + assert_eq!( + found_labels.len(), + 2, + "both labeled outputs should be found" + ); + assert!( + found_labels.contains(&CHANGE_LABEL), + "change label must be recognized" + ); + assert!(found_labels.contains(&other_label)); + for hit in &labeled_hits { + assert_hit_verifies(hit, &keys); + } + + assert!(SilentPaymentScanner::new(Data::material(0x77, 0x88)) + .with_labels([CHANGE_LABEL, other_label]) + .scan(&published[0], &output_scripts) + .is_empty()); + } + + #[test] + fn k_max_is_enforced_for_sender_and_scanner() { + let keys = Data::material(0x11, 0x22); + let sender = + SilentPaymentSender::from_inputs(&[(Data::outpoint(0x10, 0), Data::secret_key(0x31))]) + .unwrap(); + + assert!(sender + .try_derive_output(&keys.address(), SilentPaymentScanner::K_MAX - 1) + .is_some()); + assert!( + sender + .try_derive_output(&keys.address(), SilentPaymentScanner::K_MAX) + .is_none(), + "sender must refuse to build an output the receiver may not scan for" + ); + + let beyond = sender + .shared_secret(&keys.address()) + .derive_output(&keys.address().spend, SilentPaymentScanner::K_MAX); + let t = PartialTweak::from_observed(&sender.inputs().observed()); + let hits = SilentPaymentScanner::new(keys) + .with_gap_limit(u32::MAX) + .scan(&t, &[beyond.script_pubkey()]); + assert!( + hits.is_empty(), + "scanner must not find an output past K_max (and must terminate)" + ); + } +} diff --git a/lwk_wollet/src/silentpayments/sender.rs b/lwk_wollet/src/silentpayments/sender.rs new file mode 100644 index 000000000..4a923781a --- /dev/null +++ b/lwk_wollet/src/silentpayments/sender.rs @@ -0,0 +1,83 @@ +//! The sending side of a silent payment. + +use crate::secp256k1::SecretKey; +use crate::silentpayments::inputs::InputHasher; +use crate::silentpayments::{ + SharedSecret, SilentPaymentAddress, SilentPaymentInputs, SilentPaymentOutput, + SilentPaymentScanner, +}; +use crate::util::EC; + +/// Derives outputs for a silent-payment address. +#[derive(Debug, Clone, Copy)] +pub struct SilentPaymentSender { + inputs: SilentPaymentInputs, +} + +impl SilentPaymentSender { + /// Build a sender from the wallet's aggregated eligible inputs. + pub fn new(inputs: SilentPaymentInputs) -> Self { + SilentPaymentSender { inputs } + } + + /// Aggregate `(outpoint, private_key)` pairs and build the sender in one step. + pub fn from_inputs( + inputs: &[(crate::elements::OutPoint, SecretKey)], + ) -> Result { + Ok(Self::new(SilentPaymentInputs::aggregate(inputs)?)) + } + + /// Aggregates tagged inputs and keyless-input outpoints. + pub fn from_input_keys( + inputs: &[(crate::elements::OutPoint, crate::silentpayments::InputKey)], + extra_outpoints: &[crate::elements::OutPoint], + ) -> Result { + Ok(Self::new( + SilentPaymentInputs::aggregate_with_extra_outpoints(inputs, extra_outpoints)?, + )) + } + + /// The aggregated inputs backing this sender. + pub fn inputs(&self) -> &SilentPaymentInputs { + &self.inputs + } + + /// Derives `S = input_hash · a · B_scan`. + pub fn shared_secret(&self, address: &SilentPaymentAddress) -> SharedSecret { + SharedSecret::for_sender(&address.scan, &self.inputs) + } + + /// Derives output `k`; panics when `k >= K_MAX`. + pub fn derive_output(&self, address: &SilentPaymentAddress, k: u32) -> SilentPaymentOutput { + self.try_derive_output(address, k) + .expect("output index within K_max") + } + + /// Derives output `k`, or `None` when `k >= K_MAX`. + pub fn try_derive_output( + &self, + address: &SilentPaymentAddress, + k: u32, + ) -> Option { + if k >= SilentPaymentScanner::K_MAX { + return None; + } + Some(self.shared_secret(address).derive_output(&address.spend, k)) + } + + /// Derives an output from a summed key and serialized outpoint. + pub fn derive_output_from_raw( + address: &SilentPaymentAddress, + a_sum: &SecretKey, + outpoint_l: &[u8], + k: u32, + ) -> SilentPaymentOutput { + let a_pubkey = a_sum.public_key(&EC); + let inputs = SilentPaymentInputs { + a_sum: *a_sum, + a_pubkey, + input_hash: InputHasher::hash(outpoint_l, &a_pubkey), + }; + Self::new(inputs).derive_output(address, k) + } +} diff --git a/lwk_wollet/src/silentpayments/shared_secret.rs b/lwk_wollet/src/silentpayments/shared_secret.rs new file mode 100644 index 000000000..98523da69 --- /dev/null +++ b/lwk_wollet/src/silentpayments/shared_secret.rs @@ -0,0 +1,146 @@ +//! Shared-secret and per-output silent-payment derivation. + +use crate::hashes::{Hash, HashEngine}; +use crate::secp256k1::{PublicKey, Scalar, SecretKey}; +use crate::silentpayments::tags::{BlindHash, SharedSecretHash}; +use crate::silentpayments::{SilentPaymentInputs, SilentPaymentOutput}; +use crate::util::EC; + +/// An ECDH shared secret for one transaction and receiver. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SharedSecret(PublicKey); + +impl SharedSecret { + /// Sender's ECDH shared secret `S = input_hash · a · B_scan` from aggregated inputs. + pub fn for_sender(scan_pubkey: &PublicKey, inputs: &SilentPaymentInputs) -> Self { + let a_ih = inputs + .a_sum + .mul_tweak(&inputs.input_hash) + .expect("scalar mul"); + SharedSecret( + scan_pubkey + .mul_tweak( + &EC, + &Scalar::from_be_bytes(a_ih.secret_bytes()).expect("scalar"), + ) + .expect("ecdh point mul"), + ) + } + + /// Receiver's ECDH shared secret `S = input_hash · b_scan · A`. + pub fn for_receiver(scan_seckey: &SecretKey, a_sum_pubkey: &PublicKey, ih: &Scalar) -> Self { + let bscan_ih = scan_seckey.mul_tweak(ih).expect("scalar mul"); + SharedSecret( + a_sum_pubkey + .mul_tweak( + &EC, + &Scalar::from_be_bytes(bscan_ih.secret_bytes()).expect("scalar"), + ) + .expect("ecdh point mul"), + ) + } + + /// Derives `S = b_scan · T` from a server partial tweak. + pub fn from_partial_tweak(scan_seckey: &SecretKey, partial_tweak: &PublicKey) -> Self { + SharedSecret( + partial_tweak + .mul_tweak( + &EC, + &Scalar::from_be_bytes(scan_seckey.secret_bytes()).expect("scalar"), + ) + .expect("ecdh point mul"), + ) + } + + /// The underlying point, `serP(S)` being what the tagged hashes commit to. + pub fn as_pubkey(&self) -> &PublicKey { + &self.0 + } + + /// `t_k = H_BIP0352/SharedSecret(serP(S) || ser32(k))`, returned as a tweak scalar. + pub fn spend_tweak(&self, k: u32) -> Scalar { + let mut eng = SharedSecretHash::engine(); + eng.input(&self.0.serialize()); + eng.input(&k.to_be_bytes()); + let h = SharedSecretHash::from_engine(eng); + Scalar::from_be_bytes(h.to_byte_array()).expect("shared secret tweak within curve order") + } + + /// `bk_k = H_LiquidSilentPayments/Blind(serP(S) || ser32(k))`. + pub fn blinding_key(&self, k: u32) -> SecretKey { + let mut eng = BlindHash::engine(); + eng.input(&self.0.serialize()); + eng.input(&k.to_be_bytes()); + let h = BlindHash::from_engine(eng); + SecretKey::from_slice(&h.to_byte_array()).expect("blinding key within curve order") + } + + /// Derives the output spend and blinding keys for `k`. + pub fn derive_output(&self, spend_base: &PublicKey, k: u32) -> SilentPaymentOutput { + let t_k = self.spend_tweak(k); + let spend_pubkey = spend_base.add_exp_tweak(&EC, &t_k).expect("add exp tweak"); + + let blinding_seckey = self.blinding_key(k); + let blinding_pubkey = blinding_seckey.public_key(&EC); + + SilentPaymentOutput { + spend_pubkey, + blinding_pubkey, + blinding_seckey, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::silentpayments::test_fixture::SilentPaymentTestData as Data; + use crate::silentpayments::SilentPaymentScan; + + /// Q3: the spend tweak `t_k` and the blinding key `bk_k` are derived from the + /// SAME shared secret `S` but in DIFFERENT tagged-hash domains, so they are + /// independent — knowing one reveals nothing about the other, and the blinding + /// key is never accidentally equal to (a function of) the spend tweak. + #[test] + fn blinding_key_domain_separated_from_spend_tweak() { + let keys = Data::material(0x11, 0x22); + let a_sum = Data::secret_key(0x33); + let inputs = [(Data::outpoint(0xAB, 0), a_sum)]; + let agg = SilentPaymentInputs::aggregate(&inputs).unwrap(); + let s = SharedSecret::for_sender(&keys.address().scan, &agg); + + for k in 0..4u32 { + let t_k = s.spend_tweak(k); + let bk_k = s.blinding_key(k); + // Distinct domains → distinct 32-byte values. + assert_ne!( + t_k.to_be_bytes(), + bk_k.secret_bytes(), + "spend tweak and blinding key must differ at k={k}" + ); + } + } + + /// The three routes to `S` — sender, receiver, and tweak-server partial tweak — + /// must all land on the same point, or the scheme simply does not work. + #[test] + fn all_shared_secret_paths_agree() { + let keys = Data::material(0x11, 0x22); + let inputs = [ + (Data::outpoint(0x10, 0), Data::secret_key(0x31)), + (Data::outpoint(0x20, 1), Data::secret_key(0x32)), + ]; + let agg = SilentPaymentInputs::aggregate(&inputs).unwrap(); + + let sender = SharedSecret::for_sender(&keys.address().scan, &agg); + let receiver = + SharedSecret::for_receiver(&keys.scan_seckey(), &agg.a_pubkey, &agg.input_hash); + let via_server = SharedSecret::from_partial_tweak( + &keys.scan_seckey(), + crate::silentpayments::PartialTweak::new(&agg.a_pubkey, &agg.input_hash).as_pubkey(), + ); + + assert_eq!(sender, receiver); + assert_eq!(sender, via_server); + } +} diff --git a/lwk_wollet/src/silentpayments/sync.rs b/lwk_wollet/src/silentpayments/sync.rs new file mode 100644 index 000000000..a5fde9dab --- /dev/null +++ b/lwk_wollet/src/silentpayments/sync.rs @@ -0,0 +1,157 @@ +//! Scans block ranges for silent-payment outputs. + +use crate::elements::{Script, Txid}; +use crate::silentpayments::{PartialTweak, SilentPaymentScanMaterial, SilentPaymentScanner}; +use std::collections::HashMap; + +/// Plans a silent-payment scan over transaction tweaks. +#[derive(Debug, Clone)] +pub struct SilentPaymentSync { + material: SilentPaymentScanMaterial, + labels: Vec, + candidates_per_tx: u32, +} + +impl SilentPaymentSync { + /// Candidate output indices per transaction. + pub const DEFAULT_CANDIDATES_PER_TX: u32 = 3; + + /// Scans transactions for the plain address. + pub fn new(material: SilentPaymentScanMaterial) -> Self { + SilentPaymentSync { + material, + labels: Vec::new(), + candidates_per_tx: Self::DEFAULT_CANDIDATES_PER_TX, + } + } + + /// Also watch these BIP-352 labels (e.g. [`crate::silentpayments::CHANGE_LABEL`]). + pub fn with_labels(mut self, labels: impl IntoIterator) -> Self { + self.labels = labels.into_iter().collect(); + self + } + + /// Sets the candidate count, capped at [`SilentPaymentScanner::K_MAX`]. + pub fn with_candidates_per_tx(mut self, n: u32) -> Self { + self.candidates_per_tx = n.min(SilentPaymentScanner::K_MAX); + self + } + + /// Maps candidate scripts to their source transactions. + pub fn candidate_scripts(&self, tweaks: &[(Txid, PartialTweak)]) -> HashMap { + let scanner = self.scanner(); + let mut out = HashMap::new(); + for (txid, tweak) in tweaks { + for script in scanner.candidate_scripts(tweak, self.candidates_per_tx) { + out.entry(script).or_insert(*txid); + } + } + out + } + + /// Returns tweaks whose candidate scripts exist on chain. + pub fn tweaks_to_scan( + &self, + tweaks: &[(Txid, PartialTweak)], + found_scripts: &[Script], + ) -> Vec<(Txid, PartialTweak)> { + let by_script = self.candidate_scripts(tweaks); + let hit: std::collections::HashSet = found_scripts + .iter() + .filter_map(|s| by_script.get(s).copied()) + .collect(); + + tweaks + .iter() + .filter(|(txid, _)| hit.contains(txid)) + .cloned() + .collect() + } + + /// Returns a scanner configured with this sync's material and labels. + pub fn scanner(&self) -> SilentPaymentScanner { + SilentPaymentScanner::new(self.material).with_labels(self.labels.iter().copied()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::silentpayments::test_fixture::SilentPaymentTestData as Data; + use crate::silentpayments::{ + SilentPaymentInputs, SilentPaymentScan, SilentPaymentSender, CHANGE_LABEL, + }; + + fn keys() -> SilentPaymentScanMaterial { + Data::material(0x11, 0x22) + } + + fn payer(outpoint: u8, key: u8) -> (PartialTweak, SilentPaymentSender) { + let inputs = + SilentPaymentInputs::aggregate(&[(Data::outpoint(outpoint, 0), Data::secret_key(key))]) + .unwrap(); + let tweak = PartialTweak::from_observed(&inputs.observed()); + (tweak, SilentPaymentSender::new(inputs)) + } + + #[test] + fn candidate_generation() { + let keys = keys(); + let txid = Data::txid(0x01); + let (tweak, sender) = payer(0x10, 0x31); + + let plain = sender.derive_output(&keys.address(), 0).script_pubkey(); + let labeled = sender + .derive_output(&keys.labeled_address(CHANGE_LABEL), 0) + .script_pubkey(); + + let unlabeled_scan = SilentPaymentSync::new(keys).candidate_scripts(&[(txid, tweak)]); + assert_eq!( + unlabeled_scan.get(&plain), + Some(&txid), + "a real payment's script must be a candidate" + ); + assert!( + !unlabeled_scan.contains_key(&labeled), + "unlabeled scan must not generate labeled candidates" + ); + + let labeled_scan = SilentPaymentSync::new(keys) + .with_labels([CHANGE_LABEL]) + .candidate_scripts(&[(txid, tweak)]); + assert!( + labeled_scan.contains_key(&labeled), + "configured label must generate its candidate" + ); + + assert_eq!( + SilentPaymentSync::new(keys) + .with_candidates_per_tx(u32::MAX) + .candidates_per_tx, + SilentPaymentScanner::K_MAX, + "candidate count must stay bounded by K_MAX" + ); + } + + #[test] + fn narrowing_keeps_only_hit_transactions() { + let keys = keys(); + let (our_tweak, our_sender) = payer(0x10, 0x31); + let (their_tweak, _) = payer(0x20, 0x32); + + let our_txid = Data::txid(0x01); + let tweaks = vec![(our_txid, our_tweak), (Data::txid(0x02), their_tweak)]; + let our_script = our_sender.derive_output(&keys.address(), 0).script_pubkey(); + + let sync = SilentPaymentSync::new(keys); + + let to_scan = sync.tweaks_to_scan(&tweaks, &[our_script]); + assert_eq!(to_scan.len(), 1, "only the paying tx should be scanned"); + assert_eq!(to_scan[0].0, our_txid); + + assert!( + sync.tweaks_to_scan(&tweaks, &[]).is_empty(), + "no candidate seen on chain means no transaction to fetch" + ); + } +} diff --git a/lwk_wollet/src/silentpayments/tags.rs b/lwk_wollet/src/silentpayments/tags.rs new file mode 100644 index 000000000..8285195e0 --- /dev/null +++ b/lwk_wollet/src/silentpayments/tags.rs @@ -0,0 +1,26 @@ +use crate::hashes::sha256t_hash_newtype; + +sha256t_hash_newtype! { + pub(crate) struct InputsTag = hash_str("BIP0352/Inputs"); + /// `input_hash = H_BIP0352/Inputs(outpoint_L || A)`. + #[hash_newtype(forward)] + pub(crate) struct InputsHash(_); + + pub(crate) struct SharedSecretTag = hash_str("BIP0352/SharedSecret"); + /// `t_k = H_BIP0352/SharedSecret(serP(S) || ser32(k))`. + #[hash_newtype(forward)] + pub(crate) struct SharedSecretHash(_); + + pub(crate) struct BlindTag = hash_str("LiquidSilentPayments/Blind"); + /// `bk_k = H_LiquidSilentPayments/Blind(serP(S) || ser32(k))`. + /// + /// Shares its preimage with [`SharedSecretHash`]; only the tag makes `bk_k` and + /// `t_k` independent. + #[hash_newtype(forward)] + pub(crate) struct BlindHash(_); + + pub(crate) struct LabelTag = hash_str("BIP0352/Label"); + /// `label_tweak_m = H_BIP0352/Label(ser256(b_scan) || ser32(m))`. + #[hash_newtype(forward)] + pub(crate) struct LabelHash(_); +} diff --git a/lwk_wollet/src/silentpayments/test_fixture.rs b/lwk_wollet/src/silentpayments/test_fixture.rs new file mode 100644 index 000000000..fd29cf22c --- /dev/null +++ b/lwk_wollet/src/silentpayments/test_fixture.rs @@ -0,0 +1,150 @@ +//! Shared construction of silent-payment test transactions. + +use crate::elements::confidential::{Asset, Value}; +use crate::elements::{ + AssetId, LockTime, OutPoint, Script, Transaction, TxIn, TxOut, TxOutWitness, +}; +use crate::secp256k1::SecretKey; +use crate::silentpayments::{ + SilentPaymentAddress, SilentPaymentScan, SilentPaymentScanMaterial, + SilentPaymentSender, SpTxOutBuilder, +}; +use lwk_test_util::ElementsTestData; + +pub(crate) struct SilentPaymentTestData; + +impl SilentPaymentTestData { + pub(crate) fn secret_key(byte: u8) -> SecretKey { + ElementsTestData::secret_key(byte) + } + + pub(crate) fn outpoint(txid_byte: u8, vout: u32) -> OutPoint { + ElementsTestData::outpoint(txid_byte, vout) + } + + pub(crate) fn txid(byte: u8) -> crate::elements::Txid { + ElementsTestData::txid(byte) + } + + pub(crate) fn asset() -> AssetId { + AssetId::from_slice(&[0x42u8; 32]).unwrap() + } + + pub(crate) fn material(scan: u8, spend: u8) -> SilentPaymentScanMaterial { + SilentPaymentScanMaterial::new( + crate::silentpayments::SilentPaymentAccount::liquid_testnet(0), + ElementsTestData::secret_key(scan), + ElementsTestData::public_key(spend), + ) + } +} + +/// A silent payment built for a test. +pub(crate) struct SpPayment { + pub(crate) tx: Transaction, + /// The scriptPubKeys the transaction's inputs spend. + pub(crate) prevouts: Vec<(OutPoint, Script)>, +} + +impl SpPayment { + /// Resolves prevout scripts, borrowing from the fixture rather than the argument. + pub(crate) fn prevout_lookup<'a>(&'a self) -> impl FnMut(&OutPoint) -> Option<&'a Script> { + move |o: &OutPoint| self.prevouts.iter().find(|(p, _)| p == o).map(|(_, s)| s) + } +} + +/// Builds silent-payment transactions for tests, defaulting to a two-input payment +/// at index 0. +pub(crate) struct SpPaymentBuilder { + inputs: Vec<(OutPoint, SecretKey)>, + k: u32, + value: u64, + asset: AssetId, + /// Whether to append a second, non-silent output. + extra_output: bool, +} + +impl Default for SpPaymentBuilder { + fn default() -> Self { + Self::new() + } +} + +impl SpPaymentBuilder { + pub(crate) fn new() -> Self { + SpPaymentBuilder { + inputs: vec![ + ( + ElementsTestData::outpoint(0x22, 1), + ElementsTestData::secret_key(0xA1), + ), + ( + ElementsTestData::outpoint(0x11, 0), + ElementsTestData::secret_key(0xA2), + ), + ], + k: 0, + value: 50_000, + asset: SilentPaymentTestData::asset(), + extra_output: true, + } + } + + pub(crate) fn with_inputs(mut self, inputs: &[(OutPoint, SecretKey)]) -> Self { + self.inputs = inputs.to_vec(); + self + } + + pub(crate) fn with_value(mut self, value: u64) -> Self { + self.value = value; + self + } + + /// Build a payment to `address`. + pub(crate) fn build(self, address: &SilentPaymentAddress) -> SpPayment { + let sender = SilentPaymentSender::from_inputs(&self.inputs) + .expect("the fixture's inputs must aggregate"); + let output = sender.derive_output(address, self.k); + + let (sp_txout, _) = + SpTxOutBuilder::build(&output, self.asset, self.value, &mut rand::thread_rng()) + .expect("building the silent payment output must succeed"); + + let mut outputs = vec![sp_txout]; + if self.extra_output { + outputs.push(TxOut { + asset: Asset::Explicit(self.asset), + value: Value::Explicit(500), + nonce: Default::default(), + script_pubkey: Script::new(), + witness: TxOutWitness::default(), + }); + } + + let tx = Transaction { + version: 2, + lock_time: LockTime::ZERO, + input: self.inputs.iter().map(Self::witness_input).collect(), + output: outputs, + }; + + SpPayment { + prevouts: self.inputs.iter().map(Self::prevout).collect(), + tx, + } + } + + /// Build a payment to the address `material` publishes. + pub(crate) fn build_for(self, material: &SilentPaymentScanMaterial) -> SpPayment { + self.build(&material.address()) + } + + /// A P2WPKH input whose witness carries the pubkey the key is recovered from. + fn witness_input((outpoint, key): &(OutPoint, SecretKey)) -> TxIn { + ElementsTestData::p2wpkh_input(*outpoint, key) + } + + fn prevout((outpoint, key): &(OutPoint, SecretKey)) -> (OutPoint, Script) { + (*outpoint, ElementsTestData::p2wpkh(key)) + } +} diff --git a/lwk_wollet/src/silentpayments/tweak_server.rs b/lwk_wollet/src/silentpayments/tweak_server.rs new file mode 100644 index 000000000..4d6b7b869 --- /dev/null +++ b/lwk_wollet/src/silentpayments/tweak_server.rs @@ -0,0 +1,82 @@ +//! The tweak/index server model (design §4.5, BIP0352-index-server-specification). + +use crate::secp256k1::{PublicKey, Scalar}; +use crate::silentpayments::{ObservedInputs, SilentPaymentInputError}; +use crate::util::EC; + +/// A server-published partial tweak: `T = input_hash · A`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PartialTweak(PublicKey); + +impl PartialTweak { + /// `T = input_hash · A`. + pub fn new(a_pubkey: &PublicKey, input_hash: &Scalar) -> Self { + PartialTweak( + a_pubkey + .mul_tweak(&EC, input_hash) + .expect("partial tweak point mul"), + ) + } + + /// Compute a partial tweak directly from an observer's aggregated inputs. + pub fn from_observed(observed: &ObservedInputs) -> Self { + Self::new(&observed.a_pubkey, &observed.input_hash) + } + + /// Computes a partial tweak from observed input keys. + pub fn from_inputs( + inputs: &[(crate::elements::OutPoint, PublicKey)], + ) -> Result { + Ok(Self::from_observed(&ObservedInputs::aggregate(inputs)?)) + } + + /// The underlying point, as published by the server and consumed by clients. + pub fn as_pubkey(&self) -> &PublicKey { + &self.0 + } +} + +impl From for PublicKey { + fn from(t: PartialTweak) -> Self { + t.0 + } +} + +/// A source of per-transaction BIP-352 partial tweaks. +pub trait SilentPaymentTweakClient { + /// The error type returned by the backend. + type Error; + + /// Return the partial tweaks `T = input_hash·A` for every SP-eligible + /// transaction in the block at `height`. + fn tweaks(&self, height: u32) -> Result, Self::Error>; +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::silentpayments::test_fixture::SilentPaymentTestData as Data; + use crate::silentpayments::{SharedSecret, SilentPaymentInputs}; + + #[test] + fn every_route_to_a_partial_tweak_agrees() { + let keys = Data::material(0x11, 0x22); + let inputs = [ + (Data::outpoint(0x10, 0), Data::secret_key(0x31)), + (Data::outpoint(0x20, 1), Data::secret_key(0x32)), + ]; + let agg = SilentPaymentInputs::aggregate(&inputs).unwrap(); + let observed: Vec<_> = inputs + .iter() + .map(|(o, s)| (*o, s.public_key(&EC))) + .collect(); + + let t = PartialTweak::new(&agg.a_pubkey, &agg.input_hash); + assert_eq!(t, PartialTweak::from_observed(&agg.observed())); + assert_eq!(t, PartialTweak::from_inputs(&observed).unwrap()); + + let client = SharedSecret::from_partial_tweak(&keys.scan_seckey(), t.as_pubkey()); + let sender = SharedSecret::for_sender(&keys.scan_seckey().public_key(&EC), &agg); + assert_eq!(client, sender); + } +} diff --git a/lwk_wollet/src/silentpayments/tx_inputs.rs b/lwk_wollet/src/silentpayments/tx_inputs.rs new file mode 100644 index 000000000..f849635f8 --- /dev/null +++ b/lwk_wollet/src/silentpayments/tx_inputs.rs @@ -0,0 +1,629 @@ +//! Public silent-payment input-key recovery. + +use crate::elements::{OutPoint, Script, Transaction}; +use crate::secp256k1::PublicKey; +use crate::silentpayments::{ObservedInputs, SilentPaymentInputError}; + +/// Recovers the single pubkey one input contributes to `A = Σ A_i`. +pub struct InputPubkeyRecovery<'a> { + prevout_script: &'a Script, + script_sig: &'a Script, + witness: &'a [Vec], +} + +impl<'a> InputPubkeyRecovery<'a> { + const COMPRESSED_LEN: usize = 33; + const P2SH_P2WPKH_REDEEM_LEN: usize = 22; + const ANNEX_PREFIX: u8 = 0x50; + + /// Build a recovery view over one input. + pub fn new(prevout_script: &'a Script, script_sig: &'a Script, witness: &'a [Vec]) -> Self { + InputPubkeyRecovery { + prevout_script, + script_sig, + witness, + } + } + + /// Recovers this input's eligible pubkey. + pub fn recover(&self) -> Option { + if self.prevout_script.is_v1_p2tr() { + self.taproot_output_key() + } else if self.prevout_script.is_v0_p2wpkh() { + self.witness_pubkey() + } else if self.prevout_script.is_p2sh() { + self.nested_p2wpkh_pubkey() + } else if self.prevout_script.is_p2pkh() { + self.script_sig_pubkey() + } else { + None + } + } + + /// The NUMS point `H` from BIP-341, `lift_x(0x50929b74...803ac)`. + const NUMS_H: [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, + ]; + + /// Recovers an eligible Taproot output key. + fn taproot_output_key(&self) -> Option { + if !self.is_eligible_taproot_spend() { + return None; + } + + let spk = self.prevout_script.as_bytes(); + let x_only = spk.get(2..34)?; + let key = crate::elements::secp256k1_zkp::XOnlyPublicKey::from_slice(x_only).ok()?; + Some(PublicKey::from_x_only_public_key( + key, + crate::elements::secp256k1_zkp::Parity::Even, + )) + } + + fn is_eligible_taproot_spend(&self) -> bool { + let mut stack = self.witness; + // A single witness item is always the key-path signature, never an annex. + if stack.len() > 1 { + if let Some((last, rest)) = stack.split_last() { + if last.first() == Some(&Self::ANNEX_PREFIX) { + stack = rest; + } + } + } + + match stack.len() { + 1 => true, + n if n >= 2 => { + let control_block = &stack[n - 1]; + match crate::elements::taproot::ControlBlock::from_slice(control_block) { + Ok(cb) => cb.internal_key.serialize() != Self::NUMS_H, + Err(_) => false, + } + } + _ => false, + } + } + + /// P2WPKH: witness is `[signature, pubkey]`. + fn witness_pubkey(&self) -> Option { + if self.witness.len() != 2 { + return None; + } + Self::parse_pubkey(self.witness.get(1)?) + } + + /// Recovers a P2SH-wrapped P2WPKH pubkey. + fn nested_p2wpkh_pubkey(&self) -> Option { + let sig = self.script_sig.as_bytes(); + let redeem = sig.strip_prefix(&[Self::P2SH_P2WPKH_REDEEM_LEN as u8])?; + if redeem.len() != Self::P2SH_P2WPKH_REDEEM_LEN || redeem[0] != 0x00 || redeem[1] != 0x14 { + return None; + } + self.witness_pubkey() + } + + /// Recovers the committed compressed P2PKH pubkey. + fn script_sig_pubkey(&self) -> Option { + let spk_hash = self.prevout_script.as_bytes().get(3..3 + 20)?; + let sig = self.script_sig.as_bytes(); + + (Self::COMPRESSED_LEN..=sig.len()).rev().find_map(|end| { + let candidate = &sig[end - Self::COMPRESSED_LEN..end]; + use crate::elements::hashes::{hash160, Hash as _}; + (hash160::Hash::hash(candidate).to_byte_array() == spk_hash) + .then(|| Self::parse_pubkey(candidate)) + .flatten() + }) + } + + /// Parses a compressed pubkey. + fn parse_pubkey(bytes: &[u8]) -> Option { + if bytes.len() != Self::COMPRESSED_LEN { + return None; + } + PublicKey::from_slice(bytes).ok() + } +} + +/// Transaction inputs classified for silent payments. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SilentPaymentTxInputs { + eligible: Vec<(OutPoint, PublicKey)>, + ineligible: Vec, + unknown_segwit_version: bool, +} + +impl SilentPaymentTxInputs { + /// Classifies transaction inputs from their prevout scripts. + pub fn extract<'a, F>(tx: &Transaction, mut prevout_script: F) -> Self + where + F: FnMut(&OutPoint) -> Option<&'a Script>, + { + let mut eligible = Vec::new(); + let mut ineligible = Vec::new(); + let mut unknown_segwit_version = false; + + for input in &tx.input { + let outpoint = input.previous_output; + + if input.is_pegin() { + ineligible.push(outpoint); + continue; + } + + let spk = prevout_script(&outpoint); + + if spk.is_some_and(Self::is_unknown_segwit_version) { + unknown_segwit_version = true; + } + + let pubkey = spk.and_then(|spk| { + InputPubkeyRecovery::new(spk, &input.script_sig, &input.witness.script_witness) + .recover() + }); + + match pubkey { + Some(pk) => eligible.push((outpoint, pk)), + None => ineligible.push(outpoint), + } + } + + SilentPaymentTxInputs { + eligible, + ineligible, + unknown_segwit_version, + } + } + + fn is_unknown_segwit_version(spk: &Script) -> bool { + if !spk.is_witness_program() { + return false; + } + let version_byte = spk.as_bytes()[0]; + let version = if version_byte == 0 { + 0 + } else { + version_byte.wrapping_sub(0x50) + }; + version > 1 + } + + /// The inputs contributing to `A`, as `(outpoint, pubkey)` pairs. + pub fn eligible(&self) -> &[(OutPoint, PublicKey)] { + &self.eligible + } + + /// The outpoints of inputs contributing no key but still entering `outpoint_L`. + pub fn ineligible(&self) -> &[OutPoint] { + &self.ineligible + } + + /// Whether this transaction could carry a silent payment at all. + pub fn is_eligible(&self) -> bool { + !self.eligible.is_empty() && !self.unknown_segwit_version + } + + /// Whether an input uses an unsupported SegWit version. + pub fn must_not_be_scanned(&self) -> bool { + self.unknown_segwit_version + } + + /// Aggregates the observer-side input data. + pub fn observed(&self) -> Result { + if self.unknown_segwit_version { + return Err(SilentPaymentInputError::NoInputs); + } + ObservedInputs::aggregate_with_extra_outpoints(&self.eligible, &self.ineligible) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::elements::{Transaction, TxIn}; + use crate::util::EC; + use lwk_test_util::ElementsTestData as Data; + + /// Prevout scripts of each shape the recovery has to classify. + /// + /// Built through elements' own hash types: `elements` and `elements::bitcoin` + /// have distinct `WPubkeyHash`/`PubkeyHash` newtypes over the same digest. + struct Spk; + + impl Spk { + fn p2wpkh(pk: &PublicKey) -> Script { + Data::p2wpkh_of(pk) + } + + fn p2pkh_of(key_bytes: &[u8]) -> Script { + use crate::elements::hashes::{hash160, Hash as _}; + let hash = hash160::Hash::hash(key_bytes); + Script::new_p2pkh(&crate::elements::PubkeyHash::from_byte_array( + hash.to_byte_array(), + )) + } + + fn p2pkh(pk: &PublicKey) -> Script { + Self::p2pkh_of(&pk.serialize()) + } + + fn p2tr(pk: &PublicKey) -> Script { + let (x_only, _) = pk.x_only_public_key(); + Script::new_v1_p2tr_tweaked(crate::elements::schnorr::TweakedPublicKey::new(x_only)) + } + + fn p2sh(redeem: &Script) -> Script { + Script::new_p2sh(&redeem.script_hash()) + } + + /// OP_2 PUSH32: a well-formed witness program of a version SP does not know. + fn unknown_witness_version() -> Script { + let mut bytes = vec![0x52u8, 0x20]; + bytes.extend_from_slice(&[0xAA; 32]); + Script::from(bytes) + } + } + + struct ScriptSig; + + impl ScriptSig { + fn push(data: &[u8]) -> Script { + let mut bytes = vec![data.len() as u8]; + bytes.extend_from_slice(data); + Script::from(bytes) + } + + fn p2pkh(key_bytes: &[u8]) -> Script { + let mut bytes = vec![71u8]; + bytes.extend_from_slice(&[0x30; 71]); + bytes.push(key_bytes.len() as u8); + bytes.extend_from_slice(key_bytes); + Script::from(bytes) + } + + fn p2pkh_with_trailing_push(key_bytes: &[u8], junk: &[u8]) -> Script { + let mut bytes = Self::p2pkh(key_bytes).as_bytes().to_vec(); + bytes.push(junk.len() as u8); + bytes.extend_from_slice(junk); + Script::from(bytes) + } + } + + struct Witness; + + impl Witness { + fn p2wpkh(pk: &PublicKey) -> Vec> { + Data::p2wpkh_witness(pk) + } + + fn keypath() -> Vec> { + vec![vec![0x30; 64]] + } + + /// Signature, one leaf script, then a control block: leaf-version/parity byte + /// followed by the x-only internal key, with no merkle branch. + fn script_path(internal_key: &crate::secp256k1::XOnlyPublicKey) -> Vec> { + let mut control = vec![0xc0u8]; + control.extend_from_slice(&internal_key.serialize()); + vec![vec![0x30; 64], vec![0xab; 32], control] + } + } + + /// One prevout/spend shape and the key it must contribute, if any. + struct RecoveryCase { + why: &'static str, + spk: Script, + script_sig: Script, + witness: Vec>, + expected: Option, + } + + impl RecoveryCase { + fn new( + why: &'static str, + spk: Script, + script_sig: Script, + witness: Vec>, + expected: Option, + ) -> Self { + Self { + why, + spk, + script_sig, + witness, + expected, + } + } + + fn check(&self) { + assert_eq!( + InputPubkeyRecovery::new(&self.spk, &self.script_sig, &self.witness).recover(), + self.expected, + "{}", + self.why + ); + } + } + + struct TxFixture; + + impl TxFixture { + fn input(previous_output: OutPoint, script_witness: Vec>, is_pegin: bool) -> TxIn { + Data::input(previous_output, script_witness, is_pegin) + } + + fn of(input: Vec) -> Transaction { + Transaction { + version: 2, + lock_time: crate::elements::LockTime::ZERO, + input, + output: vec![], + } + } + } + + /// Taproot keys must come back in even-Y form even when the signer's key is + /// odd-Y, because that is all an observer can see in the scriptPubKey. Getting + /// this wrong makes half of all taproot inputs derive the wrong `A`. + #[test] + fn taproot_recovers_even_y_output_key() { + // Find a key with odd Y so the negation path is actually exercised. + let (secret, pk) = (1u8..40) + .map(|b| (Data::secret_key(b), Data::secret_key(b).public_key(&EC))) + .find(|(_, pk)| pk.x_only_public_key().1 == crate::elements::secp256k1_zkp::Parity::Odd) + .expect("some seed yields an odd-Y key"); + let spk = Spk::p2tr(&pk); + let witness = Witness::keypath(); + + let got = InputPubkeyRecovery::new(&spk, &Script::new(), &witness) + .recover() + .expect("key-path taproot is eligible"); + + assert_eq!(got, secret.negate().public_key(&EC)); + assert_ne!(got, pk, "the odd-Y key must not be summed as-is"); + assert_eq!( + got, + crate::silentpayments::InputKey::Taproot(secret).public_key() + ); + } + + /// Every prevout shape the recovery has to classify, plus the malleations that + /// must not change its answer. `None` means the input contributes no key — + /// either the shape is not SP-eligible, or the key it commits to is not permitted. + #[test] + fn single_input_key_recovery() { + let pubk = |b: u8| Data::secret_key(b).public_key(&EC); + let taproot = + |b: u8| crate::silentpayments::InputKey::Taproot(Data::secret_key(b)).public_key(); + + let nums_h = + crate::secp256k1::XOnlyPublicKey::from_slice(&InputPubkeyRecovery::NUMS_H).unwrap(); + let junk = pubk(0x99); + let redeem = Spk::p2wpkh(&pubk(0x23)); + let not_p2wpkh = Script::from(vec![0x52, 0x53, 0x54]); + let uncompressed = pubk(0x26).serialize_uncompressed(); + + // A 64-byte BIP-340 signature begins with 0x50 about 1 time in 256, and + // BIP-341 recognizes an annex only from 2 witness elements up. + let sig_resembling_an_annex = { + let mut sig = vec![0x50u8]; + sig.extend_from_slice(&[0xAB; 63]); + vec![sig] + }; + + let cases = [ + RecoveryCase::new( + "p2wpkh key is summed as-is", + Spk::p2wpkh(&pubk(0x21)), + Script::new(), + Witness::p2wpkh(&pubk(0x21)), + Some(pubk(0x21)), + ), + RecoveryCase::new( + "script-path taproot contributes the output key, not the internal key", + Spk::p2tr(&pubk(0x22)), + Script::new(), + Witness::script_path(&junk.x_only_public_key().0), + Some(taproot(0x22)), + ), + RecoveryCase::new( + "script-path taproot using NUMS H is not eligible", + Spk::p2tr(&pubk(0x23)), + Script::new(), + Witness::script_path(&nums_h), + None, + ), + RecoveryCase::new( + "a one-element witness is a key-path spend, not an annex", + Spk::p2tr(&pubk(0x25)), + Script::new(), + sig_resembling_an_annex, + Some(taproot(0x25)), + ), + RecoveryCase::new( + "malformed control block is not eligible", + Spk::p2tr(&pubk(0x24)), + Script::new(), + vec![vec![0x30; 64], vec![0xab; 32], vec![0xc0; 10]], + None, + ), + RecoveryCase::new( + "p2sh is eligible when it wraps p2wpkh", + Spk::p2sh(&redeem), + ScriptSig::push(redeem.as_bytes()), + Witness::p2wpkh(&pubk(0x23)), + Some(pubk(0x23)), + ), + RecoveryCase::new( + "p2sh wrapping anything else is not", + Spk::p2sh(&redeem), + ScriptSig::push(not_p2wpkh.as_bytes()), + Witness::p2wpkh(&pubk(0x23)), + None, + ), + RecoveryCase::new( + "legacy p2pkh key matches the committed hash160", + Spk::p2pkh(&pubk(0x24)), + ScriptSig::p2pkh(&pubk(0x24).serialize()), + vec![], + Some(pubk(0x24)), + ), + RecoveryCase::new( + "BIP-352 permits only compressed and x-only keys", + Spk::p2pkh_of(&uncompressed), + ScriptSig::p2pkh(&uncompressed), + vec![], + None, + ), + RecoveryCase::new( + "p2pkh key is found by hash160, not by taking the last push", + Spk::p2pkh(&pubk(0x27)), + ScriptSig::p2pkh_with_trailing_push(&pubk(0x27).serialize(), &junk.serialize()), + vec![], + Some(pubk(0x27)), + ), + ]; + + for case in &cases { + case.check(); + } + } + + #[test] + fn extract_over_transaction_agrees_with_direct_aggregation() { + let keys = [Data::secret_key(0x31), Data::secret_key(0x32)]; + let outpoints = [Data::outpoint(0x30, 1), Data::outpoint(0x10, 0)]; + let spks: Vec