Skip to content

Commit 51e6079

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 ffbb8fe commit 51e6079

2 files changed

Lines changed: 176 additions & 52 deletions

File tree

lightning/src/ln/channel.rs

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11671,30 +11671,28 @@ where
1167111671
.iter_mut()
1167211672
.find(|funding| funding.get_funding_txid() == Some(splice_txid))
1167311673
.unwrap();
11674-
let prev_funding_txid = self.funding.get_funding_txid();
1167511674

1167611675
if let Some(scid) = self.funding.short_channel_id {
1167711676
self.context.historical_scids.push(scid);
1167811677
}
1167911678

1168011679
core::mem::swap(&mut self.funding, funding);
1168111680

11682-
// The swap above places the previous `FundingScope` into `pending_funding`.
11683-
pending_splice
11684-
.negotiated_candidates
11685-
.drain(..)
11686-
.filter(|funding| funding.get_funding_txid() != prev_funding_txid)
11687-
.map(|mut funding| {
11688-
funding
11689-
.funding_transaction
11690-
.take()
11691-
.map(|tx| FundingInfo::Tx { transaction: tx })
11692-
.unwrap_or_else(|| FundingInfo::OutPoint {
11693-
outpoint: funding
11694-
.get_funding_txo()
11695-
.expect("Negotiated splices must have a known funding outpoint"),
11696-
})
11681+
let promoted_tx = self
11682+
.funding
11683+
.funding_transaction
11684+
.as_ref()
11685+
.expect("Promoted splice funding should have a funding transaction");
11686+
let contributions = core::mem::take(&mut pending_splice.contributions);
11687+
contributions
11688+
.into_iter()
11689+
.filter_map(|contribution| {
11690+
contribution.into_unique_contributions(
11691+
promoted_tx.input.iter().map(|i| i.previous_output),
11692+
promoted_tx.output.iter(),
11693+
)
1169711694
})
11695+
.map(|(inputs, outputs)| FundingInfo::Contribution { inputs, outputs })
1169811696
.collect::<Vec<_>>()
1169911697
};
1170011698

lightning/src/ln/splicing_tests.rs

Lines changed: 162 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -683,9 +683,15 @@ pub fn splice_channel<'a, 'b, 'c, 'd>(
683683
(splice_tx, new_funding_script)
684684
}
685685

686+
pub struct SpliceLockedResult {
687+
pub stfu: Option<MessageSendEvent>,
688+
pub node_a_discarded: Vec<(Vec<bitcoin::OutPoint>, Vec<TxOut>)>,
689+
pub node_b_discarded: Vec<(Vec<bitcoin::OutPoint>, Vec<TxOut>)>,
690+
}
691+
686692
pub fn lock_splice_after_blocks<'a, 'b, 'c, 'd>(
687693
node_a: &'a Node<'b, 'c, 'd>, node_b: &'a Node<'b, 'c, 'd>, num_blocks: u32,
688-
) -> Option<MessageSendEvent> {
694+
) -> SpliceLockedResult {
689695
connect_blocks(node_a, num_blocks);
690696
connect_blocks(node_b, num_blocks);
691697

@@ -698,7 +704,7 @@ pub fn lock_splice_after_blocks<'a, 'b, 'c, 'd>(
698704
pub fn lock_splice<'a, 'b, 'c, 'd>(
699705
node_a: &'a Node<'b, 'c, 'd>, node_b: &'a Node<'b, 'c, 'd>,
700706
splice_locked_for_node_b: &msgs::SpliceLocked, is_0conf: bool, expected_discard_txids: &[Txid],
701-
) -> Option<MessageSendEvent> {
707+
) -> SpliceLockedResult {
702708
let prev_funding_txid = node_a
703709
.chain_monitor
704710
.chain_monitor
@@ -735,29 +741,23 @@ pub fn lock_splice<'a, 'b, 'c, 'd>(
735741
}
736742
}
737743

738-
let mut all_discard_txids = Vec::new();
739-
let expected_num_events = 1 + expected_discard_txids.len();
740-
for node in [node_a, node_b] {
744+
let mut node_a_discarded = Vec::new();
745+
let mut node_b_discarded = Vec::new();
746+
for (idx, node) in [node_a, node_b].into_iter().enumerate() {
741747
let events = node.node.get_and_clear_pending_events();
742-
assert_eq!(events.len(), expected_num_events, "{events:?}");
748+
assert!(!events.is_empty(), "Expected at least ChannelReady, got {events:?}");
743749
assert!(matches!(events[0], Event::ChannelReady { .. }));
744-
let discard_txids: Vec<_> = events[1..]
745-
.iter()
746-
.map(|e| match e {
747-
Event::DiscardFunding { funding_info: FundingInfo::Tx { transaction }, .. } => {
748-
transaction.compute_txid()
749-
},
750+
let discarded = if idx == 0 { &mut node_a_discarded } else { &mut node_b_discarded };
751+
for event in &events[1..] {
752+
match event {
750753
Event::DiscardFunding {
751-
funding_info: FundingInfo::OutPoint { outpoint }, ..
752-
} => outpoint.txid,
753-
other => panic!("Expected DiscardFunding, got {:?}", other),
754-
})
755-
.collect();
756-
for txid in expected_discard_txids {
757-
assert!(discard_txids.contains(txid), "Missing DiscardFunding for txid {}", txid);
758-
}
759-
if all_discard_txids.is_empty() {
760-
all_discard_txids = discard_txids;
754+
funding_info: FundingInfo::Contribution { inputs, outputs },
755+
..
756+
} => {
757+
discarded.push((inputs.clone(), outputs.clone()));
758+
},
759+
other => panic!("Expected DiscardFunding with Contribution, got {:?}", other),
760+
}
761761
}
762762
check_added_monitors(node, 1);
763763
}
@@ -795,18 +795,18 @@ pub fn lock_splice<'a, 'b, 'c, 'd>(
795795
// old funding as it is no longer being tracked.
796796
for node in [node_a, node_b] {
797797
node.chain_source.remove_watched_by_txid(prev_funding_txid);
798-
for txid in &all_discard_txids {
798+
for txid in expected_discard_txids {
799799
node.chain_source.remove_watched_by_txid(*txid);
800800
}
801801
}
802802

803-
node_a_stfu.or(node_b_stfu)
803+
SpliceLockedResult { stfu: node_a_stfu.or(node_b_stfu), node_a_discarded, node_b_discarded }
804804
}
805805

806806
pub fn lock_rbf_splice_after_blocks<'a, 'b, 'c, 'd>(
807807
node_a: &'a Node<'b, 'c, 'd>, node_b: &'a Node<'b, 'c, 'd>, tx: &Transaction, num_blocks: u32,
808808
expected_discard_txids: &[Txid],
809-
) -> Option<MessageSendEvent> {
809+
) -> SpliceLockedResult {
810810
mine_transaction(node_a, tx);
811811
mine_transaction(node_b, tx);
812812

@@ -1400,7 +1400,7 @@ fn fails_initiating_concurrent_splices(reconnect: bool) {
14001400

14011401
mine_transaction(&nodes[0], &splice_tx);
14021402
mine_transaction(&nodes[1], &splice_tx);
1403-
let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1);
1403+
let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1).stfu;
14041404
// Node 0 had called splice_channel (line above) but never funding_contributed, so no stfu
14051405
// is expected from node 0 at this point.
14061406
assert!(stfu.is_none());
@@ -1428,7 +1428,7 @@ fn test_initiating_splice_holds_stfu_with_pending_splice() {
14281428
// Mine and lock the splice.
14291429
mine_transaction(&nodes[0], &splice_tx);
14301430
mine_transaction(&nodes[1], &splice_tx);
1431-
let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], 5);
1431+
let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], 5).stfu;
14321432
assert!(stfu.is_none());
14331433
}
14341434

@@ -1664,7 +1664,7 @@ fn do_test_splice_tiebreak(
16641664
mine_transaction(&nodes[1], &tx);
16651665

16661666
// After splice_locked, node 1's preserved QuiescentAction triggers STFU for retry.
1667-
let node_1_stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1);
1667+
let node_1_stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1).stfu;
16681668
let stfu_1 = if let Some(MessageSendEvent::SendStfu { msg, .. }) = node_1_stfu {
16691669
assert!(msg.initiator);
16701670
msg
@@ -4712,13 +4712,124 @@ fn test_splice_rbf_acceptor_basic() {
47124712
expect_splice_pending_event(&nodes[1], &node_id_0);
47134713

47144714
// Step 11: Mine, lock, and verify DiscardFunding for the replaced splice candidate.
4715-
lock_rbf_splice_after_blocks(
4715+
let result = lock_rbf_splice_after_blocks(
47164716
&nodes[0],
47174717
&nodes[1],
47184718
&rbf_tx,
47194719
ANTI_REORG_DELAY - 1,
47204720
&[first_splice_tx.compute_txid()],
47214721
);
4722+
4723+
// The test wallet reuses the same UTXO across RBF rounds (the wallet doesn't track
4724+
// in-flight spends), so all contributed inputs are in the promoted tx. No unique
4725+
// contributions to discard.
4726+
assert!(result.node_a_discarded.is_empty());
4727+
assert!(result.node_b_discarded.is_empty());
4728+
}
4729+
4730+
#[test]
4731+
fn test_splice_rbf_discard_unique_contribution() {
4732+
// Verify that DiscardFunding events contain the correct unique inputs and outputs when the
4733+
// RBF round uses different UTXOs than the initial splice. By clearing the wallet between
4734+
// rounds and providing fresh UTXOs, we force distinct inputs per round. Round 0 also
4735+
// includes a splice-out output with a unique script_pubkey not present in the RBF tx.
4736+
// When the RBF is promoted, round 0's inputs and splice-out output should appear in
4737+
// DiscardFunding. The change output is filtered because it shares a script_pubkey with the
4738+
// promoted tx's change output.
4739+
let chanmon_cfgs = create_chanmon_cfgs(2);
4740+
let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
4741+
let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
4742+
let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
4743+
4744+
let node_id_0 = nodes[0].node.get_our_node_id();
4745+
let node_id_1 = nodes[1].node.get_our_node_id();
4746+
4747+
let initial_channel_value_sat = 100_000;
4748+
let (_, _, channel_id, _) =
4749+
create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0);
4750+
4751+
let added_value = Amount::from_sat(50_000);
4752+
provide_utxo_reserves(&nodes, 2, added_value * 2);
4753+
4754+
// Round 0: Splice-in-and-out from node 0 with a splice-out output.
4755+
let splice_out_output = TxOut {
4756+
value: Amount::from_sat(5_000),
4757+
script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()),
4758+
};
4759+
let funding_contribution = do_initiate_splice_in_and_out(
4760+
&nodes[0],
4761+
&nodes[1],
4762+
channel_id,
4763+
added_value,
4764+
vec![splice_out_output.clone()],
4765+
);
4766+
let round_0_inputs: Vec<_> = funding_contribution.contributed_inputs().collect();
4767+
assert!(!round_0_inputs.is_empty());
4768+
4769+
let (first_splice_tx, new_funding_script) =
4770+
splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution);
4771+
4772+
// Clear node 0's wallet so round 1 must use different UTXOs.
4773+
nodes[0].wallet_source.clear_utxos();
4774+
provide_utxo_reserves(&nodes, 2, added_value * 2);
4775+
4776+
// Round 1: RBF with fresh UTXOs, splice-in only (no splice-out output).
4777+
let rbf_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64 + 25);
4778+
let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap();
4779+
let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger);
4780+
let funding_contribution = funding_template
4781+
.without_prior_contribution(rbf_feerate, FeeRate::MAX)
4782+
.with_coin_selection_source_sync(&wallet)
4783+
.add_value(added_value)
4784+
.build()
4785+
.unwrap();
4786+
nodes[0]
4787+
.node
4788+
.funding_contributed(&channel_id, &node_id_1, funding_contribution.clone(), None)
4789+
.unwrap();
4790+
let round_1_inputs: Vec<_> = funding_contribution.contributed_inputs().collect();
4791+
assert_ne!(round_0_inputs, round_1_inputs, "Rounds must use different UTXOs");
4792+
4793+
complete_rbf_handshake(&nodes[0], &nodes[1]);
4794+
4795+
complete_interactive_funding_negotiation(
4796+
&nodes[0],
4797+
&nodes[1],
4798+
channel_id,
4799+
funding_contribution,
4800+
new_funding_script.clone(),
4801+
);
4802+
4803+
let (rbf_tx, splice_locked) = sign_interactive_funding_tx(
4804+
&nodes[0],
4805+
&nodes[1],
4806+
false,
4807+
Some(first_splice_tx.compute_txid()),
4808+
);
4809+
assert!(splice_locked.is_none());
4810+
4811+
expect_splice_pending_event(&nodes[0], &node_id_1);
4812+
expect_splice_pending_event(&nodes[1], &node_id_0);
4813+
4814+
let result = lock_rbf_splice_after_blocks(
4815+
&nodes[0],
4816+
&nodes[1],
4817+
&rbf_tx,
4818+
ANTI_REORG_DELAY - 1,
4819+
&[first_splice_tx.compute_txid()],
4820+
);
4821+
4822+
// Node 0's round 0 inputs are NOT in the promoted tx (which uses round 1's fresh UTXOs),
4823+
// so they appear as unique contributions to discard. The splice-out output also survives
4824+
// because its script_pubkey is not in the promoted tx. The change output is filtered
4825+
// because it shares a script_pubkey with the promoted tx's change output.
4826+
assert_eq!(result.node_a_discarded.len(), 1);
4827+
let (ref inputs, ref outputs) = result.node_a_discarded[0];
4828+
assert_eq!(*inputs, round_0_inputs);
4829+
assert_eq!(*outputs, vec![splice_out_output]);
4830+
4831+
// Node 1 (non-contributing acceptor) has no contributions to discard.
4832+
assert!(result.node_b_discarded.is_empty());
47224833
}
47234834

47244835
#[test]
@@ -5663,13 +5774,18 @@ pub fn do_test_splice_rbf_tiebreak(
56635774
expect_splice_pending_event(&nodes[1], &node_id_0);
56645775

56655776
// Mine, lock, and verify DiscardFunding for the replaced splice candidate.
5666-
lock_rbf_splice_after_blocks(
5777+
let result = lock_rbf_splice_after_blocks(
56675778
&nodes[0],
56685779
&nodes[1],
56695780
&rbf_tx,
56705781
ANTI_REORG_DELAY - 1,
56715782
&[first_splice_tx.compute_txid()],
56725783
);
5784+
5785+
// The test wallet reuses the same UTXOs across RBF rounds, so all contributed inputs
5786+
// are in the promoted tx and nothing is unique to discard.
5787+
assert!(result.node_a_discarded.is_empty());
5788+
assert!(result.node_b_discarded.is_empty());
56735789
} else {
56745790
// Acceptor does not contribute — complete with only node 0's inputs/outputs.
56755791
complete_interactive_funding_negotiation_for_both(
@@ -5698,14 +5814,14 @@ pub fn do_test_splice_rbf_tiebreak(
56985814
// Mine, lock, and verify DiscardFunding for the replaced splice candidate.
56995815
// Node 1's QuiescentAction was preserved, so after splice_locked it re-initiates
57005816
// quiescence to retry its contribution in a future splice.
5701-
let node_b_stfu = lock_rbf_splice_after_blocks(
5817+
let result = lock_rbf_splice_after_blocks(
57025818
&nodes[0],
57035819
&nodes[1],
57045820
&rbf_tx,
57055821
ANTI_REORG_DELAY - 1,
57065822
&[first_splice_tx.compute_txid()],
57075823
);
5708-
let stfu_1 = if let Some(MessageSendEvent::SendStfu { msg, .. }) = node_b_stfu {
5824+
let stfu_1 = if let Some(MessageSendEvent::SendStfu { msg, .. }) = result.stfu {
57095825
msg
57105826
} else {
57115827
panic!("Expected SendStfu from node 1");
@@ -5985,13 +6101,18 @@ fn test_splice_rbf_acceptor_recontributes() {
59856101
expect_splice_pending_event(&nodes[1], &node_id_0);
59866102

59876103
// Step 12: Mine, lock, and verify DiscardFunding for the replaced splice candidate.
5988-
lock_rbf_splice_after_blocks(
6104+
let result = lock_rbf_splice_after_blocks(
59896105
&nodes[0],
59906106
&nodes[1],
59916107
&rbf_tx,
59926108
ANTI_REORG_DELAY - 1,
59936109
&[first_splice_tx.compute_txid()],
59946110
);
6111+
6112+
// The test wallet reuses the same UTXOs across RBF rounds, so all contributed inputs
6113+
// are in the promoted tx and nothing is unique to discard.
6114+
assert!(result.node_a_discarded.is_empty());
6115+
assert!(result.node_b_discarded.is_empty());
59956116
}
59966117

59976118
#[test]
@@ -6314,13 +6435,18 @@ fn test_splice_rbf_sequential() {
63146435
// --- Mine and lock the final RBF, verifying DiscardFunding for both replaced candidates. ---
63156436
let splice_tx_0_txid = splice_tx_0.compute_txid();
63166437
let splice_tx_1_txid = splice_tx_1.compute_txid();
6317-
lock_rbf_splice_after_blocks(
6438+
let result = lock_rbf_splice_after_blocks(
63186439
&nodes[0],
63196440
&nodes[1],
63206441
&rbf_tx_final,
63216442
ANTI_REORG_DELAY - 1,
63226443
&[splice_tx_0_txid, splice_tx_1_txid],
63236444
);
6445+
6446+
// The test wallet reuses the same UTXOs across RBF rounds, so all contributed inputs
6447+
// are in the promoted tx and nothing is unique to discard.
6448+
assert!(result.node_a_discarded.is_empty());
6449+
assert!(result.node_b_discarded.is_empty());
63246450
}
63256451

63266452
#[test]
@@ -6913,7 +7039,7 @@ fn test_funding_contributed_rbf_adjustment_exceeds_max_feerate() {
69137039
// Mine and lock the pending splice → pending_splice is cleared.
69147040
mine_transaction(&nodes[0], &_splice_tx);
69157041
mine_transaction(&nodes[1], &_splice_tx);
6916-
let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1);
7042+
let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1).stfu;
69177043

69187044
// STFU is sent during lock — the splice proceeds as a fresh splice (not RBF).
69197045
let stfu = match stfu {
@@ -6990,7 +7116,7 @@ fn test_funding_contributed_rbf_adjustment_insufficient_budget() {
69907116
// Mine and lock the pending splice → pending_splice is cleared.
69917117
mine_transaction(&nodes[0], &_splice_tx);
69927118
mine_transaction(&nodes[1], &_splice_tx);
6993-
let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1);
7119+
let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1).stfu;
69947120

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

0 commit comments

Comments
 (0)