Adjust contribution feerate to minimum RBF feerate in funding_contributed
What changed, and why it matters
This commit fixes a protocol edge case in Lightning splicing. If a user prepared a splice contribution at a low fee rate, and meanwhile the other party completed their own splice, the user's contribution might no longer satisfy Bitcoin's RBF (Replace-By-Fee) minimum-bump rule. The patch makes the node automatically raise its contribution's fee rate when the budget allows, so the splice can proceed immediately as an RBF instead of stalling or falling back to a fresh splice. If the budget doesn't allow it, the node gracefully waits instead of sending an invalid message.
Reviewers should verify that the initiator/acceptor fee responsibility split in compute_feerate_adjustment matches the protocol spec, confirm no regression in the acceptor path, and consider whether logging is sufficient for operators to understand when a splice is delayed versus adjusted.
Security signals we found
Fixes a protocol compliance issue where a splice contribution could be below the minimum RBF feerate (25/24 rule)
Prevents sending STFU/TxInitRBF with a feerate that would violate RBF requirements
Adds graceful fallback to fresh splice when adjustment is impossible
Refactors fee estimation to correctly assign common/shared transaction weight to the initiator
Adds unit and integration tests covering adjustment, max-feerate rejection, and insufficient budget
Evidence from the diff
In rust-lightning’s splice/RBF flow, funding_contributed now calls can_initiate_rbf() and, when a pending splice with negotiated candidates exists, attempts to adjust the initiator’s FundingContribution feerate up to the minimum RBF feerate (25/24 of the prior feerate). A new maybe_adjust_for_rbf helper checks holder balance, calls net_value_for_initiator_at_feerate, and uses a new for_initiator_at_feerate method. Funding.rs was refactored so compute_feerate_adjustment and net_value_at_feerate take an is_initiator flag, correctly accounting for common/shared transaction weight when adjusting as initiator. If adjustment fails (max_feerate too low or insufficient fee buffer), the original contribution is retained and try_send_stfu delays STFU until the pending splice locks, then proceeds as a fresh splice. Tests verify adjustment success, max-feerate rejection, and insufficient-budget rejection.
Changed components
lightning/src/ln/channel.rslightning/src/ln/funding.rslightning/src/ln/splicing_tests.rsInspect captured patch +377 / −30
diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index 6f23aa7..3224710 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -12077,6 +12077,47 @@ where
Ok(min_rbf_feerate)
}
+ /// Attempts to adjust the contribution's feerate to the minimum RBF feerate so the splice can
+ /// proceed as an RBF immediately rather than waiting for the pending splice to lock.
+ /// Returns the adjusted contribution on success, or the original on failure.
+ fn maybe_adjust_for_rbf<L: Logger>(
+ &self, contribution: FundingContribution, min_rbf_feerate: FeeRate, logger: &L,
+ ) -> FundingContribution {
+ if contribution.feerate() >= min_rbf_feerate {
+ return contribution;
+ }
+
+ let holder_balance = match self
+ .get_holder_counterparty_balances_floor_incl_fee(&self.funding)
+ .map(|(holder, _)| holder)
+ {
+ Ok(balance) => balance,
+ Err(_) => return contribution,
+ };
+
+ if let Err(e) =
+ contribution.net_value_for_initiator_at_feerate(min_rbf_feerate, holder_balance)
+ {
+ log_info!(
+ logger,
+ "Cannot adjust to minimum RBF feerate {}: {}; will proceed as fresh splice after lock",
+ min_rbf_feerate,
+ e,
+ );
+ return contribution;
+ }
+
+ log_info!(
+ logger,
+ "Adjusting contribution feerate from {} to minimum RBF feerate {}",
+ contribution.feerate(),
+ min_rbf_feerate,
+ );
+ contribution
+ .for_initiator_at_feerate(min_rbf_feerate, holder_balance)
+ .expect("feerate compatibility already checked")
+ }
+
pub fn funding_contributed<L: Logger>(
&mut self, contribution: FundingContribution, locktime: LockTime, logger: &L,
) -> Result<Option<msgs::Stfu>, QuiescentError> {
@@ -12161,6 +12202,15 @@ where
}));
}
+ // 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.
+ let contribution = if let Ok(Some(min_rbf_feerate)) = self.can_initiate_rbf() {
+ self.maybe_adjust_for_rbf(contribution, min_rbf_feerate, logger)
+ } else {
+ contribution
+ };
+
self.propose_quiescence(logger, QuiescentAction::Splice { contribution, locktime })
}
@@ -13758,13 +13808,26 @@ where
#[allow(irrefutable_let_patterns)]
if let QuiescentAction::Splice { contribution, .. } = action {
if self.pending_splice.is_some() {
- if let Err(msg) = self.can_initiate_rbf() {
- log_given_level!(
- logger,
- logger_level,
- "Waiting on sending stfu for splice RBF: {msg}"
- );
- return None;
+ match self.can_initiate_rbf() {
+ Err(msg) => {
+ log_given_level!(
+ logger,
+ logger_level,
+ "Waiting on sending stfu for splice RBF: {msg}"
+ );
+ return None;
+ },
+ Ok(Some(min_rbf_feerate)) if contribution.feerate() < min_rbf_feerate => {
+ log_given_level!(
+ logger,
+ logger_level,
+ "Waiting for splice to lock: feerate {} below minimum RBF feerate {}",
+ contribution.feerate(),
+ min_rbf_feerate,
+ );
+ return None;
+ },
+ _ => {},
}
}
}
diff --git a/lightning/src/ln/funding.rs b/lightning/src/ln/funding.rs
index 52aabe5..acad13c 100644
--- a/lightning/src/ln/funding.rs
+++ b/lightning/src/ln/funding.rs
@@ -545,8 +545,12 @@ impl FundingContribution {
Ok(())
}
- /// Computes the adjusted fee and change output value for the acceptor at the initiator's
- /// proposed feerate, which may differ from the feerate used during coin selection.
+ /// Computes the adjusted fee and change output value at the given target feerate, which may
+ /// differ from the feerate used during coin selection.
+ ///
+ /// The `is_initiator` parameter determines fee responsibility: the initiator pays for common
+ /// transaction fields, the shared input, and the shared output, while the acceptor only pays
+ /// for their own contributed inputs and outputs.
///
/// On success, returns the new estimated fee and, if applicable, the new change output value:
/// - `Some(change)` — the adjusted change output value
@@ -554,7 +558,7 @@ impl FundingContribution {
///
/// Returns `Err` if the contribution cannot accommodate the target feerate.
fn compute_feerate_adjustment(
- &self, target_feerate: FeeRate, holder_balance: Amount,
+ &self, target_feerate: FeeRate, holder_balance: Amount, is_initiator: bool,
) -> Result<(Amount, Option<Amount>), FeeRateAdjustmentError> {
if target_feerate < self.feerate {
return Err(FeeRateAdjustmentError::FeeRateTooLow {
@@ -564,14 +568,15 @@ impl FundingContribution {
}
// If the target fee rate exceeds our max fee rate, we may still add our contribution
- // if we pay less in fees. This may happen because the acceptor doesn't pay for common
- // fields and the shared input / output.
+ // if we pay less in fees at the target feerate than at the original feerate. This can
+ // happen when adjusting as acceptor, since the acceptor doesn't pay for common fields
+ // and the shared input / output.
if target_feerate > self.max_feerate {
let target_fee = estimate_transaction_fee(
&self.inputs,
&self.outputs,
self.change_output.as_ref(),
- false,
+ is_initiator,
self.is_splice,
target_feerate,
);
@@ -595,7 +600,7 @@ impl FundingContribution {
&self.inputs,
&self.outputs,
self.change_output.as_ref(),
- false,
+ is_initiator,
self.is_splice,
target_feerate,
);
@@ -615,7 +620,7 @@ impl FundingContribution {
&self.inputs,
&self.outputs,
None,
- false,
+ is_initiator,
self.is_splice,
target_feerate,
);
@@ -636,7 +641,7 @@ impl FundingContribution {
&self.inputs,
&self.outputs,
None,
- false,
+ is_initiator,
self.is_splice,
target_feerate,
);
@@ -666,7 +671,7 @@ impl FundingContribution {
&[],
&self.outputs,
None,
- false,
+ is_initiator,
self.is_splice,
target_feerate,
);
@@ -688,17 +693,14 @@ impl FundingContribution {
}
}
- /// Adjusts the contribution's change output for the initiator's feerate.
- ///
- /// When the acceptor has a pending contribution (from the quiescence tie-breaker scenario),
- /// the initiator's proposed feerate may differ from the feerate used during coin selection.
- /// This adjusts the change output so the acceptor pays their target fee at the target
- /// feerate.
- pub(super) fn for_acceptor_at_feerate(
- mut self, feerate: FeeRate, holder_balance: Amount,
+ /// Adjusts the contribution for a different feerate, updating the change output, fee
+ /// estimate, and feerate. Returns the adjusted contribution, or an error if the feerate
+ /// can't be accommodated.
+ fn at_feerate(
+ mut self, feerate: FeeRate, holder_balance: Amount, is_initiator: bool,
) -> Result<Self, FeeRateAdjustmentError> {
let (new_estimated_fee, new_change) =
- self.compute_feerate_adjustment(feerate, holder_balance)?;
+ self.compute_feerate_adjustment(feerate, holder_balance, is_initiator)?;
let surplus = self.fee_buffer_surplus(new_estimated_fee, &new_change);
match new_change {
Some(value) => self.change_output.as_mut().unwrap().value = value,
@@ -710,16 +712,39 @@ impl FundingContribution {
Ok(self)
}
+ /// Adjusts the contribution's change output for the initiator's feerate.
+ ///
+ /// When the acceptor has a pending contribution (from the quiescence tie-breaker scenario),
+ /// the initiator's proposed feerate may differ from the feerate used during coin selection.
+ /// This adjusts the change output so the acceptor pays their target fee at the target
+ /// feerate.
+ pub(super) fn for_acceptor_at_feerate(
+ self, feerate: FeeRate, holder_balance: Amount,
+ ) -> Result<Self, FeeRateAdjustmentError> {
+ self.at_feerate(feerate, holder_balance, false)
+ }
+
+ /// Adjusts the contribution's change output for the minimum RBF feerate.
+ ///
+ /// When a pending splice exists with negotiated candidates and the contribution's feerate
+ /// is below the minimum RBF feerate (25/24 of the previous feerate), this adjusts the change output
+ /// so the initiator pays fees at the minimum RBF feerate.
+ pub(super) fn for_initiator_at_feerate(
+ self, feerate: FeeRate, holder_balance: Amount,
+ ) -> Result<Self, FeeRateAdjustmentError> {
+ self.at_feerate(feerate, holder_balance, true)
+ }
+
/// Returns the net value at the given target feerate without mutating `self`.
///
/// This serves double duty: it checks feerate compatibility (returning `Err` if the feerate
/// can't be accommodated) and computes the adjusted net value (returning `Ok` with the value
/// accounting for the target feerate).
- pub(super) fn net_value_for_acceptor_at_feerate(
- &self, target_feerate: FeeRate, holder_balance: Amount,
+ fn net_value_at_feerate(
+ &self, target_feerate: FeeRate, holder_balance: Amount, is_initiator: bool,
) -> Result<SignedAmount, FeeRateAdjustmentError> {
let (new_estimated_fee, new_change) =
- self.compute_feerate_adjustment(target_feerate, holder_balance)?;
+ self.compute_feerate_adjustment(target_feerate, holder_balance, is_initiator)?;
let surplus = self
.fee_buffer_surplus(new_estimated_fee, &new_change)
.to_signed()
@@ -731,6 +756,22 @@ impl FundingContribution {
Ok(net_value)
}
+ /// Returns the net value at the given target feerate without mutating `self`,
+ /// assuming acceptor fee responsibility.
+ pub(super) fn net_value_for_acceptor_at_feerate(
+ &self, target_feerate: FeeRate, holder_balance: Amount,
+ ) -> Result<SignedAmount, FeeRateAdjustmentError> {
+ self.net_value_at_feerate(target_feerate, holder_balance, false)
+ }
+
+ /// Returns the net value at the given target feerate without mutating `self`,
+ /// assuming initiator fee responsibility.
+ pub(super) fn net_value_for_initiator_at_feerate(
+ &self, target_feerate: FeeRate, holder_balance: Amount,
+ ) -> Result<SignedAmount, FeeRateAdjustmentError> {
+ self.net_value_at_feerate(target_feerate, holder_balance, true)
+ }
+
/// Returns the fee buffer surplus when a change output is removed.
///
/// The fee buffer is the actual amount available for fees from inputs: total input value
@@ -1867,4 +1908,43 @@ mod tests {
let result = contribution.net_value_for_acceptor_at_feerate(target_feerate, holder_balance);
assert!(matches!(result, Err(FeeRateAdjustmentError::FeeBufferInsufficient { .. })));
}
+
+ #[test]
+ fn test_for_initiator_at_feerate_higher_fee_than_acceptor() {
+ // Verify that the initiator fee estimate is higher than the acceptor estimate at the
+ // same feerate, since the initiator pays for common fields + shared input/output.
+ let original_feerate = FeeRate::from_sat_per_kwu(2000);
+ let target_feerate = FeeRate::from_sat_per_kwu(3000);
+ let inputs = vec![funding_input_sats(100_000)];
+ let change = funding_output_sats(10_000);
+
+ let estimated_fee =
+ estimate_transaction_fee(&inputs, &[], Some(&change), true, true, original_feerate);
+
+ let contribution = FundingContribution {
+ value_added: Amount::from_sat(50_000),
+ estimated_fee,
+ inputs,
+ outputs: vec![],
+ change_output: Some(change),
+ feerate: original_feerate,
+ max_feerate: FeeRate::MAX,
+ is_splice: true,
+ };
+
+ let acceptor =
+ contribution.clone().for_acceptor_at_feerate(target_feerate, Amount::MAX).unwrap();
+ let initiator = contribution.for_initiator_at_feerate(target_feerate, Amount::MAX).unwrap();
+
+ // Initiator pays more in fees (common fields + shared input/output weight).
+ assert!(initiator.estimated_fee > acceptor.estimated_fee);
+ // Initiator has less change remaining.
+ assert!(
+ initiator.change_output.as_ref().unwrap().value
+ < acceptor.change_output.as_ref().unwrap().value
+ );
+ // Both have the adjusted feerate.
+ assert_eq!(initiator.feerate, target_feerate);
+ assert_eq!(acceptor.feerate, target_feerate);
+ }
}
diff --git a/lightning/src/ln/splicing_tests.rs b/lightning/src/ln/splicing_tests.rs
index fbc2a81..07f2abe 100644
--- a/lightning/src/ln/splicing_tests.rs
+++ b/lightning/src/ln/splicing_tests.rs
@@ -746,8 +746,16 @@ pub fn lock_splice<'a, 'b, 'c, 'd>(
check_added_monitors(node, 1);
}
+ let mut node_a_stfu = None;
if !is_0conf {
let mut msg_events = node_a.node.get_and_clear_pending_msg_events();
+
+ // If node_a had a pending QuiescentAction, filter out the stfu message.
+ node_a_stfu = msg_events
+ .iter()
+ .position(|event| matches!(event, MessageSendEvent::SendStfu { .. }))
+ .map(|i| msg_events.remove(i));
+
assert_eq!(msg_events.len(), 2, "{msg_events:?}");
if let MessageSendEvent::SendAnnouncementSignatures { msg, .. } = msg_events.remove(0) {
node_b.node.handle_announcement_signatures(node_id_a, &msg);
@@ -776,7 +784,7 @@ pub fn lock_splice<'a, 'b, 'c, 'd>(
}
}
- node_b_stfu
+ node_a_stfu.or(node_b_stfu)
}
pub fn lock_rbf_splice_after_blocks<'a, 'b, 'c, 'd>(
@@ -5655,3 +5663,199 @@ fn test_splice_channel_with_pending_splice_includes_rbf_floor() {
.splice_in_sync(added_value, expected_floor, FeeRate::MAX, &wallet)
.is_ok());
}
+
+#[test]
+fn test_funding_contributed_adjusts_feerate_for_rbf() {
+ // Test that funding_contributed adjusts the contribution's feerate to the minimum RBF feerate when a
+ // pending splice appears between splice_channel and funding_contributed.
+ //
+ // Node 0 calls splice_channel (no pending splice → min_rbf_feerate = None) and builds a
+ // contribution at floor feerate. Node 1 then initiates and completes a splice. When node 0
+ // calls funding_contributed, the contribution is adjusted to the minimum RBF feerate and STFU is sent
+ // immediately.
+ 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, 4, added_value * 2);
+
+ // Node 0 calls splice_channel before any pending splice exists.
+ let floor_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64);
+ let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap();
+ assert!(funding_template.min_rbf_feerate().is_none());
+
+ // Build contribution at floor feerate with high max_feerate to allow adjustment.
+ let wallet = WalletSync::new(Arc::clone(&nodes[0].wallet_source), nodes[0].logger);
+ let contribution =
+ funding_template.splice_in_sync(added_value, floor_feerate, FeeRate::MAX, &wallet).unwrap();
+
+ // Node 1 initiates and completes a splice, creating pending_splice with negotiated candidates.
+ let node_1_contribution = do_initiate_splice_in(&nodes[1], &nodes[0], channel_id, added_value);
+ let (_first_splice_tx, _new_funding_script) =
+ splice_channel(&nodes[1], &nodes[0], channel_id, node_1_contribution);
+
+ // Node 0 calls funding_contributed. The contribution's feerate (floor) is below the RBF
+ // floor (25/24 of floor), but funding_contributed adjusts it upward.
+ nodes[0].node.funding_contributed(&channel_id, &node_id_1, contribution.clone(), None).unwrap();
+
+ // STFU should be sent immediately (the adjusted feerate satisfies the RBF check).
+ let stfu = get_event_msg!(nodes[0], MessageSendEvent::SendStfu, node_id_1);
+ nodes[1].node.handle_stfu(node_id_0, &stfu);
+ let stfu_resp = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0);
+ nodes[0].node.handle_stfu(node_id_1, &stfu_resp);
+
+ // Verify the RBF handshake proceeds.
+ let tx_init_rbf = get_event_msg!(nodes[0], MessageSendEvent::SendTxInitRbf, node_id_1);
+ let rbf_feerate = FeeRate::from_sat_per_kwu(tx_init_rbf.feerate_sat_per_1000_weight as u64);
+ let expected_floor =
+ FeeRate::from_sat_per_kwu((FEERATE_FLOOR_SATS_PER_KW as u64 * 25).div_ceil(24));
+ assert!(rbf_feerate >= expected_floor);
+}
+
+#[test]
+fn test_funding_contributed_rbf_adjustment_exceeds_max_feerate() {
+ // Test that when the minimum RBF feerate exceeds max_feerate, the adjustment in funding_contributed
+ // fails gracefully and the contribution keeps its original feerate. The splice still
+ // proceeds (STFU is sent) and the RBF negotiation handles the feerate mismatch.
+ 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, 4, added_value * 2);
+
+ // Node 0 calls splice_channel and builds contribution with max_feerate = floor_feerate.
+ // This means the minimum RBF feerate (25/24 of floor) will exceed max_feerate, preventing adjustment.
+ let floor_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64);
+ 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, floor_feerate, floor_feerate, &wallet)
+ .unwrap();
+
+ // Node 1 initiates and completes a splice.
+ let node_1_contribution = do_initiate_splice_in(&nodes[1], &nodes[0], channel_id, added_value);
+ let (_splice_tx, _) = splice_channel(&nodes[1], &nodes[0], channel_id, node_1_contribution);
+
+ // Node 0 calls funding_contributed. The adjustment fails (minimum RBF feerate > max_feerate), but
+ // funding_contributed still succeeds — the contribution keeps its original feerate.
+ nodes[0].node.funding_contributed(&channel_id, &node_id_1, contribution, None).unwrap();
+
+ // STFU is NOT sent — the feerate is below the minimum RBF feerate so try_send_stfu delays.
+ assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty());
+
+ // Mine and lock the pending splice → pending_splice is cleared.
+ mine_transaction(&nodes[0], &_splice_tx);
+ mine_transaction(&nodes[1], &_splice_tx);
+ let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1);
+
+ // STFU is sent during lock — the splice proceeds as a fresh splice (not RBF).
+ let stfu = match stfu {
+ Some(MessageSendEvent::SendStfu { msg, .. }) => {
+ assert!(msg.initiator);
+ msg
+ },
+ other => panic!("Expected SendStfu, got {:?}", other),
+ };
+
+ // Complete the fresh splice and verify it uses the original floor feerate.
+ nodes[1].node.handle_stfu(node_id_0, &stfu);
+ let stfu_resp = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0);
+ nodes[0].node.handle_stfu(node_id_1, &stfu_resp);
+
+ let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1);
+ assert_eq!(splice_init.funding_feerate_per_kw, FEERATE_FLOOR_SATS_PER_KW);
+}
+
+#[test]
+fn test_funding_contributed_rbf_adjustment_insufficient_budget() {
+ // Test that when the change output can't absorb the fee increase needed for the minimum RBF feerate
+ // (even though max_feerate allows it), the adjustment fails gracefully and the splice
+ // proceeds with the original feerate.
+ 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, 4, added_value * 2);
+
+ // Node 0 calls splice_channel before any pending splice exists.
+ let floor_feerate = FeeRate::from_sat_per_kwu(FEERATE_FLOOR_SATS_PER_KW as u64);
+ let funding_template = nodes[0].node.splice_channel(&channel_id, &node_id_1).unwrap();
+
+ // Build node 0's contribution at floor feerate with a tight budget.
+ let wallet = TightBudgetWallet {
+ utxo_value: added_value + Amount::from_sat(3000),
+ change_value: Amount::from_sat(300),
+ };
+ let contribution =
+ funding_template.splice_in_sync(added_value, floor_feerate, FeeRate::MAX, &wallet).unwrap();
+
+ // Node 1 initiates a splice at a HIGH feerate (10,000 sat/kwu). The minimum RBF feerate will be
+ // 25/24 of 10,000 = 10,417 sat/kwu — far above what node 0's tight budget can handle.
+ let high_feerate = FeeRate::from_sat_per_kwu(10_000);
+ let node_1_template = nodes[1].node.splice_channel(&channel_id, &node_id_0).unwrap();
+ let node_1_wallet = WalletSync::new(Arc::clone(&nodes[1].wallet_source), nodes[1].logger);
+ let node_1_contribution = node_1_template
+ .splice_in_sync(added_value, high_feerate, FeeRate::MAX, &node_1_wallet)
+ .unwrap();
+ nodes[1]
+ .node
+ .funding_contributed(&channel_id, &node_id_0, node_1_contribution.clone(), None)
+ .unwrap();
+ let (_splice_tx, _) = splice_channel(&nodes[1], &nodes[0], channel_id, node_1_contribution);
+
+ // Node 0 calls funding_contributed. Adjustment fails (insufficient fee buffer), so the
+ // contribution keeps its original feerate.
+ nodes[0].node.funding_contributed(&channel_id, &node_id_1, contribution, None).unwrap();
+
+ // STFU is NOT sent — the feerate is below the minimum RBF feerate so try_send_stfu delays.
+ assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty());
+
+ // Mine and lock the pending splice → pending_splice is cleared.
+ mine_transaction(&nodes[0], &_splice_tx);
+ mine_transaction(&nodes[1], &_splice_tx);
+ let stfu = lock_splice_after_blocks(&nodes[0], &nodes[1], ANTI_REORG_DELAY - 1);
+
+ // STFU is sent during lock — the splice proceeds as a fresh splice (not RBF).
+ let stfu = match stfu {
+ Some(MessageSendEvent::SendStfu { msg, .. }) => {
+ assert!(msg.initiator);
+ msg
+ },
+ other => panic!("Expected SendStfu, got {:?}", other),
+ };
+
+ // Complete the fresh splice and verify it uses the original floor feerate.
+ nodes[1].node.handle_stfu(node_id_0, &stfu);
+ let stfu_resp = get_event_msg!(nodes[1], MessageSendEvent::SendStfu, node_id_0);
+ nodes[0].node.handle_stfu(node_id_1, &stfu_resp);
+
+ let splice_init = get_event_msg!(nodes[0], MessageSendEvent::SendSpliceInit, node_id_1);
+ assert_eq!(splice_init.funding_feerate_per_kw, FEERATE_FLOOR_SATS_PER_KW);
+}
Why this scored 34/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.