Reject RBF with non-confirming feerate after several attempts
What changed, and why it matters
This change tightens the rules for fee-bumping (RBF) during Lightning channel splicing. After 10 RBF attempts, any new attempt must use a feerate high enough to actually get the transaction confirmed according to the node's own fee estimator. Before this patch, a user or counterparty could keep submitting slightly higher but still-too-low fees, draining the RBF budget without ever confirming. The patch enforces the BOLT spec requirement to 'set a high enough feerate to ensure quick confirmation.'
Review whether the hard-coded threshold of 10 attempts is appropriate for all network conditions and fee-estimator implementations. Ensure the fee estimator's NonAnchorChannelFee target cannot be manipulated by an attacker (e.g., via local mempool state) to force unnecessary RBF failures. Consider whether the threshold should be configurable or derived from the RBF budget.
Security signals we found
Denial-of-service mitigation: prevents RBF budget exhaustion via repeated low-feerate bumps
Spec-compliance enforcement: BOLT requirement to use a confirming feerate
New validation gate on counterparty RBF messages
New validation gate on local RBF contributions
Hard-coded threshold (10 attempts) before enforcing confirming feerate
Evidence from the diff
A new helper is_rbf_feerate_sufficient is added to PendingFunding. It returns true unconditionally until MAX_LOW_FEERATE_RBF_ATTEMPTS (10) negotiated candidates exist, then requires feerate_sat_per_kw >= fee_estimator.bounded_sat_per_1000_weight(ConfirmationTarget::NonAnchorChannelFee). This check is invoked in two places: (1) Channel::funding_contributed when handling a local splice/RBF contribution, and (2) Channel::handle_tx_init_rbf when processing a counterparty-initiated RBF. If the check fails, the splice/RBF is aborted with QuiescentError::FailSplice or ChannelError::Abort(AbortReason::InsufficientRbfFeerate). Tests cover both local and remote rejection paths.
Changed components
lightning/src/ln/channel.rslightning/src/ln/channelmanager.rslightning/src/ln/splicing_tests.rsInspect captured patch +193 / −3
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index 0882880..3cc6a6b 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -3100,6 +3100,22 @@ impl PendingFunding {
}
}
+ /// After several RBF attempts, checks that the feerate is high enough to confirm. Returns
+ /// `true` if the feerate is sufficient or the threshold hasn't been reached.
+ ///
+ /// The spec requires: "MUST set a high enough feerate to ensure quick confirmation."
+ fn is_rbf_feerate_sufficient<F: FeeEstimator>(
+ &self, feerate_sat_per_kw: u32, fee_estimator: &LowerBoundedFeeEstimator<F>,
+ ) -> bool {
+ const MAX_LOW_FEERATE_RBF_ATTEMPTS: usize = 10;
+ if self.negotiated_candidates.len() <= MAX_LOW_FEERATE_RBF_ATTEMPTS {
+ return true;
+ }
+ let min_feerate =
+ fee_estimator.bounded_sat_per_1000_weight(ConfirmationTarget::NonAnchorChannelFee);
+ feerate_sat_per_kw >= min_feerate
+ }
+
fn contributed_inputs(&self) -> impl Iterator<Item = bitcoin::OutPoint> + '_ {
self.contributions.iter().flat_map(|c| c.contributed_inputs())
}
@@ -12153,8 +12169,9 @@ where
.expect("feerate compatibility already checked")
}
- pub fn funding_contributed<L: Logger>(
- &mut self, contribution: FundingContribution, locktime: LockTime, logger: &L,
+ pub fn funding_contributed<F: FeeEstimator, L: Logger>(
+ &mut self, contribution: FundingContribution, locktime: LockTime,
+ fee_estimator: &LowerBoundedFeeEstimator<F>, logger: &L,
) -> Result<Option<msgs::Stfu>, QuiescentError> {
debug_assert!(contribution.is_splice());
@@ -12229,6 +12246,23 @@ where
return Err(QuiescentError::FailSplice(self.splice_funding_failed_for(contribution)));
}
+ if let Some(pending_splice) = self.pending_splice.as_ref() {
+ if !pending_splice.is_rbf_feerate_sufficient(
+ contribution.feerate().to_sat_per_kwu() as u32,
+ fee_estimator,
+ ) {
+ log_error!(
+ logger,
+ "Channel {} RBF feerate {} below fee estimator minimum",
+ self.context.channel_id(),
+ contribution.feerate(),
+ );
+ return Err(QuiescentError::FailSplice(
+ self.splice_funding_failed_for(contribution),
+ ));
+ }
+ }
+
// If a pending splice exists with negotiated candidates, attempt to adjust the
// contribution's feerate to the minimum RBF feerate so it can proceed as an RBF immediately
// rather than waiting for the splice to lock.
@@ -12682,6 +12716,10 @@ where
return Err(ChannelError::Abort(AbortReason::InsufficientRbfFeerate));
}
+ if !pending_splice.is_rbf_feerate_sufficient(new_feerate, fee_estimator) {
+ return Err(ChannelError::Abort(AbortReason::InsufficientRbfFeerate));
+ }
+
let their_funding_contribution = match msg.funding_output_contribution {
Some(value) => SignedAmount::from_sat(value),
None => SignedAmount::ZERO,
diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs
index f33873c..8356e5f 100644
--- a/lightning/src/ln/channelmanager.rs
+++ b/lightning/src/ln/channelmanager.rs
@@ -6633,7 +6633,12 @@ impl<
locktime.unwrap_or_else(|| self.current_best_block().height),
);
let logger = WithChannelContext::from(&self.logger, chan.context(), None);
- match chan.funding_contributed(contribution, locktime, &&logger) {
+ match chan.funding_contributed(
+ contribution,
+ locktime,
+ &self.fee_estimator,
+ &&logger,
+ ) {
Ok(msg_opt) => {
if let Some(msg) = msg_opt {
peer_state.pending_msg_events.push(
diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs
index 7c169b4..20339e4 100644
--- a/lightning/src/ln/splicing_tests.rs
+++ b/lightning/src/ln/splicing_tests.rs
@@ -6304,3 +6304,150 @@ fn test_splice_revalidation_at_quiescence() {
expect_splice_failed_events(&nodes[0], &channel_id, contribution);
}
+
+#[test]
+fn test_splice_rbf_rejects_low_feerate_after_several_attempts() {
+ // After several RBF attempts, the counterparty's RBF feerate must be high enough to
+ // confirm (per the fee estimator). Early attempts at low feerates are accepted, but
+ // once the threshold is crossed and the fee estimator expects a higher feerate, the
+ // attempt is rejected.
+ 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);
+
+ // Round 0: Initial splice-in at floor feerate (253).
+ let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value);
+ let (_, new_funding_script) =
+ splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution);
+
+ // Bump the fee estimator on node 1 (the RBF receiver) early so the feerate check
+ // would reject once the threshold is crossed.
+ let high_feerate = 10_000;
+ *chanmon_cfgs[1].fee_estimator.sat_per_kw.lock().unwrap() = high_feerate;
+
+ // Rounds 1-10: RBF at minimum bump. Accepted (at or below threshold).
+ let mut prev_feerate = FEERATE_FLOOR_SATS_PER_KW as u64;
+ for _ in 0..10 {
+ let feerate = (prev_feerate * 25).div_ceil(24);
+ provide_utxo_reserves(&nodes, 2, added_value * 2);
+ let rbf_feerate = FeeRate::from_sat_per_kwu(feerate);
+ let contribution =
+ do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate);
+ complete_rbf_handshake(&nodes[0], &nodes[1]);
+ complete_interactive_funding_negotiation(
+ &nodes[0],
+ &nodes[1],
+ channel_id,
+ contribution,
+ new_funding_script.clone(),
+ );
+ let (_, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false);
+ assert!(splice_locked.is_none());
+ expect_splice_pending_event(&nodes[0], &node_id_1);
+ expect_splice_pending_event(&nodes[1], &node_id_0);
+ prev_feerate = feerate;
+ }
+
+ // Round 11: RBF at minimum bump. Should be rejected because feerate < fee estimator.
+ let next_feerate = (prev_feerate * 25).div_ceil(24);
+ provide_utxo_reserves(&nodes, 2, added_value * 2);
+ let rbf_feerate = FeeRate::from_sat_per_kwu(next_feerate);
+ let _contribution =
+ do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate);
+ let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1);
+ nodes[1].node.handle_stfu(node_id_0, &stfu_0);
+ let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0);
+ nodes[0].node.handle_stfu(node_id_1, &stfu_1);
+
+ // Node 0 sends tx_init_rbf. Node 1 rejects the low feerate after the threshold.
+ let tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1);
+ nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf);
+ get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0);
+}
+
+#[test]
+fn test_splice_rbf_rejects_own_low_feerate_after_several_attempts() {
+ // Same as test_splice_rbf_rejects_low_feerate_after_several_attempts, but for our own
+ // initiated RBF. The spec requires: "MUST set a high enough feerate to ensure quick
+ // confirmation." After several attempts, funding_contributed should reject our contribution
+ // if the feerate is below the fee estimator's target.
+ 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);
+
+ // Round 0: Initial splice-in at floor feerate (253).
+ let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value);
+ let (_, new_funding_script) =
+ splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution);
+
+ // Bump node 0's fee estimator early so the feerate check would reject once the
+ // threshold is crossed.
+ let high_feerate = 10_000;
+ *chanmon_cfgs[0].fee_estimator.sat_per_kw.lock().unwrap() = high_feerate;
+
+ // Rounds 1-10: RBF at minimum bump. Accepted (at or below threshold).
+ let mut prev_feerate = FEERATE_FLOOR_SATS_PER_KW as u64;
+ for _ in 0..10 {
+ let feerate = (prev_feerate * 25).div_ceil(24);
+ provide_utxo_reserves(&nodes, 2, added_value * 2);
+ let rbf_feerate = FeeRate::from_sat_per_kwu(feerate);
+ let contribution =
+ do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate);
+ complete_rbf_handshake(&nodes[0], &nodes[1]);
+ complete_interactive_funding_negotiation(
+ &nodes[0],
+ &nodes[1],
+ channel_id,
+ contribution,
+ new_funding_script.clone(),
+ );
+ let (_, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false);
+ assert!(splice_locked.is_none());
+ expect_splice_pending_event(&nodes[0], &node_id_1);
+ expect_splice_pending_event(&nodes[1], &node_id_0);
+ prev_feerate = feerate;
+ }
+
+ // Round 11: Our own RBF at minimum bump. funding_contributed should reject it.
+ let next_feerate = (prev_feerate * 25).div_ceil(24);
+ provide_utxo_reserves(&nodes, 2, added_value * 2);
+ let rbf_feerate = FeeRate::from_sat_per_kwu(next_feerate);
+ let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap();
+ let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger);
+ let contribution =
+ funding_template.splice_in_sync(added_value, rbf_feerate, FeeRate::MAX, &wallet).unwrap();
+
+ let result = nodes[0].node.funding_contributed(&channel_id, &node_id_1, contribution, None);
+ assert!(result.is_err(), "Expected rejection for low feerate: {:?}", result);
+
+ // SpliceFailed is emitted. DiscardFunding is not emitted because all inputs/outputs
+ // are filtered out (same UTXOs reused for RBF, still committed to the prior splice tx).
+ let events = nodes[0].node.get_and_clear_pending_events();
+ assert_eq!(events.len(), 1, "{events:?}");
+ match &events[0] {
+ Event::SpliceFailed { channel_id: cid, .. } => assert_eq!(*cid, channel_id),
+ other => panic!("Expected SpliceFailed, got {:?}", other),
+ }
+}
Why this scored 48/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.