Handle FeeRateAdjustmentError variants in splice acceptor path
What changed, and why it matters
This commit fixes how a Lightning node responds when a peer proposes a splice or RBF transaction fee rate that is too high for the node to afford. Previously, the node would silently drop its own contribution and continue, which could let the peer push through an unexpectedly expensive transaction. Now the node explicitly rejects the splice with a warning and disconnects when the fee rate is too high, while still allowing lower or retryable cases to proceed safely.
Review the new variant-specific handling to ensure no other FeeRateAdjustmentError variants are silently mishandled, and confirm that WarnAndDisconnect behavior is correctly wired through the message handler. Consider whether FeeRateTooHigh should also trigger a force-close or policy-level ban in addition to disconnect.
Security signals we found
Explicit rejection of unaffordable fee rates in splice/RBF acceptor path
Replacement of generic error swallowing with variant-specific handling
Addition of AbortReason::FeeRateTooHigh and corresponding tx_abort behavior
New regression tests covering high-fee-rate rejection for both splice and RBF
Potential DoS vector: peer could previously force acceptor to drop contribution and continue
Evidence from the diff
The change refactors resolve_queued_contribution in channel.rs to match on FeeRateAdjustmentError variants instead of swallowing all errors. FeeRateTooHigh now returns ChannelError::Abort(AbortReason::FeeRateTooHigh), causing the acceptor to send tx_abort and disconnect. FeeRateTooLow and FeeBufferInsufficient still proceed without contribution, with the former preserving the QuiescentAction for an RBF retry. The patch also updates error messages and adds unit tests for both splice_init and tx_init_rbf paths.
Changed components
lightning/src/ln/channel.rslightning/src/ln/funding.rslightning/src/ln/interactivetxs.rslightning/src/ln/splicing_tests.rsInspect captured patch +194 / −18
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index 0ab7292..0e8f46b 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -55,7 +55,9 @@ use crate::ln::channelmanager::{
PendingHTLCStatus, RAACommitmentOrder, SentHTLCId, BREAKDOWN_TIMEOUT,
MAX_LOCAL_BREAKDOWN_TIMEOUT, MIN_CLTV_EXPIRY_DELTA,
};
-use crate::ln::funding::{FundingContribution, FundingTemplate, FundingTxInput};
+use crate::ln::funding::{
+ FeeRateAdjustmentError, FundingContribution, FundingTemplate, FundingTxInput,
+};
use crate::ln::interactivetxs::{
AbortReason, HandleTxCompleteValue, InteractiveTxConstructor, InteractiveTxConstructorArgs,
InteractiveTxMessageSend, InteractiveTxSigningSession, SharedOwnedInput, SharedOwnedOutput,
@@ -12417,7 +12419,7 @@ where
fn resolve_queued_contribution<L: Logger>(
&self, feerate: FeeRate, logger: &L,
- ) -> (Option<SignedAmount>, Option<Amount>) {
+ ) -> Result<(Option<SignedAmount>, Option<Amount>), ChannelError> {
let holder_balance = self
.get_holder_counterparty_balances_floor_incl_fee(&self.funding)
.map(|(holder, _)| holder)
@@ -12432,23 +12434,29 @@ where
})
.ok();
- let net_value =
- holder_balance.and_then(|_| self.queued_funding_contribution()).and_then(|c| {
- c.net_value_for_acceptor_at_feerate(feerate, holder_balance.unwrap())
- .map_err(|e| {
+ let net_value = match holder_balance.and_then(|_| self.queued_funding_contribution()) {
+ Some(c) => {
+ match c.net_value_for_acceptor_at_feerate(feerate, holder_balance.unwrap()) {
+ Ok(net_value) => Some(net_value),
+ Err(FeeRateAdjustmentError::FeeRateTooHigh { .. }) => {
+ return Err(ChannelError::Abort(AbortReason::FeeRateTooHigh));
+ },
+ Err(e) => {
log_info!(
logger,
- "Cannot accommodate initiator's feerate ({}) for channel {}: {}; \
- proceeding without contribution",
+ "Cannot accommodate initiator's feerate ({}) for channel {}: {}",
feerate,
self.context.channel_id(),
e,
);
- })
- .ok()
- });
+ None
+ },
+ }
+ },
+ None => None,
+ };
- (net_value, holder_balance)
+ Ok((net_value, holder_balance))
}
pub(crate) fn splice_init<ES: EntropySource, L: Logger>(
@@ -12457,7 +12465,7 @@ where
) -> Result<msgs::SpliceAck, ChannelError> {
let feerate = FeeRate::from_sat_per_kwu(msg.funding_feerate_per_kw as u64);
let (our_funding_contribution, holder_balance) =
- self.resolve_queued_contribution(feerate, logger);
+ self.resolve_queued_contribution(feerate, logger)?;
let splice_funding =
self.validate_splice_init(msg, our_funding_contribution.unwrap_or(SignedAmount::ZERO))?;
@@ -12615,7 +12623,8 @@ where
fee_estimator: &LowerBoundedFeeEstimator<F>, logger: &L,
) -> Result<msgs::TxAckRbf, ChannelError> {
let feerate = FeeRate::from_sat_per_kwu(msg.feerate_sat_per_1000_weight as u64);
- let (queued_net_value, holder_balance) = self.resolve_queued_contribution(feerate, logger);
+ let (queued_net_value, holder_balance) =
+ self.resolve_queued_contribution(feerate, logger)?;
// If no queued contribution, try prior contribution from previous negotiation.
// Failing here means the RBF would erase our splice — reject it.
diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs
index 7c1bada..c81024c 100644
--- a/lightning/src/ln/funding.rs
+++ b/lightning/src/ln/funding.rs
@@ -42,7 +42,7 @@ use crate::util::wallet_utils::{
#[derive(Debug)]
pub(super) enum FeeRateAdjustmentError {
/// The counterparty's proposed feerate is below `min_feerate`, which was used as the feerate
- /// during coin selection.
+ /// during coin selection. We'll retry via RBF at our preferred feerate.
FeeRateTooLow { target_feerate: FeeRate, min_feerate: FeeRate },
/// The counterparty's proposed feerate is above `max_feerate` and the re-estimated fee for
/// our contributed inputs and outputs exceeds the original fee estimate (computed at
@@ -68,7 +68,12 @@ impl core::fmt::Display for FeeRateAdjustmentError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
FeeRateAdjustmentError::FeeRateTooLow { target_feerate, min_feerate } => {
- write!(f, "Target feerate {} is below our minimum {}", target_feerate, min_feerate)
+ write!(
+ f,
+ "Target feerate {} is below our minimum {}; \
+ proceeding without contribution, will RBF later",
+ target_feerate, min_feerate,
+ )
},
FeeRateAdjustmentError::FeeRateTooHigh {
target_feerate,
@@ -83,12 +88,17 @@ impl core::fmt::Display for FeeRateAdjustmentError {
)
},
FeeRateAdjustmentError::FeeBufferOverflow => {
- write!(f, "Arithmetic overflow when computing available fee buffer")
+ write!(
+ f,
+ "Arithmetic overflow when computing available fee buffer; \
+ proceeding without contribution",
+ )
},
FeeRateAdjustmentError::FeeBufferInsufficient { source, available, required } => {
write!(
f,
- "Fee buffer {} ({}) is insufficient for required fee {}",
+ "Fee buffer {} ({}) is insufficient for required fee {}; \
+ proceeding without contribution",
available, source, required,
)
},
diff --git a/lightning/src/ln/interactivetxs.rs b/lightning/src/ln/interactivetxs.rs
index 5a9964a..3636761 100644
--- a/lightning/src/ln/interactivetxs.rs
+++ b/lightning/src/ln/interactivetxs.rs
@@ -141,6 +141,8 @@ pub(crate) enum AbortReason {
InsufficientRbfFeerate,
/// A funding negotiation is already in progress.
NegotiationInProgress,
+ /// The initiator's feerate exceeds our maximum.
+ FeeRateTooHigh,
/// Internal error
InternalError(&'static str),
}
@@ -204,6 +206,9 @@ impl Display for AbortReason {
AbortReason::NegotiationInProgress => {
f.write_str("A funding negotiation is already in progress")
},
+ AbortReason::FeeRateTooHigh => {
+ f.write_str("The initiator's feerate exceeds our maximum")
+ },
AbortReason::InternalError(text) => {
f.write_fmt(format_args!("Internal error: {}", text))
},
diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs
index bf689ae..bdfe146 100644
--- a/lightning/src/ln/splicing_tests.rs
+++ b/lightning/src/ln/splicing_tests.rs
@@ -1782,6 +1782,78 @@ fn do_test_splice_tiebreak(
}
}
+#[test]
+fn test_splice_tiebreak_feerate_too_high_rejected() {
+ // Node 0 (winner) proposes a feerate far above node 1's (loser) max_feerate, and node 1's
+ // fair fee at that feerate exceeds its budget. This triggers FeeRateAdjustmentError::TooHigh,
+ // causing node 1 to reject with tx_abort.
+ let chanmon_cfgs = create_chanmon_cfgs(2);
+ let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
+ let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
+ let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
+
+ let node_id_0 = nodes[0].node.get_our_node_id();
+ let node_id_1 = nodes[1].node.get_our_node_id();
+
+ let initial_channel_value_sat = 100_000;
+ let (_, _, channel_id, _) =
+ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0);
+
+ provide_utxo_reserves(&nodes, 2, Amount::from_sat(100_000));
+
+ // Node 0 uses an extremely high feerate (100,000 sat/kwu). Node 1 uses the floor feerate
+ // with a moderate splice-in (50,000 sats from a 100,000 sat UTXO) and a low max_feerate
+ // (3,000 sat/kwu). The target (100k) far exceeds node 1's max (3k), and the fair fee at
+ // 100k exceeds node 1's budget, triggering TooHigh.
+ let high_feerate = FeeRate::from_sat_per_kwu(100_000);
+ let floor_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64);
+ let node_0_added_value = Amount::from_sat(50_000);
+ let node_1_added_value = Amount::from_sat(50_000);
+ let node_1_max_feerate = FeeRate::from_sat_per_kwu(3_000);
+
+ // Node 0: very high feerate, moderate splice-in.
+ let funding_template_0 =
+ nodes[0].node.splice_channel(&channel_id, &node_id_1, high_feerate, FeeRate::MAX).unwrap();
+ let wallet_0 = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger);
+ let node_0_funding_contribution =
+ funding_template_0.splice_in_sync(node_0_added_value, &wallet_0).unwrap();
+ nodes[0]
+ .node
+ .funding_contributed(&channel_id, &node_id_1, node_0_funding_contribution.clone(), None)
+ .unwrap();
+
+ // Node 1: floor feerate, moderate splice-in, low max_feerate.
+ let funding_template_1 = nodes[1]
+ .node
+ .splice_channel(&channel_id, &node_id_0, floor_feerate, node_1_max_feerate)
+ .unwrap();
+ let wallet_1 = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger);
+ let node_1_funding_contribution =
+ funding_template_1.splice_in_sync(node_1_added_value, &wallet_1).unwrap();
+ nodes[1]
+ .node
+ .funding_contributed(&channel_id, &node_id_0, node_1_funding_contribution.clone(), None)
+ .unwrap();
+
+ // Both emit STFU.
+ let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1);
+ let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0);
+
+ // Tie-break: node 0 wins.
+ nodes[1].node.handle_stfu(node_id_0, &stfu_0);
+ assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty());
+ nodes[0].node.handle_stfu(node_id_1, &stfu_1);
+
+ // Node 0 sends SpliceInit at 100,000 sat/kwu.
+ let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1);
+
+ // Node 1 handles SpliceInit — TooHigh: target (100k) >> max (3k) and fair fee > budget.
+ nodes[1].node.handle_splice_init(node_id_0, &splice_init);
+
+ let tx_abort = get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0);
+ assert_eq!(tx_abort.channel_id, channel_id);
+}
+
#[cfg(test)]
#[derive(PartialEq)]
enum SpliceStatus {
@@ -4944,6 +5016,86 @@ pub fn do_test_splice_rbf_tiebreak(
}
}
+#[test]
+fn test_splice_rbf_tiebreak_feerate_too_high_rejected() {
+ // Node 0 (winner) proposes an RBF feerate far above node 1's (loser) max_feerate, and
+ // node 1's fair fee at that feerate exceeds its budget. This triggers
+ // FeeRateAdjustmentError::TooHigh in the queued contribution path, causing node 1 to
+ // reject with tx_abort.
+ let chanmon_cfgs = create_chanmon_cfgs(2);
+ let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
+ let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
+ let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
+
+ let node_id_0 = nodes[0].node.get_our_node_id();
+ let node_id_1 = nodes[1].node.get_our_node_id();
+
+ let initial_channel_value_sat = 100_000;
+ let (_, _, channel_id, _) =
+ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0);
+
+ let added_value = Amount::from_sat(50_000);
+ provide_utxo_reserves(&nodes, 2, added_value * 2);
+
+ // Complete an initial splice-in from node 0.
+ let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value);
+ let (_first_splice_tx, _new_funding_script) =
+ splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution);
+
+ // Provide more UTXOs for both nodes' RBF attempts.
+ provide_utxo_reserves(&nodes, 2, added_value * 2);
+
+ // Node 0 uses an extremely high feerate (100,000 sat/kwu). Node 1 uses the minimum RBF
+ // feerate with a moderate splice-in (50,000 sats) and a low max_feerate (3,000 sat/kwu).
+ // The target (100k) far exceeds node 1's max (3k), and the fair fee at 100k exceeds
+ // node 1's budget, triggering TooHigh.
+ let high_feerate = FeeRate::from_sat_per_kwu(100_000);
+ let min_rbf_feerate_sat_per_kwu = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24);
+ let min_rbf_feerate = FeeRate::from_sat_per_kwu(min_rbf_feerate_sat_per_kwu);
+ let node_1_max_feerate = FeeRate::from_sat_per_kwu(3_000);
+
+ let funding_template_0 =
+ nodes[0].node.rbf_channel(&channel_id, &node_id_1, high_feerate, FeeRate::MAX).unwrap();
+ let wallet_0 = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger);
+ let node_0_funding_contribution =
+ funding_template_0.splice_in_sync(added_value, &wallet_0).unwrap();
+ nodes[0]
+ .node
+ .funding_contributed(&channel_id, &node_id_1, node_0_funding_contribution.clone(), None)
+ .unwrap();
+
+ let funding_template_1 = nodes[1]
+ .node
+ .rbf_channel(&channel_id, &node_id_0, min_rbf_feerate, node_1_max_feerate)
+ .unwrap();
+ let wallet_1 = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger);
+ let node_1_funding_contribution =
+ funding_template_1.splice_in_sync(added_value, &wallet_1).unwrap();
+ nodes[1]
+ .node
+ .funding_contributed(&channel_id, &node_id_0, node_1_funding_contribution.clone(), None)
+ .unwrap();
+
+ // Both sent STFU.
+ let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1);
+ let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0);
+
+ // Tie-break: node 0 wins.
+ nodes[1].node.handle_stfu(node_id_0, &stfu_0);
+ assert!(nodes[1].node.get_and_clear_pending_msg_events().is_empty());
+ nodes[0].node.handle_stfu(node_id_1, &stfu_1);
+
+ // Node 0 sends tx_init_rbf at 100,000 sat/kwu.
+ let tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1);
+ assert_eq!(tx_init_rbf.feerate_sat_per_1000_weight, high_feerate.to_sat_per_kwu() as u32);
+
+ // Node 1 handles tx_init_rbf — TooHigh: target (100k) >> max (3k) and fair fee > budget.
+ nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf);
+
+ let tx_abort = get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0);
+ assert_eq!(tx_abort.channel_id, channel_id);
+}
+
#[test]
fn test_splice_rbf_acceptor_recontributes() {
// When the counterparty RBFs a splice and we have no pending QuiescentAction,
Why this scored 44/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.