diff --git a/.github/workflows/cont_integration.yml b/.github/workflows/cont_integration.yml index 11574da46..dba3c2ec2 100644 --- a/.github/workflows/cont_integration.yml +++ b/.github/workflows/cont_integration.yml @@ -24,6 +24,8 @@ jobs: - run: cargo build - run: cargo build --features serial - run: cargo test --lib # integration tests requires binaries for the test env... + # `silentpayments` is opt-in, so the default build above never compiles it. + - run: cargo test --lib -p lwk_common -p lwk_signer -p lwk_wollet --features lwk_common/silentpayments,lwk_signer/silentpayments,lwk_wollet/silentpayments nix: runs-on: ubuntu-22.04 diff --git a/Cargo.lock b/Cargo.lock index 12539992a..32bf8a08f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -588,9 +588,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.4" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "block-buffer" @@ -1769,7 +1769,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "ignore", "walkdir", ] @@ -3026,6 +3026,7 @@ dependencies = [ "aes-gcm-siv", "age", "base64 0.21.7", + "bech32 0.11.0", "bip39", "bitcoincore-rpc", "cbc", @@ -3225,7 +3226,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "libc", @@ -3551,7 +3552,7 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "getopts", "memchr", "unicase", @@ -3721,7 +3722,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", ] [[package]] @@ -3875,7 +3876,7 @@ version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -3944,7 +3945,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -3957,7 +3958,7 @@ version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.11.0", @@ -4343,7 +4344,7 @@ version = "4.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4d91116f97173694f1642263b2ff837f80d933aa837e2314969f6728f661df3" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "cfg-if", "core-foundation 0.10.0", "core-foundation-sys", @@ -4700,7 +4701,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -5049,7 +5050,7 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.13.1", "bytes", "futures-util", "http", 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) + ); + } +} 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 212ec8b4e..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) } @@ -467,7 +502,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 +621,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 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; diff --git a/lwk_wollet/CHANGELOG.md b/lwk_wollet/CHANGELOG.md index d839df988..a8ee00129 100644 --- a/lwk_wollet/CHANGELOG.md +++ b/lwk_wollet/CHANGELOG.md @@ -2,6 +2,19 @@ ## Unreleased +* Add Silent Payments (BIP-352 on Liquid, per the Liquid silent payments ELIP) behind the new opt-in `silentpayments` feature, in module `lwk_wollet::silentpayments`: + * Send: `TxBuilder::add_silent_payment_recipient()` plus `TxBuilder::finish_silent_payment()`, which needs the funding inputs' private keys (via `SilentPaymentInputProvider`) because the output is derived from the transaction's own inputs. Calling plain `TxBuilder::finish()` with a pending silent payment recipient fails with `Error::SilentPaymentRequiresKeys` rather than dropping the recipient. + * Receive: **scan-only** material (`SilentPaymentScanMaterial`, i.e. `b_scan` plus the *public* `B_spend`) is configured once with `WolletBuilder::with_silent_payment_material()`, then `Wollet::silent_payment_address()` and `Wollet::labeled_silent_payment_address()` produce reusable addresses. Obtain it from a signer via `lwk_common::silentpayments::SilentPaymentSigner::silent_payment_scan_material()`, implemented by `lwk_signer::SwSigner`, which derives both ELIP branches (`m/352'/{1776'|1'}/account'/{1'|0'}/0`) and exports only the scan half. + * Trust boundary: a `Wollet` can detect, unblind, track and select silent payments, but holds no `b_spend` and so **cannot sign** for them. Scan results carry a `SpendTweak` (the scalar `t_k`, or `t_k + label_tweak_m`), never a completed key; correctness is checked publicly as `B_spend + spend_tweak*G == output spend pubkey`. Spending requires `lwk_signer::SwSigner::sign()`, which re-derives `b_spend` internally, verifies the wallet's tweak against the output being spent, and only then signs. Hardware signers are explicitly unsupported (`SignerError::UnsupportedSilentPayments`) until their protocols expose the necessary operation. + * Discovery: `BlockchainBackend::scan_silent_payments()` finds them through a light client. Backends advertise `Capability::SilentPayments` and answer `BlockchainBackend::silent_payment_tweaks()` with each block's `T = input_hash·A`; the wallet derives the candidate scripts those tweaks would produce for its keys and confirms them with the ordinary script-history query, so only transactions that actually pay it are downloaded. Implemented for `EsploraClient`, which computes tweaks from blocks with no server change. A backend without the capability returns `Error::SilentPaymentsUnsupportedByBackend` rather than an empty result. + * **Fixed**: silent-payment signatures now commit to the PSET's genesis hash (ELIP-101). + * **Fixed**: a crafted PSET naming a silent-payment account with bit 31 set (e.g. `u32::MAX`) panicked the signer instead of erroring, since those values have no hardened BIP-32 form. `SilentPaymentAccount::from_raw` is now checked and returns `SilentPaymentAccountError`; the new `SilentPaymentPsetMetaError::Account` surfaces it at the decode boundary. + * Silent-payment discovery and spending are covered end to end on regtest, including network acceptance of the finalized spend. + * Spend, standalone: outputs from `scan_silent_payments()` that were never applied to the wallet go through `TxBuilder::add_silent_payment_utxos()`, which registers the funding view *and* keeps the spend tweak. `SilentPaymentUtxo::external_utxo()` alone funds the input but cannot carry the tweak, so it produces an input no signer can complete — its docs now say so and point here. + * Spend: coin selection picks silent payment outputs like any other, and `TxBuilder` annotates each such PSET input with `lwk_common::silentpayments::SilentPaymentInputMeta` (account + spend tweak + expected `B_spend`; never a key). This is what stands in for the `bip32_derivation` an SP input cannot have: the ELIP path `m/352'/1776'/account'/0'/0` reaches `b_spend`, but the key that signs is `b_spend + t_k`, tweaked by the paying transaction and so off any BIP-32 path. `lwk_signer::SwSigner::sign()` verifies that metadata against the prevout and signs; a cache entry whose tweak does not verify against the wallet's own `B_spend` is refused at build time rather than shipped as a PSET that could only fail later. + * Found outputs are ordinary wallet money: after `Wollet::apply_silent_payments()` they appear in `Wollet::balance()`, `Wollet::utxos()` and `Wollet::txos()`, and coin selection spends them with no silent-payment-specific handling. Only the spend *tweak* is cached, never a spending key. They can still be spent standalone via `SilentPaymentUtxo::external_utxo()`. + * Discovery runs as part of `full_scan()`: a wallet built with `WolletBuilder::with_silent_payment_material()` finds silent payments during an ordinary scan, and after `apply_update()` they are in `balance()`, `utxos()` and `txos()` with no silent-payment-specific call. Findings travel in the `Update` (wire version 6) so they are persisted and replayed on restore like everything else a scan finds; older update versions decode unchanged. How far discovery has run is tracked separately from the wallet tip, since a wallet that scanned before its scan material was configured must not treat that history as already searched. `Wollet::status()` incorporates both `B_scan` and `B_spend` (never a secret), so changing signer or account invalidates an incompatible persisted cache. + * `Wollet::scan_silent_payments()` remains available for callers holding transactions from a source of their own. * Add Waterfalls descriptor subscriptions, returning `tip`, `mempool`, `block`, and `reorg` events that callers can use as wallet rescan hints. * Add the `electrum_oidc` feature: `TokenProvider::Blockstream` support for `ElectrumClient` (automatic OAuth2 token fetch, plus invalidate and retry once when the server denies a call with an authentication error). Not available on wasm. * `Wollet::assets_owned()` returns all assets ever owned instead of only unspent ones. diff --git a/lwk_wollet/Cargo.toml b/lwk_wollet/Cargo.toml index 83ec8ce02..8db224c73 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" @@ -98,7 +99,7 @@ js-sys = { version = "0.3.68" } [dev-dependencies] lwk_jade = { version = "0.18.1", features = ["test_emulator"] } lwk_ledger = { version = "0.18.1", features = ["test_emulator"] } -lwk_signer = { version = "0.18.1", features = ["jade", "ledger"] } +lwk_signer = { version = "0.18.1", features = ["jade", "ledger", "silentpayments"] } lwk_containers = { version = "0.18.1" } lwk_test_util = { version = "0.18.0" } lwk_common = { version = "0.18.1", features = ["sqlite"]} @@ -148,13 +149,26 @@ 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. Opt-in while it settles. +silentpayments = ["bech32", "lwk_common/silentpayments"] +# The e2e target requires the backend features it imports. [[test]] name = "e2e" path = "tests/e2e.rs" +required-features = ["electrum", "esplora", "amp0"] +[[test]] +name = "silentpayments_e2e" +path = "tests/silentpayments_e2e.rs" +required-features = ["silentpayments", "esplora"] + +# 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 diff --git a/lwk_wollet/src/cache.rs b/lwk_wollet/src/cache.rs index 180fb8418..20f8bf656 100644 --- a/lwk_wollet/src/cache.rs +++ b/lwk_wollet/src/cache.rs @@ -20,7 +20,8 @@ fn tx_key(txid: &Txid) -> String { } /// `Cache` is a cache of wallet data, like wallet transactions. -/// It is fully reconstructable from the CT Descriptor and the blockchain. +/// +/// Silent-payment scan keys also contribute to persistent wallet status. pub struct Cache { /// Store for all wallet transactions txs_store: Arc, @@ -64,6 +65,14 @@ pub struct Cache { /// last unused index for internal addresses (changes) for current descriptor pub last_unused_internal: AtomicU32, + + /// Discovered silent-payment outputs, keyed by script. + #[cfg(feature = "silentpayments")] + pub silent_payments: HashMap, + + /// Height through which silent-payment discovery has run. + #[cfg(feature = "silentpayments")] + pub silent_payments_scanned_to: Option, } impl Default for Cache { @@ -82,6 +91,10 @@ impl Default for Cache { last_unused_internal: 0.into(), last_unused_external: 0.into(), timestamps: HashMap::default(), + #[cfg(feature = "silentpayments")] + silent_payments: HashMap::default(), + #[cfg(feature = "silentpayments")] + silent_payments_scanned_to: None, } } } @@ -122,6 +135,20 @@ impl std::hash::Hash for Cache { self.last_unused_internal .load(Ordering::Relaxed) .hash(state); + + // Skip empty state to preserve existing wallet status hashes. + #[cfg(feature = "silentpayments")] + if !self.silent_payments.is_empty() { + let mut vec: Vec<_> = self.silent_payments.keys().collect(); + vec.sort(); + vec.hash(state); + } + + // Skip absent state to preserve existing wallet status hashes. + #[cfg(feature = "silentpayments")] + if let Some(height) = self.silent_payments_scanned_to { + height.hash(state); + } } } @@ -326,6 +353,18 @@ impl Cache { self.sorted_txids = sorted; } + /// Whether the wallet owns `script`. + pub(crate) fn owns_script(&self, script: &Script) -> bool { + if self.paths.contains_key(script) { + return true; + } + #[cfg(feature = "silentpayments")] + if self.silent_payments.contains_key(script) { + return true; + } + false + } + fn update_unspent( &mut self, txid_height_new: &[(Txid, Option)], @@ -342,7 +381,7 @@ impl Cache { self.outpoint_script(op, new_txs) .map(|script| (*op, script)) }) - .filter(|(_, script)| self.paths.contains_key(script)) + .filter(|(_, script)| self.owns_script(script)) .collect(); let inputs_new: HashSet = txids_new @@ -362,7 +401,7 @@ impl Cache { self.outpoint_script(&op, new_txs) .map(|script| (op, script)) }) - .filter(|(_, script)| self.paths.contains_key(script)) + .filter(|(_, script)| self.owns_script(script)) .collect(); // Add outputs of new txs @@ -539,6 +578,39 @@ impl Cache { } } +/// Silent payment outputs in the cache. +#[cfg(feature = "silentpayments")] +impl Cache { + /// Records a discovered silent-payment output. + pub(crate) fn insert_silent_payment( + &mut self, + entry: crate::silentpayments::SilentPaymentCacheEntry, + unblinded: TxOutSecrets, + ) { + let outpoint = entry.outpoint; + let script = entry.script_pubkey.clone(); + self.unblinded.insert(outpoint, unblinded); + self.silent_payments.insert(script.clone(), entry); + if !self.is_spent(&outpoint) { + self.unspent.insert(outpoint, script); + } + } + + /// Whether a cached transaction spends `outpoint`. + fn is_spent(&self, outpoint: &OutPoint) -> bool { + self.all_txs() + .any(|(_, tx)| tx.input.iter().any(|i| i.previous_output == *outpoint)) + } + + /// The silent payment entry for `script`, if the wallet owns it. + pub(crate) fn silent_payment( + &self, + script: &Script, + ) -> Option<&crate::silentpayments::SilentPaymentCacheEntry> { + self.silent_payments.get(script) + } +} + #[cfg(test)] mod tests { use crate::{cache::Cache, WolletDescriptor}; @@ -603,6 +675,46 @@ mod tests { // TODO test other fields change the hash } + /// Empty silent-payment state preserves the legacy status hash. + #[cfg(feature = "silentpayments")] + #[test] + fn silent_payments_hash_only_once_non_empty() { + use crate::silentpayments::SilentPaymentCacheEntry; + + let hash_of = |cache: &Cache| { + let mut hasher = DefaultHasher::new(); + cache.hash(&mut hasher); + hasher.finish() + }; + + let mut cache = Cache::default(); + assert_eq!( + hash_of(&cache), + 11565483422739161174, + "an empty silent payment set must hash exactly as before the field existed" + ); + + let script = elements::Script::from(vec![0x51, 0x20, 0xAB]); + cache.silent_payments.insert( + script.clone(), + SilentPaymentCacheEntry { + outpoint: elements::OutPoint::new(::all_zeros(), 0), + script_pubkey: script, + k: 0, + label: None, + spend_tweak: crate::silentpayments::SpendTweak::from_be_bytes([0x11; 32]).unwrap(), + blinding_pubkey: crate::secp256k1::SecretKey::from_slice(&[0x22; 32]) + .unwrap() + .public_key(&crate::util::EC), + }, + ); + assert_ne!( + hash_of(&cache), + 11565483422739161174, + "a found silent payment is cache content and must change the hash" + ); + } + #[test] fn test_v5_restore_keeps_deleted_txids() { let tx = Transaction { diff --git a/lwk_wollet/src/clients/asyncr/block_prevouts.rs b/lwk_wollet/src/clients/asyncr/block_prevouts.rs new file mode 100644 index 000000000..fc4ce171d --- /dev/null +++ b/lwk_wollet/src/clients/asyncr/block_prevouts.rs @@ -0,0 +1,184 @@ +//! Reads block-input prevout scripts from Esplora transaction listings. + +use std::collections::HashMap; + +use crate::elements::{OutPoint, Script, Txid}; + +/// A `/block/{hash}/txs` page. +#[derive(serde::Deserialize)] +pub(crate) struct BlockTxsPage(Vec); + +#[derive(serde::Deserialize)] +struct BlockTx { + vin: Vec, +} + +#[derive(serde::Deserialize)] +struct BlockTxIn { + txid: Txid, + vout: u32, + /// Absent for coinbase and peg-in inputs. + prevout: Option, + #[serde(default)] + is_coinbase: bool, + #[serde(default)] + is_pegin: bool, +} + +#[derive(serde::Deserialize)] +struct BlockPrevout { + /// Hex-encoded scriptPubKey of the output being spent. + scriptpubkey: String, +} + +impl BlockTxsPage { + /// Number of transactions in this page. + pub(crate) fn len(&self) -> usize { + self.0.len() + } + + /// Adds decodable non-coinbase, non-peg-in prevout scripts to `out`. + pub(crate) fn collect_into(&self, out: &mut HashMap) { + for tx in &self.0 { + for input in &tx.vin { + if input.is_coinbase || input.is_pegin { + continue; + } + let Some(prevout) = &input.prevout else { + continue; + }; + let Ok(script) = prevout.scriptpubkey.parse::