Skip to content

Commit 4928ea2

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 0a08b1d commit 4928ea2

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
@@ -11611,30 +11611,28 @@ where
1161111611
.iter_mut()
1161211612
.find(|funding| funding.get_funding_txid() == Some(splice_txid))
1161311613
.unwrap();
11614-
let prev_funding_txid = self.funding.get_funding_txid();
1161511614

1161611615
if let Some(scid) = self.funding.short_channel_id {
1161711616
self.context.historical_scids.push(scid);
1161811617
}
1161911618

1162011619
core::mem::swap(&mut self.funding, funding);
1162111620

11622-
// The swap above places the previous `FundingScope` into `pending_funding`.
11623-
pending_splice
11624-
.negotiated_candidates
11625-
.drain(..)
11626-
.filter(|funding| funding.get_funding_txid() != prev_funding_txid)
11627-
.map(|mut funding| {
11628-
funding
11629-
.funding_transaction
11630-
.take()
11631-
.map(|tx| FundingInfo::Tx { transaction: tx })
11632-
.unwrap_or_else(|| FundingInfo::OutPoint {
11633-
outpoint: funding
11634-
.get_funding_txo()
11635-
.expect("Negotiated splices must have a known funding outpoint"),
11636-
})
11621+
let promoted_tx = self
11622+
.funding
11623+
.funding_transaction
11624+
.as_ref()
11625+
.expect("Promoted splice funding should have a funding transaction");
11626+
let contributions = core::mem::take(&mut pending_splice.contributions);
11627+
contributions
11628+
.into_iter()
11629+
.filter_map(|contribution| {
11630+
contribution.into_unique_contributions(
11631+
promoted_tx.input.iter().map(|i| i.previous_output),
11632+
promoted_tx.output.iter(),
11633+
)
1163711634
})
11635+
.map(|(inputs, outputs)| FundingInfo::Contribution { inputs, outputs })
1163811636
.collect::<Vec<_>>()
1163911637
};
1164011638

lightning/src/ln/splicing_tests.rs

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

655+
pub struct SpliceLockedResult {
656+
pub stfu: Option<MessageSendEvent>,
657+
pub node_a_discarded: Vec<(Vec<bitcoin::OutPoint>, Vec<TxOut>)>,
658+
pub node_b_discarded: Vec<(Vec<bitcoin::OutPoint>, Vec<TxOut>)>,
659+
}
660+
655661
pub fn lock_splice_after_blocks<'a, 'b, 'c, 'd>(
656662
node_a: &'a Node<'b, 'c, 'd>, node_b: &'a Node<'b, 'c, 'd>, num_blocks: u32,
657-
) -> Option<MessageSendEvent> {
663+
) -> SpliceLockedResult {
658664
connect_blocks(node_a, num_blocks);
659665
connect_blocks(node_b, num_blocks);
660666

@@ -667,7 +673,7 @@ pub fn lock_splice_after_blocks<'a, 'b, 'c, 'd>(
667673
pub fn lock_splice<'a, 'b, 'c, 'd>(
668674
node_a: &'a Node<'b, 'c, 'd>, node_b: &'a Node<'b, 'c, 'd>,
669675
splice_locked_for_node_b: &msgs::SpliceLocked, is_0conf: bool, expected_discard_txids: &[Txid],
670-
) -> Option<MessageSendEvent> {
676+
) -> SpliceLockedResult {
671677
let prev_funding_txid = node_a
672678
.chain_monitor
673679
.chain_monitor
@@ -704,29 +710,23 @@ pub fn lock_splice<'a, 'b, 'c, 'd>(
704710
}
705711
}
706712

707-
let mut all_discard_txids = Vec::new();
708-
let expected_num_events = 1 + expected_discard_txids.len();
709-
for node in [node_a, node_b] {
713+
let mut node_a_discarded = Vec::new();
714+
let mut node_b_discarded = Vec::new();
715+
for (idx, node) in [node_a, node_b].into_iter().enumerate() {
710716
let events = node.node.get_and_clear_pending_events();
711-
assert_eq!(events.len(), expected_num_events, "{events:?}");
717+
assert!(!events.is_empty(), "Expected at least ChannelReady, got {events:?}");
712718
assert!(matches!(events[0], Event::ChannelReady { .. }));
713-
let discard_txids: Vec<_> = events[1..]
714-
.iter()
715-
.map(|e| match e {
716-
Event::DiscardFunding { funding_info: FundingInfo::Tx { transaction }, .. } => {
717-
transaction.compute_txid()
718-
},
719+
let discarded = if idx == 0 { &mut node_a_discarded } else { &mut node_b_discarded };
720+
for event in &events[1..] {
721+
match event {
719722
Event::DiscardFunding {
720-
funding_info: FundingInfo::OutPoint { outpoint }, ..
721-
} => outpoint.txid,
722-
other => panic!("Expected DiscardFunding, got {:?}", other),
723-
})
724-
.collect();
725-
for txid in expected_discard_txids {
726-
assert!(discard_txids.contains(txid), "Missing DiscardFunding for txid {}", txid);
727-
}
728-
if all_discard_txids.is_empty() {
729-
all_discard_txids = discard_txids;
723+
funding_info: FundingInfo::Contribution { inputs, outputs },
724+
..
725+
} => {
726+
discarded.push((inputs.clone(), outputs.clone()));
727+
},
728+
other => panic!("Expected DiscardFunding with Contribution, got {:?}", other),
729+
}
730730
}
731731
check_added_monitors(node, 1);
732732
}
@@ -764,18 +764,18 @@ pub fn lock_splice<'a, 'b, 'c, 'd>(
764764
// old funding as it is no longer being tracked.
765765
for node in [node_a, node_b] {
766766
node.chain_source.remove_watched_by_txid(prev_funding_txid);
767-
for txid in &all_discard_txids {
767+
for txid in expected_discard_txids {
768768
node.chain_source.remove_watched_by_txid(*txid);
769769
}
770770
}
771771

772-
node_a_stfu.or(node_b_stfu)
772+
SpliceLockedResult { stfu: node_a_stfu.or(node_b_stfu), node_a_discarded, node_b_discarded }
773773
}
774774

775775
pub fn lock_rbf_splice_after_blocks<'a, 'b, 'c, 'd>(
776776
node_a: &'a Node<'b, 'c, 'd>, node_b: &'a Node<'b, 'c, 'd>, tx: &Transaction, num_blocks: u32,
777777
expected_discard_txids: &[Txid],
778-
) -> Option<MessageSendEvent> {
778+
) -> SpliceLockedResult {
779779
mine_transaction(node_a, tx);
780780
mine_transaction(node_b, tx);
781781

@@ -1366,7 +1366,7 @@ fn fails_initiating_concurrent_splices(reconnect: bool) {
13661366

13671367
mine_transaction(&nodes[0], &splice_tx);
13681368
mine_transaction(&nodes[1], &splice_tx);
1369-
let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1);
1369+
let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1).stfu;
13701370
// Node 0 had called splice_channel (line above) but never funding_contributed, so no stfu
13711371
// is expected from node 0 at this point.
13721372
assert!(stfu.is_none());
@@ -1394,7 +1394,7 @@ fn test_initiating_splice_holds_stfu_with_pending_splice() {
13941394
// Mine and lock the splice.
13951395
mine_transaction(&nodes[0], &splice_tx);
13961396
mine_transaction(&nodes[1], &splice_tx);
1397-
let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], 5);
1397+
let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], 5).stfu;
13981398
assert!(stfu.is_none());
13991399
}
14001400

@@ -1630,7 +1630,7 @@ fn do_test_splice_tiebreak(
16301630
mine_transaction(&nodes[1], &tx);
16311631

16321632
// After splice_locked, node 1's preserved QuiescentAction triggers STFU for retry.
1633-
let node_1_stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1);
1633+
let node_1_stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1).stfu;
16341634
let stfu_1 = if let Some(MessageSendEvent::SendStfu { msg, .. }) = node_1_stfu {
16351635
assert!(msg.initiator);
16361636
msg
@@ -4672,13 +4672,109 @@ fn test_splice_rbf_acceptor_basic() {
46724672
expect_splice_pending_event(&nodes[1], &node_id_0);
46734673

46744674
// Step 11: Mine, lock, and verify DiscardFunding for the replaced splice candidate.
4675-
lock_rbf_splice_after_blocks(
4675+
let result = lock_rbf_splice_after_blocks(
46764676
&nodes[0],
46774677
&nodes[1],
46784678
&rbf_tx,
46794679
ANTI_REORG_DELAY - 1,
46804680
&[first_splice_tx.compute_txid()],
46814681
);
4682+
4683+
// The test wallet reuses the same UTXO across RBF rounds (the wallet doesn't track
4684+
// in-flight spends), so all contributed inputs are in the promoted tx. No unique
4685+
// contributions to discard.
4686+
assert!(result.node_a_discarded.is_empty());
4687+
assert!(result.node_b_discarded.is_empty());
4688+
}
4689+
4690+
#[test]
4691+
fn test_splice_rbf_discard_unique_contribution() {
4692+
// Verify that DiscardFunding events contain the correct unique inputs and outputs when the
4693+
// RBF round uses different UTXOs than the initial splice. By clearing the wallet between
4694+
// rounds and providing fresh UTXOs, we force distinct inputs per round. Round 0 also
4695+
// includes a splice-out output with a unique script_pubkey not present in the RBF tx.
4696+
// When the RBF is promoted, round 0's inputs and splice-out output should appear in
4697+
// DiscardFunding. The change output is filtered because it shares a script_pubkey with the
4698+
// promoted tx's change output.
4699+
let chanmon_cfgs = create_chanmon_cfgs(2);
4700+
let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
4701+
let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
4702+
let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
4703+
4704+
let node_id_0 = nodes[0].node.get_our_node_id();
4705+
let node_id_1 = nodes[1].node.get_our_node_id();
4706+
4707+
let initial_channel_value_sat = 100_000;
4708+
let (_, _, channel_id, _) =
4709+
create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0);
4710+
4711+
let added_value = Amount::from_sat(50_000);
4712+
provide_utxo_reserves(&nodes, 2, added_value * 2);
4713+
4714+
// Round 0: Splice-in-and-out from node 0 with a splice-out output.
4715+
let splice_out_output = TxOut {
4716+
value: Amount::from_sat(5_000),
4717+
script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::all_zeros()),
4718+
};
4719+
let funding_contribution = do_initiate_splice_in_and_out(
4720+
&nodes[0],
4721+
&nodes[1],
4722+
channel_id,
4723+
added_value,
4724+
vec![splice_out_output.clone()],
4725+
);
4726+
let round_0_inputs: Vec<_> = funding_contribution.contributed_inputs().collect();
4727+
assert!(!round_0_inputs.is_empty());
4728+
4729+
let (first_splice_tx, new_funding_script) =
4730+
splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution);
4731+
4732+
// Clear node 0's wallet so round 1 must use different UTXOs.
4733+
nodes[0].wallet_source.clear_utxos();
4734+
provide_utxo_reserves(&nodes, 2, added_value * 2);
4735+
4736+
// Round 1: RBF with fresh UTXOs, splice-in only (no splice-out output).
4737+
let rbf_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64 + 25);
4738+
let funding_contribution =
4739+
do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate);
4740+
let round_1_inputs: Vec<_> = funding_contribution.contributed_inputs().collect();
4741+
assert_ne!(round_0_inputs, round_1_inputs, "Rounds must use different UTXOs");
4742+
4743+
complete_rbf_handshake(&nodes[0], &nodes[1]);
4744+
4745+
complete_interactive_funding_negotiation(
4746+
&nodes[0],
4747+
&nodes[1],
4748+
channel_id,
4749+
funding_contribution,
4750+
new_funding_script.clone(),
4751+
);
4752+
4753+
let (rbf_tx, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false);
4754+
assert!(splice_locked.is_none());
4755+
4756+
expect_splice_pending_event(&nodes[0], &node_id_1);
4757+
expect_splice_pending_event(&nodes[1], &node_id_0);
4758+
4759+
let result = lock_rbf_splice_after_blocks(
4760+
&nodes[0],
4761+
&nodes[1],
4762+
&rbf_tx,
4763+
ANTI_REORG_DELAY - 1,
4764+
&[first_splice_tx.compute_txid()],
4765+
);
4766+
4767+
// Node 0's round 0 inputs are NOT in the promoted tx (which uses round 1's fresh UTXOs),
4768+
// so they appear as unique contributions to discard. The splice-out output also survives
4769+
// because its script_pubkey is not in the promoted tx. The change output is filtered
4770+
// because it shares a script_pubkey with the promoted tx's change output.
4771+
assert_eq!(result.node_a_discarded.len(), 1);
4772+
let (ref inputs, ref outputs) = result.node_a_discarded[0];
4773+
assert_eq!(*inputs, round_0_inputs);
4774+
assert_eq!(*outputs, vec![splice_out_output]);
4775+
4776+
// Node 1 (non-contributing acceptor) has no contributions to discard.
4777+
assert!(result.node_b_discarded.is_empty());
46824778
}
46834779

46844780
#[test]
@@ -5604,13 +5700,18 @@ pub fn do_test_splice_rbf_tiebreak(
56045700
expect_splice_pending_event(&nodes[1], &node_id_0);
56055701

56065702
// Mine, lock, and verify DiscardFunding for the replaced splice candidate.
5607-
lock_rbf_splice_after_blocks(
5703+
let result = lock_rbf_splice_after_blocks(
56085704
&nodes[0],
56095705
&nodes[1],
56105706
&rbf_tx,
56115707
ANTI_REORG_DELAY - 1,
56125708
&[first_splice_tx.compute_txid()],
56135709
);
5710+
5711+
// The test wallet reuses the same UTXOs across RBF rounds, so all contributed inputs
5712+
// are in the promoted tx and nothing is unique to discard.
5713+
assert!(result.node_a_discarded.is_empty());
5714+
assert!(result.node_b_discarded.is_empty());
56145715
} else {
56155716
// Acceptor does not contribute — complete with only node 0's inputs/outputs.
56165717
complete_interactive_funding_negotiation_for_both(
@@ -5635,14 +5736,14 @@ pub fn do_test_splice_rbf_tiebreak(
56355736
// Mine, lock, and verify DiscardFunding for the replaced splice candidate.
56365737
// Node 1's QuiescentAction was preserved, so after splice_locked it re-initiates
56375738
// quiescence to retry its contribution in a future splice.
5638-
let node_b_stfu = lock_rbf_splice_after_blocks(
5739+
let result = lock_rbf_splice_after_blocks(
56395740
&nodes[0],
56405741
&nodes[1],
56415742
&rbf_tx,
56425743
ANTI_REORG_DELAY - 1,
56435744
&[first_splice_tx.compute_txid()],
56445745
);
5645-
let stfu_1 = if let Some(MessageSendEvent::SendStfu { msg, .. }) = node_b_stfu {
5746+
let stfu_1 = if let Some(MessageSendEvent::SendStfu { msg, .. }) = result.stfu {
56465747
msg
56475748
} else {
56485749
panic!("Expected SendStfu from node 1");
@@ -5916,13 +6017,18 @@ fn test_splice_rbf_acceptor_recontributes() {
59166017
expect_splice_pending_event(&nodes[1], &node_id_0);
59176018

59186019
// Step 12: Mine, lock, and verify DiscardFunding for the replaced splice candidate.
5919-
lock_rbf_splice_after_blocks(
6020+
let result = lock_rbf_splice_after_blocks(
59206021
&nodes[0],
59216022
&nodes[1],
59226023
&rbf_tx,
59236024
ANTI_REORG_DELAY - 1,
59246025
&[first_splice_tx.compute_txid()],
59256026
);
6027+
6028+
// The test wallet reuses the same UTXOs across RBF rounds, so all contributed inputs
6029+
// are in the promoted tx and nothing is unique to discard.
6030+
assert!(result.node_a_discarded.is_empty());
6031+
assert!(result.node_b_discarded.is_empty());
59266032
}
59276033

59286034
#[test]
@@ -6241,13 +6347,18 @@ fn test_splice_rbf_sequential() {
62416347
// --- Mine and lock the final RBF, verifying DiscardFunding for both replaced candidates. ---
62426348
let splice_tx_0_txid = splice_tx_0.compute_txid();
62436349
let splice_tx_1_txid = splice_tx_1.compute_txid();
6244-
lock_rbf_splice_after_blocks(
6350+
let result = lock_rbf_splice_after_blocks(
62456351
&nodes[0],
62466352
&nodes[1],
62476353
&rbf_tx_final,
62486354
ANTI_REORG_DELAY - 1,
62496355
&[splice_tx_0_txid, splice_tx_1_txid],
62506356
);
6357+
6358+
// The test wallet reuses the same UTXOs across RBF rounds, so all contributed inputs
6359+
// are in the promoted tx and nothing is unique to discard.
6360+
assert!(result.node_a_discarded.is_empty());
6361+
assert!(result.node_b_discarded.is_empty());
62516362
}
62526363

62536364
#[test]
@@ -6838,7 +6949,7 @@ fn test_funding_contributed_rbf_adjustment_exceeds_max_feerate() {
68386949
// Mine and lock the pending splice → pending_splice is cleared.
68396950
mine_transaction(&nodes[0], &_splice_tx);
68406951
mine_transaction(&nodes[1], &_splice_tx);
6841-
let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1);
6952+
let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1).stfu;
68426953

68436954
// STFU is sent during lock — the splice proceeds as a fresh splice (not RBF).
68446955
let stfu = match stfu {
@@ -6915,7 +7026,7 @@ fn test_funding_contributed_rbf_adjustment_insufficient_budget() {
69157026
// Mine and lock the pending splice → pending_splice is cleared.
69167027
mine_transaction(&nodes[0], &_splice_tx);
69177028
mine_transaction(&nodes[1], &_splice_tx);
6918-
let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1);
7029+
let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1).stfu;
69197030

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

0 commit comments

Comments
 (0)