Skip to content

Commit aad7c22

Browse files
authored
Gate counterparty-initiated splices behind conf depth (#20)
On 0-conf channels, splice_locked is sent immediately because the channel's minimum_depth is 0. This is fine when the LSP initiates the splice (it constructed the funding output), but when the counterparty initiates a splice, the LSP trusts a funding output it didn't build. The counterparty can double-spend the splice tx after the LSP has already sent splice_locked. Add ChannelHandshakeConfig::splice_minimum_depth which, when set, overrides the per-FundingScope minimum_depth for splices where is_initiator is false. The override is applied in on_tx_signatures_exchange before the funding is pushed to negotiated_candidates, so check_funding_meets_minimum_depth sees the configured depth instead of the channel's 0-conf minimum. Self-initiated splices are unaffected: the LSP constructed the funding output and the existing 0-conf behavior is correct.
1 parent 3b4ec0a commit aad7c22

3 files changed

Lines changed: 299 additions & 1 deletion

File tree

lightning/src/ln/channel.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3014,6 +3014,7 @@ where
30143014
counterparty_max_accepted_htlcs: u16,
30153015
holder_max_accepted_htlcs: u16,
30163016
minimum_depth: Option<u32>,
3017+
splice_minimum_depth: Option<u32>,
30173018

30183019
counterparty_forwarding_info: Option<CounterpartyForwardingInfo>,
30193020

@@ -3658,6 +3659,7 @@ where
36583659
counterparty_max_accepted_htlcs: open_channel_fields.max_accepted_htlcs,
36593660
holder_max_accepted_htlcs: cmp::min(config.channel_handshake_config.our_max_accepted_htlcs, max_htlcs(&channel_type)),
36603661
minimum_depth,
3662+
splice_minimum_depth: config.channel_handshake_config.splice_minimum_depth,
36613663

36623664
counterparty_forwarding_info: None,
36633665

@@ -3898,6 +3900,7 @@ where
38983900
counterparty_max_accepted_htlcs: 0,
38993901
holder_max_accepted_htlcs: cmp::min(config.channel_handshake_config.our_max_accepted_htlcs, max_htlcs(&channel_type)),
39003902
minimum_depth: None, // Filled in in accept_channel
3903+
splice_minimum_depth: config.channel_handshake_config.splice_minimum_depth,
39013904

39023905
counterparty_forwarding_info: None,
39033906

@@ -8958,11 +8961,17 @@ where
89588961

89598962
if let Some(pending_splice) = self.pending_splice.as_mut() {
89608963
self.context.channel_state.clear_quiescent();
8961-
if let Some(FundingNegotiation::AwaitingSignatures { mut funding, .. }) =
8964+
if let Some(FundingNegotiation::AwaitingSignatures { mut funding, is_initiator }) =
89628965
pending_splice.funding_negotiation.take()
89638966
{
89648967
funding.funding_transaction = Some(funding_tx);
89658968

8969+
if !is_initiator {
8970+
if let Some(depth) = self.context.splice_minimum_depth {
8971+
funding.minimum_depth_override = Some(depth);
8972+
}
8973+
}
8974+
89668975
let funding_txo =
89678976
funding.get_funding_txo().expect("funding outpoint should be set");
89688977
let channel_type = funding.get_channel_type().clone();
@@ -14905,6 +14914,7 @@ where
1490514914
(65, self.quiescent_action, option), // Added in 0.2
1490614915
(67, pending_outbound_held_htlc_flags, optional_vec), // Added in 0.2
1490714916
(69, holding_cell_held_htlc_flags, optional_vec), // Added in 0.2
14917+
(71, self.context.splice_minimum_depth, option),
1490814918
});
1490914919

1491014920
Ok(())
@@ -15272,6 +15282,7 @@ where
1527215282

1527315283
let mut pending_outbound_held_htlc_flags_opt: Option<Vec<Option<()>>> = None;
1527415284
let mut holding_cell_held_htlc_flags_opt: Option<Vec<Option<()>>> = None;
15285+
let mut splice_minimum_depth: Option<u32> = None;
1527515286

1527615287
read_tlv_fields!(reader, {
1527715288
(0, announcement_sigs, option),
@@ -15319,6 +15330,7 @@ where
1531915330
(65, quiescent_action, upgradable_option), // Added in 0.2
1532015331
(67, pending_outbound_held_htlc_flags_opt, optional_vec), // Added in 0.2
1532115332
(69, holding_cell_held_htlc_flags_opt, optional_vec), // Added in 0.2
15333+
(71, splice_minimum_depth, option),
1532215334
});
1532315335

1532415336
let holder_signer = signer_provider.derive_channel_signer(channel_keys_id);
@@ -15708,6 +15720,7 @@ where
1570815720
is_manual_broadcast: is_manual_broadcast.unwrap_or(false),
1570915721

1571015722
interactive_tx_signing_session,
15723+
splice_minimum_depth,
1571115724
},
1571215725
holder_commitment_point,
1571315726
pending_splice,

lightning/src/ln/splicing_tests.rs

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2056,3 +2056,277 @@ fn test_splice_with_inflight_htlc_forward_and_resolution() {
20562056
do_test_splice_with_inflight_htlc_forward_and_resolution(true);
20572057
do_test_splice_with_inflight_htlc_forward_and_resolution(false);
20582058
}
2059+
2060+
#[test]
2061+
fn test_splice_minimum_depth_for_counterparty_initiated() {
2062+
// When a 0-conf channel is spliced by the counterparty, the LSP (acceptor) should gate
2063+
// splice_locked behind the configured splice_minimum_depth rather than inheriting 0-conf.
2064+
let chanmon_cfgs = create_chanmon_cfgs(2);
2065+
let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
2066+
2067+
// node_a (LSP): accepts 0-conf, but requires 6 confirmations for counterparty-initiated splices.
2068+
let mut lsp_config = test_default_channel_config();
2069+
lsp_config.manually_accept_inbound_channels = true;
2070+
lsp_config.channel_handshake_limits.trust_own_funding_0conf = true;
2071+
lsp_config.channel_handshake_config.splice_minimum_depth = Some(6);
2072+
2073+
// node_b (client): vanilla 0-conf, no splice_minimum_depth.
2074+
let mut client_config = test_default_channel_config();
2075+
client_config.manually_accept_inbound_channels = true;
2076+
client_config.channel_handshake_limits.trust_own_funding_0conf = true;
2077+
2078+
let node_chanmgrs =
2079+
create_node_chanmgrs(2, &node_cfgs, &[Some(lsp_config), Some(client_config)]);
2080+
let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
2081+
2082+
let node_id_a = nodes[0].node.get_our_node_id();
2083+
let node_id_b = nodes[1].node.get_our_node_id();
2084+
2085+
// Open a 0-conf channel: node_b initiates, node_a accepts 0-conf.
2086+
let initial_channel_value_sat = 1_000_000;
2087+
let (funding_tx, channel_id) =
2088+
open_zero_conf_channel_with_value(&nodes[1], &nodes[0], None, initial_channel_value_sat, 0);
2089+
mine_transaction(&nodes[0], &funding_tx);
2090+
mine_transaction(&nodes[1], &funding_tx);
2091+
2092+
// node_b (client) initiates the splice → is_initiator=true on node_b, is_initiator=false on node_a.
2093+
let coinbase_tx = provide_anchor_reserves(&nodes);
2094+
let splice_in_sats = 500_000;
2095+
let initiator_contribution = SpliceContribution::SpliceIn {
2096+
value: Amount::from_sat(splice_in_sats),
2097+
inputs: vec![FundingTxInput::new_p2wpkh(coinbase_tx, 1).unwrap()],
2098+
change_script: Some(nodes[1].wallet_source.get_change_script().unwrap()),
2099+
};
2100+
2101+
let new_funding_script =
2102+
complete_splice_handshake(&nodes[1], &nodes[0], channel_id, initiator_contribution.clone());
2103+
2104+
let initial_commit_sig = complete_interactive_funding_negotiation(
2105+
&nodes[1],
2106+
&nodes[0],
2107+
channel_id,
2108+
initiator_contribution,
2109+
new_funding_script,
2110+
);
2111+
2112+
// --- Sign the splice tx (custom flow for asymmetric splice_locked) ---
2113+
// The standard sign_interactive_funding_tx helper expects symmetric 0-conf behavior.
2114+
// Here, node_b (initiator) will send splice_locked, but node_a (acceptor) will not.
2115+
2116+
// acceptor (node_a) receives initiator's commitment_signed, responds with commitment_signed + tx_signatures.
2117+
assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty());
2118+
nodes[0].node.handle_commitment_signed(node_id_b, &initial_commit_sig);
2119+
let msg_events = nodes[0].node.get_and_clear_pending_msg_events();
2120+
assert_eq!(msg_events.len(), 2, "{msg_events:?}");
2121+
if let MessageSendEvent::UpdateHTLCs { ref updates, .. } = &msg_events[0] {
2122+
nodes[1].node.handle_commitment_signed(node_id_a, &updates.commitment_signed[0]);
2123+
} else {
2124+
panic!("Expected UpdateHTLCs, got {:?}", &msg_events[0]);
2125+
}
2126+
if let MessageSendEvent::SendTxSignatures { ref msg, .. } = &msg_events[1] {
2127+
nodes[1].node.handle_tx_signatures(node_id_a, msg);
2128+
} else {
2129+
panic!("Expected SendTxSignatures, got {:?}", &msg_events[1]);
2130+
}
2131+
2132+
// initiator (node_b) signs the funding tx.
2133+
let event = get_event!(nodes[1], Event::FundingTransactionReadyForSigning);
2134+
if let Event::FundingTransactionReadyForSigning {
2135+
channel_id: cid,
2136+
counterparty_node_id,
2137+
unsigned_transaction,
2138+
..
2139+
} = event
2140+
{
2141+
assert_eq!(cid, channel_id);
2142+
let signed_tx = nodes[1].wallet_source.sign_tx(unsigned_transaction).unwrap();
2143+
nodes[1].node.funding_transaction_signed(&cid, &counterparty_node_id, signed_tx).unwrap();
2144+
}
2145+
2146+
// node_b (initiator, client) should emit TxSignatures + SpliceLocked (0-conf, no override).
2147+
let mut initiator_msgs = nodes[1].node.get_and_clear_pending_msg_events();
2148+
assert_eq!(
2149+
initiator_msgs.len(),
2150+
2,
2151+
"client should send TxSignatures + SpliceLocked: {initiator_msgs:?}"
2152+
);
2153+
let initiator_splice_locked =
2154+
if let MessageSendEvent::SendTxSignatures { ref msg, .. } = &initiator_msgs[0] {
2155+
// Forward TxSignatures to acceptor (node_a).
2156+
nodes[0].node.handle_tx_signatures(node_id_b, msg);
2157+
match initiator_msgs.remove(1) {
2158+
MessageSendEvent::SendSpliceLocked { msg, .. } => msg,
2159+
ref e => panic!("Expected SendSpliceLocked, got {:?}", e),
2160+
}
2161+
} else {
2162+
panic!("Expected SendTxSignatures, got {:?}", &initiator_msgs[0]);
2163+
};
2164+
2165+
check_added_monitors(&nodes[1], 1);
2166+
check_added_monitors(&nodes[0], 1);
2167+
2168+
let splice_tx = {
2169+
let mut txn = nodes[1].tx_broadcaster.txn_broadcast();
2170+
assert_eq!(txn.len(), 1);
2171+
assert_eq!(nodes[0].tx_broadcaster.txn_broadcast(), txn);
2172+
txn.remove(0)
2173+
};
2174+
2175+
expect_splice_pending_event(&nodes[1], &node_id_a);
2176+
expect_splice_pending_event(&nodes[0], &node_id_b);
2177+
2178+
// --- Key assertion: node_a (LSP/acceptor) should NOT have emitted SpliceLocked ---
2179+
let acceptor_msgs = nodes[0].node.get_and_clear_pending_msg_events();
2180+
assert!(
2181+
acceptor_msgs.is_empty(),
2182+
"LSP should not send splice_locked before required depth, got: {acceptor_msgs:?}"
2183+
);
2184+
2185+
// Confirm the splice tx and mine blocks up to (but not including) the required depth.
2186+
mine_transaction(&nodes[0], &splice_tx);
2187+
mine_transaction(&nodes[1], &splice_tx);
2188+
// 1 conf from mine_transaction. We need 6 total, so connect 4 more (will be at 5 confs).
2189+
connect_blocks(&nodes[0], 4);
2190+
connect_blocks(&nodes[1], 4);
2191+
2192+
// Still not enough depth — node_a should still not have emitted splice_locked.
2193+
// (Other messages like SendAnnouncementSignatures may appear.)
2194+
let acceptor_msgs = nodes[0].node.get_and_clear_pending_msg_events();
2195+
assert!(
2196+
!acceptor_msgs.iter().any(|m| matches!(m, MessageSendEvent::SendSpliceLocked { .. })),
2197+
"LSP should not send splice_locked at 5 confs (need 6), got: {acceptor_msgs:?}"
2198+
);
2199+
// Clear any announcement signatures from node_b as well.
2200+
nodes[1].node.get_and_clear_pending_msg_events();
2201+
2202+
// Mine one more block → 6 confirmations on both. node_a should now emit splice_locked.
2203+
connect_blocks(&nodes[0], 1);
2204+
connect_blocks(&nodes[1], 1);
2205+
2206+
let acceptor_splice_locked =
2207+
get_event_msg!(nodes[0], MessageSendEvent::SendSpliceLocked, node_id_b);
2208+
2209+
// Save old funding info for chain source cleanup BEFORE any splice_locked handling,
2210+
// since handle_splice_locked updates the monitor's funding outpoint.
2211+
let (prev_funding_outpoint, prev_funding_script) = nodes[0]
2212+
.chain_monitor
2213+
.chain_monitor
2214+
.get_monitor(channel_id)
2215+
.map(|monitor| (monitor.get_funding_txo(), monitor.get_funding_script()))
2216+
.unwrap();
2217+
2218+
// Send node_b's splice_locked (from earlier) to node_a.
2219+
nodes[0].node.handle_splice_locked(node_id_b, &initiator_splice_locked);
2220+
2221+
// Send node_a's splice_locked to node_b. node_b responds with SendAnnouncementSignatures.
2222+
nodes[1].node.handle_splice_locked(node_id_a, &acceptor_splice_locked);
2223+
let mut b_msgs = nodes[1].node.get_and_clear_pending_msg_events();
2224+
// node_b already sent splice_locked earlier, so it only emits SendAnnouncementSignatures.
2225+
assert_eq!(b_msgs.len(), 1, "{b_msgs:?}");
2226+
if let MessageSendEvent::SendAnnouncementSignatures { ref msg, .. } = b_msgs.remove(0) {
2227+
nodes[0].node.handle_announcement_signatures(node_id_b, msg);
2228+
} else {
2229+
panic!("Expected SendAnnouncementSignatures, got {:?}", b_msgs);
2230+
}
2231+
2232+
expect_channel_ready_event(&nodes[0], &node_id_b);
2233+
check_added_monitors(&nodes[0], 1);
2234+
expect_channel_ready_event(&nodes[1], &node_id_a);
2235+
check_added_monitors(&nodes[1], 1);
2236+
2237+
// node_a now emits its own announcement_signatures + BroadcastChannelAnnouncement.
2238+
let mut a_msgs = nodes[0].node.get_and_clear_pending_msg_events();
2239+
assert_eq!(a_msgs.len(), 2, "{a_msgs:?}");
2240+
if let MessageSendEvent::SendAnnouncementSignatures { ref msg, .. } = a_msgs.remove(0) {
2241+
nodes[1].node.handle_announcement_signatures(node_id_a, msg);
2242+
} else {
2243+
panic!("Expected SendAnnouncementSignatures");
2244+
}
2245+
assert!(matches!(a_msgs.remove(0), MessageSendEvent::BroadcastChannelAnnouncement { .. }));
2246+
2247+
let mut b_msgs = nodes[1].node.get_and_clear_pending_msg_events();
2248+
assert_eq!(b_msgs.len(), 1, "{b_msgs:?}");
2249+
assert!(matches!(b_msgs.remove(0), MessageSendEvent::BroadcastChannelAnnouncement { .. }));
2250+
2251+
// Cleanup chain source watches for old funding.
2252+
nodes[0]
2253+
.chain_source
2254+
.remove_watched_txn_and_outputs(prev_funding_outpoint, prev_funding_script.clone());
2255+
nodes[1]
2256+
.chain_source
2257+
.remove_watched_txn_and_outputs(prev_funding_outpoint, prev_funding_script);
2258+
2259+
// Verify the channel is usable.
2260+
send_payment(&nodes[1], &[&nodes[0]], 100_000);
2261+
}
2262+
2263+
#[test]
2264+
fn test_splice_minimum_depth_not_applied_for_self_initiated() {
2265+
// When the LSP itself initiates a splice, the splice_minimum_depth override should NOT apply
2266+
// (is_initiator=true on LSP side), and the splice should lock immediately (0-conf).
2267+
let chanmon_cfgs = create_chanmon_cfgs(2);
2268+
let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
2269+
2270+
// node_a (LSP): 0-conf + splice_minimum_depth.
2271+
let mut lsp_config = test_default_channel_config();
2272+
lsp_config.manually_accept_inbound_channels = true;
2273+
lsp_config.channel_handshake_limits.trust_own_funding_0conf = true;
2274+
lsp_config.channel_handshake_config.splice_minimum_depth = Some(6);
2275+
2276+
// node_b (client): vanilla 0-conf.
2277+
let mut client_config = test_default_channel_config();
2278+
client_config.manually_accept_inbound_channels = true;
2279+
client_config.channel_handshake_limits.trust_own_funding_0conf = true;
2280+
2281+
let node_chanmgrs =
2282+
create_node_chanmgrs(2, &node_cfgs, &[Some(lsp_config), Some(client_config)]);
2283+
let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
2284+
2285+
let node_id_a = nodes[0].node.get_our_node_id();
2286+
let node_id_b = nodes[1].node.get_our_node_id();
2287+
2288+
// Open a 0-conf channel: node_b initiates channel, node_a accepts 0-conf.
2289+
let initial_channel_value_sat = 1_000_000;
2290+
let (funding_tx, channel_id) =
2291+
open_zero_conf_channel_with_value(&nodes[1], &nodes[0], None, initial_channel_value_sat, 0);
2292+
mine_transaction(&nodes[0], &funding_tx);
2293+
mine_transaction(&nodes[1], &funding_tx);
2294+
2295+
// node_a (LSP) initiates the splice → is_initiator=true on node_a, override should NOT apply.
2296+
let coinbase_tx = provide_anchor_reserves(&nodes);
2297+
let splice_in_sats = 500_000;
2298+
let initiator_contribution = SpliceContribution::SpliceIn {
2299+
value: Amount::from_sat(splice_in_sats),
2300+
inputs: vec![FundingTxInput::new_p2wpkh(coinbase_tx, 0).unwrap()],
2301+
change_script: Some(nodes[0].wallet_source.get_change_script().unwrap()),
2302+
};
2303+
2304+
let new_funding_script =
2305+
complete_splice_handshake(&nodes[0], &nodes[1], channel_id, initiator_contribution.clone());
2306+
2307+
let initial_commit_sig = complete_interactive_funding_negotiation(
2308+
&nodes[0],
2309+
&nodes[1],
2310+
channel_id,
2311+
initiator_contribution,
2312+
new_funding_script,
2313+
);
2314+
2315+
// Use the standard helper — both sides should be 0-conf since the override doesn't apply
2316+
// to self-initiated splices.
2317+
let (_splice_tx, splice_locked) =
2318+
sign_interactive_funding_tx(&nodes[0], &nodes[1], initial_commit_sig, true);
2319+
2320+
// node_a (LSP, initiator) DOES emit SpliceLocked immediately — the override is not applied
2321+
// because is_initiator=true.
2322+
let (splice_locked_msg, for_node_id) = splice_locked.unwrap();
2323+
assert_eq!(for_node_id, node_id_b);
2324+
2325+
expect_splice_pending_event(&nodes[0], &node_id_b);
2326+
expect_splice_pending_event(&nodes[1], &node_id_a);
2327+
2328+
lock_splice(&nodes[0], &nodes[1], &splice_locked_msg, true);
2329+
2330+
// Verify the channel is usable.
2331+
send_payment(&nodes[0], &[&nodes[1]], 100_000);
2332+
}

lightning/src/util/config.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,15 @@ pub struct ChannelHandshakeConfig {
267267
///
268268
/// [`max_htlcs`]: crate::ln::chan_utils::max_htlcs
269269
pub our_max_accepted_htlcs: u16,
270+
271+
/// If set, counterparty-initiated splices on 0-conf channels will require this many
272+
/// confirmations before we send `splice_locked`. This prevents inheriting 0-conf trust
273+
/// on funding outputs we didn't construct.
274+
///
275+
/// Has no effect on self-initiated splices or non-0-conf channels.
276+
///
277+
/// Default value: `None` (splice inherits the channel's `minimum_depth`)
278+
pub splice_minimum_depth: Option<u32>,
270279
}
271280

272281
impl Default for ChannelHandshakeConfig {
@@ -284,6 +293,7 @@ impl Default for ChannelHandshakeConfig {
284293
negotiate_anchors_zero_fee_htlc_tx: false,
285294
negotiate_anchor_zero_fee_commitments: false,
286295
our_max_accepted_htlcs: 50,
296+
splice_minimum_depth: None,
287297
}
288298
}
289299
}
@@ -307,6 +317,7 @@ impl Readable for ChannelHandshakeConfig {
307317
negotiate_anchors_zero_fee_htlc_tx: Readable::read(reader)?,
308318
negotiate_anchor_zero_fee_commitments: Readable::read(reader)?,
309319
our_max_accepted_htlcs: Readable::read(reader)?,
320+
splice_minimum_depth: Readable::read(reader)?,
310321
})
311322
}
312323
}

0 commit comments

Comments
 (0)