Skip to content

Commit 58f226e

Browse files
committed
Disallow net-negative contributions when adding value
When a user requests to add value via coin-selected inputs, we should strive to fulfill their request. Allowing them to remove value from the channel is undesired as it goes against their request. While we still allow adding outputs to enabled mixed contributions, their funds must now always come from the set of coin-selected inputs, and must never draw from the channel balance resulting in a smaller added value.
1 parent 11610af commit 58f226e

3 files changed

Lines changed: 93 additions & 178 deletions

File tree

lightning/src/ln/channelmanager.rs

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6638,20 +6638,32 @@ impl<
66386638
/// The splice initiator is responsible for paying fees for common fields, shared inputs, and
66396639
/// shared outputs along with any contributed inputs and outputs. When building a
66406640
/// [`FundingContribution`], fees are estimated at `min_feerate` assuming initiator
6641-
/// responsibility and must be covered by the supplied inputs for splice-in or the channel
6642-
/// balance for splice-out. If the counterparty also initiates a splice and wins the
6643-
/// tie-break, they become the initiator and choose the feerate. The fee is then
6644-
/// re-estimated at the counterparty's feerate for only our contributed inputs and outputs,
6645-
/// which may be higher or lower than the original estimate. The contribution is dropped and
6646-
/// the splice proceeds without it when:
6641+
/// responsibility. Contributions fall into two cases:
6642+
/// - **input-backed contributions**: when wallet inputs are selected, those inputs pay for both
6643+
/// the requested value added to the channel and any explicit withdrawal outputs. For
6644+
/// example, a 60,000 sat input might add 50,000 sat to the channel, pay a 2,000 sat fee,
6645+
/// and return 8,000 sat as change. A later RBF first tries to preserve that 50,000 sat
6646+
/// value added and cover any higher fee or newly requested withdrawal from the original
6647+
/// 10,000 sat fee buffer (2,000 sat fee + 8,000 sat change). If that buffer is not enough,
6648+
/// the prior contribution cannot be reused without selecting new wallet inputs.
6649+
/// - **input-less contributions**: when no wallet inputs are selected, fees and explicit
6650+
/// withdrawal outputs are paid from the channel balance. For example, a pure splice-out that
6651+
/// withdraws 20,000 sat from a 100,000 sat holder balance leaves up to 80,000 sat available
6652+
/// for fees. A later RBF keeps the 20,000 sat withdrawal only while that remaining balance
6653+
/// can still cover the re-estimated fee.
6654+
///
6655+
/// If the counterparty also initiates a splice and wins the tie-break, they become the
6656+
/// initiator and choose the feerate. The fee is then re-estimated at the counterparty's
6657+
/// feerate for only our contributed inputs and outputs, which may be higher or lower than the
6658+
/// original estimate. The contribution is dropped and the splice proceeds without it when:
66476659
/// - the counterparty's feerate is below `min_feerate`
66486660
/// - the counterparty's feerate is above `max_feerate` and the re-estimated fee exceeds the
66496661
/// original fee estimate
66506662
/// - the re-estimated fee exceeds the *fee buffer* regardless of `max_feerate`
66516663
///
66526664
/// The fee buffer is the maximum fee that can be accommodated:
6653-
/// - **splice-in**: the selected inputs' value minus the contributed amount
6654-
/// - **splice-out**: the channel balance minus the withdrawal outputs
6665+
/// - **input-backed contributions**: the original fee plus any change output value
6666+
/// - **input-less contributions**: the channel balance minus the withdrawal outputs
66556667
///
66566668
/// # Events
66576669
///

lightning/src/ln/funding.rs

Lines changed: 54 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,8 @@ pub(super) enum FeeRateAdjustmentError {
5959
FeeBufferOverflow,
6060
/// The re-estimated fee exceeds the available fee buffer regardless of `max_feerate`. The fee
6161
/// buffer is the maximum fee that can be accommodated:
62-
/// - **splice-in**: the selected inputs' value minus the contributed amount
63-
/// - **splice-out**: the channel balance minus the withdrawal outputs
62+
/// - **input-backed contributions**: the original fee plus any change output value
63+
/// - **input-less contributions**: the channel balance minus the withdrawal outputs
6464
FeeBufferInsufficient { source: &'static str, available: Amount, required: Amount },
6565
}
6666

@@ -288,7 +288,7 @@ macro_rules! build_funding_contribution {
288288
let max_feerate: FeeRate = $max_feerate;
289289
let force_coin_selection: bool = $force_coin_selection;
290290

291-
let value_removed = validate_funding_contribution_params(
291+
let _value_removed = validate_funding_contribution_params(
292292
value_added,
293293
&outputs,
294294
min_rbf_feerate,
@@ -312,8 +312,6 @@ macro_rules! build_funding_contribution {
312312
.map(|shared_input| shared_input.previous_utxo.value)
313313
.unwrap_or(Amount::ZERO)
314314
.checked_add(value_added)
315-
.ok_or(FundingContributionError::InvalidSpliceValue)?
316-
.checked_sub(value_removed)
317315
.ok_or(FundingContributionError::InvalidSpliceValue)?,
318316
script_pubkey: make_funding_redeemscript(&dummy_pubkey, &dummy_pubkey).to_p2wsh(),
319317
};
@@ -469,7 +467,8 @@ impl FundingTemplate {
469467
/// `value_added` and `outputs` are the complete parameters for this contribution, not
470468
/// increments on top of a prior contribution. When replacing a prior contribution via RBF,
471469
/// use [`FundingTemplate::prior_contribution`] to inspect the prior parameters and combine
472-
/// them as needed.
470+
/// them as needed. The withdrawal `outputs` are funded by the selected wallet inputs and do
471+
/// not reduce the requested `value_added` to the channel.
473472
pub async fn splice_in_and_out<W: CoinSelectionSource + MaybeSend>(
474473
self, value_added: Amount, outputs: Vec<TxOut>, min_feerate: FeeRate, max_feerate: FeeRate,
475474
wallet: W,
@@ -528,9 +527,9 @@ impl FundingTemplate {
528527
/// the fee difference. For splice-out (no wallet inputs), the holder's channel balance
529528
/// covers the higher fees.
530529
/// - If adjustment fails, coin selection is re-run using the prior contribution's
531-
/// parameters and the caller's `max_feerate`. For splice-out contributions, this changes
532-
/// the fee source: wallet inputs are selected to cover fees instead of deducting them
533-
/// from the channel balance.
530+
/// parameters and the caller's `max_feerate`. For prior contributions without inputs,
531+
/// this changes the funding source: wallet inputs are selected to cover the outputs and
532+
/// fees instead of deducting them from the channel balance.
534533
/// - If no prior contribution exists, coin selection is run for a fee-bump-only contribution
535534
/// (`value_added = 0`), covering fees for the common fields and shared input/output via
536535
/// a newly selected input. Check [`FundingTemplate::prior_contribution`] to see if this
@@ -712,8 +711,10 @@ pub struct FundingContribution {
712711
/// excess amount will be sent to a change output.
713712
inputs: Vec<FundingTxInput>,
714713

715-
/// The outputs to include in the funding transaction. The total value of all outputs plus fees
716-
/// will be the amount that is removed.
714+
/// The outputs to include in the funding transaction.
715+
///
716+
/// When no wallet inputs are contributed, these outputs are paid from the channel balance.
717+
/// Otherwise, they are paid by the contributed inputs.
717718
outputs: Vec<TxOut>,
718719

719720
/// The output where any change will be sent.
@@ -912,82 +913,63 @@ impl FundingContribution {
912913
}
913914
}
914915

916+
let target_fee = estimate_transaction_fee(
917+
&self.inputs,
918+
&self.outputs,
919+
self.change_output.as_ref(),
920+
is_initiator,
921+
self.is_splice,
922+
target_feerate,
923+
);
924+
915925
if !self.inputs.is_empty() {
916-
if let Some(ref change_output) = self.change_output {
917-
let old_change_value = change_output.value;
918-
let dust_limit = change_output.script_pubkey.minimal_non_dust();
926+
let fee_buffer = self
927+
.estimated_fee
928+
.checked_add(
929+
self.change_output.as_ref().map_or(Amount::ZERO, |output| output.value),
930+
)
931+
.ok_or(FeeRateAdjustmentError::FeeBufferOverflow)?;
919932

920-
// Target fee including the change output's weight.
921-
let target_fee = estimate_transaction_fee(
922-
&self.inputs,
923-
&self.outputs,
924-
self.change_output.as_ref(),
925-
is_initiator,
926-
self.is_splice,
927-
target_feerate,
928-
);
933+
if let Some(change_output) = self.change_output.as_ref() {
934+
let dust_limit = change_output.script_pubkey.minimal_non_dust();
935+
if let Some(new_change_value) = fee_buffer.checked_sub(target_fee) {
936+
if new_change_value >= dust_limit {
937+
return Ok((target_fee, Some(new_change_value)));
938+
}
929939

930-
let fee_buffer = self
931-
.estimated_fee
932-
.checked_add(old_change_value)
933-
.ok_or(FeeRateAdjustmentError::FeeBufferOverflow)?;
934-
935-
match fee_buffer.checked_sub(target_fee) {
936-
Some(new_change_value) if new_change_value >= dust_limit => {
937-
Ok((target_fee, Some(new_change_value)))
938-
},
939-
_ => {
940-
// Change would be below dust or negative. Try without change.
941-
let target_fee_no_change = estimate_transaction_fee(
942-
&self.inputs,
943-
&self.outputs,
944-
None,
945-
is_initiator,
946-
self.is_splice,
947-
target_feerate,
948-
);
949-
if target_fee_no_change > fee_buffer {
950-
Err(FeeRateAdjustmentError::FeeBufferInsufficient {
951-
source: "estimated fee + change value",
952-
available: fee_buffer,
953-
required: target_fee_no_change,
954-
})
955-
} else {
956-
Ok((target_fee_no_change, None))
957-
}
958-
},
940+
// Our remaining change was not enough to be a valid output, fallthrough to the
941+
// no remaining change case.
959942
}
960-
} else {
961-
// No change output.
962-
let target_fee = estimate_transaction_fee(
943+
944+
let target_fee_no_change = estimate_transaction_fee(
963945
&self.inputs,
964946
&self.outputs,
965947
None,
966948
is_initiator,
967949
self.is_splice,
968950
target_feerate,
969951
);
970-
if target_fee > self.estimated_fee {
971-
return Err(FeeRateAdjustmentError::FeeBufferInsufficient {
972-
source: "estimated fee",
973-
available: self.estimated_fee,
974-
required: target_fee,
975-
});
952+
if target_fee_no_change > fee_buffer {
953+
Err(FeeRateAdjustmentError::FeeBufferInsufficient {
954+
source: "estimated fee + change value",
955+
available: fee_buffer,
956+
required: target_fee_no_change,
957+
})
958+
} else {
959+
Ok((target_fee_no_change, None))
976960
}
961+
} else if let Some(_surplus) = fee_buffer.checked_sub(target_fee) {
977962
Ok((target_fee, None))
963+
} else {
964+
Err(FeeRateAdjustmentError::FeeBufferInsufficient {
965+
source: "estimated fee",
966+
available: fee_buffer,
967+
required: target_fee,
968+
})
978969
}
979970
} else {
980-
// No inputs (splice-out): fees paid from channel balance.
981-
let target_fee = estimate_transaction_fee(
982-
&[],
983-
&self.outputs,
984-
None,
985-
is_initiator,
986-
self.is_splice,
987-
target_feerate,
988-
);
989-
990-
// Check that the channel balance can cover the withdrawal outputs plus fees.
971+
// Without coin-selected inputs, both the withdrawals and the fee come from the channel
972+
// balance.
991973
let value_removed: Amount = self.outputs.iter().map(|o| o.value).sum();
992974
let total_cost = target_fee
993975
.checked_add(value_removed)
@@ -999,7 +981,6 @@ impl FundingContribution {
999981
required: target_fee,
1000982
});
1001983
}
1002-
// Surplus goes back to the channel balance.
1003984
Ok((target_fee, None))
1004985
}
1005986
}

lightning/src/ln/splicing_tests.rs

Lines changed: 19 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -1177,7 +1177,7 @@ fn test_splice_out() {
11771177
}
11781178

11791179
#[test]
1180-
fn test_splice_in_and_out() {
1180+
fn test_splice_in_and_out_funds_outputs_from_inputs() {
11811181
let chanmon_cfgs = create_chanmon_cfgs(2);
11821182
let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
11831183
let mut config = test_default_channel_config();
@@ -1190,118 +1190,40 @@ fn test_splice_in_and_out() {
11901190
let (_, _, channel_id, _) =
11911191
create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0);
11921192

1193-
let _ = send_payment(&nodes[0], &[&nodes[1]], 100_000);
1194-
1195-
// Contribute a net negative value, with fees taken from the contributed inputs and the
1196-
// remaining value sent to change
1197-
let htlc_limit_msat = nodes[0].node.list_channels()[0].next_outbound_htlc_limit_msat;
1198-
let added_value = Amount::from_sat(htlc_limit_msat / 1000);
1199-
let removed_value = added_value * 2;
1200-
let utxo_value = added_value * 3 / 4;
1201-
let fees = if cfg!(feature = "grind_signatures") {
1202-
Amount::from_sat(385)
1203-
} else {
1204-
Amount::from_sat(385)
1205-
};
1206-
1207-
assert!(htlc_limit_msat > initial_channel_value_sat / 2 * 1000);
1208-
1209-
provide_utxo_reserves(&nodes, 2, utxo_value);
1210-
1193+
let value_added = Amount::from_sat(20_000);
1194+
let utxo_value = Amount::from_sat(50_000);
12111195
let outputs = vec![
12121196
TxOut {
1213-
value: removed_value / 2,
1197+
value: Amount::from_sat(20_000),
12141198
script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(),
12151199
},
12161200
TxOut {
1217-
value: removed_value / 2,
1201+
value: Amount::from_sat(20_000),
12181202
script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(),
12191203
},
12201204
];
1221-
let funding_contribution =
1222-
do_initiate_splice_in_and_out(&nodes[0], &nodes[1], channel_id, added_value, outputs);
1223-
1224-
let (splice_tx, new_funding_script) =
1225-
splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution);
1226-
let expected_change = utxo_value * 2 - added_value - fees;
1227-
assert_eq!(
1228-
splice_tx
1229-
.output
1230-
.iter()
1231-
.filter(|txout| txout.value != removed_value / 2)
1232-
.find(|txout| txout.script_pubkey != new_funding_script)
1233-
.unwrap()
1234-
.value,
1235-
expected_change,
1236-
);
1237-
1238-
mine_transaction(&nodes[0], &splice_tx);
1239-
mine_transaction(&nodes[1], &splice_tx);
1240-
1241-
let htlc_limit_msat = nodes[0].node.list_channels()[0].next_outbound_htlc_limit_msat;
1242-
assert!(htlc_limit_msat < added_value.to_sat() * 1000);
1243-
let _ = send_payment(&nodes[0], &[&nodes[1]], htlc_limit_msat);
1244-
1245-
lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1);
1246-
1247-
let htlc_limit_msat = nodes[0].node.list_channels()[0].next_outbound_htlc_limit_msat;
1248-
assert!(htlc_limit_msat < added_value.to_sat() * 1000);
1249-
let _ = send_payment(&nodes[0], &[&nodes[1]], htlc_limit_msat);
1250-
1251-
// Contribute a net positive value, with fees taken from the contributed inputs and the
1252-
// remaining value sent to change
1253-
let added_value = Amount::from_sat(initial_channel_value_sat * 2);
1254-
let removed_value = added_value / 2;
1255-
let utxo_value = added_value * 3 / 4;
1256-
let fees = if cfg!(feature = "grind_signatures") {
1257-
Amount::from_sat(385)
1258-
} else {
1259-
Amount::from_sat(385)
1260-
};
1261-
1262-
// Clear UTXOs so that the change output from the previous splice isn't considered
1263-
nodes[0].wallet_source.clear_utxos();
1264-
12651205
provide_utxo_reserves(&nodes, 2, utxo_value);
12661206

1267-
let outputs = vec![
1268-
TxOut {
1269-
value: removed_value / 2,
1270-
script_pubkey: nodes[0].wallet_source.get_change_script().unwrap(),
1271-
},
1272-
TxOut {
1273-
value: removed_value / 2,
1274-
script_pubkey: nodes[1].wallet_source.get_change_script().unwrap(),
1275-
},
1276-
];
12771207
let funding_contribution =
1278-
do_initiate_splice_in_and_out(&nodes[0], &nodes[1], channel_id, added_value, outputs);
1279-
1280-
let (splice_tx, new_funding_script) =
1281-
splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution);
1282-
let expected_change = utxo_value * 2 - added_value - fees;
1283-
assert_eq!(
1284-
splice_tx
1285-
.output
1286-
.iter()
1287-
.filter(|txout| txout.value != removed_value / 2)
1288-
.find(|txout| txout.script_pubkey != new_funding_script)
1289-
.unwrap()
1290-
.value,
1291-
expected_change,
1292-
);
1208+
initiate_splice_in_and_out(&nodes[0], &nodes[1], channel_id, value_added, outputs);
1209+
let fees = Amount::from_sat(385);
1210+
let total_output_value: Amount =
1211+
funding_contribution.outputs().iter().map(|output| output.value).sum();
1212+
let expected_change = utxo_value * 2 - value_added - total_output_value - fees;
1213+
assert_eq!(funding_contribution.change_output().unwrap().value, expected_change);
1214+
assert!(funding_contribution.net_value() >= value_added.to_signed().unwrap());
12931215

1216+
let (splice_tx, _) =
1217+
splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution.clone());
12941218
mine_transaction(&nodes[0], &splice_tx);
12951219
mine_transaction(&nodes[1], &splice_tx);
1296-
1297-
let htlc_limit_msat = nodes[0].node.list_channels()[0].next_outbound_htlc_limit_msat;
1298-
assert_eq!(htlc_limit_msat, 0);
1299-
13001220
lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1);
13011221

1302-
let htlc_limit_msat = nodes[0].node.list_channels()[0].next_outbound_htlc_limit_msat;
1303-
assert!(htlc_limit_msat > initial_channel_value_sat / 2 * 1000);
1304-
let _ = send_payment(&nodes[0], &[&nodes[1]], htlc_limit_msat);
1222+
let channel = &nodes[0].node.list_channels()[0];
1223+
assert_eq!(
1224+
channel.channel_value_satoshis,
1225+
initial_channel_value_sat + funding_contribution.net_value().to_sat() as u64,
1226+
);
13051227
}
13061228

13071229
#[test]

0 commit comments

Comments
 (0)