Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@
transaction.

## Bug Fixes and Improvements
- `Node::bump_channel_funding_fee` can now fee-bump a splice made with
`Node::splice_in_with_all` without spending the anchor reserve or failing for lack of a change
output. The splice now spends the wallet's confirmed UTXOs in full, so a bump is paid by adding
less to the channel, and any required anchor reserve is kept as a fixed output back to the
wallet. A bump past the splice's maximum feerate rebuilds it from all confirmed funds,
including coins confirmed since. (#1168)
- Building a fresh node against a Bitcoin Core RPC or REST chain source that fails to return the
current chain tip now aborts with a new `BuildError::ChainTipFetchFailed` variant instead of
silently pinning the wallet birthday to genesis, which would have forced a full-history rescan
Expand Down
271 changes: 192 additions & 79 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,19 +153,22 @@ use io::utils::update_and_persist_node_metrics;
pub use lightning;
use lightning::chain::BlockLocator;
use lightning::impl_writeable_tlv_based;
use lightning::ln::chan_utils::FUNDING_TRANSACTION_WITNESS_WEIGHT;
use lightning::ln::channel_state::ChannelDetails as LdkChannelDetails;
pub use lightning::ln::channel_state::{
ChannelShutdownState, InboundHTLCDetails, InboundHTLCStateDetails, OutboundHTLCDetails,
OutboundHTLCStateDetails,
};
use lightning::ln::channelmanager::PaymentId;
use lightning::ln::funding::{
FundingBuilder, FundingContribution, FundingContributionError, FundingTemplate,
};
use lightning::ln::msgs::{BaseMessageHandler, SocketAddress};
use lightning::ln::peer_handler::CustomMessageHandler;
use lightning::routing::gossip::NodeAlias;

use lightning::sign::EntropySource;
use lightning::util::persist::KVStore;
use lightning::util::wallet_utils::{Input, Wallet as LdkWallet};
use lightning::util::wallet_utils::{ConfirmedUtxo, Wallet as LdkWallet};
use lightning_background_processor::process_events_async;
pub use lightning_invoice;
pub use lightning_liquidity;
Expand Down Expand Up @@ -207,7 +210,7 @@ use crate::config::{LIQUIDITY_DISCOVERY_RETRY_INITIAL_DELAY, LIQUIDITY_DISCOVERY
use crate::ffi::{maybe_deref, maybe_map_fee_rate_opt, maybe_wrap, FfiFeeRate};
use crate::liquidity::Liquidity;
use crate::scoring::setup_background_pathfinding_scores_sync;
use crate::wallet::FundingAmount;
use crate::wallet::{FundingAmount, DUST_LIMIT_SATS};

#[cfg(not(feature = "uniffi"))]
type NodeFeatures = LdkNodeFeatures;
Expand Down Expand Up @@ -1817,61 +1820,9 @@ impl Node {
});
let max_feerate = max_funding_feerate(min_feerate);

let splice_amount_sats = match splice_amount_sats {
FundingAmount::Exact { amount_sats } => amount_sats,
FundingAmount::Max => {
let cur_anchor_reserve_sats =
total_anchor_channels_reserve_sats(&self.channel_manager, &self.config);

const EMPTY_SCRIPT_SIG_WEIGHT: u64 =
1 /* empty script_sig */ * bitcoin::constants::WITNESS_SCALE_FACTOR as u64;

let funding_txo = channel_details.funding_txo.ok_or_else(|| {
log_error!(self.logger, "Failed to splice channel: channel not yet ready",);
Error::ChannelSplicingFailed
})?;

let funding_output = channel_details.get_funding_output().ok_or_else(|| {
log_error!(self.logger, "Failed to splice channel: channel not yet ready");
Error::ChannelSplicingFailed
})?;

let shared_input = Input {
outpoint: funding_txo.into_bitcoin_outpoint(),
previous_utxo: funding_output.clone(),
satisfaction_weight: EMPTY_SCRIPT_SIG_WEIGHT
+ FUNDING_TRANSACTION_WITNESS_WEIGHT,
};

let amount = self
.wallet
.get_max_splice_in_amount(
shared_input,
funding_output.script_pubkey.clone(),
cur_anchor_reserve_sats,
min_feerate,
)
.map_err(|e| {
log_error!(
self.logger,
"Failed to determine max splice-in amount: {e:?}"
);
e
})?;

log_info!(
self.logger,
"Splicing in with all balance: {}sats (fee rate: {} sat/kw, anchor reserve: {}sats)",
amount,
min_feerate.to_sat_per_kwu(),
cur_anchor_reserve_sats,
);

amount
},
};

self.check_sufficient_onchain_funds(splice_amount_sats, &counterparty_node_id, false)?;
if let FundingAmount::Exact { amount_sats } = splice_amount_sats {
self.check_sufficient_onchain_funds(amount_sats, &counterparty_node_id, false)?;
}

let funding_template = self
.channel_manager
Expand Down Expand Up @@ -1899,18 +1850,26 @@ impl Node {
_ => min_feerate,
};

let contribution = self
.runtime
.block_on(funding_template.splice_in(
Amount::from_sat(splice_amount_sats),
let contribution = match splice_amount_sats {
FundingAmount::Exact { amount_sats } => self
.runtime
.block_on(funding_template.splice_in(
Amount::from_sat(amount_sats),
feerate,
max_feerate,
Arc::clone(&self.wallet),
))
.map_err(|e| {
log_error!(self.logger, "Failed to splice channel: {}", e);
Error::ChannelSplicingFailed
})?,
FundingAmount::Max => self.splice_in_with_all_contribution(
funding_template,
Vec::new(),
feerate,
max_feerate,
Arc::clone(&self.wallet),
))
.map_err(|e| {
log_error!(self.logger, "Failed to splice channel: {}", e);
Error::ChannelSplicingFailed
})?;
)?,
};

self.channel_manager
.funding_contributed(
Expand All @@ -1934,6 +1893,136 @@ impl Node {
}
}

/// Builds a contribution splicing in all confirmed on-chain funds, consumed in full, with any
/// anchor reserve retained as an output to one of our own addresses. `pending_inputs` are the
/// inputs of a pending splice round to spend as well, which the wallet may already count as
/// spent by that round's transaction. The contribution replaces any prior one of the template.
fn splice_in_with_all_contribution(
&self, funding_template: FundingTemplate, pending_inputs: Vec<ConfirmedUtxo>,
feerate: bitcoin::FeeRate, max_feerate: bitcoin::FeeRate,
) -> Result<FundingContribution, Error> {
let cur_anchor_reserve_sats =
total_anchor_channels_reserve_sats(&self.channel_manager, &self.config);
// A reserve below the dust limit cannot be kept as an output of its own, so keep the dust
// limit instead.
let reserve_output_sats = if cur_anchor_reserve_sats == 0 {
0
} else {
cur_anchor_reserve_sats.max(DUST_LIMIT_SATS)
};

// Splicing in all funds spends every confirmed UTXO in full, so the inputs are chosen here
// rather than by coin selection against an amount. The fee is only known once the
// contribution is built, so first check that the inputs can at least retain the anchor
// reserve and still add a non-dust amount, rather than asking LDK to build a contribution
// from nothing.
let mut inputs = pending_inputs;
for input in self.wallet.list_confirmed_funding_inputs().map_err(|e| {
log_error!(self.logger, "Failed to list confirmed UTXOs: {e:?}");
Error::ChannelSplicingFailed
})? {
if !inputs.iter().any(|pending| pending.outpoint() == input.outpoint()) {
inputs.push(input);
}
}
let input_total_sats: u64 = inputs.iter().map(|input| input.output().value.to_sat()).sum();
if input_total_sats.saturating_sub(reserve_output_sats) < DUST_LIMIT_SATS {
log_error!(
self.logger,
"Unable to splice-in due to insufficient funds. Confirmed: {}sats, anchor reserve: {}sats",
input_total_sats,
reserve_output_sats,
);
return Err(Error::InsufficientFunds);
}

// Manually selected inputs are consumed in full with no change output: the amount added to
// the channel is what remains after fees, and a later fee bump lowers that amount instead of
// needing further inputs. The anchor reserve is retained as an explicit output to one of our
// own addresses, which stays fixed across fee bumps. The address is reserved so that other
// change-address consumers skip it until the splice transaction reaches the wallet.
let reserve_output = if reserve_output_sats > 0 {
let script_pubkey =
self.runtime.block_on(self.wallet.reserve_internal_address())?.script_pubkey();
Some(bitcoin::TxOut { value: Amount::from_sat(reserve_output_sats), script_pubkey })
} else {
None
};

let builder = funding_template.without_prior_contribution(feerate, max_feerate);
let built = self.build_splice_in_with_all_contribution(
builder,
inputs,
reserve_output.clone(),
feerate,
);
match built {
Ok(contribution) => Ok(contribution),
Err(e) => {
// LDK never took the contribution, so no `DiscardFunding` event will free the
// reserved address; release it here instead.
if let Some(reserve_output) = reserve_output {
if let Err(e) =
self.runtime.block_on(self.wallet.release_outputs(vec![reserve_output]))
{
log_error!(
self.logger,
"Failed to release the anchor reserve address: {e:?}"
);
}
}
Err(e)
},
}
}

/// Builds the contribution for splicing in all on-chain funds: `inputs` are consumed in full
/// and `reserve_output`, if any, keeps the anchor reserve on-chain. Fails with
/// [`Error::InsufficientFunds`] when the inputs cannot cover the reserve and the fee, or when
/// fees would leave less than dust to add to the channel. `feerate` is the builder's, for
/// logging.
fn build_splice_in_with_all_contribution(
&self, builder: FundingBuilder, inputs: Vec<ConfirmedUtxo>,
reserve_output: Option<bitcoin::TxOut>, feerate: bitcoin::FeeRate,
) -> Result<FundingContribution, Error> {
let cur_anchor_reserve_sats =
reserve_output.as_ref().map_or(0, |output| output.value.to_sat());

let contribution = builder
.add_inputs(inputs)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since a bump now keeps these manually selected inputs, LDK no longer falls back to coin selection (it returns ManuallySelectedInputsInsufficient instead of MissingCoinSelectionSource). So bumping above the prior contribution's max feerate (1.5x) now fails, even if the wallet has received new confirmed coins since the splice. Before, the bump could use those new coins. Is that OK?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm... good point.

Claude:

The check is LDK's: the builder adjusts the prior contribution against its old max_feerate before storing the new one, so adding inputs to the prior-seeded builder fails the same way (filed as https://git.rust-bitcoin.org/lightningdevkit/rust-lightning/issues/5282). Added a commit so bump_channel_funding_fee handles it: when rbf_prior_contribution returns ManuallySelectedInputsInsufficient, the bump is rebuilt via without_prior_contribution from all confirmed funds, the pending round's inputs plus any coins confirmed since, with the reserve paid to a fresh reserved address. splice_in_with_all_bump_fee_above_max_feerate_adds_new_coins covers it. Once LDK applies the caller's maximum, a bump past the ceiling without new coins is adjusted in place again; the rebuild stays for pulling in new coins.

.and_then(|builder| builder.add_outputs(reserve_output.into_iter().collect()).build())
.map_err(|e| {
log_error!(self.logger, "Failed to splice channel: {}", e);
match e {
FundingContributionError::ManuallySelectedInputsInsufficient => {
Error::InsufficientFunds
},
_ => Error::ChannelSplicingFailed,
}
})?;

let value_added_sats = contribution.value_added().to_sat();
if value_added_sats < DUST_LIMIT_SATS {
log_error!(
self.logger,
"Unable to splice-in: available funds would be consumed entirely by fees. Remaining: {}sats, fee: {}sats",
value_added_sats,
contribution.estimated_fee().to_sat(),
);
return Err(Error::InsufficientFunds);
}

log_info!(
self.logger,
"Splicing in with all balance: {}sats (fee rate: {} sat/kw, anchor reserve: {}sats)",
value_added_sats,
feerate.to_sat_per_kwu(),
cur_anchor_reserve_sats,
);

Ok(contribution)
}

/// Add funds to an existing channel from a transaction output you control.
///
/// This provides for increasing a channel's outbound liquidity without re-balancing or closing
Expand Down Expand Up @@ -1981,7 +2070,10 @@ impl Node {
/// pending splice that requires a higher feerate to be replaced, or when the counterparty
/// initiates a splice concurrently and our contribution is added to theirs.
///
/// The amount spliced in is determined at `min_fee_rate`, or at the estimate if unset.
/// The amount spliced in is what the confirmed on-chain funds leave after the anchor reserve
/// and the transaction fee at the feerate used: `min_fee_rate`, or the estimate if unset, or a
/// pending splice's RBF minimum if that is higher. A later [`Node::bump_channel_funding_fee`]
/// pays its higher fee from the same funds and so reduces the amount spliced in.
///
/// # Experimental API
///
Expand Down Expand Up @@ -2109,6 +2201,11 @@ impl Node {
/// (RBF). The splice's amount and destination are preserved; only the fee rate is raised.
/// Errors if the channel has no pending splice to bump.
///
/// A splice of all on-chain funds (see [`Node::splice_in_with_all`]) has no amount to preserve:
/// the bump is paid by adding less to the channel, or, if the pending splice's maximum feerate
/// does not allow that, the splice is rebuilt from all confirmed funds, including any confirmed
/// since.
///
/// If `min_fee_rate` is set, it will be used as the minimum feerate for the replacement instead
/// of a feerate estimate retrieved from the configured chain source. As with the estimate, we
/// pay the feerate required to replace the pending splice if it is higher, but no more than 1.5
Expand Down Expand Up @@ -2155,17 +2252,33 @@ impl Node {
return Err(Error::ChannelSplicingFailed);
};

let contribution = self
.runtime
.block_on(funding_template.rbf_prior_contribution(
Some(target_feerate),
max_feerate,
Arc::clone(&self.wallet),
))
.map_err(|e| {
let rbf_template = funding_template.clone();
let contribution = match self.runtime.block_on(rbf_template.rbf_prior_contribution(
Some(target_feerate),
max_feerate,
Arc::clone(&self.wallet),
)) {
Ok(contribution) => contribution,
// The pending splice spends manually selected inputs, as a splice-in of all funds
// does. LDK adjusts such a contribution only up to its maximum feerate and never
// supplements it by coin selection, so replace it with one built the same way
// from all confirmed funds: its inputs and any coins confirmed since.
Err(FundingContributionError::ManuallySelectedInputsInsufficient) => {
let pending_inputs = funding_template
.prior_contribution()
.map_or(Vec::new(), |contribution| contribution.inputs().to_vec());
self.splice_in_with_all_contribution(
funding_template,
pending_inputs,
target_feerate,
max_feerate,
)?
},
Err(e) => {
log_error!(self.logger, "Failed to RBF channel: {}", e);
Error::ChannelSplicingFailed
})?;
return Err(Error::ChannelSplicingFailed);
},
};

self.channel_manager
.funding_contributed(
Expand Down
Loading
Loading