Skip to content

Commit 9f6c678

Browse files
jkczyzclaude
andcommitted
Make FundingContribution::net_value() infallible
Split FundingContribution::net_value() (which returned Result<SignedAmount, String>) into a separate validate() method and an infallible net_value() that returns SignedAmount directly. The validate() method checks prevtx sizes and input sufficiency, while net_value() computes the net contribution amount. To make net_value() safe to call without error handling, add MAX_MONEY bounds checks in the build_funding_contribution! macro before coin selection. This ensures FundingContribution is valid by construction: value_added and the sum of outputs are each bounded by MAX_MONEY (~2.1e15 sat), so the worst-case net_value() computation (-2 * MAX_MONEY ~= -4.2e15) is well within i64 range (~-9.2e18). Update callers in channel.rs to use the new separate methods, simplifying error handling at call sites where net_value() previously required unwrapping a Result. Co-Authored-By: Claude Opus 4.6 <[email protected]>
1 parent 04130c1 commit 9f6c678

File tree

2 files changed

+116
-38
lines changed

2 files changed

+116
-38
lines changed

lightning/src/ln/channel.rs

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12195,9 +12195,10 @@ where
1219512195
) -> Result<Option<msgs::Stfu>, SpliceFundingFailed> {
1219612196
debug_assert!(contribution.is_splice());
1219712197

12198-
if let Err(e) = contribution.net_value().and_then(|our_funding_contribution| {
12198+
if let Err(e) = contribution.validate().and_then(|()| {
1219912199
// For splice-out, our_funding_contribution is adjusted to cover fees if there
1220012200
// aren't any inputs.
12201+
let our_funding_contribution = contribution.net_value();
1220112202
self.validate_splice_contributions(our_funding_contribution, SignedAmount::ZERO)
1220212203
}) {
1220312204
log_error!(logger, "Channel {} cannot be funded: {}", self.context.channel_id(), e);
@@ -13540,18 +13541,7 @@ where
1354013541
}
1354113542

1354213543
let prev_funding_input = self.funding.to_splice_funding_input();
13543-
let our_funding_contribution = match contribution.net_value() {
13544-
Ok(net_value) => net_value,
13545-
Err(e) => {
13546-
debug_assert!(false);
13547-
return Err(ChannelError::WarnAndDisconnect(
13548-
format!(
13549-
"Internal Error: Insufficient funding contribution: {}",
13550-
e,
13551-
)
13552-
));
13553-
},
13554-
};
13544+
let our_funding_contribution = contribution.net_value();
1355513545
let funding_feerate_per_kw = contribution.feerate().to_sat_per_kwu() as u32;
1355613546
let (our_funding_inputs, our_funding_outputs) = contribution.into_tx_parts();
1355713547

lightning/src/ln/funding.rs

Lines changed: 113 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,22 @@ macro_rules! build_funding_contribution {
6060
let shared_input: Option<Input> = $shared_input;
6161
let feerate: FeeRate = $feerate;
6262

63-
let value_removed = outputs.iter().map(|txout| txout.value).sum();
63+
// Validate user-provided amounts are within MAX_MONEY before coin selection to
64+
// ensure FundingContribution::net_value() arithmetic cannot overflow. With all
65+
// amounts bounded by MAX_MONEY (~2.1e15 sat), the worst-case net_value()
66+
// computation is -2 * MAX_MONEY (~-4.2e15), well within i64::MIN (~-9.2e18).
67+
if value_added > Amount::MAX_MONEY {
68+
return Err(());
69+
}
70+
71+
let mut value_removed = Amount::ZERO;
72+
for txout in outputs.iter() {
73+
value_removed = match value_removed.checked_add(txout.value) {
74+
Some(sum) if sum <= Amount::MAX_MONEY => sum,
75+
_ => return Err(()),
76+
};
77+
}
78+
6479
let is_splice = shared_input.is_some();
6580

6681
let coin_selection = if value_added == Amount::ZERO {
@@ -102,6 +117,7 @@ macro_rules! build_funding_contribution {
102117
// purposes — this is conservative, overestimating rather than underestimating fees if
103118
// the node ends up as the acceptor.
104119
let estimated_fee = estimate_transaction_fee(&inputs, &outputs, true, is_splice, feerate);
120+
debug_assert!(estimated_fee <= Amount::MAX_MONEY);
105121

106122
let contribution = FundingContribution {
107123
value_added,
@@ -305,10 +321,9 @@ impl FundingContribution {
305321
(inputs.into_iter().map(|input| input.utxo.outpoint).collect(), outputs)
306322
}
307323

308-
/// The net value contributed to a channel by the splice. If negative, more value will be
309-
/// spliced out than spliced in. Fees will be deducted from the expected splice-out amount
310-
/// if no inputs were included.
311-
pub fn net_value(&self) -> Result<SignedAmount, String> {
324+
/// Validates that the funding inputs are suitable for use in the interactive transaction
325+
/// protocol, checking prevtx sizes and input sufficiency.
326+
pub fn validate(&self) -> Result<(), String> {
312327
for FundingTxInput { utxo, prevtx, .. } in self.inputs.iter() {
313328
use crate::util::ser::Writeable;
314329
const MESSAGE_TEMPLATE: msgs::TxAddInput = msgs::TxAddInput {
@@ -361,26 +376,32 @@ impl FundingContribution {
361376
}
362377
}
363378

379+
Ok(())
380+
}
381+
382+
/// The net value contributed to a channel by the splice. If negative, more value will be
383+
/// spliced out than spliced in. Fees will be deducted from the expected splice-out amount
384+
/// if no inputs were included.
385+
pub fn net_value(&self) -> SignedAmount {
364386
let unpaid_fees = if self.inputs.is_empty() { self.estimated_fee } else { Amount::ZERO }
365387
.to_signed()
366-
.expect("fees should never exceed Amount::MAX_MONEY");
367-
let value_added = self.value_added.to_signed().map_err(|_| "Value added too large")?;
388+
.expect("estimated_fee is validated to not exceed Amount::MAX_MONEY");
389+
let value_added = self
390+
.value_added
391+
.to_signed()
392+
.expect("value_added is validated to not exceed Amount::MAX_MONEY");
368393
let value_removed = self
369394
.outputs
370395
.iter()
371396
.map(|txout| txout.value)
372397
.sum::<Amount>()
373398
.to_signed()
374-
.map_err(|_| "Value removed too large")?;
399+
.expect("value_removed is validated to not exceed Amount::MAX_MONEY");
375400

376401
let contribution_amount = value_added - value_removed;
377-
let adjusted_contribution = contribution_amount.checked_sub(unpaid_fees).ok_or(format!(
378-
"{} splice-out amount plus {} fee estimate exceeds the total bitcoin supply",
379-
contribution_amount.unsigned_abs(),
380-
self.estimated_fee,
381-
))?;
382-
383-
Ok(adjusted_contribution)
402+
contribution_amount
403+
.checked_sub(unpaid_fees)
404+
.expect("all amounts are validated to not exceed Amount::MAX_MONEY")
384405
}
385406
}
386407

@@ -390,10 +411,12 @@ pub type FundingTxInput = crate::util::wallet_utils::ConfirmedUtxo;
390411

391412
#[cfg(test)]
392413
mod tests {
393-
use super::{estimate_transaction_fee, FundingContribution, FundingTxInput};
414+
use super::{estimate_transaction_fee, FundingContribution, FundingTemplate, FundingTxInput};
415+
use crate::chain::ClaimId;
416+
use crate::util::wallet_utils::{CoinSelection, CoinSelectionSourceSync, Input};
394417
use bitcoin::hashes::Hash;
395418
use bitcoin::transaction::{Transaction, TxOut, Version};
396-
use bitcoin::{Amount, FeeRate, ScriptBuf, SignedAmount, WPubkeyHash};
419+
use bitcoin::{Amount, FeeRate, Psbt, ScriptBuf, SignedAmount, WPubkeyHash};
397420

398421
#[test]
399422
#[rustfmt::skip]
@@ -483,7 +506,8 @@ mod tests {
483506
is_splice: true,
484507
feerate: FeeRate::from_sat_per_kwu(2000),
485508
};
486-
assert_eq!(contribution.net_value(), Ok(contribution.value_added.to_signed().unwrap()));
509+
assert!(contribution.validate().is_ok());
510+
assert_eq!(contribution.net_value(), contribution.value_added.to_signed().unwrap());
487511
}
488512

489513
// Net splice-in
@@ -503,7 +527,8 @@ mod tests {
503527
is_splice: true,
504528
feerate: FeeRate::from_sat_per_kwu(2000),
505529
};
506-
assert_eq!(contribution.net_value(), Ok(SignedAmount::from_sat(220_000 - 200_000)));
530+
assert!(contribution.validate().is_ok());
531+
assert_eq!(contribution.net_value(), SignedAmount::from_sat(220_000 - 200_000));
507532
}
508533

509534
// Net splice-out
@@ -523,7 +548,8 @@ mod tests {
523548
is_splice: true,
524549
feerate: FeeRate::from_sat_per_kwu(2000),
525550
};
526-
assert_eq!(contribution.net_value(), Ok(SignedAmount::from_sat(220_000 - 400_000)));
551+
assert!(contribution.validate().is_ok());
552+
assert_eq!(contribution.net_value(), SignedAmount::from_sat(220_000 - 400_000));
527553
}
528554

529555
// Net splice-out, inputs insufficient to cover fees
@@ -544,7 +570,7 @@ mod tests {
544570
feerate: FeeRate::from_sat_per_kwu(90000),
545571
};
546572
assert_eq!(
547-
contribution.net_value(),
573+
contribution.validate(),
548574
Err(format!(
549575
"Total input amount 0.00300000 BTC is lower than needed for splice-in contribution 0.00220000 BTC, considering fees of {}. Need more inputs.",
550576
Amount::from_sat(expected_fee),
@@ -567,7 +593,7 @@ mod tests {
567593
feerate: FeeRate::from_sat_per_kwu(2000),
568594
};
569595
assert_eq!(
570-
contribution.net_value(),
596+
contribution.validate(),
571597
Err(format!(
572598
"Total input amount 0.00100000 BTC is lower than needed for splice-in contribution 0.00220000 BTC, considering fees of {}. Need more inputs.",
573599
Amount::from_sat(expected_fee),
@@ -590,7 +616,8 @@ mod tests {
590616
is_splice: true,
591617
feerate: FeeRate::from_sat_per_kwu(2000),
592618
};
593-
assert_eq!(contribution.net_value(), Ok(contribution.value_added.to_signed().unwrap()));
619+
assert!(contribution.validate().is_ok());
620+
assert_eq!(contribution.net_value(), contribution.value_added.to_signed().unwrap());
594621
}
595622

596623
// higher fee rate, does not cover
@@ -609,7 +636,7 @@ mod tests {
609636
feerate: FeeRate::from_sat_per_kwu(2200),
610637
};
611638
assert_eq!(
612-
contribution.net_value(),
639+
contribution.validate(),
613640
Err(format!(
614641
"Total input amount 0.00300000 BTC is lower than needed for splice-in contribution 0.00298032 BTC, considering fees of {}. Need more inputs.",
615642
Amount::from_sat(expected_fee),
@@ -632,7 +659,68 @@ mod tests {
632659
is_splice: false,
633660
feerate: FeeRate::from_sat_per_kwu(2000),
634661
};
635-
assert_eq!(contribution.net_value(), Ok(contribution.value_added.to_signed().unwrap()));
662+
assert!(contribution.validate().is_ok());
663+
assert_eq!(contribution.net_value(), contribution.value_added.to_signed().unwrap());
664+
}
665+
}
666+
667+
struct UnreachableWallet;
668+
669+
impl CoinSelectionSourceSync for UnreachableWallet {
670+
fn select_confirmed_utxos(
671+
&self, _claim_id: Option<ClaimId>, _must_spend: Vec<Input>, _must_pay_to: &[TxOut],
672+
_target_feerate_sat_per_1000_weight: u32, _max_tx_weight: u64,
673+
) -> Result<CoinSelection, ()> {
674+
unreachable!("should not reach coin selection")
675+
}
676+
fn sign_psbt(&self, _psbt: Psbt) -> Result<Transaction, ()> {
677+
unreachable!("should not reach signing")
678+
}
679+
}
680+
681+
#[test]
682+
fn test_build_funding_contribution_validates_max_money() {
683+
let over_max = Amount::MAX_MONEY + Amount::from_sat(1);
684+
let feerate = FeeRate::from_sat_per_kwu(2000);
685+
686+
// splice_in_sync with value_added > MAX_MONEY
687+
{
688+
let template = FundingTemplate::new(None, feerate);
689+
assert!(template.splice_in_sync(over_max, UnreachableWallet).is_err());
690+
}
691+
692+
// splice_out_sync with single output value > MAX_MONEY
693+
{
694+
let template = FundingTemplate::new(None, feerate);
695+
let outputs = vec![funding_output_sats(over_max.to_sat())];
696+
assert!(template.splice_out_sync(outputs, UnreachableWallet).is_err());
697+
}
698+
699+
// splice_out_sync with multiple outputs summing > MAX_MONEY
700+
{
701+
let template = FundingTemplate::new(None, feerate);
702+
let half_over = Amount::MAX_MONEY / 2 + Amount::from_sat(1);
703+
let outputs = vec![
704+
funding_output_sats(half_over.to_sat()),
705+
funding_output_sats(half_over.to_sat()),
706+
];
707+
assert!(template.splice_out_sync(outputs, UnreachableWallet).is_err());
708+
}
709+
710+
// splice_in_and_out_sync with value_added > MAX_MONEY
711+
{
712+
let template = FundingTemplate::new(None, feerate);
713+
let outputs = vec![funding_output_sats(1_000)];
714+
assert!(template.splice_in_and_out_sync(over_max, outputs, UnreachableWallet).is_err());
715+
}
716+
717+
// splice_in_and_out_sync with output sum > MAX_MONEY
718+
{
719+
let template = FundingTemplate::new(None, feerate);
720+
let outputs = vec![funding_output_sats(over_max.to_sat())];
721+
assert!(template
722+
.splice_in_and_out_sync(Amount::from_sat(1_000), outputs, UnreachableWallet)
723+
.is_err());
636724
}
637725
}
638726
}

0 commit comments

Comments
 (0)