Skip to content

Commit 0d8de54

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 b1c352c commit 0d8de54

2 files changed

Lines changed: 171 additions & 52 deletions

File tree

lightning/src/ln/channel.rs

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11613,30 +11613,28 @@ where
1161311613
.iter_mut()
1161411614
.find(|funding| funding.get_funding_txid() == Some(splice_txid))
1161511615
.unwrap();
11616-
let prev_funding_txid = self.funding.get_funding_txid();
1161711616

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

1162211621
core::mem::swap(&mut self.funding, funding);
1162311622

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

lightning/src/ln/splicing_tests.rs

Lines changed: 157 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -660,9 +660,15 @@ pub fn splice_channel<'a, 'b, 'c, 'd>(
660660
(splice_tx, new_funding_script)
661661
}
662662

663+
pub struct SpliceLockedResult {
664+
pub stfu: Option<MessageSendEvent>,
665+
pub node_a_discarded: Vec<(Vec<bitcoin::OutPoint>, Vec<TxOut>)>,
666+
pub node_b_discarded: Vec<(Vec<bitcoin::OutPoint>, Vec<TxOut>)>,
667+
}
668+
663669
pub fn lock_splice_after_blocks<'a, 'b, 'c, 'd>(
664670
node_a: &'a Node<'b, 'c, 'd>, node_b: &'a Node<'b, 'c, 'd>, num_blocks: u32,
665-
) -> Option<MessageSendEvent> {
671+
) -> SpliceLockedResult {
666672
connect_blocks(node_a, num_blocks);
667673
connect_blocks(node_b, num_blocks);
668674

@@ -675,7 +681,7 @@ pub fn lock_splice_after_blocks<'a, 'b, 'c, 'd>(
675681
pub fn lock_splice<'a, 'b, 'c, 'd>(
676682
node_a: &'a Node<'b, 'c, 'd>, node_b: &'a Node<'b, 'c, 'd>,
677683
splice_locked_for_node_b: &msgs::SpliceLocked, is_0conf: bool, expected_discard_txids: &[Txid],
678-
) -> Option<MessageSendEvent> {
684+
) -> SpliceLockedResult {
679685
let prev_funding_txid = node_a
680686
.chain_monitor
681687
.chain_monitor
@@ -712,29 +718,23 @@ pub fn lock_splice<'a, 'b, 'c, 'd>(
712718
}
713719
}
714720

715-
let mut all_discard_txids = Vec::new();
716-
let expected_num_events = 1 + expected_discard_txids.len();
717-
for node in [node_a, node_b] {
721+
let mut node_a_discarded = Vec::new();
722+
let mut node_b_discarded = Vec::new();
723+
for (idx, node) in [node_a, node_b].into_iter().enumerate() {
718724
let events = node.node.get_and_clear_pending_events();
719-
assert_eq!(events.len(), expected_num_events, "{events:?}");
725+
assert!(!events.is_empty(), "Expected at least ChannelReady, got {events:?}");
720726
assert!(matches!(events[0], Event::ChannelReady { .. }));
721-
let discard_txids: Vec<_> = events[1..]
722-
.iter()
723-
.map(|e| match e {
724-
Event::DiscardFunding { funding_info: FundingInfo::Tx { transaction }, .. } => {
725-
transaction.compute_txid()
726-
},
727+
let discarded = if idx == 0 { &mut node_a_discarded } else { &mut node_b_discarded };
728+
for event in &events[1..] {
729+
match event {
727730
Event::DiscardFunding {
728-
funding_info: FundingInfo::OutPoint { outpoint }, ..
729-
} => outpoint.txid,
730-
other => panic!("Expected DiscardFunding, got {:?}", other),
731-
})
732-
.collect();
733-
for txid in expected_discard_txids {
734-
assert!(discard_txids.contains(txid), "Missing DiscardFunding for txid {}", txid);
735-
}
736-
if all_discard_txids.is_empty() {
737-
all_discard_txids = discard_txids;
731+
funding_info: FundingInfo::Contribution { inputs, outputs },
732+
..
733+
} => {
734+
discarded.push((inputs.clone(), outputs.clone()));
735+
},
736+
other => panic!("Expected DiscardFunding with Contribution, got {:?}", other),
737+
}
738738
}
739739
check_added_monitors(node, 1);
740740
}
@@ -772,18 +772,18 @@ pub fn lock_splice<'a, 'b, 'c, 'd>(
772772
// old funding as it is no longer being tracked.
773773
for node in [node_a, node_b] {
774774
node.chain_source.remove_watched_by_txid(prev_funding_txid);
775-
for txid in &all_discard_txids {
775+
for txid in expected_discard_txids {
776776
node.chain_source.remove_watched_by_txid(*txid);
777777
}
778778
}
779779

780-
node_a_stfu.or(node_b_stfu)
780+
SpliceLockedResult { stfu: node_a_stfu.or(node_b_stfu), node_a_discarded, node_b_discarded }
781781
}
782782

783783
pub fn lock_rbf_splice_after_blocks<'a, 'b, 'c, 'd>(
784784
node_a: &'a Node<'b, 'c, 'd>, node_b: &'a Node<'b, 'c, 'd>, tx: &Transaction, num_blocks: u32,
785785
expected_discard_txids: &[Txid],
786-
) -> Option<MessageSendEvent> {
786+
) -> SpliceLockedResult {
787787
mine_transaction(node_a, tx);
788788
mine_transaction(node_b, tx);
789789

@@ -1377,7 +1377,7 @@ fn fails_initiating_concurrent_splices(reconnect: bool) {
13771377

13781378
mine_transaction(&nodes[0], &splice_tx);
13791379
mine_transaction(&nodes[1], &splice_tx);
1380-
let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1);
1380+
let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1).stfu;
13811381
// Node 0 had called splice_channel (line above) but never funding_contributed, so no stfu
13821382
// is expected from node 0 at this point.
13831383
assert!(stfu.is_none());
@@ -1405,7 +1405,7 @@ fn test_initiating_splice_holds_stfu_with_pending_splice() {
14051405
// Mine and lock the splice.
14061406
mine_transaction(&nodes[0], &splice_tx);
14071407
mine_transaction(&nodes[1], &splice_tx);
1408-
let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], 5);
1408+
let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], 5).stfu;
14091409
assert!(stfu.is_none());
14101410
}
14111411

@@ -1641,7 +1641,7 @@ fn do_test_splice_tiebreak(
16411641
mine_transaction(&nodes[1], &tx);
16421642

16431643
// After splice_locked, node 1's preserved QuiescentAction triggers STFU for retry.
1644-
let node_1_stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1);
1644+
let node_1_stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1).stfu;
16451645
let stfu_1 = if let Some(MessageSendEvent::SendStfu { msg, .. }) = node_1_stfu {
16461646
assert!(msg.initiator);
16471647
msg
@@ -4683,13 +4683,119 @@ fn test_splice_rbf_acceptor_basic() {
46834683
expect_splice_pending_event(&nodes[1], &node_id_0);
46844684

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

46954801
#[test]
@@ -5623,13 +5729,18 @@ pub fn do_test_splice_rbf_tiebreak(
56235729
expect_splice_pending_event(&nodes[1], &node_id_0);
56245730

56255731
// Mine, lock, and verify DiscardFunding for the replaced splice candidate.
5626-
lock_rbf_splice_after_blocks(
5732+
let result = lock_rbf_splice_after_blocks(
56275733
&nodes[0],
56285734
&nodes[1],
56295735
&rbf_tx,
56305736
ANTI_REORG_DELAY - 1,
56315737
&[first_splice_tx.compute_txid()],
56325738
);
5739+
5740+
// The test wallet reuses the same UTXOs across RBF rounds, so all contributed inputs
5741+
// are in the promoted tx and nothing is unique to discard.
5742+
assert!(result.node_a_discarded.is_empty());
5743+
assert!(result.node_b_discarded.is_empty());
56335744
} else {
56345745
// Acceptor does not contribute — complete with only node 0's inputs/outputs.
56355746
complete_interactive_funding_negotiation_for_both(
@@ -5654,14 +5765,14 @@ pub fn do_test_splice_rbf_tiebreak(
56545765
// Mine, lock, and verify DiscardFunding for the replaced splice candidate.
56555766
// Node 1's QuiescentAction was preserved, so after splice_locked it re-initiates
56565767
// quiescence to retry its contribution in a future splice.
5657-
let node_b_stfu = lock_rbf_splice_after_blocks(
5768+
let result = lock_rbf_splice_after_blocks(
56585769
&nodes[0],
56595770
&nodes[1],
56605771
&rbf_tx,
56615772
ANTI_REORG_DELAY - 1,
56625773
&[first_splice_tx.compute_txid()],
56635774
);
5664-
let stfu_1 = if let Some(MessageSendEvent::SendStfu { msg, .. }) = node_b_stfu {
5775+
let stfu_1 = if let Some(MessageSendEvent::SendStfu { msg, .. }) = result.stfu {
56655776
msg
56665777
} else {
56675778
panic!("Expected SendStfu from node 1");
@@ -5935,13 +6046,18 @@ fn test_splice_rbf_acceptor_recontributes() {
59356046
expect_splice_pending_event(&nodes[1], &node_id_0);
59366047

59376048
// Step 12: Mine, lock, and verify DiscardFunding for the replaced splice candidate.
5938-
lock_rbf_splice_after_blocks(
6049+
let result = lock_rbf_splice_after_blocks(
59396050
&nodes[0],
59406051
&nodes[1],
59416052
&rbf_tx,
59426053
ANTI_REORG_DELAY - 1,
59436054
&[first_splice_tx.compute_txid()],
59446055
);
6056+
6057+
// The test wallet reuses the same UTXOs across RBF rounds, so all contributed inputs
6058+
// are in the promoted tx and nothing is unique to discard.
6059+
assert!(result.node_a_discarded.is_empty());
6060+
assert!(result.node_b_discarded.is_empty());
59456061
}
59466062

59476063
#[test]
@@ -6260,13 +6376,18 @@ fn test_splice_rbf_sequential() {
62606376
// --- Mine and lock the final RBF, verifying DiscardFunding for both replaced candidates. ---
62616377
let splice_tx_0_txid = splice_tx_0.compute_txid();
62626378
let splice_tx_1_txid = splice_tx_1.compute_txid();
6263-
lock_rbf_splice_after_blocks(
6379+
let result = lock_rbf_splice_after_blocks(
62646380
&nodes[0],
62656381
&nodes[1],
62666382
&rbf_tx_final,
62676383
ANTI_REORG_DELAY - 1,
62686384
&[splice_tx_0_txid, splice_tx_1_txid],
62696385
);
6386+
6387+
// The test wallet reuses the same UTXOs across RBF rounds, so all contributed inputs
6388+
// are in the promoted tx and nothing is unique to discard.
6389+
assert!(result.node_a_discarded.is_empty());
6390+
assert!(result.node_b_discarded.is_empty());
62706391
}
62716392

62726393
#[test]
@@ -6856,7 +6977,7 @@ fn test_funding_contributed_rbf_adjustment_exceeds_max_feerate() {
68566977
// Mine and lock the pending splice → pending_splice is cleared.
68576978
mine_transaction(&nodes[0], &_splice_tx);
68586979
mine_transaction(&nodes[1], &_splice_tx);
6859-
let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1);
6980+
let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1).stfu;
68606981

68616982
// STFU is sent during lock — the splice proceeds as a fresh splice (not RBF).
68626983
let stfu = match stfu {
@@ -6933,7 +7054,7 @@ fn test_funding_contributed_rbf_adjustment_insufficient_budget() {
69337054
// Mine and lock the pending splice → pending_splice is cleared.
69347055
mine_transaction(&nodes[0], &_splice_tx);
69357056
mine_transaction(&nodes[1], &_splice_tx);
6936-
let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1);
7057+
let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1).stfu;
69377058

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

0 commit comments

Comments
 (0)