From c340391a9554fef1bef175d67e2af93654bc5bd3 Mon Sep 17 00:00:00 2001 From: Abeeujah Date: Sat, 27 Jun 2026 16:08:14 +0100 Subject: [PATCH 1/4] Derive per-channel destination/shutdown scripts Previously, `KeysManager` used a single global `destination_script` and `shutdown_pubkey` for all channels. With V2 remote key derivation enabled, each channel now gets a unique destination and cooperative-close script derived from its `channel_keys_id`. This improves on-chain privacy by avoiding script reuse across channels. Funds sent to per-channel scripts remain recoverable from the seed alone by scanning for scripts returned by `possible_v2_static_output_spks`. A new `find_static_output_key` method added on `KeysManager` locates the correct spending key for `StaticOutput` descriptors, supporting both legacy (global) and per-channel keys. The watchtower justice transaction test is updated to use legacy V1 keys since per-channel destination scripts cannot be predicted before channel creation. --- lightning/src/ln/functional_tests.rs | 15 ++- lightning/src/sign/mod.rs | 193 +++++++++++++++++++++------ 2 files changed, 166 insertions(+), 42 deletions(-) diff --git a/lightning/src/ln/functional_tests.rs b/lightning/src/ln/functional_tests.rs index 826b07750fa..6e41ced0cea 100644 --- a/lightning/src/ln/functional_tests.rs +++ b/lightning/src/ln/functional_tests.rs @@ -74,6 +74,7 @@ use crate::prelude::*; use crate::sync::{Arc, Mutex, RwLock}; use alloc::collections::BTreeSet; use bitcoin::hashes::Hash; +use bitcoin::hex::FromHex; use core::iter::repeat; use lightning_macros::xtest; @@ -1030,7 +1031,19 @@ fn do_test_forming_justice_tx_from_monitor_updates(broadcast_initial_commitment: // is properly formed and can be broadcasted/confirmed successfully in the event // that a revoked commitment transaction is broadcasted // (Similar to `revoked_output_claim` test but we get the justice tx + broadcast manually) - let chanmon_cfgs = create_chanmon_cfgs(2); + // + // Use legacy (V1) key derivation so that the destination scripts we hand to the watchtower + // match the per-node static destination scripts that the channels close to. With per-channel + // (V2) derivation each channel uses a distinct destination script which we cannot predict + // before the channel is created. + let node0_key_id = + <[u8; 32]>::from_hex("0000000000000000000000004D49E5DA0000000000000000000000000000002A") + .unwrap(); + let node1_key_id = + <[u8; 32]>::from_hex("0000000000000000000000004D49E5DAD000D6201F116BAFD379F1D61DF161B9") + .unwrap(); + let predefined_key_ids = Some(vec![node0_key_id, node1_key_id]); + let chanmon_cfgs = create_chanmon_cfgs_with_legacy_keys(2, predefined_key_ids); let destination_script0 = chanmon_cfgs[0].keys_manager.get_destination_script([0; 32]).unwrap(); let destination_script1 = chanmon_cfgs[1].keys_manager.get_destination_script([0; 32]).unwrap(); let persisters = [ diff --git a/lightning/src/sign/mod.rs b/lightning/src/sign/mod.rs index 70bd9e68f60..c210411b7d9 100644 --- a/lightning/src/sign/mod.rs +++ b/lightning/src/sign/mod.rs @@ -1977,6 +1977,8 @@ pub struct KeysManager { inbound_payment_key: ExpandedKey, destination_script: ScriptBuf, shutdown_pubkey: PublicKey, + destination_key: Xpriv, + shutdown_key: Xpriv, channel_master_key: Xpriv, static_payment_key: Xpriv, v2_remote_key_derivation: bool, @@ -2016,6 +2018,13 @@ impl KeysManager { /// possible `script_pubkey`s. This only applies to new or spliced channels, however if this is /// set you *MUST NOT* downgrade to a version of LDK prior to 0.2. /// + /// If `v2_remote_key_derivation` is set, a fresh destination `script_pubkey` (where we receive + /// funds when claiming on-chain contestable outputs) and a fresh cooperative-close + /// `script_pubkey` are derived for each new or spliced channel, rather than reusing a single + /// `script_pubkey` across all channels. This avoids linking our on-chain funds across channels. + /// Funds paid to these scripts remain recoverable from the `seed` alone by scanning the chain + /// for the `script_pubkey's` returned by [`KeysManager::possible_v2_static_output_spks`]. + /// /// [`ChannelMonitor`]: crate::chain::channelmonitor::ChannelMonitor pub fn new( seed: &[u8; 32], starting_time_secs: u64, starting_time_nanos: u32, @@ -2040,24 +2049,22 @@ impl KeysManager { .expect("Your RNG is busted") .private_key; let node_id = PublicKey::from_secret_key(&secp_ctx, &node_secret); - let destination_script = - match master_key.derive_priv(&secp_ctx, &DESTINATION_SCRIPT_INDEX) { - Ok(destination_key) => { - let wpubkey_hash = WPubkeyHash::hash( - &Xpub::from_priv(&secp_ctx, &destination_key).to_pub().to_bytes(), - ); - Builder::new() - .push_opcode(opcodes::all::OP_PUSHBYTES_0) - .push_slice(&wpubkey_hash.to_byte_array()) - .into_script() - }, - Err(_) => panic!("Your RNG is busted"), - }; - let shutdown_pubkey = - match master_key.derive_priv(&secp_ctx, &SHUTDOWN_PUBKEY_INDEX) { - Ok(shutdown_key) => Xpub::from_priv(&secp_ctx, &shutdown_key).public_key, - Err(_) => panic!("Your RNG is busted"), - }; + let destination_key = master_key + .derive_priv(&secp_ctx, &DESTINATION_SCRIPT_INDEX) + .expect("Your RNG is busted"); + let destination_script = { + let wpubkey_hash = WPubkeyHash::hash( + &Xpub::from_priv(&secp_ctx, &destination_key).to_pub().to_bytes(), + ); + Builder::new() + .push_opcode(opcodes::all::OP_PUSHBYTES_0) + .push_slice(&wpubkey_hash.to_byte_array()) + .into_script() + }; + let shutdown_key = master_key + .derive_priv(&secp_ctx, &SHUTDOWN_PUBKEY_INDEX) + .expect("Your RNG is busted"); + let shutdown_pubkey = Xpub::from_priv(&secp_ctx, &shutdown_key).public_key; let channel_master_key = master_key .derive_priv(&secp_ctx, &CHANNEL_MASTER_KEY_INDEX) .expect("Your RNG is busted"); @@ -2100,6 +2107,8 @@ impl KeysManager { destination_script, shutdown_pubkey, + destination_key, + shutdown_key, channel_master_key, channel_child_index: AtomicUsize::new(0), @@ -2171,6 +2180,101 @@ impl KeysManager { .private_key } + /// Derives the index into the static destination/shutdown key set for a given channel. + /// + /// The index is a pure function of `channel_keys_id` so that the same script can be re-derived + /// both when handing the script out (e.g. in [`SignerProvider::get_destination_script`]) and + /// when later signing a spend of an output paying to it. By offsetting the per-channel counter + /// (the first four bytes of a [`KeysManager`]-generated `channel_keys_id`) by an + /// instance-specific value, the first [`STATIC_PAYMENT_KEY_COUNT`] channels opened in a process + /// are guaranteed to use distinct indices, avoiding script reuse for privacy. + fn static_output_key_idx(channel_keys_id: &[u8; 32]) -> u16 { + let counter = u32::from_be_bytes(channel_keys_id[0..4].try_into().unwrap()); + let mut engine = Sha256::engine(); + engine.input(b"LDK Static Output Key Index Offset"); + engine.input(&channel_keys_id[4..16]); + let hash = Sha256::from_engine(engine).to_byte_array(); + let offset = u32::from_be_bytes(hash[0..4].try_into().unwrap()); + (counter.wrapping_add(offset) % u32::from(STATIC_PAYMENT_KEY_COUNT)) as u16 + } + + /// Derives the `idx`th hardened child of one of the static destination/shutdown key roots. + fn derive_static_output_key(&self, root: &Xpriv, idx: u16) -> Xpriv { + root.derive_priv( + &self.secp_ctx, + &ChildNumber::from_hardened_idx(u32::from(idx)).expect("key space exhausted"), + ) + .expect("Your RNG is busted") + } + + /// Returns the P2WPKH `script_pubkey` controlled by the given key. + fn p2wpkh_spk_for_key(&self, key: &Xpriv) -> ScriptBuf { + let pubkey = Xpub::from_priv(&self.secp_ctx, key).to_pub(); + bitcoin::Address::p2wpkh(&pubkey, Network::Testnet).script_pubkey() + } + + /// Finds the key controlling the given `script_pubkey` for a [`SpendableOutputDescriptor::StaticOutput`]. + /// + /// This checks both the legacy single destination/shutdown keys (used by all channels opened + /// without `v2_remote_key_derivation`) and, if a `channel_keys_id` is available, the + /// per-channel destination/shutdown keys derived for `v2_remote_key_derivation` channels. + fn find_static_output_key( + &self, channel_keys_id: Option<[u8; 32]>, script_pubkey: &ScriptBuf, + ) -> Option { + if &self.p2wpkh_spk_for_key(&self.destination_key) == script_pubkey { + return Some(self.destination_key); + } + if &self.p2wpkh_spk_for_key(&self.shutdown_key) == script_pubkey { + return Some(self.shutdown_key); + } + if let Some(channel_keys_id) = channel_keys_id { + let idx = Self::static_output_key_idx(&channel_keys_id); + let destination_key = self.derive_static_output_key(&self.destination_key, idx); + if &self.p2wpkh_spk_for_key(&destination_key) == script_pubkey { + return Some(destination_key); + } + let shutdown_key = self.derive_static_output_key(&self.shutdown_key, idx); + if &self.p2wpkh_spk_for_key(&shutdown_key) == script_pubkey { + return Some(shutdown_key); + } + } else { + // Without a `channel_keys_id` (e.g. when recovering from the seed alone) we don't know + // which per-channel index was used, so scan the full set of possible per-channel + // destination/shutdown keys (the same set returned by + // `possible_v2_static_output_spks`). + for idx in 0..STATIC_PAYMENT_KEY_COUNT { + let destination_key = self.derive_static_output_key(&self.destination_key, idx); + if &self.p2wpkh_spk_for_key(&destination_key) == script_pubkey { + return Some(destination_key); + } + let shutdown_key = self.derive_static_output_key(&self.shutdown_key, idx); + if &self.p2wpkh_spk_for_key(&shutdown_key) == script_pubkey { + return Some(shutdown_key); + } + } + } + None + } + + /// Gets the set of possible `script_pubkey`s which can appear on chain paying to our + /// destination or cooperative-close scripts for channels opened (or spliced) while using a + /// [`KeysManager`] with the `v2_remote_key_derivation` argument to [`KeysManager::new`] set. + /// + /// If you've lost all data except your seed, scanning the chain for these `script_pubkey`s can + /// allow you to recover (some of) your funds even without the corresponding [`ChannelMonitor`]. + /// + /// [`ChannelMonitor`]: crate::chain::channelmonitor::ChannelMonitor + pub fn possible_v2_static_output_spks(&self) -> Vec { + let mut res = Vec::with_capacity(usize::from(STATIC_PAYMENT_KEY_COUNT) * 2); + for idx in 0..STATIC_PAYMENT_KEY_COUNT { + let destination_key = self.derive_static_output_key(&self.destination_key, idx); + res.push(self.p2wpkh_spk_for_key(&destination_key)); + let shutdown_key = self.derive_static_output_key(&self.shutdown_key, idx); + res.push(self.p2wpkh_spk_for_key(&shutdown_key)); + } + res + } + /// Derive an old [`EcdsaChannelSigner`] containing per-channel secrets based on a key derivation parameters. pub fn derive_channel_keys(&self, params: &[u8; 32]) -> InMemorySigner { let chan_id = u64::from_be_bytes(params[0..8].try_into().unwrap()); @@ -2297,30 +2401,29 @@ impl KeysManager { )?; psbt.inputs[input_idx].final_script_witness = Some(witness); }, - SpendableOutputDescriptor::StaticOutput { ref outpoint, ref output, .. } => { + SpendableOutputDescriptor::StaticOutput { + ref outpoint, + ref output, + channel_keys_id, + } => { let input_idx = get_input_idx(outpoint)?; - let derivation_idx = - if output.script_pubkey == self.destination_script { 1 } else { 2 }; - let secret = { - // Note that when we aren't serializing the key, network doesn't matter - match Xpriv::new_master(Network::Testnet, &self.seed) { - Ok(master_key) => { - match master_key.derive_priv( - &secp_ctx, - &ChildNumber::from_hardened_idx(derivation_idx) - .expect("key space exhausted"), - ) { - Ok(key) => key, - Err(_) => panic!("Your RNG is busted"), - } - }, - Err(_) => panic!("Your rng is busted"), - } - }; - let pubkey = Xpub::from_priv(&secp_ctx, &secret).to_pub(); - if derivation_idx == 2 { - assert_eq!(pubkey.0, self.shutdown_pubkey); + let secret = self + .find_static_output_key(*channel_keys_id, &output.script_pubkey) + .ok_or(())?; + #[cfg(test)] + if self.v2_remote_key_derivation + && output.script_pubkey != self.destination_script + && output.script_pubkey + != ShutdownScript::new_p2wpkh_from_pubkey(self.shutdown_pubkey) + .into_inner() + { + // In tests, use this opportunity to ensure per-channel static output + // scripts are recoverable by scanning the chain via + // `possible_v2_static_output_spks`. + let possible_spks = self.possible_v2_static_output_spks(); + assert!(possible_spks.contains(&output.script_pubkey)); } + let pubkey = Xpub::from_priv(&secp_ctx, &secret).to_pub(); let witness_script = bitcoin::Address::p2pkh(&pubkey, Network::Testnet).script_pubkey(); let payment_script = @@ -2484,7 +2587,15 @@ impl SignerProvider for KeysManager { self.derive_channel_keys(&channel_keys_id) } - fn get_destination_script(&self, _channel_keys_id: [u8; 32]) -> Result { + fn get_destination_script(&self, channel_keys_id: [u8; 32]) -> Result { + if self.v2_remote_key_derivation { + // Derive a fresh per-channel destination script for privacy. Funds paid to it remain + // recoverable from the seed alone by scanning the chain for the scripts returned by + // `possible_v2_static_output_spks`. + let idx = Self::static_output_key_idx(&channel_keys_id); + let key = self.derive_static_output_key(&self.destination_key, idx); + return Ok(self.p2wpkh_spk_for_key(&key)); + } Ok(self.destination_script.clone()) } From 4a1abe65d49db142bdd1bde1ab0d84903d26e759 Mon Sep 17 00:00:00 2001 From: Abeeujah Date: Sat, 27 Jun 2026 17:08:52 +0100 Subject: [PATCH 2/4] Generate fresh shutdown scripts per-channel `get_shutdown_scriptpubkey` now takes a `channel_keys_id` parameter, allowing signers to derive a unique shutdown script per channel. When `v2_remote_key_derivation` is enabled, `KeysManager` uses `channel_keys_id` to derive a fresh per-channel cooperative-close script from the shutdown key, avoiding address reuse across channels and improving on-chain privacy. --- fuzz/src/chanmon_consistency.rs | 2 +- fuzz/src/full_stack.rs | 2 +- fuzz/src/onion_message.rs | 2 +- lightning/src/ln/async_signer_tests.rs | 5 +++-- lightning/src/ln/channel.rs | 29 +++++++++++++++----------- lightning/src/ln/shutdown_tests.rs | 2 +- lightning/src/sign/mod.rs | 24 ++++++++++++++------- lightning/src/util/dyn_signer.rs | 6 +++--- lightning/src/util/test_utils.rs | 6 +++--- 9 files changed, 47 insertions(+), 31 deletions(-) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index b0703d8e6ed..2509f4a6fcd 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -870,7 +870,7 @@ impl SignerProvider for KeyProvider { .into_script()) } - fn get_shutdown_scriptpubkey(&self) -> Result { + fn get_shutdown_scriptpubkey(&self, _channel_keys_id: [u8; 32]) -> Result { let secp_ctx = Secp256k1::signing_only(); #[rustfmt::skip] let secret_key = SecretKey::from_slice(&[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, self.node_secret[31]]).unwrap(); diff --git a/fuzz/src/full_stack.rs b/fuzz/src/full_stack.rs index d57bec8ac74..17469af8127 100644 --- a/fuzz/src/full_stack.rs +++ b/fuzz/src/full_stack.rs @@ -510,7 +510,7 @@ impl SignerProvider for KeyProvider { .into_script()) } - fn get_shutdown_scriptpubkey(&self) -> Result { + fn get_shutdown_scriptpubkey(&self, _channel_keys_id: [u8; 32]) -> Result { let secp_ctx = Secp256k1::signing_only(); let secret_key = SecretKey::from_slice(&[ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, diff --git a/fuzz/src/onion_message.rs b/fuzz/src/onion_message.rs index 4859f7379fb..8c918791c09 100644 --- a/fuzz/src/onion_message.rs +++ b/fuzz/src/onion_message.rs @@ -309,7 +309,7 @@ impl SignerProvider for KeyProvider { unreachable!() } - fn get_shutdown_scriptpubkey(&self) -> Result { + fn get_shutdown_scriptpubkey(&self, _channel_keys_id: [u8; 32]) -> Result { unreachable!() } } diff --git a/lightning/src/ln/async_signer_tests.rs b/lightning/src/ln/async_signer_tests.rs index f36c19748f0..c0ca5be24d2 100644 --- a/lightning/src/ln/async_signer_tests.rs +++ b/lightning/src/ln/async_signer_tests.rs @@ -1437,8 +1437,9 @@ fn do_test_closing_signed(extra_closing_signed: bool, reconnect: bool) { if extra_closing_signed { let node_1_closing_signed_2_bad = { let mut node_1_closing_signed_2 = node_1_closing_signed.clone(); - let holder_script = nodes[0].keys_manager.get_shutdown_scriptpubkey().unwrap(); - let counterparty_script = nodes[1].keys_manager.get_shutdown_scriptpubkey().unwrap(); + let holder_script = nodes[0].keys_manager.get_shutdown_scriptpubkey([0; 32]).unwrap(); + let counterparty_script = + nodes[1].keys_manager.get_shutdown_scriptpubkey([0; 32]).unwrap(); let funding_outpoint = bitcoin::OutPoint { txid: funding_tx.compute_txid(), vout: 0 }; let closing_tx_2 = ClosingTransaction::new( 50000, diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index d609f8912d6..095d48b1f04 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -4085,7 +4085,7 @@ impl ChannelContext { let shutdown_scriptpubkey = if config.channel_handshake_config.commit_upfront_shutdown_pubkey { - match signer_provider.get_shutdown_scriptpubkey() { + match signer_provider.get_shutdown_scriptpubkey(channel_keys_id) { Ok(scriptpubkey) => Some(scriptpubkey), Err(_) => { return Err(ChannelError::close( @@ -4408,7 +4408,7 @@ impl ChannelContext { let shutdown_scriptpubkey = if config.channel_handshake_config.commit_upfront_shutdown_pubkey { - match signer_provider.get_shutdown_scriptpubkey() { + match signer_provider.get_shutdown_scriptpubkey(channel_keys_id) { Ok(scriptpubkey) => Some(scriptpubkey), Err(_) => { return Err(APIError::ChannelUnavailable { @@ -11197,14 +11197,15 @@ where Some(_) => false, None => { assert!(send_shutdown); - let shutdown_scriptpubkey = match signer_provider.get_shutdown_scriptpubkey() { - Ok(scriptpubkey) => scriptpubkey, - Err(_) => { - return Err(ChannelError::close( - "Failed to get shutdown scriptpubkey".to_owned(), - )) - }, - }; + let shutdown_scriptpubkey = + match signer_provider.get_shutdown_scriptpubkey(self.context.channel_keys_id) { + Ok(scriptpubkey) => scriptpubkey, + Err(_) => { + return Err(ChannelError::close( + "Failed to get shutdown scriptpubkey".to_owned(), + )) + }, + }; if !shutdown_scriptpubkey.is_compatible(their_features) { return Err(ChannelError::close(format!( "Provided a scriptpubkey format not accepted by peer: {}", @@ -14420,7 +14421,9 @@ where Some(script) => script, None => { // otherwise, use the shutdown scriptpubkey provided by the signer - match signer_provider.get_shutdown_scriptpubkey() { + match signer_provider + .get_shutdown_scriptpubkey(self.context.channel_keys_id) + { Ok(scriptpubkey) => scriptpubkey, Err(_) => { return Err(APIError::ChannelUnavailable { @@ -17488,7 +17491,9 @@ mod tests { .into_script()) } - fn get_shutdown_scriptpubkey(&self) -> Result { + fn get_shutdown_scriptpubkey( + &self, _channel_keys_id: [u8; 32], + ) -> Result { let secp_ctx = Secp256k1::signing_only(); let hex = "0fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; let channel_close_key = diff --git a/lightning/src/ln/shutdown_tests.rs b/lightning/src/ln/shutdown_tests.rs index d70b240e4e4..e5585ed6ba8 100644 --- a/lightning/src/ln/shutdown_tests.rs +++ b/lightning/src/ln/shutdown_tests.rs @@ -1196,7 +1196,7 @@ fn test_unsupported_anysegwit_shutdown_script() { // Check that using an unsupported shutdown script fails and a supported one succeeds. let supported_shutdown_script = - chanmon_cfgs[1].keys_manager.get_shutdown_scriptpubkey().unwrap(); + chanmon_cfgs[1].keys_manager.get_shutdown_scriptpubkey([0; 32]).unwrap(); let unsupported_witness_program = WitnessProgram::new(WitnessVersion::V16, &[0, 40]).unwrap(); let unsupported_shutdown_script = ShutdownScript::new_witness_program(&unsupported_witness_program).unwrap(); diff --git a/lightning/src/sign/mod.rs b/lightning/src/sign/mod.rs index c210411b7d9..0909ff75b01 100644 --- a/lightning/src/sign/mod.rs +++ b/lightning/src/sign/mod.rs @@ -1121,8 +1121,9 @@ pub trait SignerProvider { /// channel force close. /// /// This method should return a different value each time it is called, to avoid linking - /// on-chain funds across channels as controlled to the same user. - fn get_shutdown_scriptpubkey(&self) -> Result; + /// on-chain funds across channels as controlled to the same user. `channel_keys_id` may be + /// used to derive a unique value for each channel. + fn get_shutdown_scriptpubkey(&self, channel_keys_id: [u8; 32]) -> Result; } impl> SignerProvider for SP { @@ -1140,8 +1141,8 @@ impl> SignerProvider for SP { self.deref().get_destination_script(channel_keys_id) } - fn get_shutdown_scriptpubkey(&self) -> Result { - self.deref().get_shutdown_scriptpubkey() + fn get_shutdown_scriptpubkey(&self, channel_keys_id: [u8; 32]) -> Result { + self.deref().get_shutdown_scriptpubkey(channel_keys_id) } } @@ -2599,7 +2600,16 @@ impl SignerProvider for KeysManager { Ok(self.destination_script.clone()) } - fn get_shutdown_scriptpubkey(&self) -> Result { + fn get_shutdown_scriptpubkey(&self, channel_keys_id: [u8; 32]) -> Result { + if self.v2_remote_key_derivation { + // Derive a fresh per-channel cooperative-close script for privacy. Funds paid to it + // remain recoverable from the seed alone by scanning the chain for the scripts + // returned by `possible_v2_static_output_spks`. + let idx = Self::static_output_key_idx(&channel_keys_id); + let key = self.derive_static_output_key(&self.shutdown_key, idx); + let pubkey = Xpub::from_priv(&self.secp_ctx, &key).public_key; + return Ok(ShutdownScript::new_p2wpkh_from_pubkey(pubkey)); + } Ok(ShutdownScript::new_p2wpkh_from_pubkey(self.shutdown_pubkey.clone())) } } @@ -2734,8 +2744,8 @@ impl SignerProvider for PhantomKeysManager { self.inner.get_destination_script(channel_keys_id) } - fn get_shutdown_scriptpubkey(&self) -> Result { - self.inner.get_shutdown_scriptpubkey() + fn get_shutdown_scriptpubkey(&self, channel_keys_id: [u8; 32]) -> Result { + self.inner.get_shutdown_scriptpubkey(channel_keys_id) } } diff --git a/lightning/src/util/dyn_signer.rs b/lightning/src/util/dyn_signer.rs index 5da284d25a4..60cd3d7ed5d 100644 --- a/lightning/src/util/dyn_signer.rs +++ b/lightning/src/util/dyn_signer.rs @@ -157,7 +157,7 @@ inner, delegate!(DynKeysInterface, SignerProvider, inner, fn get_destination_script(, channel_keys_id: [u8; 32]) -> Result, - fn get_shutdown_scriptpubkey(,) -> Result, + fn get_shutdown_scriptpubkey(, channel_keys_id: [u8; 32]) -> Result, fn generate_channel_keys_id(, _inbound: bool, _user_channel_id: u128) -> [u8; 32], fn derive_channel_signer(, _channel_keys_id: [u8; 32]) -> Self::EcdsaSigner; type EcdsaSigner = DynSigner, @@ -213,8 +213,8 @@ impl SignerProvider for DynPhantomKeysInterface { self.inner.get_destination_script(channel_keys_id) } - fn get_shutdown_scriptpubkey(&self) -> Result { - self.inner.get_shutdown_scriptpubkey() + fn get_shutdown_scriptpubkey(&self, channel_keys_id: [u8; 32]) -> Result { + self.inner.get_shutdown_scriptpubkey(channel_keys_id) } fn generate_channel_keys_id(&self, _inbound: bool, _user_channel_id: u128) -> [u8; 32] { diff --git a/lightning/src/util/test_utils.rs b/lightning/src/util/test_utils.rs index 7af41c19586..f15607aedf0 100644 --- a/lightning/src/util/test_utils.rs +++ b/lightning/src/util/test_utils.rs @@ -486,7 +486,7 @@ impl SignerProvider for OnlyReadsKeysInterface { fn get_destination_script(&self, _channel_keys_id: [u8; 32]) -> Result { Err(()) } - fn get_shutdown_scriptpubkey(&self) -> Result { + fn get_shutdown_scriptpubkey(&self, _channel_keys_id: [u8; 32]) -> Result { Err(()) } } @@ -2043,9 +2043,9 @@ impl SignerProvider for TestKeysInterface { self.backing.get_destination_script(channel_keys_id) } - fn get_shutdown_scriptpubkey(&self) -> Result { + fn get_shutdown_scriptpubkey(&self, channel_keys_id: [u8; 32]) -> Result { match &mut *self.expectations.lock().unwrap() { - None => self.backing.get_shutdown_scriptpubkey(), + None => self.backing.get_shutdown_scriptpubkey(channel_keys_id), Some(expectations) => match expectations.pop_front() { None => panic!("Unexpected get_shutdown_scriptpubkey"), Some(expectation) => Ok(expectation.returns), From 1c8424a3cf780c0c39846ffdc51d091f30867a13 Mon Sep 17 00:00:00 2001 From: Abeeujah Date: Sat, 27 Jun 2026 18:53:38 +0100 Subject: [PATCH 3/4] Add tests for per-channel static output scripts Verify that v2 (static_remote_key) destination and shutdown scripts are per-channel, distinct from each other, non-legacy, re-derivable across restarts, and recoverable via chain scan. Also confirm that v1 scripts remain static and that `find_static_output_key` correctly resolves scripts under various scenarios. --- lightning/src/sign/mod.rs | 109 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/lightning/src/sign/mod.rs b/lightning/src/sign/mod.rs index 0909ff75b01..d1dee327b2a 100644 --- a/lightning/src/sign/mod.rs +++ b/lightning/src/sign/mod.rs @@ -2884,3 +2884,112 @@ pub mod benches { } } } + +#[cfg(test)] +mod static_output_tests { + use super::{KeysManager, STATIC_PAYMENT_KEY_COUNT}; + use crate::sign::SignerProvider; + + fn keys_id(counter: u32) -> [u8; 32] { + let mut id = [7u8; 32]; + id[0..4].copy_from_slice(&counter.to_be_bytes()); + id + } + + #[test] + fn v2_destination_and_shutdown_scripts_are_per_channel() { + let keys = KeysManager::new(&[42; 32], 0, 0, true); + + let dest_a = keys.get_destination_script(keys_id(0)).unwrap(); + let dest_b = keys.get_destination_script(keys_id(1)).unwrap(); + let shutdown_a = keys.get_shutdown_scriptpubkey(keys_id(0)).unwrap().into_inner(); + let shutdown_b = keys.get_shutdown_scriptpubkey(keys_id(1)).unwrap().into_inner(); + + // Distinct channels get distinct destination and shutdown scripts. + assert_ne!(dest_a, dest_b); + assert_ne!(shutdown_a, shutdown_b); + // Destination and shutdown scripts for the same channel are independent. + assert_ne!(dest_a, shutdown_a); + // They are not the single legacy script. + assert_ne!(dest_a, keys.destination_script); + } + + #[test] + fn v1_scripts_are_static() { + let keys = KeysManager::new(&[42; 32], 0, 0, false); + + let dest_a = keys.get_destination_script(keys_id(0)).unwrap(); + let dest_b = keys.get_destination_script(keys_id(1)).unwrap(); + let shutdown_a = keys.get_shutdown_scriptpubkey(keys_id(0)).unwrap().into_inner(); + let shutdown_b = keys.get_shutdown_scriptpubkey(keys_id(1)).unwrap().into_inner(); + + assert_eq!(dest_a, dest_b); + assert_eq!(dest_a, keys.destination_script); + assert_eq!(shutdown_a, shutdown_b); + } + + #[test] + fn v2_scripts_are_re_derivable_across_restart() { + // `starting_time_*` differ but the seed and `channel_keys_id` are the same, so the same + // scripts must be re-derived (these only depend on the seed and `channel_keys_id`). + let keys_a = KeysManager::new(&[42; 32], 1, 2, true); + let keys_b = KeysManager::new(&[42; 32], 3, 4, true); + + assert_eq!( + keys_a.get_destination_script(keys_id(5)).unwrap(), + keys_b.get_destination_script(keys_id(5)).unwrap() + ); + assert_eq!( + keys_a.get_shutdown_scriptpubkey(keys_id(5)).unwrap().into_inner(), + keys_b.get_shutdown_scriptpubkey(keys_id(5)).unwrap().into_inner() + ); + } + + #[test] + fn v2_scripts_are_recoverable_via_chain_scan() { + let keys = KeysManager::new(&[42; 32], 0, 0, true); + let possible_spks = keys.possible_v2_static_output_spks(); + assert_eq!(possible_spks.len(), usize::from(STATIC_PAYMENT_KEY_COUNT) * 2); + + for counter in 0..16 { + let id = keys_id(counter); + let dest = keys.get_destination_script(id).unwrap(); + let shutdown = keys.get_shutdown_scriptpubkey(id).unwrap().into_inner(); + assert!(possible_spks.contains(&dest)); + assert!(possible_spks.contains(&shutdown)); + } + } + + #[test] + fn find_static_output_key_matches_handed_out_scripts() { + let keys = KeysManager::new(&[42; 32], 0, 0, true); + let id = keys_id(3); + + // Per-channel destination key resolves with the matching `channel_keys_id`. + let dest = keys.get_destination_script(id).unwrap(); + let dest_key = keys.find_static_output_key(Some(id), &dest).expect("destination key"); + assert_eq!(keys.p2wpkh_spk_for_key(&dest_key), dest); + + // Per-channel shutdown key resolves with the matching `channel_keys_id`. + let shutdown = keys.get_shutdown_scriptpubkey(id).unwrap().into_inner(); + let shutdown_key = keys.find_static_output_key(Some(id), &shutdown).expect("shutdown key"); + assert_eq!(keys.p2wpkh_spk_for_key(&shutdown_key), shutdown); + + // A per-channel script does not resolve under the wrong `channel_keys_id`. + assert!(keys.find_static_output_key(Some(keys_id(4)), &dest).is_none()); + + // Per-channel scripts also resolve without a `channel_keys_id` (seed-only recovery) by + // scanning the full set of possible keys. + let recovered = + keys.find_static_output_key(None, &dest).expect("recovered destination key"); + assert_eq!(keys.p2wpkh_spk_for_key(&recovered), dest); + + // Legacy scripts always resolve, even without a `channel_keys_id`. + let legacy_dest = keys.destination_script.clone(); + assert!(keys.find_static_output_key(None, &legacy_dest).is_some()); + + // Unknown scripts never resolve. + let unknown = keys.get_destination_script(keys_id(9)).unwrap(); + assert!(keys.find_static_output_key(Some(keys_id(8)), &unknown).is_none()); + } +} From 0170743198a514abafe667f3fc4dd0767e6c32ca Mon Sep 17 00:00:00 2001 From: Abeeujah Date: Sat, 27 Jun 2026 19:11:36 +0100 Subject: [PATCH 4/4] Add changelog entry for (#1139) --- pending_changelog/1139.txt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 pending_changelog/1139.txt diff --git a/pending_changelog/1139.txt b/pending_changelog/1139.txt new file mode 100644 index 00000000000..b4aa815dc6d --- /dev/null +++ b/pending_changelog/1139.txt @@ -0,0 +1,17 @@ +## API Updates +* `SignerProvider::get_shutdown_scriptpubkey` now takes a `channel_keys_id` argument (matching +`get_destination_script`), allowing implementations to derive a unique cooperative-close +`script_pubkey` for each channel (#1139). + +## Privacy +* When a `KeysManager` is built with `v2_remote_key_derivation` set, it now derives a fresh +destination `script_pubkey` and cooperative-close `script_pubkey` for each new or spliced channel, +rather than reusing a single `script_pubkey` across all channels, avoiding linking our on-chain +funds across channels. Funds paid to these scripts remain recoverable from the seed alone via +`KeysManager::possible_v2_static_output_spks` (#1139). + +## Backwards Compat +* Channels opened (or spliced) by a `KeysManager` built with `v2_remote_key_derivation` set now +close to per-channel destination and cooperative-close `script_pubkey`s. As with the +`v2_remote_key_derivation` remote-key changes, if this is set you *MUST NOT* downgrade to a version +of LDK prior to 0.2 (#1139).