Skip to content

Commit 3b296f2

Browse files
authored
Merge pull request #888 from jkczyz/2026-04-splicing-payment-using-tx-type
Add splice RBF support
2 parents ad4a3a8 + 5d21bd7 commit 3b296f2

11 files changed

Lines changed: 1375 additions & 69 deletions

File tree

bindings/ldk_node.udl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,8 @@ interface Node {
124124
[Throws=NodeError]
125125
void splice_out([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id, [ByRef]Address address, u64 splice_amount_sats);
126126
[Throws=NodeError]
127+
void bump_channel_funding_fee([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id);
128+
[Throws=NodeError]
127129
void close_channel([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id);
128130
[Throws=NodeError]
129131
void force_close_channel([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id, string? reason);

src/builder.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1618,6 +1618,8 @@ fn build_with_store_internal(
16181618
Arc::clone(&pending_payment_store),
16191619
));
16201620

1621+
tx_broadcaster.set_wallet(Arc::downgrade(&wallet));
1622+
16211623
// Initialize the KeysManager
16221624
let cur_time = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).map_err(|e| {
16231625
log_error!(logger, "Failed to get current time: {}", e);

src/chain/mod.rs

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use std::collections::{HashMap, HashSet};
1313
use std::sync::{Arc, Mutex};
1414
use std::time::Duration;
1515

16-
use bitcoin::{Script, Txid};
16+
use bitcoin::{Script, Transaction, Txid};
1717
use lightning::chain::{BlockLocator, Filter};
1818

1919
use crate::chain::bitcoind::{BitcoindChainSource, UtxoSourceClient};
@@ -24,7 +24,7 @@ use crate::config::{
2424
WALLET_SYNC_INTERVAL_MINIMUM_SECS,
2525
};
2626
use crate::fee_estimator::OnchainFeeEstimator;
27-
use crate::logger::{log_debug, log_info, log_trace, LdkLogger, Logger};
27+
use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger};
2828
use crate::runtime::Runtime;
2929
use crate::types::{Broadcaster, ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet};
3030
use crate::{Error, PersistedNodeMetrics};
@@ -453,15 +453,30 @@ impl ChainSource {
453453
return;
454454
}
455455
Some(next_package) = receiver.recv() => {
456+
// Classify funding broadcasts into payment records before sending. If
457+
// classification fails we skip the broadcast, since broadcasting a tx we
458+
// failed to record would leave it on-chain without a payment.
459+
let package = match self.tx_broadcaster.classify_package(next_package).await {
460+
Ok(package) => package,
461+
Err(e) => {
462+
log_error!(
463+
tx_bcast_logger,
464+
"Skipping broadcast: failed to persist payment records: {:?}",
465+
e,
466+
);
467+
continue;
468+
},
469+
};
470+
let txs: Vec<Transaction> = package.into_transactions();
456471
match &self.kind {
457472
ChainSourceKind::Esplora(esplora_chain_source) => {
458-
esplora_chain_source.process_broadcast_package(next_package).await
473+
esplora_chain_source.process_broadcast_package(txs).await
459474
},
460475
ChainSourceKind::Electrum(electrum_chain_source) => {
461-
electrum_chain_source.process_broadcast_package(next_package).await
476+
electrum_chain_source.process_broadcast_package(txs).await
462477
},
463478
ChainSourceKind::Bitcoind(bitcoind_chain_source) => {
464-
bitcoind_chain_source.process_broadcast_package(next_package).await
479+
bitcoind_chain_source.process_broadcast_package(txs).await
465480
},
466481
}
467482
}

src/fee_estimator.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,3 +164,42 @@ pub(crate) fn apply_post_estimation_adjustments(
164164
_ => estimated_rate,
165165
}
166166
}
167+
168+
/// The most we are willing to pay for a channel funding transaction: `1.5x` our funding feerate
169+
/// estimate. Used as the `max_feerate` ceiling for splices and their RBF fee bumps.
170+
pub(crate) fn max_funding_feerate(estimate: FeeRate) -> FeeRate {
171+
FeeRate::from_sat_per_kwu(estimate.to_sat_per_kwu() * 3 / 2)
172+
}
173+
174+
/// Picks the `(target, max)` feerates for replacing a pending splice's in-flight funding
175+
/// transaction via RBF, or `None` if the RBF can't be done within our fee ceiling.
176+
///
177+
/// `max` is the most we are willing to pay (see [`max_funding_feerate`]), which tracks our current
178+
/// estimate and so may have risen or fallen since the original splice; it is never inflated to meet
179+
/// the RBF minimum. `target` is what we actually pay — our current estimate, or the template's RBF
180+
/// minimum if that is higher (required to replace the transaction). If that minimum exceeds `max`,
181+
/// we can't RBF.
182+
pub(crate) fn rbf_splice_feerates(
183+
estimate: FeeRate, min_rbf_feerate: FeeRate,
184+
) -> Option<(FeeRate, FeeRate)> {
185+
let max = max_funding_feerate(estimate);
186+
let target = estimate.max(min_rbf_feerate);
187+
(target <= max).then_some((target, max))
188+
}
189+
190+
#[cfg(test)]
191+
mod tests {
192+
use super::*;
193+
194+
#[test]
195+
fn rbf_splice_feerates_target_and_max() {
196+
let kwu = FeeRate::from_sat_per_kwu;
197+
// Estimate below the RBF minimum but within our ceiling: pay the minimum to replace the
198+
// transaction; the max stays 1.5x the estimate (never inflated) and already clears it.
199+
assert_eq!(rbf_splice_feerates(kwu(253), kwu(278)), Some((kwu(278), kwu(253 * 3 / 2))));
200+
// Estimate risen above the RBF minimum: pay the higher estimate, not the stale minimum.
201+
assert_eq!(rbf_splice_feerates(kwu(500), kwu(278)), Some((kwu(500), kwu(500 * 3 / 2))));
202+
// RBF minimum above our max (1.5x a fallen estimate): we can't RBF within our ceiling.
203+
assert_eq!(rbf_splice_feerates(kwu(100), kwu(278)), None);
204+
}
205+
}

src/lib.rs

Lines changed: 100 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,6 @@ pub use bitcoin;
119119
use bitcoin::secp256k1::PublicKey;
120120
#[cfg(feature = "uniffi")]
121121
pub use bitcoin::FeeRate;
122-
#[cfg(not(feature = "uniffi"))]
123-
use bitcoin::FeeRate;
124122
use bitcoin::{Address, Amount, BlockHash, Network};
125123
#[cfg(feature = "uniffi")]
126124
pub use builder::ArcedNodeBuilder as Builder;
@@ -138,7 +136,9 @@ pub use error::Error as NodeError;
138136
use error::Error;
139137
pub use event::Event;
140138
use event::{EventHandler, EventQueue};
141-
use fee_estimator::{ConfirmationTarget, FeeEstimator, OnchainFeeEstimator};
139+
use fee_estimator::{
140+
max_funding_feerate, rbf_splice_feerates, ConfirmationTarget, FeeEstimator, OnchainFeeEstimator,
141+
};
142142
#[cfg(feature = "uniffi")]
143143
use ffi::*;
144144
use gossip::GossipSource;
@@ -1584,7 +1584,7 @@ impl Node {
15841584
{
15851585
let min_feerate =
15861586
self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding);
1587-
let max_feerate = FeeRate::from_sat_per_kwu(min_feerate.to_sat_per_kwu() * 3 / 2);
1587+
let max_feerate = max_funding_feerate(min_feerate);
15881588

15891589
let splice_amount_sats = match splice_amount_sats {
15901590
FundingAmount::Exact { amount_sats } => amount_sats,
@@ -1653,16 +1653,26 @@ impl Node {
16531653
if funding_template.prior_contribution().is_some() {
16541654
log_error!(
16551655
self.logger,
1656-
"Failed to splice channel: a prior splice contribution is pending"
1656+
"Failed to splice channel: a prior splice contribution is pending; use bump_channel_funding_fee to bump its fee"
16571657
);
16581658
return Err(Error::ChannelSplicingFailed);
16591659
}
16601660

1661+
// When contributing to a pending splice, the funding template requires at least the RBF
1662+
// minimum feerate to replace the in-flight transaction. Use it in place of our funding
1663+
// feerate estimate when it's higher, as long as it stays within our max.
1664+
let feerate = match funding_template.min_rbf_feerate() {
1665+
Some(min_rbf_feerate) if min_rbf_feerate <= max_feerate => {
1666+
min_feerate.max(min_rbf_feerate)
1667+
},
1668+
_ => min_feerate,
1669+
};
1670+
16611671
let contribution = self
16621672
.runtime
16631673
.block_on(funding_template.splice_in(
16641674
Amount::from_sat(splice_amount_sats),
1665-
min_feerate,
1675+
feerate,
16661676
max_feerate,
16671677
Arc::clone(&self.wallet),
16681678
))
@@ -1763,7 +1773,7 @@ impl Node {
17631773

17641774
let min_feerate =
17651775
self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding);
1766-
let max_feerate = FeeRate::from_sat_per_kwu(min_feerate.to_sat_per_kwu() * 3 / 2);
1776+
let max_feerate = max_funding_feerate(min_feerate);
17671777

17681778
let funding_template = self
17691779
.channel_manager
@@ -1776,17 +1786,27 @@ impl Node {
17761786
if funding_template.prior_contribution().is_some() {
17771787
log_error!(
17781788
self.logger,
1779-
"Failed to splice channel: a prior splice contribution is pending"
1789+
"Failed to splice channel: a prior splice contribution is pending; use bump_channel_funding_fee to bump its fee"
17801790
);
17811791
return Err(Error::ChannelSplicingFailed);
17821792
}
17831793

1794+
// When contributing to a pending splice, the funding template requires at least the RBF
1795+
// minimum feerate to replace the in-flight transaction. Use it in place of our funding
1796+
// feerate estimate when it's higher, as long as it stays within our max.
1797+
let feerate = match funding_template.min_rbf_feerate() {
1798+
Some(min_rbf_feerate) if min_rbf_feerate <= max_feerate => {
1799+
min_feerate.max(min_rbf_feerate)
1800+
},
1801+
_ => min_feerate,
1802+
};
1803+
17841804
let outputs = vec![bitcoin::TxOut {
17851805
value: Amount::from_sat(splice_amount_sats),
17861806
script_pubkey: address.script_pubkey(),
17871807
}];
17881808
let contribution =
1789-
funding_template.splice_out(outputs, min_feerate, max_feerate).map_err(|e| {
1809+
funding_template.splice_out(outputs, feerate, max_feerate).map_err(|e| {
17901810
log_error!(self.logger, "Failed to splice channel: {}", e);
17911811
Error::ChannelSplicingFailed
17921812
})?;
@@ -1813,6 +1833,77 @@ impl Node {
18131833
}
18141834
}
18151835

1836+
/// Fee-bumps the pending splice on a channel by replacing its in-flight funding transaction
1837+
/// (RBF). The splice's amount and destination are preserved; only the fee rate is raised.
1838+
/// Errors if the channel has no pending splice to bump.
1839+
pub fn bump_channel_funding_fee(
1840+
&self, user_channel_id: &UserChannelId, counterparty_node_id: PublicKey,
1841+
) -> Result<(), Error> {
1842+
let open_channels =
1843+
self.channel_manager.list_channels_with_counterparty(&counterparty_node_id);
1844+
if let Some(channel_details) =
1845+
open_channels.iter().find(|c| c.user_channel_id == user_channel_id.0)
1846+
{
1847+
let min_feerate =
1848+
self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding);
1849+
1850+
let funding_template = self
1851+
.channel_manager
1852+
.splice_channel(&channel_details.channel_id, &counterparty_node_id)
1853+
.map_err(|e| {
1854+
log_error!(self.logger, "Failed to RBF channel: {:?}", e);
1855+
Error::ChannelSplicingFailed
1856+
})?;
1857+
1858+
let Some(min_rbf_feerate) = funding_template.min_rbf_feerate() else {
1859+
log_error!(self.logger, "Failed to RBF channel: no pending splice to replace");
1860+
return Err(Error::ChannelSplicingFailed);
1861+
};
1862+
1863+
let Some((target_feerate, max_feerate)) =
1864+
rbf_splice_feerates(min_feerate, min_rbf_feerate)
1865+
else {
1866+
log_error!(
1867+
self.logger,
1868+
"Failed to RBF channel: the RBF minimum feerate exceeds our maximum"
1869+
);
1870+
return Err(Error::ChannelSplicingFailed);
1871+
};
1872+
1873+
let contribution = self
1874+
.runtime
1875+
.block_on(funding_template.rbf_prior_contribution(
1876+
Some(target_feerate),
1877+
max_feerate,
1878+
Arc::clone(&self.wallet),
1879+
))
1880+
.map_err(|e| {
1881+
log_error!(self.logger, "Failed to RBF channel: {}", e);
1882+
Error::ChannelSplicingFailed
1883+
})?;
1884+
1885+
self.channel_manager
1886+
.funding_contributed(
1887+
&channel_details.channel_id,
1888+
&counterparty_node_id,
1889+
contribution,
1890+
None,
1891+
)
1892+
.map_err(|e| {
1893+
log_error!(self.logger, "Failed to RBF channel: {:?}", e);
1894+
Error::ChannelSplicingFailed
1895+
})
1896+
} else {
1897+
log_error!(
1898+
self.logger,
1899+
"Channel not found for user_channel_id {} and counterparty {}",
1900+
user_channel_id,
1901+
counterparty_node_id
1902+
);
1903+
Err(Error::ChannelSplicingFailed)
1904+
}
1905+
}
1906+
18161907
/// Manually sync the LDK and BDK wallets with the current chain state and update the fee rate
18171908
/// cache.
18181909
///

src/payment/mod.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,11 @@ pub use bolt11::Bolt11Payment;
2020
pub(crate) use bolt11::PaymentMetadata;
2121
pub use bolt12::Bolt12Payment;
2222
pub use onchain::OnchainPayment;
23+
pub(crate) use pending_payment_store::FundingTxCandidate;
2324
pub(crate) use pending_payment_store::PendingPaymentDetails;
2425
pub use spontaneous::SpontaneousPayment;
2526
pub use store::{
26-
ConfirmationStatus, LSPS2Parameters, PaymentDetails, PaymentDirection, PaymentKind,
27-
PaymentStatus,
27+
Channel, ConfirmationStatus, LSPS2Parameters, PaymentDetails, PaymentDirection, PaymentKind,
28+
PaymentStatus, TransactionType,
2829
};
2930
pub use unified::{UnifiedPayment, UnifiedPaymentResult};

0 commit comments

Comments
 (0)