Skip to content

Commit 97858df

Browse files
jkczyzclaude
andcommitted
Add tests for prior-contribution filtering in SpliceFundingFailed events
Add test_splice_rbf_disconnect_filters_prior_contributions covering the reset_pending_splice_state macro path: when disconnecting during an RBF round that reuses the same UTXOs as a prior round, the DiscardFunding event should filter out inputs still committed to the prior round while keeping change outputs that differ due to the higher feerate. Extend do_abandon_splice_quiescent_action_on_shutdown with a pending_splice parameter covering the abandon_quiescent_action path: when shutdown occurs while a splice is queued and a prior splice is pending, the DiscardFunding event should similarly filter overlapping inputs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 47baf7c commit 97858df

1 file changed

Lines changed: 138 additions & 6 deletions

File tree

lightning/src/ln/splicing_tests.rs

Lines changed: 138 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2812,12 +2812,14 @@ fn fail_quiescent_action_on_channel_close() {
28122812

28132813
#[test]
28142814
fn abandon_splice_quiescent_action_on_shutdown() {
2815-
do_abandon_splice_quiescent_action_on_shutdown(true);
2816-
do_abandon_splice_quiescent_action_on_shutdown(false);
2815+
do_abandon_splice_quiescent_action_on_shutdown(true, false);
2816+
do_abandon_splice_quiescent_action_on_shutdown(false, false);
2817+
do_abandon_splice_quiescent_action_on_shutdown(true, true);
2818+
do_abandon_splice_quiescent_action_on_shutdown(false, true);
28172819
}
28182820

28192821
#[cfg(test)]
2820-
fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool) {
2822+
fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool, pending_splice: bool) {
28212823
let chanmon_cfgs = create_chanmon_cfgs(2);
28222824
let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
28232825
let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
@@ -2831,6 +2833,19 @@ fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool) {
28312833
let (_, _, channel_id, _) =
28322834
create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_capacity, 0);
28332835

2836+
// When testing with a prior pending splice, complete splice A first so that
2837+
// `quiescent_action_into_error` filters against `pending_splice.contributed_inputs/outputs`.
2838+
if pending_splice {
2839+
let funding_contribution = do_initiate_splice_in(
2840+
&nodes[0],
2841+
&nodes[1],
2842+
channel_id,
2843+
Amount::from_sat(initial_channel_capacity / 2),
2844+
);
2845+
let (_splice_tx, _new_funding_script) =
2846+
splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution);
2847+
}
2848+
28342849
// Since we cannot close after having sent `stfu`, send an HTLC so that when we attempt to
28352850
// splice, the `stfu` message is held back.
28362851
let payment_amount = 1_000_000;
@@ -2843,17 +2858,22 @@ fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool) {
28432858
check_added_monitors(&nodes[0], 1);
28442859

28452860
nodes[1].node.handle_update_add_htlc(node_id_0, &update.update_add_htlcs[0]);
2846-
nodes[1].node.handle_commitment_signed(node_id_0, &update.commitment_signed[0]);
2861+
// After a splice, commitment_signed messages are batched across funding scopes.
2862+
nodes[1].node.handle_commitment_signed_batch_test(node_id_0, &update.commitment_signed);
28472863
check_added_monitors(&nodes[1], 1);
28482864
let (revoke_and_ack, _) = get_revoke_commit_msgs(&nodes[1], &node_id_0);
28492865

28502866
nodes[0].node.handle_revoke_and_ack(node_id_1, &revoke_and_ack);
28512867
check_added_monitors(&nodes[0], 1);
28522868

28532869
// Attempt the splice. `stfu` should not go out yet as the state machine is pending.
2854-
let splice_in_amount = initial_channel_capacity / 2;
2870+
// Use a different amount when there's a prior splice so the change output differs.
2871+
let splice_in_amount =
2872+
if pending_splice { initial_channel_capacity / 4 } else { initial_channel_capacity / 2 };
28552873
let funding_contribution =
28562874
initiate_splice_in(&nodes[0], &nodes[1], channel_id, Amount::from_sat(splice_in_amount));
2875+
let splice_b_change_output =
2876+
if pending_splice { funding_contribution.change_output().cloned() } else { None };
28572877
assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty());
28582878

28592879
// Close the channel. We should see a `SpliceFailed` event for the pending splice
@@ -2867,7 +2887,33 @@ fn do_abandon_splice_quiescent_action_on_shutdown(local_shutdown: bool) {
28672887
let shutdown = get_event_msg!(closer_node, MessageSendEvent::SendShutdown, closee_node_id);
28682888
closee_node.node.handle_shutdown(closer_node_id, &shutdown);
28692889

2870-
expect_splice_failed_events(&nodes[0], &channel_id, funding_contribution);
2890+
if pending_splice {
2891+
// With a prior pending splice, contributions are filtered against committed inputs/outputs.
2892+
let events = nodes[0].node.get_and_clear_pending_events();
2893+
assert_eq!(events.len(), 2, "{events:?}");
2894+
match &events[0] {
2895+
Event::SpliceFailed { channel_id: cid, .. } => {
2896+
assert_eq!(*cid, channel_id);
2897+
},
2898+
other => panic!("Expected SpliceFailed, got {:?}", other),
2899+
}
2900+
match &events[1] {
2901+
Event::DiscardFunding {
2902+
funding_info: FundingInfo::Contribution { inputs, outputs },
2903+
..
2904+
} => {
2905+
// The UTXO was filtered: it's still committed to the prior splice.
2906+
assert!(inputs.is_empty(), "Expected empty inputs (filtered), got {:?}", inputs);
2907+
// The change output was NOT filtered: different splice-in amount produces a
2908+
// different change.
2909+
let expected_outputs: Vec<_> = splice_b_change_output.into_iter().collect();
2910+
assert_eq!(*outputs, expected_outputs);
2911+
},
2912+
other => panic!("Expected DiscardFunding with Contribution, got {:?}", other),
2913+
}
2914+
} else {
2915+
expect_splice_failed_events(&nodes[0], &channel_id, funding_contribution);
2916+
}
28712917
let _ = get_event_msg!(closee_node, MessageSendEvent::SendShutdown, closer_node_id);
28722918
}
28732919

@@ -5344,3 +5390,89 @@ fn test_splice_rbf_sequential() {
53445390
node.chain_source.remove_watched_txn_and_outputs(outpoint_1, new_funding_script.clone());
53455391
}
53465392
}
5393+
5394+
#[test]
5395+
fn test_splice_rbf_disconnect_filters_prior_contributions() {
5396+
// When disconnecting during an RBF round that reuses the same UTXOs as a prior round,
5397+
// the SpliceFundingFailed event should filter out inputs/outputs still committed to the prior
5398+
// round. This exercises the `reset_pending_splice_state` → `maybe_create_splice_funding_failed`
5399+
// macro path.
5400+
let chanmon_cfgs = create_chanmon_cfgs(2);
5401+
let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
5402+
let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
5403+
let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
5404+
5405+
let node_id_0 = nodes[0].node.get_our_node_id();
5406+
let node_id_1 = nodes[1].node.get_our_node_id();
5407+
5408+
let initial_channel_value_sat = 100_000;
5409+
let (_, _, channel_id, _) =
5410+
create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0);
5411+
5412+
let added_value = Amount::from_sat(50_000);
5413+
// Provide exactly 1 UTXO per node so coin selection is deterministic.
5414+
provide_utxo_reserves(&nodes, 1, added_value * 2);
5415+
5416+
// --- Round 0: Initial splice-in at floor feerate (253). ---
5417+
let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value);
5418+
let (_splice_tx_0, _new_funding_script) =
5419+
splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution);
5420+
5421+
// --- Round 1: RBF at higher feerate without providing new UTXOs. ---
5422+
// The wallet reselects the same UTXO since the splice tx hasn't been mined.
5423+
let feerate_1_sat_per_kwu = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25 + 23) / 24;
5424+
let rbf_feerate = FeeRate::from_sat_per_kwu(feerate_1_sat_per_kwu);
5425+
let funding_template = nodes[0].node.rbf_channel(&channel_id, &node_id_1, rbf_feerate).unwrap();
5426+
let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger);
5427+
let funding_contribution_1 = funding_template.splice_in_sync(added_value, &wallet).unwrap();
5428+
let rbf_change_output = funding_contribution_1.change_output().cloned();
5429+
nodes[0]
5430+
.node
5431+
.funding_contributed(&channel_id, &node_id_1, funding_contribution_1, None)
5432+
.unwrap();
5433+
5434+
// STFU exchange.
5435+
let stfu_a = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1);
5436+
nodes[1].node.handle_stfu(node_id_0, &stfu_a);
5437+
let stfu_b = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0);
5438+
nodes[0].node.handle_stfu(node_id_1, &stfu_b);
5439+
5440+
// RBF handshake to start interactive TX.
5441+
let tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1);
5442+
assert_eq!(tx_init_rbf.feerate_sat_per_1000_weight, feerate_1_sat_per_kwu as u32);
5443+
nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf);
5444+
let tx_ack_rbf = get_event_msg!(nodes[1], MessageSendEvent::SendTxAckRbf, node_id_0);
5445+
nodes[0].node.handle_tx_ack_rbf(node_id_1, &tx_ack_rbf);
5446+
5447+
// Disconnect mid-negotiation. Stale interactive TX messages are cleared by peer_disconnected.
5448+
nodes[0].node.peer_disconnected(node_id_1);
5449+
nodes[1].node.peer_disconnected(node_id_0);
5450+
5451+
// The initiator should get SpliceFailed + DiscardFunding with filtered contributions.
5452+
let events = nodes[0].node.get_and_clear_pending_events();
5453+
assert_eq!(events.len(), 2, "{events:?}");
5454+
match &events[0] {
5455+
Event::SpliceFailed { channel_id: cid, .. } => {
5456+
assert_eq!(*cid, channel_id);
5457+
},
5458+
other => panic!("Expected SpliceFailed, got {:?}", other),
5459+
}
5460+
match &events[1] {
5461+
Event::DiscardFunding {
5462+
funding_info: FundingInfo::Contribution { inputs, outputs },
5463+
..
5464+
} => {
5465+
// The UTXO was filtered out: it's still committed to round 0's splice.
5466+
assert!(inputs.is_empty(), "Expected empty inputs (filtered), got {:?}", inputs);
5467+
// The change output was NOT filtered: different feerate produces a different amount.
5468+
let expected_outputs: Vec<_> = rbf_change_output.into_iter().collect();
5469+
assert_eq!(*outputs, expected_outputs);
5470+
},
5471+
other => panic!("Expected DiscardFunding with Contribution, got {:?}", other),
5472+
}
5473+
5474+
// Reconnect. After a completed splice, channel_ready is not re-sent.
5475+
let mut reconnect_args = ReconnectArgs::new(&nodes[0], &nodes[1]);
5476+
reconnect_args.send_announcement_sigs = (true, true);
5477+
reconnect_nodes(reconnect_args);
5478+
}

0 commit comments

Comments
 (0)