Skip to content

Commit 62847a1

Browse files
jkczyzclaude
andcommitted
Derive DiscardFunding inputs and outputs from contributions on promotion
When a splice funding is promoted, produce FundingInfo::Contribution instead of FundingInfo::Tx for the discarded funding events. Each contribution is filtered against the promoted funding transaction's inputs and outputs, so only inputs and outputs unique to the discarded round are reported. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7de125c commit 62847a1

2 files changed

Lines changed: 161 additions & 52 deletions

File tree

lightning/src/ln/channel.rs

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11569,30 +11569,28 @@ where
1156911569
.iter_mut()
1157011570
.find(|funding| funding.get_funding_txid() == Some(splice_txid))
1157111571
.unwrap();
11572-
let prev_funding_txid = self.funding.get_funding_txid();
1157311572

1157411573
if let Some(scid) = self.funding.short_channel_id {
1157511574
self.context.historical_scids.push(scid);
1157611575
}
1157711576

1157811577
core::mem::swap(&mut self.funding, funding);
1157911578

11580-
// The swap above places the previous `FundingScope` into `pending_funding`.
11581-
pending_splice
11582-
.negotiated_candidates
11583-
.drain(..)
11584-
.filter(|funding| funding.get_funding_txid() != prev_funding_txid)
11585-
.map(|mut funding| {
11586-
funding
11587-
.funding_transaction
11588-
.take()
11589-
.map(|tx| FundingInfo::Tx { transaction: tx })
11590-
.unwrap_or_else(|| FundingInfo::OutPoint {
11591-
outpoint: funding
11592-
.get_funding_txo()
11593-
.expect("Negotiated splices must have a known funding outpoint"),
11594-
})
11579+
let promoted_tx = self
11580+
.funding
11581+
.funding_transaction
11582+
.as_ref()
11583+
.expect("Promoted splice funding should have a funding transaction");
11584+
let contributions = core::mem::take(&mut pending_splice.contributions);
11585+
contributions
11586+
.into_iter()
11587+
.filter_map(|contribution| {
11588+
contribution.into_unique_contributions(
11589+
promoted_tx.input.iter().map(|i| i.previous_output),
11590+
promoted_tx.output.iter(),
11591+
)
1159511592
})
11593+
.map(|(inputs, outputs)| FundingInfo::Contribution { inputs, outputs })
1159611594
.collect::<Vec<_>>()
1159711595
};
1159811596

lightning/src/ln/splicing_tests.rs

Lines changed: 147 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -675,9 +675,15 @@ pub fn splice_channel<'a, 'b, 'c, 'd>(
675675
(splice_tx, new_funding_script)
676676
}
677677

678+
pub struct SpliceLockedResult {
679+
pub stfu: Option<MessageSendEvent>,
680+
pub node_a_discarded: Vec<(Vec<bitcoin::OutPoint>, Vec<TxOut>)>,
681+
pub node_b_discarded: Vec<(Vec<bitcoin::OutPoint>, Vec<TxOut>)>,
682+
}
683+
678684
pub fn lock_splice_after_blocks<'a, 'b, 'c, 'd>(
679685
node_a: &'a Node<'b, 'c, 'd>, node_b: &'a Node<'b, 'c, 'd>, num_blocks: u32,
680-
) -> Option<MessageSendEvent> {
686+
) -> SpliceLockedResult {
681687
connect_blocks(node_a, num_blocks);
682688
connect_blocks(node_b, num_blocks);
683689

@@ -690,7 +696,7 @@ pub fn lock_splice_after_blocks<'a, 'b, 'c, 'd>(
690696
pub fn lock_splice<'a, 'b, 'c, 'd>(
691697
node_a: &'a Node<'b, 'c, 'd>, node_b: &'a Node<'b, 'c, 'd>,
692698
splice_locked_for_node_b: &msgs::SpliceLocked, is_0conf: bool, expected_discard_txids: &[Txid],
693-
) -> Option<MessageSendEvent> {
699+
) -> SpliceLockedResult {
694700
let prev_funding_txid = node_a
695701
.chain_monitor
696702
.chain_monitor
@@ -727,29 +733,23 @@ pub fn lock_splice<'a, 'b, 'c, 'd>(
727733
}
728734
}
729735

730-
let mut all_discard_txids = Vec::new();
731-
let expected_num_events = 1 + expected_discard_txids.len();
732-
for node in [node_a, node_b] {
736+
let mut node_a_discarded = Vec::new();
737+
let mut node_b_discarded = Vec::new();
738+
for (idx, node) in [node_a, node_b].into_iter().enumerate() {
733739
let events = node.node.get_and_clear_pending_events();
734-
assert_eq!(events.len(), expected_num_events, "{events:?}");
740+
assert!(!events.is_empty(), "Expected at least ChannelReady, got {events:?}");
735741
assert!(matches!(events[0], Event::ChannelReady { .. }));
736-
let discard_txids: Vec<_> = events[1..]
737-
.iter()
738-
.map(|e| match e {
739-
Event::DiscardFunding { funding_info: FundingInfo::Tx { transaction }, .. } => {
740-
transaction.compute_txid()
741-
},
742+
let discarded = if idx == 0 { &mut node_a_discarded } else { &mut node_b_discarded };
743+
for event in &events[1..] {
744+
match event {
742745
Event::DiscardFunding {
743-
funding_info: FundingInfo::OutPoint { outpoint }, ..
744-
} => outpoint.txid,
745-
other => panic!("Expected DiscardFunding, got {:?}", other),
746-
})
747-
.collect();
748-
for txid in expected_discard_txids {
749-
assert!(discard_txids.contains(txid), "Missing DiscardFunding for txid {}", txid);
750-
}
751-
if all_discard_txids.is_empty() {
752-
all_discard_txids = discard_txids;
746+
funding_info: FundingInfo::Contribution { inputs, outputs },
747+
..
748+
} => {
749+
discarded.push((inputs.clone(), outputs.clone()));
750+
},
751+
other => panic!("Expected DiscardFunding with Contribution, got {:?}", other),
752+
}
753753
}
754754
check_added_monitors(node, 1);
755755
}
@@ -787,18 +787,18 @@ pub fn lock_splice<'a, 'b, 'c, 'd>(
787787
// old funding as it is no longer being tracked.
788788
for node in [node_a, node_b] {
789789
node.chain_source.remove_watched_by_txid(prev_funding_txid);
790-
for txid in &all_discard_txids {
790+
for txid in expected_discard_txids {
791791
node.chain_source.remove_watched_by_txid(*txid);
792792
}
793793
}
794794

795-
node_a_stfu.or(node_b_stfu)
795+
SpliceLockedResult { stfu: node_a_stfu.or(node_b_stfu), node_a_discarded, node_b_discarded }
796796
}
797797

798798
pub fn lock_rbf_splice_after_blocks<'a, 'b, 'c, 'd>(
799799
node_a: &'a Node<'b, 'c, 'd>, node_b: &'a Node<'b, 'c, 'd>, tx: &Transaction, num_blocks: u32,
800800
expected_discard_txids: &[Txid],
801-
) -> Option<MessageSendEvent> {
801+
) -> SpliceLockedResult {
802802
mine_transaction(node_a, tx);
803803
mine_transaction(node_b, tx);
804804

@@ -1468,7 +1468,7 @@ fn fails_initiating_concurrent_splices(reconnect: bool) {
14681468

14691469
mine_transaction(&nodes[0], &splice_tx);
14701470
mine_transaction(&nodes[1], &splice_tx);
1471-
let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1);
1471+
let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1).stfu;
14721472
// Node 0 had called splice_channel (line above) but never funding_contributed, so no stfu
14731473
// is expected from node 0 at this point.
14741474
assert!(stfu.is_none());
@@ -1496,7 +1496,7 @@ fn test_initiating_splice_holds_stfu_with_pending_splice() {
14961496
// Mine and lock the splice.
14971497
mine_transaction(&nodes[0], &splice_tx);
14981498
mine_transaction(&nodes[1], &splice_tx);
1499-
let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], 5);
1499+
let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], 5).stfu;
15001500
assert!(stfu.is_none());
15011501
}
15021502

@@ -1732,7 +1732,7 @@ fn do_test_splice_tiebreak(
17321732
mine_transaction(&nodes[1], &tx);
17331733

17341734
// After splice_locked, node 1's preserved QuiescentAction triggers STFU for retry.
1735-
let node_1_stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1);
1735+
let node_1_stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1).stfu;
17361736
let stfu_1 = if let Some(MessageSendEvent::SendStfu { msg, .. }) = node_1_stfu {
17371737
assert!(msg.initiator);
17381738
msg
@@ -4561,13 +4561,109 @@ fn test_splice_rbf_acceptor_basic() {
45614561
expect_splice_pending_event(&nodes[1], &node_id_0);
45624562

45634563
// Step 11: Mine, lock, and verify DiscardFunding for the replaced splice candidate.
4564-
lock_rbf_splice_after_blocks(
4564+
let result = lock_rbf_splice_after_blocks(
45654565
&nodes[0],
45664566
&nodes[1],
45674567
&rbf_tx,
45684568
ANTI_REORG_DELAY - 1,
45694569
&[first_splice_tx.compute_txid()],
45704570
);
4571+
4572+
// The test wallet reuses the same UTXO across RBF rounds (the wallet doesn't track
4573+
// in-flight spends), so all contributed inputs are in the promoted tx. No unique
4574+
// contributions to discard.
4575+
assert!(result.node_a_discarded.is_empty());
4576+
assert!(result.node_b_discarded.is_empty());
4577+
}
4578+
4579+
#[test]
4580+
fn test_splice_rbf_discard_unique_contribution() {
4581+
// Verify that DiscardFunding events contain the correct unique inputs and outputs when the
4582+
// RBF round uses different UTXOs than the initial splice. By clearing the wallet between
4583+
// rounds and providing fresh UTXOs, we force distinct inputs per round. Round 0 also
4584+
// includes a splice-out output with a unique script_pubkey not present in the RBF tx.
4585+
// When the RBF is promoted, round 0's inputs and splice-out output should appear in
4586+
// DiscardFunding. The change output is filtered because it shares a script_pubkey with the
4587+
// promoted tx's change output.
4588+
let chanmon_cfgs = create_chanmon_cfgs(2);
4589+
let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
4590+
let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
4591+
let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
4592+
4593+
let node_id_0 = nodes[0].node.get_our_node_id();
4594+
let node_id_1 = nodes[1].node.get_our_node_id();
4595+
4596+
let initial_channel_value_sat = 100_000;
4597+
let (_, _, channel_id, _) =
4598+
create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0);
4599+
4600+
let added_value = Amount::from_sat(50_000);
4601+
provide_utxo_reserves(&nodes, 2, added_value * 2);
4602+
4603+
// Round 0: Splice-in-and-out from node 0 with a splice-out output.
4604+
let splice_out_output = TxOut {
4605+
value: Amount::from_sat(5_000),
4606+
script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()),
4607+
};
4608+
let funding_contribution = do_initiate_splice_in_and_out(
4609+
&nodes[0],
4610+
&nodes[1],
4611+
channel_id,
4612+
added_value,
4613+
vec![splice_out_output.clone()],
4614+
);
4615+
let round_0_inputs: Vec<_> = funding_contribution.contributed_inputs().collect();
4616+
assert!(!round_0_inputs.is_empty());
4617+
4618+
let (first_splice_tx, new_funding_script) =
4619+
splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution);
4620+
4621+
// Clear node 0's wallet so round 1 must use different UTXOs.
4622+
nodes[0].wallet_source.clear_utxos();
4623+
provide_utxo_reserves(&nodes, 2, added_value * 2);
4624+
4625+
// Round 1: RBF with fresh UTXOs, splice-in only (no splice-out output).
4626+
let rbf_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64 + 25);
4627+
let funding_contribution =
4628+
do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate);
4629+
let round_1_inputs: Vec<_> = funding_contribution.contributed_inputs().collect();
4630+
assert_ne!(round_0_inputs, round_1_inputs, "Rounds must use different UTXOs");
4631+
4632+
complete_rbf_handshake(&nodes[0], &nodes[1]);
4633+
4634+
complete_interactive_funding_negotiation(
4635+
&nodes[0],
4636+
&nodes[1],
4637+
channel_id,
4638+
funding_contribution,
4639+
new_funding_script.clone(),
4640+
);
4641+
4642+
let (rbf_tx, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false);
4643+
assert!(splice_locked.is_none());
4644+
4645+
expect_splice_pending_event(&nodes[0], &node_id_1);
4646+
expect_splice_pending_event(&nodes[1], &node_id_0);
4647+
4648+
let result = lock_rbf_splice_after_blocks(
4649+
&nodes[0],
4650+
&nodes[1],
4651+
&rbf_tx,
4652+
ANTI_REORG_DELAY - 1,
4653+
&[first_splice_tx.compute_txid()],
4654+
);
4655+
4656+
// Node 0's round 0 inputs are NOT in the promoted tx (which uses round 1's fresh UTXOs),
4657+
// so they appear as unique contributions to discard. The splice-out output also survives
4658+
// because its script_pubkey is not in the promoted tx. The change output is filtered
4659+
// because it shares a script_pubkey with the promoted tx's change output.
4660+
assert_eq!(result.node_a_discarded.len(), 1);
4661+
let (ref inputs, ref outputs) = result.node_a_discarded[0];
4662+
assert_eq!(*inputs, round_0_inputs);
4663+
assert_eq!(*outputs, vec![splice_out_output]);
4664+
4665+
// Node 1 (non-contributing acceptor) has no contributions to discard.
4666+
assert!(result.node_b_discarded.is_empty());
45714667
}
45724668

45734669
#[test]
@@ -5321,13 +5417,18 @@ pub fn do_test_splice_rbf_tiebreak(
53215417
expect_splice_pending_event(&nodes[1], &node_id_0);
53225418

53235419
// Mine, lock, and verify DiscardFunding for the replaced splice candidate.
5324-
lock_rbf_splice_after_blocks(
5420+
let result = lock_rbf_splice_after_blocks(
53255421
&nodes[0],
53265422
&nodes[1],
53275423
&rbf_tx,
53285424
ANTI_REORG_DELAY - 1,
53295425
&[first_splice_tx.compute_txid()],
53305426
);
5427+
5428+
// The test wallet reuses the same UTXOs across RBF rounds, so all contributed inputs
5429+
// are in the promoted tx and nothing is unique to discard.
5430+
assert!(result.node_a_discarded.is_empty());
5431+
assert!(result.node_b_discarded.is_empty());
53315432
} else {
53325433
// Acceptor does not contribute — complete with only node 0's inputs/outputs.
53335434
complete_interactive_funding_negotiation_for_both(
@@ -5352,14 +5453,14 @@ pub fn do_test_splice_rbf_tiebreak(
53525453
// Mine, lock, and verify DiscardFunding for the replaced splice candidate.
53535454
// Node 1's QuiescentAction was preserved, so after splice_locked it re-initiates
53545455
// quiescence to retry its contribution in a future splice.
5355-
let node_b_stfu = lock_rbf_splice_after_blocks(
5456+
let result = lock_rbf_splice_after_blocks(
53565457
&nodes[0],
53575458
&nodes[1],
53585459
&rbf_tx,
53595460
ANTI_REORG_DELAY - 1,
53605461
&[first_splice_tx.compute_txid()],
53615462
);
5362-
let stfu_1 = if let Some(MessageSendEvent::SendStfu { msg, .. }) = node_b_stfu {
5463+
let stfu_1 = if let Some(MessageSendEvent::SendStfu { msg, .. }) = result.stfu {
53635464
msg
53645465
} else {
53655466
panic!("Expected SendStfu from node 1");
@@ -5618,13 +5719,18 @@ fn test_splice_rbf_acceptor_recontributes() {
56185719
expect_splice_pending_event(&nodes[1], &node_id_0);
56195720

56205721
// Step 12: Mine, lock, and verify DiscardFunding for the replaced splice candidate.
5621-
lock_rbf_splice_after_blocks(
5722+
let result = lock_rbf_splice_after_blocks(
56225723
&nodes[0],
56235724
&nodes[1],
56245725
&rbf_tx,
56255726
ANTI_REORG_DELAY - 1,
56265727
&[first_splice_tx.compute_txid()],
56275728
);
5729+
5730+
// The test wallet reuses the same UTXOs across RBF rounds, so all contributed inputs
5731+
// are in the promoted tx and nothing is unique to discard.
5732+
assert!(result.node_a_discarded.is_empty());
5733+
assert!(result.node_b_discarded.is_empty());
56285734
}
56295735

56305736
#[test]
@@ -5941,13 +6047,18 @@ fn test_splice_rbf_sequential() {
59416047
// --- Mine and lock the final RBF, verifying DiscardFunding for both replaced candidates. ---
59426048
let splice_tx_0_txid = splice_tx_0.compute_txid();
59436049
let splice_tx_1_txid = splice_tx_1.compute_txid();
5944-
lock_rbf_splice_after_blocks(
6050+
let result = lock_rbf_splice_after_blocks(
59456051
&nodes[0],
59466052
&nodes[1],
59476053
&rbf_tx_final,
59486054
ANTI_REORG_DELAY - 1,
59496055
&[splice_tx_0_txid, splice_tx_1_txid],
59506056
);
6057+
6058+
// The test wallet reuses the same UTXOs across RBF rounds, so all contributed inputs
6059+
// are in the promoted tx and nothing is unique to discard.
6060+
assert!(result.node_a_discarded.is_empty());
6061+
assert!(result.node_b_discarded.is_empty());
59516062
}
59526063

59536064
#[test]
@@ -6309,7 +6420,7 @@ fn test_funding_contributed_rbf_adjustment_exceeds_max_feerate() {
63096420
// Mine and lock the pending splice → pending_splice is cleared.
63106421
mine_transaction(&nodes[0], &_splice_tx);
63116422
mine_transaction(&nodes[1], &_splice_tx);
6312-
let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1);
6423+
let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1).stfu;
63136424

63146425
// STFU is sent during lock — the splice proceeds as a fresh splice (not RBF).
63156426
let stfu = match stfu {
@@ -6386,7 +6497,7 @@ fn test_funding_contributed_rbf_adjustment_insufficient_budget() {
63866497
// Mine and lock the pending splice → pending_splice is cleared.
63876498
mine_transaction(&nodes[0], &_splice_tx);
63886499
mine_transaction(&nodes[1], &_splice_tx);
6389-
let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1);
6500+
let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1).stfu;
63906501

63916502
// STFU is sent during lock — the splice proceeds as a fresh splice (not RBF).
63926503
let stfu = match stfu {

0 commit comments

Comments
 (0)