Skip to content

Commit 145c7b8

Browse files
jkczyzclaude
andcommitted
Reject RBF with non-confirming feerate after several attempts
After a few RBF attempts, both our own and the counterparty's RBF should target a feerate that will actually confirm. Reject attempts with feerates below the fee estimator's NonAnchorChannelFee target to prevent exhausting the RBF budget at low feerates. The spec requires: "MUST set a high enough feerate to ensure quick confirmation." Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3a25324 commit 145c7b8

3 files changed

Lines changed: 196 additions & 3 deletions

File tree

lightning/src/ln/channel.rs

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3100,6 +3100,22 @@ impl PendingFunding {
31003100
}
31013101
}
31023102

3103+
/// After several RBF attempts, checks that the feerate is high enough to confirm. Returns
3104+
/// `true` if the feerate is sufficient or the threshold hasn't been reached.
3105+
///
3106+
/// The spec requires: "MUST set a high enough feerate to ensure quick confirmation."
3107+
fn is_rbf_feerate_sufficient<F: FeeEstimator>(
3108+
&self, feerate_sat_per_kw: u32, fee_estimator: &LowerBoundedFeeEstimator<F>,
3109+
) -> bool {
3110+
const MAX_LOW_FEERATE_RBF_ATTEMPTS: usize = 3;
3111+
if self.negotiated_candidates.len() <= MAX_LOW_FEERATE_RBF_ATTEMPTS {
3112+
return true;
3113+
}
3114+
let min_feerate =
3115+
fee_estimator.bounded_sat_per_1000_weight(ConfirmationTarget::NonAnchorChannelFee);
3116+
feerate_sat_per_kw >= min_feerate
3117+
}
3118+
31033119
fn contributed_inputs(&self) -> impl Iterator<Item = bitcoin::OutPoint> + '_ {
31043120
self.contributions.iter().flat_map(|c| c.contributed_inputs())
31053121
}
@@ -12153,8 +12169,9 @@ where
1215312169
.expect("feerate compatibility already checked")
1215412170
}
1215512171

12156-
pub fn funding_contributed<L: Logger>(
12157-
&mut self, contribution: FundingContribution, locktime: LockTime, logger: &L,
12172+
pub fn funding_contributed<F: FeeEstimator, L: Logger>(
12173+
&mut self, contribution: FundingContribution, locktime: LockTime,
12174+
fee_estimator: &LowerBoundedFeeEstimator<F>, logger: &L,
1215812175
) -> Result<Option<msgs::Stfu>, QuiescentError> {
1215912176
debug_assert!(contribution.is_splice());
1216012177

@@ -12229,6 +12246,23 @@ where
1222912246
return Err(QuiescentError::FailSplice(self.splice_funding_failed_for(contribution)));
1223012247
}
1223112248

12249+
if let Some(pending_splice) = self.pending_splice.as_ref() {
12250+
if !pending_splice.is_rbf_feerate_sufficient(
12251+
contribution.feerate().to_sat_per_kwu() as u32,
12252+
fee_estimator,
12253+
) {
12254+
log_error!(
12255+
logger,
12256+
"Channel {} RBF feerate {} below fee estimator minimum",
12257+
self.context.channel_id(),
12258+
contribution.feerate(),
12259+
);
12260+
return Err(QuiescentError::FailSplice(
12261+
self.splice_funding_failed_for(contribution),
12262+
));
12263+
}
12264+
}
12265+
1223212266
// If a pending splice exists with negotiated candidates, attempt to adjust the
1223312267
// contribution's feerate to the minimum RBF feerate so it can proceed as an RBF immediately
1223412268
// rather than waiting for the splice to lock.
@@ -12682,6 +12716,10 @@ where
1268212716
return Err(ChannelError::Abort(AbortReason::InsufficientRbfFeerate));
1268312717
}
1268412718

12719+
if !pending_splice.is_rbf_feerate_sufficient(new_feerate, fee_estimator) {
12720+
return Err(ChannelError::Abort(AbortReason::InsufficientRbfFeerate));
12721+
}
12722+
1268512723
let their_funding_contribution = match msg.funding_output_contribution {
1268612724
Some(value) => SignedAmount::from_sat(value),
1268712725
None => SignedAmount::ZERO,

lightning/src/ln/channelmanager.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6633,7 +6633,12 @@ impl<
66336633
locktime.unwrap_or_else(|| self.current_best_block().height),
66346634
);
66356635
let logger = WithChannelContext::from(&self.logger, chan.context(), None);
6636-
match chan.funding_contributed(contribution, locktime, &&logger) {
6636+
match chan.funding_contributed(
6637+
contribution,
6638+
locktime,
6639+
&self.fee_estimator,
6640+
&&logger,
6641+
) {
66376642
Ok(msg_opt) => {
66386643
if let Some(msg) = msg_opt {
66396644
peer_state.pending_msg_events.push(

lightning/src/ln/splicing_tests.rs

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6304,3 +6304,153 @@ fn test_splice_revalidation_at_quiescence() {
63046304

63056305
expect_splice_failed_events(&nodes[0], &channel_id, contribution);
63066306
}
6307+
6308+
#[test]
6309+
fn test_splice_rbf_rejects_low_feerate_after_several_attempts() {
6310+
// After several RBF attempts, the counterparty's RBF feerate must be high enough to
6311+
// confirm (per the fee estimator). Early attempts at low feerates are accepted, but
6312+
// once the threshold is crossed and the fee estimator expects a higher feerate, the
6313+
// attempt is rejected.
6314+
let chanmon_cfgs = create_chanmon_cfgs(2);
6315+
let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
6316+
let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
6317+
let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
6318+
6319+
let node_id_0 = nodes[0].node.get_our_node_id();
6320+
let node_id_1 = nodes[1].node.get_our_node_id();
6321+
6322+
let initial_channel_value_sat = 100_000;
6323+
let (_, _, channel_id, _) =
6324+
create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0);
6325+
6326+
let added_value = Amount::from_sat(50_000);
6327+
provide_utxo_reserves(&nodes, 2, added_value * 2);
6328+
6329+
// Round 0: Initial splice-in at floor feerate (253).
6330+
let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value);
6331+
let (_, new_funding_script) =
6332+
splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution);
6333+
6334+
// Feerate progression: 253 → 264 → 275 → 287 → 300
6335+
let feerate_1 = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24);
6336+
let feerate_2 = (feerate_1 * 25).div_ceil(24);
6337+
let feerate_3 = (feerate_2 * 25).div_ceil(24);
6338+
let feerate_4 = (feerate_3 * 25).div_ceil(24);
6339+
6340+
// Rounds 1-3: RBF at minimum bump. Accepted (at or below threshold).
6341+
for feerate in [feerate_1, feerate_2, feerate_3] {
6342+
provide_utxo_reserves(&nodes, 2, added_value * 2);
6343+
let rbf_feerate = FeeRate::from_sat_per_kwu(feerate);
6344+
let contribution =
6345+
do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate);
6346+
complete_rbf_handshake(&nodes[0], &nodes[1]);
6347+
complete_interactive_funding_negotiation(
6348+
&nodes[0],
6349+
&nodes[1],
6350+
channel_id,
6351+
contribution,
6352+
new_funding_script.clone(),
6353+
);
6354+
let (_, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false);
6355+
assert!(splice_locked.is_none());
6356+
expect_splice_pending_event(&nodes[0], &node_id_1);
6357+
expect_splice_pending_event(&nodes[1], &node_id_0);
6358+
}
6359+
6360+
// Now 4 negotiated candidates (round 0 + rounds 1-3). Bump the fee estimator on node 1
6361+
// (the RBF receiver) so the next minimum RBF feerate (300) is below it.
6362+
let high_feerate = 1000;
6363+
*chanmon_cfgs[1].fee_estimator.sat_per_kw.lock().unwrap() = high_feerate;
6364+
6365+
// Round 4: RBF at minimum bump (300). Should be rejected because 300 < 1000.
6366+
provide_utxo_reserves(&nodes, 2, added_value * 2);
6367+
let rbf_feerate_3 = FeeRate::from_sat_per_kwu(feerate_4);
6368+
let _contribution =
6369+
do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate_3);
6370+
let stfu_0 = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1);
6371+
nodes[1].node.handle_stfu(node_id_0, &stfu_0);
6372+
let stfu_1 = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0);
6373+
nodes[0].node.handle_stfu(node_id_1, &stfu_1);
6374+
6375+
// Node 0 sends tx_init_rbf. Node 1 rejects the low feerate after the threshold.
6376+
let tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1);
6377+
nodes[1].node.handle_tx_init_rbf(node_id_0, &tx_init_rbf);
6378+
get_event_msg!(nodes[1], MessageSendEvent::SendTxAbort, node_id_0);
6379+
}
6380+
6381+
#[test]
6382+
fn test_splice_rbf_rejects_own_low_feerate_after_several_attempts() {
6383+
// Same as test_splice_rbf_rejects_low_feerate_after_several_attempts, but for our own
6384+
// initiated RBF. The spec requires: "MUST set a high enough feerate to ensure quick
6385+
// confirmation." After several attempts, funding_contributed should reject our contribution
6386+
// if the feerate is below the fee estimator's target.
6387+
let chanmon_cfgs = create_chanmon_cfgs(2);
6388+
let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
6389+
let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
6390+
let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
6391+
6392+
let node_id_0 = nodes[0].node.get_our_node_id();
6393+
let node_id_1 = nodes[1].node.get_our_node_id();
6394+
6395+
let initial_channel_value_sat = 100_000;
6396+
let (_, _, channel_id, _) =
6397+
create_announced_chan_between_nodes_with_value(&nodes, 0, 1, initial_channel_value_sat, 0);
6398+
6399+
let added_value = Amount::from_sat(50_000);
6400+
provide_utxo_reserves(&nodes, 2, added_value * 2);
6401+
6402+
// Round 0: Initial splice-in at floor feerate (253).
6403+
let funding_contribution = do_initiate_splice_in(&nodes[0], &nodes[1], channel_id, added_value);
6404+
let (_, new_funding_script) =
6405+
splice_channel(&nodes[0], &nodes[1], channel_id, funding_contribution);
6406+
6407+
// Feerate progression: 253 → 264 → 275 → 287 → 300
6408+
let feerate_1 = (FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24);
6409+
let feerate_2 = (feerate_1 * 25).div_ceil(24);
6410+
let feerate_3 = (feerate_2 * 25).div_ceil(24);
6411+
let feerate_4 = (feerate_3 * 25).div_ceil(24);
6412+
6413+
// Rounds 1-3: RBF at minimum bump. Accepted.
6414+
for feerate in [feerate_1, feerate_2, feerate_3] {
6415+
provide_utxo_reserves(&nodes, 2, added_value * 2);
6416+
let rbf_feerate = FeeRate::from_sat_per_kwu(feerate);
6417+
let contribution =
6418+
do_initiate_rbf_splice_in(&nodes[0], &nodes[1], channel_id, added_value, rbf_feerate);
6419+
complete_rbf_handshake(&nodes[0], &nodes[1]);
6420+
complete_interactive_funding_negotiation(
6421+
&nodes[0],
6422+
&nodes[1],
6423+
channel_id,
6424+
contribution,
6425+
new_funding_script.clone(),
6426+
);
6427+
let (_, splice_locked) = sign_interactive_funding_tx(&nodes[0], &nodes[1], false);
6428+
assert!(splice_locked.is_none());
6429+
expect_splice_pending_event(&nodes[0], &node_id_1);
6430+
expect_splice_pending_event(&nodes[1], &node_id_0);
6431+
}
6432+
6433+
// Bump node 0's fee estimator so the next minimum RBF feerate (300) is below it.
6434+
let high_feerate = 1000;
6435+
*chanmon_cfgs[0].fee_estimator.sat_per_kw.lock().unwrap() = high_feerate;
6436+
6437+
// Round 4: Our own RBF at minimum bump (300). funding_contributed should reject it.
6438+
provide_utxo_reserves(&nodes, 2, added_value * 2);
6439+
let rbf_feerate = FeeRate::from_sat_per_kwu(feerate_4);
6440+
let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap();
6441+
let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger);
6442+
let contribution =
6443+
funding_template.splice_in_sync(added_value, rbf_feerate, FeeRate::MAX, &wallet).unwrap();
6444+
6445+
let result = nodes[0].node.funding_contributed(&channel_id, &node_id_1, contribution, None);
6446+
assert!(result.is_err(), "Expected rejection for low feerate: {:?}", result);
6447+
6448+
// SpliceFailed is emitted. DiscardFunding is not emitted because all inputs/outputs
6449+
// are filtered out (same UTXOs reused for RBF, still committed to the prior splice tx).
6450+
let events = nodes[0].node.get_and_clear_pending_events();
6451+
assert_eq!(events.len(), 1, "{events:?}");
6452+
match &events[0] {
6453+
Event::SpliceFailed { channel_id: cid, .. } => assert_eq!(*cid, channel_id),
6454+
other => panic!("Expected SpliceFailed, got {:?}", other),
6455+
}
6456+
}

0 commit comments

Comments
 (0)