diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 7e6ee7f2c35..b8dc90ef949 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -2772,6 +2772,42 @@ impl FundingScope { context.counterparty_dust_limit_satoshis, ); + // Account for in-flight HTLCs and anchor outputs when initializing the max + // commitment tx output trackers. Unlike a fresh channel open (which has no HTLCs), + // a splice may have pending HTLCs whose amounts are subtracted from the balances + // in the commitment transaction. Without this adjustment, the monotonicity debug + // assertion in `build_commitment_transaction` would fire on the first commitment. + #[cfg(debug_assertions)] + let (local_balance_msat, remote_balance_msat) = { + let pending_outbound_htlcs_value_msat: u64 = + context.pending_outbound_htlcs.iter().map(|h| h.amount_msat).sum(); + let pending_inbound_htlcs_value_msat: u64 = + context.pending_inbound_htlcs.iter().map(|h| h.amount_msat).sum(); + let channel_type = &post_channel_transaction_parameters.channel_type_features; + let total_anchors_sat = if channel_type.supports_anchors_zero_fee_htlc_tx() { + ANCHOR_OUTPUT_VALUE_SATOSHI * 2 + } else { + 0 + }; + let post_value_to_remote_msat = + (post_channel_value * 1000).saturating_sub(post_value_to_self_msat); + if post_channel_transaction_parameters.is_outbound_from_holder { + ( + post_value_to_self_msat + .saturating_sub(pending_outbound_htlcs_value_msat) + .saturating_sub(total_anchors_sat * 1000), + post_value_to_remote_msat.saturating_sub(pending_inbound_htlcs_value_msat), + ) + } else { + ( + post_value_to_self_msat.saturating_sub(pending_outbound_htlcs_value_msat), + post_value_to_remote_msat + .saturating_sub(pending_inbound_htlcs_value_msat) + .saturating_sub(total_anchors_sat * 1000), + ) + } + }; + Self { channel_transaction_parameters: post_channel_transaction_parameters, value_to_self_msat: post_value_to_self_msat, @@ -2779,14 +2815,11 @@ impl FundingScope { counterparty_selected_channel_reserve_satoshis, holder_selected_channel_reserve_satoshis, #[cfg(debug_assertions)] - holder_max_commitment_tx_output: Mutex::new(( - post_value_to_self_msat, - (post_channel_value * 1000).saturating_sub(post_value_to_self_msat), - )), + holder_max_commitment_tx_output: Mutex::new((local_balance_msat, remote_balance_msat)), #[cfg(debug_assertions)] counterparty_max_commitment_tx_output: Mutex::new(( - post_value_to_self_msat, - (post_channel_value * 1000).saturating_sub(post_value_to_self_msat), + local_balance_msat, + remote_balance_msat, )), #[cfg(any(test, fuzzing))] next_local_fee: Mutex::new(PredictedNextFee::default()), diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs index 4846f7137cc..a35bbca7a39 100644 --- a/lightning/src/ln/splicing_tests.rs +++ b/lightning/src/ln/splicing_tests.rs @@ -2459,3 +2459,62 @@ fn test_splice_buffer_invalid_commitment_signed_closes_channel() { ); check_added_monitors(&nodes[0], 1); } + +#[test] +fn test_splice_with_pending_htlcs() { + // Test that splicing works correctly when there are bidirectional pending HTLCs (both outbound + // and inbound). This exercises the debug logic in `build_commitment_transaction` where the + // `holder_max_commitment_tx_output` and `counterparty_max_commitment_tx_output` trackers must + // account for in-flight HTLCs and anchor costs when initializing a new splice funding scope. + // Without the fix, the monotonicity debug assertion would fire on the first commitment + // transaction built for the splice funding. + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let mut config = test_default_channel_config(); + config.channel_handshake_config.max_inbound_htlc_value_in_flight_percent_of_channel = 100; + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(config.clone()), Some(config)]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let initial_channel_value_sat = 100_000; + // Push 10k sat to node 1 so it has balance to send HTLCs back. + let push_msat = 10_000_000; + let (_, _, channel_id, _) = create_announced_chan_between_nodes_with_value( + &nodes, + 0, + 1, + initial_channel_value_sat, + push_msat, + ); + + let coinbase_tx = provide_anchor_reserves(&nodes); + + // Create bidirectional pending HTLCs (routed but not claimed). + // Outbound HTLC from node 0 to node 1. + let (preimage_0_to_1, _hash_0_to_1, ..) = route_payment(&nodes[0], &[&nodes[1]], 1_000_000); + // Large inbound HTLC from node 1 to node 0, bringing node 1's remaining balance down to + // 2000 sat. The old reserve (1% of 100k) is 1000 sat so this is still above reserve. + let (preimage_1_to_0, _hash_1_to_0, ..) = route_payment(&nodes[1], &[&nodes[0]], 8_000_000); + + // Splice-in 200k sat. The new channel value becomes 300k sat, raising the reserve to 3000 + // sat. Node 1's remaining 2000 sat is now below the new reserve, which means the debug + // assertion's `balance / 1000 >= reserve` fallback (3000 > 2000) cannot mask a broken + // tracker initialization. + let initiator_contribution = SpliceContribution::splice_in( + Amount::from_sat(200_000), + vec![FundingTxInput::new_p2wpkh(coinbase_tx, 0).unwrap()], + Some(nodes[0].wallet_source.get_change_script().unwrap()), + ); + let splice_tx = splice_channel(&nodes[0], &nodes[1], channel_id, initiator_contribution); + + // Confirm and lock the splice. + mine_transaction(&nodes[0], &splice_tx); + mine_transaction(&nodes[1], &splice_tx); + lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1); + + // Claim both pending HTLCs to verify the channel is fully functional after the splice. + claim_payment(&nodes[0], &[&nodes[1]], preimage_0_to_1); + claim_payment(&nodes[1], &[&nodes[0]], preimage_1_to_0); + + // Final sanity check: send a payment using the new spliced capacity. + let _ = send_payment(&nodes[0], &[&nodes[1]], 1_000_000); +}