Merged
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
20 changes: 5 additions & 15 deletions fuzz/src/chanmon_consistency.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -1504,7 +1504,6 @@ pub fn do_test<Out: Output + MaybeSend + MaybeSync>(data: &[u8], out: Out) {
counterparty_node_id: &PublicKey,
channel_id: &ChannelId,
wallet: &TestWalletSource,
logger: Arc<dyn Logger + MaybeSend + MaybeSync>,
funding_feerate_sat_per_kw: FeeRate| {
// We conditionally splice out `MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS` only when the node
// has double the balance required to send a payment upon a `0xff` byte. We do this to
Expand All@@ -1524,12 +1523,7 @@ pub fn do_test<Out: Output + MaybeSend + MaybeSync>(data: &[u8], out: Out) {
value: Amount::from_sat(MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS),
script_pubkey: wallet.get_change_script().unwrap(),
}];
funding_template.splice_out_sync(
outputs,
feerate,
FeeRate::MAX,
&WalletSync::new(wallet, logger.clone()),
)
funding_template.splice_out(outputs, feerate, FeeRate::MAX)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Bug: splice_out now has additive output semantics when a prior contribution exists (via with_prior_contribution). On RBF calls, the builder is seeded with the prior's outputs, then add_outputs(outputs) appends the same withdrawal again — doubling the withdrawal amount.

Trace for the RBF case:

  1. Prior contribution has outputs = [TxOut { value: 546, ... }], no inputs (splice-out)
  2. Builder seeds from prior: outputs = [withdrawal]
  3. splice_out calls add_outputs([withdrawal])outputs = [withdrawal, withdrawal]
  4. build()amend_without_coin_selection constructs a splice-out with doubled outputs
  5. compute_feerate_adjustment checks fee + 1092 <= holder_balance — which passes (capacity > 20K sat)
  6. Result: the RBF splice-out silently withdraws 2 × 546 = 1092 sat instead of 546 sat

The old splice_out_sync destructured the template with .. which discarded the prior contribution, so outputs were always treated as absolute. The new splice_out is additive.

Fix: use without_prior_contribution in the closure when a prior exists, or use rbf_prior_contribution_sync for the RBF case. For example:

let has_prior = funding_template.prior_contribution().is_some();if has_prior {
funding_template.rbf_prior_contribution_sync(Some(feerate),FeeRate::MAX, wallet)}else{
funding_template.splice_out(outputs, feerate,FeeRate::MAX)}

(The splice_in closure has the analogous issue — already flagged in a prior review pass.)

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.

@wpaulino Should we address this given the earlier comment?

// We conditionally splice out `MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS` only when the node// has double the balance required to send a payment upon a `0xff` byte. We do this to// ensure there's always liquidity available for a payment to succeed then.

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.

Discussed offline. We'll wait for #4550 to land.

});
};

Expand DownExpand Up@@ -2450,30 +2444,26 @@ pub fn do_test<Out: Output + MaybeSend + MaybeSync>(data: &[u8], out: Out) {
0xa4 => {
let cp_node_id = nodes[1].get_our_node_id();
let wallet = &wallets[0];
let logger = Arc::clone(&loggers[0]);
let feerate_sat_per_kw = fee_estimators[0].feerate_sat_per_kw();
splice_out(&nodes[0], &cp_node_id, &chan_a_id, wallet, logger, feerate_sat_per_kw);
splice_out(&nodes[0], &cp_node_id, &chan_a_id, wallet, feerate_sat_per_kw);
},
0xa5 => {
let cp_node_id = nodes[0].get_our_node_id();
let wallet = &wallets[1];
let logger = Arc::clone(&loggers[1]);
let feerate_sat_per_kw = fee_estimators[1].feerate_sat_per_kw();
splice_out(&nodes[1], &cp_node_id, &chan_a_id, wallet, logger, feerate_sat_per_kw);
splice_out(&nodes[1], &cp_node_id, &chan_a_id, wallet, feerate_sat_per_kw);
},
0xa6 => {
let cp_node_id = nodes[2].get_our_node_id();
let wallet = &wallets[1];
let logger = Arc::clone(&loggers[1]);
let feerate_sat_per_kw = fee_estimators[1].feerate_sat_per_kw();
splice_out(&nodes[1], &cp_node_id, &chan_b_id, wallet, logger, feerate_sat_per_kw);
splice_out(&nodes[1], &cp_node_id, &chan_b_id, wallet, feerate_sat_per_kw);
},
0xa7 => {
let cp_node_id = nodes[1].get_our_node_id();
let wallet = &wallets[2];
let logger = Arc::clone(&loggers[2]);
let feerate_sat_per_kw = fee_estimators[2].feerate_sat_per_kw();
splice_out(&nodes[2], &cp_node_id, &chan_b_id, wallet, logger, feerate_sat_per_kw);
splice_out(&nodes[2], &cp_node_id, &chan_b_id, wallet, feerate_sat_per_kw);
},

// Sync node by 1 block to cover confirmation of a transaction.
Expand Down
28 changes: 12 additions & 16 deletions fuzz/src/full_stack.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -1083,13 +1083,9 @@ pub fn do_test(mut data: &[u8], logger: &Arc<dyn Logger + MaybeSend + MaybeSync>
value: Amount::from_sat(splice_out_sats),
script_pubkey: wallet.get_change_script().unwrap(),
}];
let wallet_sync = WalletSync::new(&wallet, Arc::clone(&logger));
if let Ok(contribution) = funding_template.splice_out_sync(
outputs,
feerate,
FeeRate::MAX,
&wallet_sync,
) {
if let Ok(contribution) =
funding_template.splice_out(outputs, feerate, FeeRate::MAX)
{
let _ = channelmanager.funding_contributed(
&chan_id,
&counterparty,
Expand DownExpand Up@@ -1890,8 +1886,8 @@ fn splice_seed() -> Vec<u8> {
// CommitmentSigned message with proper signature (r=f7, s=01...) and funding_txid TLV
// signature r encodes sighash first byte f7, s follows the pattern from funding_created
// TLV type 1 (odd/optional) for funding_txid as per impl_writeable_msg!(CommitmentSigned, ...)
// Note: txid is encoded in reverse byte order (Bitcoin standard), so to get display 0000...0033, encode 3300...0000
ext_from_hex("0084 c000000000000000000000000000000000000000000000000000000000000000 00000000000000000000000000000000000000000000000000000000000000f7 0100000000000000000000000000000000000000000000000000000000000000 0000 01 20 3300000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);
// Note: txid is encoded in reverse byte order (Bitcoin standard), so to get display 0000...0031, encode 3100...0000
ext_from_hex("0084 c000000000000000000000000000000000000000000000000000000000000000 00000000000000000000000000000000000000000000000000000000000000f7 0100000000000000000000000000000000000000000000000000000000000000 0000 01 20 3100000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);

// After commitment_signed exchange, we need to exchange tx_signatures.
// Message type IDs: TxSignatures = 71 (0x0047)
Expand All@@ -1904,19 +1900,19 @@ fn splice_seed() -> Vec<u8> {
// inbound read from peer id 0 of len 150 (134 message + 16 MAC)
ext_from_hex("030096", &mut test);
// TxSignatures message with shared_input_signature TLV (type 0)
// txid must match the splice funding txid (0x33 in reverse byte order)
// txid must match the splice funding txid (0x31 in reverse byte order)
// shared_input_signature: 64-byte fuzz signature for the shared input
ext_from_hex("0047 c000000000000000000000000000000000000000000000000000000000000000 3300000000000000000000000000000000000000000000000000000000000000 0000 00 40 00000000000000000000000000000000000000000000000000000000000000dc 0100000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);
ext_from_hex("0047 c000000000000000000000000000000000000000000000000000000000000000 3100000000000000000000000000000000000000000000000000000000000000 0000 00 40 00000000000000000000000000000000000000000000000000000000000000dc 0100000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);

// Connect a block with the splice funding transaction to confirm it
// The splice funding tx: version(4) + input_count(1) + txid(32) + vout(4) + script_len(1) + sequence(4)
// + output_count(1) + value(8) + script_len(1) + script(34) + locktime(4) = 94 bytes = 0x5e
// Transaction structure from FundingTransactionReadyForSigning:
// - Input: spending c000...00:0 with sequence 0xfffffffd
// - Output: 115536 sats to OP_0 PUSH32 6e00...00
// - Output: 115538 sats to OP_0 PUSH32 6e00...00
// - Locktime: 13
ext_from_hex("0c005e", &mut test);
ext_from_hex("02000000 01 c000000000000000000000000000000000000000000000000000000000000000 00000000 00 fdffffff 01 50c3010000000000 22 00206e00000000000000000000000000000000000000000000000000000000000000 0d000000", &mut test);
ext_from_hex("02000000 01 c000000000000000000000000000000000000000000000000000000000000000 00000000 00 fdffffff 01 52c3010000000000 22 00206e00000000000000000000000000000000000000000000000000000000000000 0d000000", &mut test);

// Connect additional blocks to reach minimum_depth confirmations
for _ in 0..5 {
Expand All@@ -1933,8 +1929,8 @@ fn splice_seed() -> Vec<u8> {
// inbound read from peer id 0 of len 82 (66 message + 16 MAC)
ext_from_hex("030052", &mut test);
// SpliceLocked message (type 77 = 0x004d): channel_id + splice_txid + mac
// splice_txid must match the splice funding txid (0x33 in reverse byte order)
ext_from_hex("004d c000000000000000000000000000000000000000000000000000000000000000 3300000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);
// splice_txid must match the splice funding txid (0x31 in reverse byte order)
ext_from_hex("004d c000000000000000000000000000000000000000000000000000000000000000 3100000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);

test
}
Expand DownExpand Up@@ -2064,6 +2060,6 @@ mod tests {

// Splice locked
assert_eq!(log_entries.get(&("lightning::ln::peer_handler".to_string(), "Handling SendSpliceLocked event in peer_handler for node 030000000000000000000000000000000000000000000000000000000000000002 for channel c000000000000000000000000000000000000000000000000000000000000000".to_string())), Some(&1));
assert_eq!(log_entries.get(&("lightning::ln::channel".to_string(), "Promoting splice funding txid 0000000000000000000000000000000000000000000000000000000000000033".to_string())), Some(&1));
assert_eq!(log_entries.get(&("lightning::ln::channel".to_string(), "Promoting splice funding txid 0000000000000000000000000000000000000000000000000000000000000031".to_string())), Some(&1));
}
}
57 changes: 28 additions & 29 deletions lightning/src/ln/channel.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -12324,7 +12324,25 @@ where
);
let min_rbf_feerate = prev_feerate.map(min_rbf_feerate);
let prior = if pending_splice.last_funding_feerate_sat_per_1000_weight.is_some() {
self.build_prior_contribution()
if let Some(prior) = self
.pending_splice
.as_ref()
.and_then(|pending_splice| pending_splice.contributions.last())
{
let holder_balance = self
.get_holder_counterparty_balances_floor_incl_fee(&self.funding)
.map(|(h, _)| h)
.map_err(|e| APIError::ChannelUnavailable {
err: format!(
"Channel {} cannot be spliced at this time: {}",
self.context.channel_id(),
e
),
})?;
Comment thread
wpaulino marked this conversation as resolved.
Some(PriorContribution::new(prior.clone(), holder_balance))
} else {
None
}
} else {
None
};
Expand All@@ -12346,21 +12364,6 @@ where
Ok(FundingTemplate::new(Some(shared_input), min_rbf_feerate, prior_contribution))
}

/// Clones the prior contribution and fetches the holder balance for deferred feerate
/// adjustment.
fn build_prior_contribution(&self) -> Option<PriorContribution> {
debug_assert!(
self.pending_splice.is_some(),
"build_prior_contribution requires pending_splice"
);
let prior = self.pending_splice.as_ref()?.contributions.last()?;
let holder_balance = self
.get_holder_counterparty_balances_floor_incl_fee(&self.funding)
.map(|(h, _)| h)
.ok();
Some(PriorContribution::new(prior.clone(), holder_balance))
}

/// Returns whether this channel can ever RBF, independent of splice state.
fn is_rbf_compatible(&self) -> Result<(), String> {
if self.context.minimum_depth(&self.funding) == Some(0) {
Expand DownExpand Up@@ -12532,14 +12535,12 @@ where
};
}

if let Err(e) = contribution.validate().and_then(|()| {
// For splice-out, our_funding_contribution is adjusted to cover fees if there
// aren't any inputs.
let our_funding_contribution = contribution.net_value();
let our_funding_contribution = contribution.net_value();

if let Err(e) =
self.validate_splice_contributions(our_funding_contribution, SignedAmount::ZERO)
}) {
{
log_error!(logger, "Channel {} cannot be funded: {}", self.context.channel_id(), e);

return Err(QuiescentError::FailSplice(self.splice_funding_failed_for(contribution)));
}

Expand DownExpand Up@@ -14101,13 +14102,11 @@ where
// funding_contributed and quiescence, reducing the holder's
// balance. If invalid, disconnect and return the contribution so
// the user can reclaim their inputs.
if let Err(e) = contribution.validate().and_then(|()| {
let our_funding_contribution = contribution.net_value();
self.validate_splice_contributions(
our_funding_contribution,
SignedAmount::ZERO,
)
}) {
let our_funding_contribution = contribution.net_value();
if let Err(e) = self.validate_splice_contributions(
our_funding_contribution,
SignedAmount::ZERO,
) {
let failed = self.splice_funding_failed_for(contribution);
return Err((
ChannelError::WarnAndDisconnect(format!(
Expand Down
28 changes: 20 additions & 8 deletions lightning/src/ln/channelmanager.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -6638,20 +6638,32 @@ impl<
/// The splice initiator is responsible for paying fees for common fields, shared inputs, and
/// shared outputs along with any contributed inputs and outputs. When building a
/// [`FundingContribution`], fees are estimated at `min_feerate` assuming initiator
/// responsibility and must be covered by the supplied inputs for splice-in or the channel
/// balance for splice-out. If the counterparty also initiates a splice and wins the
/// tie-break, they become the initiator and choose the feerate. The fee is then
/// re-estimated at the counterparty's feerate for only our contributed inputs and outputs,
/// which may be higher or lower than the original estimate. The contribution is dropped and
/// the splice proceeds without it when:
/// responsibility. Contributions fall into two cases:
/// - **input-backed contributions**: when wallet inputs are selected, those inputs pay for both
/// the requested value added to the channel and any explicit withdrawal outputs. For
/// example, a 60,000 sat input might add 50,000 sat to the channel, pay a 2,000 sat fee,
/// and return 8,000 sat as change. A later RBF first tries to preserve that 50,000 sat
/// value added and cover any higher fee or newly requested withdrawal from the original
/// 10,000 sat fee buffer (2,000 sat fee + 8,000 sat change). If that buffer is not enough,
/// the prior contribution cannot be reused without selecting new wallet inputs.
/// - **input-less contributions**: when no wallet inputs are selected, fees and explicit
/// withdrawal outputs are paid from the channel balance. For example, a pure splice-out that
/// withdraws 20,000 sat from a 100,000 sat holder balance leaves up to 80,000 sat available
/// for fees. A later RBF keeps the 20,000 sat withdrawal only while that remaining balance
/// can still cover the re-estimated fee.
///
/// If the counterparty also initiates a splice and wins the tie-break, they become the
/// initiator and choose the feerate. The fee is then re-estimated at the counterparty's
/// feerate for only our contributed inputs and outputs, which may be higher or lower than the
/// original estimate. The contribution is dropped and the splice proceeds without it when:
/// - the counterparty's feerate is below `min_feerate`
/// - the counterparty's feerate is above `max_feerate` and the re-estimated fee exceeds the
/// original fee estimate
/// - the re-estimated fee exceeds the *fee buffer* regardless of `max_feerate`
Comment thread
wpaulino marked this conversation as resolved.
///
/// The fee buffer is the maximum fee that can be accommodated:
/// - **splice-in**: the selected inputs' value minus the contributed amount
/// - **splice-out**: the channel balance minus the withdrawal outputs
/// - **input-backed contributions**: the original fee plus any change output value
/// - **input-less contributions**: the channel balance minus the withdrawal outputs
///
/// # Events
///
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
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
20 changes: 5 additions & 15 deletions fuzz/src/chanmon_consistency.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -1504,7 +1504,6 @@ pub fn do_test<Out: Output + MaybeSend + MaybeSync>(data: &[u8], out: Out) {
counterparty_node_id: &PublicKey,
channel_id: &ChannelId,
wallet: &TestWalletSource,
logger: Arc<dyn Logger + MaybeSend + MaybeSync>,
funding_feerate_sat_per_kw: FeeRate| {
// We conditionally splice out `MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS` only when the node
// has double the balance required to send a payment upon a `0xff` byte. We do this to
Expand All@@ -1524,12 +1523,7 @@ pub fn do_test<Out: Output + MaybeSend + MaybeSync>(data: &[u8], out: Out) {
value: Amount::from_sat(MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS),
script_pubkey: wallet.get_change_script().unwrap(),
}];
funding_template.splice_out_sync(
outputs,
feerate,
FeeRate::MAX,
&WalletSync::new(wallet, logger.clone()),
)
funding_template.splice_out(outputs, feerate, FeeRate::MAX)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Bug: splice_out now has additive output semantics when a prior contribution exists (via with_prior_contribution). On RBF calls, the builder is seeded with the prior's outputs, then add_outputs(outputs) appends the same withdrawal again — doubling the withdrawal amount.

Trace for the RBF case:

  1. Prior contribution has outputs = [TxOut { value: 546, ... }], no inputs (splice-out)
  2. Builder seeds from prior: outputs = [withdrawal]
  3. splice_out calls add_outputs([withdrawal])outputs = [withdrawal, withdrawal]
  4. build()amend_without_coin_selection constructs a splice-out with doubled outputs
  5. compute_feerate_adjustment checks fee + 1092 <= holder_balance — which passes (capacity > 20K sat)
  6. Result: the RBF splice-out silently withdraws 2 × 546 = 1092 sat instead of 546 sat

The old splice_out_sync destructured the template with .. which discarded the prior contribution, so outputs were always treated as absolute. The new splice_out is additive.

Fix: use without_prior_contribution in the closure when a prior exists, or use rbf_prior_contribution_sync for the RBF case. For example:

let has_prior = funding_template.prior_contribution().is_some();if has_prior {
funding_template.rbf_prior_contribution_sync(Some(feerate),FeeRate::MAX, wallet)}else{
funding_template.splice_out(outputs, feerate,FeeRate::MAX)}

(The splice_in closure has the analogous issue — already flagged in a prior review pass.)

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.

@wpaulino Should we address this given the earlier comment?

// We conditionally splice out `MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS` only when the node// has double the balance required to send a payment upon a `0xff` byte. We do this to// ensure there's always liquidity available for a payment to succeed then.

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.

Discussed offline. We'll wait for #4550 to land.

});
};

Expand DownExpand Up@@ -2450,30 +2444,26 @@ pub fn do_test<Out: Output + MaybeSend + MaybeSync>(data: &[u8], out: Out) {
0xa4 => {
let cp_node_id = nodes[1].get_our_node_id();
let wallet = &wallets[0];
let logger = Arc::clone(&loggers[0]);
let feerate_sat_per_kw = fee_estimators[0].feerate_sat_per_kw();
splice_out(&nodes[0], &cp_node_id, &chan_a_id, wallet, logger, feerate_sat_per_kw);
splice_out(&nodes[0], &cp_node_id, &chan_a_id, wallet, feerate_sat_per_kw);
},
0xa5 => {
let cp_node_id = nodes[0].get_our_node_id();
let wallet = &wallets[1];
let logger = Arc::clone(&loggers[1]);
let feerate_sat_per_kw = fee_estimators[1].feerate_sat_per_kw();
splice_out(&nodes[1], &cp_node_id, &chan_a_id, wallet, logger, feerate_sat_per_kw);
splice_out(&nodes[1], &cp_node_id, &chan_a_id, wallet, feerate_sat_per_kw);
},
0xa6 => {
let cp_node_id = nodes[2].get_our_node_id();
let wallet = &wallets[1];
let logger = Arc::clone(&loggers[1]);
let feerate_sat_per_kw = fee_estimators[1].feerate_sat_per_kw();
splice_out(&nodes[1], &cp_node_id, &chan_b_id, wallet, logger, feerate_sat_per_kw);
splice_out(&nodes[1], &cp_node_id, &chan_b_id, wallet, feerate_sat_per_kw);
},
0xa7 => {
let cp_node_id = nodes[1].get_our_node_id();
let wallet = &wallets[2];
let logger = Arc::clone(&loggers[2]);
let feerate_sat_per_kw = fee_estimators[2].feerate_sat_per_kw();
splice_out(&nodes[2], &cp_node_id, &chan_b_id, wallet, logger, feerate_sat_per_kw);
splice_out(&nodes[2], &cp_node_id, &chan_b_id, wallet, feerate_sat_per_kw);
},

// Sync node by 1 block to cover confirmation of a transaction.
Expand Down
28 changes: 12 additions & 16 deletions fuzz/src/full_stack.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -1083,13 +1083,9 @@ pub fn do_test(mut data: &[u8], logger: &Arc<dyn Logger + MaybeSend + MaybeSync>
value: Amount::from_sat(splice_out_sats),
script_pubkey: wallet.get_change_script().unwrap(),
}];
let wallet_sync = WalletSync::new(&wallet, Arc::clone(&logger));
if let Ok(contribution) = funding_template.splice_out_sync(
outputs,
feerate,
FeeRate::MAX,
&wallet_sync,
) {
if let Ok(contribution) =
funding_template.splice_out(outputs, feerate, FeeRate::MAX)
{
let _ = channelmanager.funding_contributed(
&chan_id,
&counterparty,
Expand DownExpand Up@@ -1890,8 +1886,8 @@ fn splice_seed() -> Vec<u8> {
// CommitmentSigned message with proper signature (r=f7, s=01...) and funding_txid TLV
// signature r encodes sighash first byte f7, s follows the pattern from funding_created
// TLV type 1 (odd/optional) for funding_txid as per impl_writeable_msg!(CommitmentSigned, ...)
// Note: txid is encoded in reverse byte order (Bitcoin standard), so to get display 0000...0033, encode 3300...0000
ext_from_hex("0084 c000000000000000000000000000000000000000000000000000000000000000 00000000000000000000000000000000000000000000000000000000000000f7 0100000000000000000000000000000000000000000000000000000000000000 0000 01 20 3300000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);
// Note: txid is encoded in reverse byte order (Bitcoin standard), so to get display 0000...0031, encode 3100...0000
ext_from_hex("0084 c000000000000000000000000000000000000000000000000000000000000000 00000000000000000000000000000000000000000000000000000000000000f7 0100000000000000000000000000000000000000000000000000000000000000 0000 01 20 3100000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);

// After commitment_signed exchange, we need to exchange tx_signatures.
// Message type IDs: TxSignatures = 71 (0x0047)
Expand All@@ -1904,19 +1900,19 @@ fn splice_seed() -> Vec<u8> {
// inbound read from peer id 0 of len 150 (134 message + 16 MAC)
ext_from_hex("030096", &mut test);
// TxSignatures message with shared_input_signature TLV (type 0)
// txid must match the splice funding txid (0x33 in reverse byte order)
// txid must match the splice funding txid (0x31 in reverse byte order)
// shared_input_signature: 64-byte fuzz signature for the shared input
ext_from_hex("0047 c000000000000000000000000000000000000000000000000000000000000000 3300000000000000000000000000000000000000000000000000000000000000 0000 00 40 00000000000000000000000000000000000000000000000000000000000000dc 0100000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);
ext_from_hex("0047 c000000000000000000000000000000000000000000000000000000000000000 3100000000000000000000000000000000000000000000000000000000000000 0000 00 40 00000000000000000000000000000000000000000000000000000000000000dc 0100000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);

// Connect a block with the splice funding transaction to confirm it
// The splice funding tx: version(4) + input_count(1) + txid(32) + vout(4) + script_len(1) + sequence(4)
// + output_count(1) + value(8) + script_len(1) + script(34) + locktime(4) = 94 bytes = 0x5e
// Transaction structure from FundingTransactionReadyForSigning:
// - Input: spending c000...00:0 with sequence 0xfffffffd
// - Output: 115536 sats to OP_0 PUSH32 6e00...00
// - Output: 115538 sats to OP_0 PUSH32 6e00...00
// - Locktime: 13
ext_from_hex("0c005e", &mut test);
ext_from_hex("02000000 01 c000000000000000000000000000000000000000000000000000000000000000 00000000 00 fdffffff 01 50c3010000000000 22 00206e00000000000000000000000000000000000000000000000000000000000000 0d000000", &mut test);
ext_from_hex("02000000 01 c000000000000000000000000000000000000000000000000000000000000000 00000000 00 fdffffff 01 52c3010000000000 22 00206e00000000000000000000000000000000000000000000000000000000000000 0d000000", &mut test);

// Connect additional blocks to reach minimum_depth confirmations
for _ in 0..5 {
Expand All@@ -1933,8 +1929,8 @@ fn splice_seed() -> Vec<u8> {
// inbound read from peer id 0 of len 82 (66 message + 16 MAC)
ext_from_hex("030052", &mut test);
// SpliceLocked message (type 77 = 0x004d): channel_id + splice_txid + mac
// splice_txid must match the splice funding txid (0x33 in reverse byte order)
ext_from_hex("004d c000000000000000000000000000000000000000000000000000000000000000 3300000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);
// splice_txid must match the splice funding txid (0x31 in reverse byte order)
ext_from_hex("004d c000000000000000000000000000000000000000000000000000000000000000 3100000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);

test
}
Expand DownExpand Up@@ -2064,6 +2060,6 @@ mod tests {

// Splice locked
assert_eq!(log_entries.get(&("lightning::ln::peer_handler".to_string(), "Handling SendSpliceLocked event in peer_handler for node 030000000000000000000000000000000000000000000000000000000000000002 for channel c000000000000000000000000000000000000000000000000000000000000000".to_string())), Some(&1));
assert_eq!(log_entries.get(&("lightning::ln::channel".to_string(), "Promoting splice funding txid 0000000000000000000000000000000000000000000000000000000000000033".to_string())), Some(&1));
assert_eq!(log_entries.get(&("lightning::ln::channel".to_string(), "Promoting splice funding txid 0000000000000000000000000000000000000000000000000000000000000031".to_string())), Some(&1));
}
}
57 changes: 28 additions & 29 deletions lightning/src/ln/channel.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -12324,7 +12324,25 @@ where
);
let min_rbf_feerate = prev_feerate.map(min_rbf_feerate);
let prior = if pending_splice.last_funding_feerate_sat_per_1000_weight.is_some() {
self.build_prior_contribution()
if let Some(prior) = self
.pending_splice
.as_ref()
.and_then(|pending_splice| pending_splice.contributions.last())
{
let holder_balance = self
.get_holder_counterparty_balances_floor_incl_fee(&self.funding)
.map(|(h, _)| h)
.map_err(|e| APIError::ChannelUnavailable {
err: format!(
"Channel {} cannot be spliced at this time: {}",
self.context.channel_id(),
e
),
})?;
Comment thread
wpaulino marked this conversation as resolved.
Some(PriorContribution::new(prior.clone(), holder_balance))
} else {
None
}
} else {
None
};
Expand All@@ -12346,21 +12364,6 @@ where
Ok(FundingTemplate::new(Some(shared_input), min_rbf_feerate, prior_contribution))
}

/// Clones the prior contribution and fetches the holder balance for deferred feerate
/// adjustment.
fn build_prior_contribution(&self) -> Option<PriorContribution> {
debug_assert!(
self.pending_splice.is_some(),
"build_prior_contribution requires pending_splice"
);
let prior = self.pending_splice.as_ref()?.contributions.last()?;
let holder_balance = self
.get_holder_counterparty_balances_floor_incl_fee(&self.funding)
.map(|(h, _)| h)
.ok();
Some(PriorContribution::new(prior.clone(), holder_balance))
}

/// Returns whether this channel can ever RBF, independent of splice state.
fn is_rbf_compatible(&self) -> Result<(), String> {
if self.context.minimum_depth(&self.funding) == Some(0) {
Expand DownExpand Up@@ -12532,14 +12535,12 @@ where
};
}

if let Err(e) = contribution.validate().and_then(|()| {
// For splice-out, our_funding_contribution is adjusted to cover fees if there
// aren't any inputs.
let our_funding_contribution = contribution.net_value();
let our_funding_contribution = contribution.net_value();

if let Err(e) =
self.validate_splice_contributions(our_funding_contribution, SignedAmount::ZERO)
}) {
{
log_error!(logger, "Channel {} cannot be funded: {}", self.context.channel_id(), e);

return Err(QuiescentError::FailSplice(self.splice_funding_failed_for(contribution)));
}

Expand DownExpand Up@@ -14101,13 +14102,11 @@ where
// funding_contributed and quiescence, reducing the holder's
// balance. If invalid, disconnect and return the contribution so
// the user can reclaim their inputs.
if let Err(e) = contribution.validate().and_then(|()| {
let our_funding_contribution = contribution.net_value();
self.validate_splice_contributions(
our_funding_contribution,
SignedAmount::ZERO,
)
}) {
let our_funding_contribution = contribution.net_value();
if let Err(e) = self.validate_splice_contributions(
our_funding_contribution,
SignedAmount::ZERO,
) {
let failed = self.splice_funding_failed_for(contribution);
return Err((
ChannelError::WarnAndDisconnect(format!(
Expand Down
28 changes: 20 additions & 8 deletions lightning/src/ln/channelmanager.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -6638,20 +6638,32 @@ impl<
/// The splice initiator is responsible for paying fees for common fields, shared inputs, and
/// shared outputs along with any contributed inputs and outputs. When building a
/// [`FundingContribution`], fees are estimated at `min_feerate` assuming initiator
/// responsibility and must be covered by the supplied inputs for splice-in or the channel
/// balance for splice-out. If the counterparty also initiates a splice and wins the
/// tie-break, they become the initiator and choose the feerate. The fee is then
/// re-estimated at the counterparty's feerate for only our contributed inputs and outputs,
/// which may be higher or lower than the original estimate. The contribution is dropped and
/// the splice proceeds without it when:
/// responsibility. Contributions fall into two cases:
/// - **input-backed contributions**: when wallet inputs are selected, those inputs pay for both
/// the requested value added to the channel and any explicit withdrawal outputs. For
/// example, a 60,000 sat input might add 50,000 sat to the channel, pay a 2,000 sat fee,
/// and return 8,000 sat as change. A later RBF first tries to preserve that 50,000 sat
/// value added and cover any higher fee or newly requested withdrawal from the original
/// 10,000 sat fee buffer (2,000 sat fee + 8,000 sat change). If that buffer is not enough,
/// the prior contribution cannot be reused without selecting new wallet inputs.
/// - **input-less contributions**: when no wallet inputs are selected, fees and explicit
/// withdrawal outputs are paid from the channel balance. For example, a pure splice-out that
/// withdraws 20,000 sat from a 100,000 sat holder balance leaves up to 80,000 sat available
/// for fees. A later RBF keeps the 20,000 sat withdrawal only while that remaining balance
/// can still cover the re-estimated fee.
///
/// If the counterparty also initiates a splice and wins the tie-break, they become the
/// initiator and choose the feerate. The fee is then re-estimated at the counterparty's
/// feerate for only our contributed inputs and outputs, which may be higher or lower than the
/// original estimate. The contribution is dropped and the splice proceeds without it when:
/// - the counterparty's feerate is below `min_feerate`
/// - the counterparty's feerate is above `max_feerate` and the re-estimated fee exceeds the
/// original fee estimate
/// - the re-estimated fee exceeds the *fee buffer* regardless of `max_feerate`
Comment thread
wpaulino marked this conversation as resolved.
///
/// The fee buffer is the maximum fee that can be accommodated:
/// - **splice-in**: the selected inputs' value minus the contributed amount
/// - **splice-out**: the channel balance minus the withdrawal outputs
/// - **input-backed contributions**: the original fee plus any change output value
/// - **input-less contributions**: the channel balance minus the withdrawal outputs
///
/// # Events
///
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
20 changes: 5 additions & 15 deletions fuzz/src/chanmon_consistency.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -1504,7 +1504,6 @@ pub fn do_test<Out: Output + MaybeSend + MaybeSync>(data: &[u8], out: Out) {
counterparty_node_id: &PublicKey,
channel_id: &ChannelId,
wallet: &TestWalletSource,
logger: Arc<dyn Logger + MaybeSend + MaybeSync>,
funding_feerate_sat_per_kw: FeeRate| {
// We conditionally splice out `MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS` only when the node
// has double the balance required to send a payment upon a `0xff` byte. We do this to
Expand All@@ -1524,12 +1523,7 @@ pub fn do_test<Out: Output + MaybeSend + MaybeSync>(data: &[u8], out: Out) {
value: Amount::from_sat(MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS),
script_pubkey: wallet.get_change_script().unwrap(),
}];
funding_template.splice_out_sync(
outputs,
feerate,
FeeRate::MAX,
&WalletSync::new(wallet, logger.clone()),
)
funding_template.splice_out(outputs, feerate, FeeRate::MAX)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Bug: splice_out now has additive output semantics when a prior contribution exists (via with_prior_contribution). On RBF calls, the builder is seeded with the prior's outputs, then add_outputs(outputs) appends the same withdrawal again — doubling the withdrawal amount.

Trace for the RBF case:

  1. Prior contribution has outputs = [TxOut { value: 546, ... }], no inputs (splice-out)
  2. Builder seeds from prior: outputs = [withdrawal]
  3. splice_out calls add_outputs([withdrawal])outputs = [withdrawal, withdrawal]
  4. build()amend_without_coin_selection constructs a splice-out with doubled outputs
  5. compute_feerate_adjustment checks fee + 1092 <= holder_balance — which passes (capacity > 20K sat)
  6. Result: the RBF splice-out silently withdraws 2 × 546 = 1092 sat instead of 546 sat

The old splice_out_sync destructured the template with .. which discarded the prior contribution, so outputs were always treated as absolute. The new splice_out is additive.

Fix: use without_prior_contribution in the closure when a prior exists, or use rbf_prior_contribution_sync for the RBF case. For example:

let has_prior = funding_template.prior_contribution().is_some();if has_prior {
funding_template.rbf_prior_contribution_sync(Some(feerate),FeeRate::MAX, wallet)}else{
funding_template.splice_out(outputs, feerate,FeeRate::MAX)}

(The splice_in closure has the analogous issue — already flagged in a prior review pass.)

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.

@wpaulino Should we address this given the earlier comment?

// We conditionally splice out `MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS` only when the node// has double the balance required to send a payment upon a `0xff` byte. We do this to// ensure there's always liquidity available for a payment to succeed then.

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.

Discussed offline. We'll wait for #4550 to land.

});
};

Expand DownExpand Up@@ -2450,30 +2444,26 @@ pub fn do_test<Out: Output + MaybeSend + MaybeSync>(data: &[u8], out: Out) {
0xa4 => {
let cp_node_id = nodes[1].get_our_node_id();
let wallet = &wallets[0];
let logger = Arc::clone(&loggers[0]);
let feerate_sat_per_kw = fee_estimators[0].feerate_sat_per_kw();
splice_out(&nodes[0], &cp_node_id, &chan_a_id, wallet, logger, feerate_sat_per_kw);
splice_out(&nodes[0], &cp_node_id, &chan_a_id, wallet, feerate_sat_per_kw);
},
0xa5 => {
let cp_node_id = nodes[0].get_our_node_id();
let wallet = &wallets[1];
let logger = Arc::clone(&loggers[1]);
let feerate_sat_per_kw = fee_estimators[1].feerate_sat_per_kw();
splice_out(&nodes[1], &cp_node_id, &chan_a_id, wallet, logger, feerate_sat_per_kw);
splice_out(&nodes[1], &cp_node_id, &chan_a_id, wallet, feerate_sat_per_kw);
},
0xa6 => {
let cp_node_id = nodes[2].get_our_node_id();
let wallet = &wallets[1];
let logger = Arc::clone(&loggers[1]);
let feerate_sat_per_kw = fee_estimators[1].feerate_sat_per_kw();
splice_out(&nodes[1], &cp_node_id, &chan_b_id, wallet, logger, feerate_sat_per_kw);
splice_out(&nodes[1], &cp_node_id, &chan_b_id, wallet, feerate_sat_per_kw);
},
0xa7 => {
let cp_node_id = nodes[1].get_our_node_id();
let wallet = &wallets[2];
let logger = Arc::clone(&loggers[2]);
let feerate_sat_per_kw = fee_estimators[2].feerate_sat_per_kw();
splice_out(&nodes[2], &cp_node_id, &chan_b_id, wallet, logger, feerate_sat_per_kw);
splice_out(&nodes[2], &cp_node_id, &chan_b_id, wallet, feerate_sat_per_kw);
},

// Sync node by 1 block to cover confirmation of a transaction.
Expand Down
28 changes: 12 additions & 16 deletions fuzz/src/full_stack.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -1083,13 +1083,9 @@ pub fn do_test(mut data: &[u8], logger: &Arc<dyn Logger + MaybeSend + MaybeSync>
value: Amount::from_sat(splice_out_sats),
script_pubkey: wallet.get_change_script().unwrap(),
}];
let wallet_sync = WalletSync::new(&wallet, Arc::clone(&logger));
if let Ok(contribution) = funding_template.splice_out_sync(
outputs,
feerate,
FeeRate::MAX,
&wallet_sync,
) {
if let Ok(contribution) =
funding_template.splice_out(outputs, feerate, FeeRate::MAX)
{
let _ = channelmanager.funding_contributed(
&chan_id,
&counterparty,
Expand DownExpand Up@@ -1890,8 +1886,8 @@ fn splice_seed() -> Vec<u8> {
// CommitmentSigned message with proper signature (r=f7, s=01...) and funding_txid TLV
// signature r encodes sighash first byte f7, s follows the pattern from funding_created
// TLV type 1 (odd/optional) for funding_txid as per impl_writeable_msg!(CommitmentSigned, ...)
// Note: txid is encoded in reverse byte order (Bitcoin standard), so to get display 0000...0033, encode 3300...0000
ext_from_hex("0084 c000000000000000000000000000000000000000000000000000000000000000 00000000000000000000000000000000000000000000000000000000000000f7 0100000000000000000000000000000000000000000000000000000000000000 0000 01 20 3300000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);
// Note: txid is encoded in reverse byte order (Bitcoin standard), so to get display 0000...0031, encode 3100...0000
ext_from_hex("0084 c000000000000000000000000000000000000000000000000000000000000000 00000000000000000000000000000000000000000000000000000000000000f7 0100000000000000000000000000000000000000000000000000000000000000 0000 01 20 3100000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);

// After commitment_signed exchange, we need to exchange tx_signatures.
// Message type IDs: TxSignatures = 71 (0x0047)
Expand All@@ -1904,19 +1900,19 @@ fn splice_seed() -> Vec<u8> {
// inbound read from peer id 0 of len 150 (134 message + 16 MAC)
ext_from_hex("030096", &mut test);
// TxSignatures message with shared_input_signature TLV (type 0)
// txid must match the splice funding txid (0x33 in reverse byte order)
// txid must match the splice funding txid (0x31 in reverse byte order)
// shared_input_signature: 64-byte fuzz signature for the shared input
ext_from_hex("0047 c000000000000000000000000000000000000000000000000000000000000000 3300000000000000000000000000000000000000000000000000000000000000 0000 00 40 00000000000000000000000000000000000000000000000000000000000000dc 0100000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);
ext_from_hex("0047 c000000000000000000000000000000000000000000000000000000000000000 3100000000000000000000000000000000000000000000000000000000000000 0000 00 40 00000000000000000000000000000000000000000000000000000000000000dc 0100000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);

// Connect a block with the splice funding transaction to confirm it
// The splice funding tx: version(4) + input_count(1) + txid(32) + vout(4) + script_len(1) + sequence(4)
// + output_count(1) + value(8) + script_len(1) + script(34) + locktime(4) = 94 bytes = 0x5e
// Transaction structure from FundingTransactionReadyForSigning:
// - Input: spending c000...00:0 with sequence 0xfffffffd
// - Output: 115536 sats to OP_0 PUSH32 6e00...00
// - Output: 115538 sats to OP_0 PUSH32 6e00...00
// - Locktime: 13
ext_from_hex("0c005e", &mut test);
ext_from_hex("02000000 01 c000000000000000000000000000000000000000000000000000000000000000 00000000 00 fdffffff 01 50c3010000000000 22 00206e00000000000000000000000000000000000000000000000000000000000000 0d000000", &mut test);
ext_from_hex("02000000 01 c000000000000000000000000000000000000000000000000000000000000000 00000000 00 fdffffff 01 52c3010000000000 22 00206e00000000000000000000000000000000000000000000000000000000000000 0d000000", &mut test);

// Connect additional blocks to reach minimum_depth confirmations
for _ in 0..5 {
Expand All@@ -1933,8 +1929,8 @@ fn splice_seed() -> Vec<u8> {
// inbound read from peer id 0 of len 82 (66 message + 16 MAC)
ext_from_hex("030052", &mut test);
// SpliceLocked message (type 77 = 0x004d): channel_id + splice_txid + mac
// splice_txid must match the splice funding txid (0x33 in reverse byte order)
ext_from_hex("004d c000000000000000000000000000000000000000000000000000000000000000 3300000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);
// splice_txid must match the splice funding txid (0x31 in reverse byte order)
ext_from_hex("004d c000000000000000000000000000000000000000000000000000000000000000 3100000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);

test
}
Expand DownExpand Up@@ -2064,6 +2060,6 @@ mod tests {

// Splice locked
assert_eq!(log_entries.get(&("lightning::ln::peer_handler".to_string(), "Handling SendSpliceLocked event in peer_handler for node 030000000000000000000000000000000000000000000000000000000000000002 for channel c000000000000000000000000000000000000000000000000000000000000000".to_string())), Some(&1));
assert_eq!(log_entries.get(&("lightning::ln::channel".to_string(), "Promoting splice funding txid 0000000000000000000000000000000000000000000000000000000000000033".to_string())), Some(&1));
assert_eq!(log_entries.get(&("lightning::ln::channel".to_string(), "Promoting splice funding txid 0000000000000000000000000000000000000000000000000000000000000031".to_string())), Some(&1));
}
}
57 changes: 28 additions & 29 deletions lightning/src/ln/channel.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -12324,7 +12324,25 @@ where
);
let min_rbf_feerate = prev_feerate.map(min_rbf_feerate);
let prior = if pending_splice.last_funding_feerate_sat_per_1000_weight.is_some() {
self.build_prior_contribution()
if let Some(prior) = self
.pending_splice
.as_ref()
.and_then(|pending_splice| pending_splice.contributions.last())
{
let holder_balance = self
.get_holder_counterparty_balances_floor_incl_fee(&self.funding)
.map(|(h, _)| h)
.map_err(|e| APIError::ChannelUnavailable {
err: format!(
"Channel {} cannot be spliced at this time: {}",
self.context.channel_id(),
e
),
})?;
Comment thread
wpaulino marked this conversation as resolved.
Some(PriorContribution::new(prior.clone(), holder_balance))
} else {
None
}
} else {
None
};
Expand All@@ -12346,21 +12364,6 @@ where
Ok(FundingTemplate::new(Some(shared_input), min_rbf_feerate, prior_contribution))
}

/// Clones the prior contribution and fetches the holder balance for deferred feerate
/// adjustment.
fn build_prior_contribution(&self) -> Option<PriorContribution> {
debug_assert!(
self.pending_splice.is_some(),
"build_prior_contribution requires pending_splice"
);
let prior = self.pending_splice.as_ref()?.contributions.last()?;
let holder_balance = self
.get_holder_counterparty_balances_floor_incl_fee(&self.funding)
.map(|(h, _)| h)
.ok();
Some(PriorContribution::new(prior.clone(), holder_balance))
}

/// Returns whether this channel can ever RBF, independent of splice state.
fn is_rbf_compatible(&self) -> Result<(), String> {
if self.context.minimum_depth(&self.funding) == Some(0) {
Expand DownExpand Up@@ -12532,14 +12535,12 @@ where
};
}

if let Err(e) = contribution.validate().and_then(|()| {
// For splice-out, our_funding_contribution is adjusted to cover fees if there
// aren't any inputs.
let our_funding_contribution = contribution.net_value();
let our_funding_contribution = contribution.net_value();

if let Err(e) =
self.validate_splice_contributions(our_funding_contribution, SignedAmount::ZERO)
}) {
{
log_error!(logger, "Channel {} cannot be funded: {}", self.context.channel_id(), e);

return Err(QuiescentError::FailSplice(self.splice_funding_failed_for(contribution)));
}

Expand DownExpand Up@@ -14101,13 +14102,11 @@ where
// funding_contributed and quiescence, reducing the holder's
// balance. If invalid, disconnect and return the contribution so
// the user can reclaim their inputs.
if let Err(e) = contribution.validate().and_then(|()| {
let our_funding_contribution = contribution.net_value();
self.validate_splice_contributions(
our_funding_contribution,
SignedAmount::ZERO,
)
}) {
let our_funding_contribution = contribution.net_value();
if let Err(e) = self.validate_splice_contributions(
our_funding_contribution,
SignedAmount::ZERO,
) {
let failed = self.splice_funding_failed_for(contribution);
return Err((
ChannelError::WarnAndDisconnect(format!(
Expand Down
28 changes: 20 additions & 8 deletions lightning/src/ln/channelmanager.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -6638,20 +6638,32 @@ impl<
/// The splice initiator is responsible for paying fees for common fields, shared inputs, and
/// shared outputs along with any contributed inputs and outputs. When building a
/// [`FundingContribution`], fees are estimated at `min_feerate` assuming initiator
/// responsibility and must be covered by the supplied inputs for splice-in or the channel
/// balance for splice-out. If the counterparty also initiates a splice and wins the
/// tie-break, they become the initiator and choose the feerate. The fee is then
/// re-estimated at the counterparty's feerate for only our contributed inputs and outputs,
/// which may be higher or lower than the original estimate. The contribution is dropped and
/// the splice proceeds without it when:
/// responsibility. Contributions fall into two cases:
/// - **input-backed contributions**: when wallet inputs are selected, those inputs pay for both
/// the requested value added to the channel and any explicit withdrawal outputs. For
/// example, a 60,000 sat input might add 50,000 sat to the channel, pay a 2,000 sat fee,
/// and return 8,000 sat as change. A later RBF first tries to preserve that 50,000 sat
/// value added and cover any higher fee or newly requested withdrawal from the original
/// 10,000 sat fee buffer (2,000 sat fee + 8,000 sat change). If that buffer is not enough,
/// the prior contribution cannot be reused without selecting new wallet inputs.
/// - **input-less contributions**: when no wallet inputs are selected, fees and explicit
/// withdrawal outputs are paid from the channel balance. For example, a pure splice-out that
/// withdraws 20,000 sat from a 100,000 sat holder balance leaves up to 80,000 sat available
/// for fees. A later RBF keeps the 20,000 sat withdrawal only while that remaining balance
/// can still cover the re-estimated fee.
///
/// If the counterparty also initiates a splice and wins the tie-break, they become the
/// initiator and choose the feerate. The fee is then re-estimated at the counterparty's
/// feerate for only our contributed inputs and outputs, which may be higher or lower than the
/// original estimate. The contribution is dropped and the splice proceeds without it when:
/// - the counterparty's feerate is below `min_feerate`
/// - the counterparty's feerate is above `max_feerate` and the re-estimated fee exceeds the
/// original fee estimate
/// - the re-estimated fee exceeds the *fee buffer* regardless of `max_feerate`
Comment thread
wpaulino marked this conversation as resolved.
///
/// The fee buffer is the maximum fee that can be accommodated:
/// - **splice-in**: the selected inputs' value minus the contributed amount
/// - **splice-out**: the channel balance minus the withdrawal outputs
/// - **input-backed contributions**: the original fee plus any change output value
/// - **input-less contributions**: the channel balance minus the withdrawal outputs
///
/// # Events
///
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
20 changes: 5 additions & 15 deletions fuzz/src/chanmon_consistency.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -1504,7 +1504,6 @@ pub fn do_test<Out: Output + MaybeSend + MaybeSync>(data: &[u8], out: Out) {
counterparty_node_id: &PublicKey,
channel_id: &ChannelId,
wallet: &TestWalletSource,
logger: Arc<dyn Logger + MaybeSend + MaybeSync>,
funding_feerate_sat_per_kw: FeeRate| {
// We conditionally splice out `MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS` only when the node
// has double the balance required to send a payment upon a `0xff` byte. We do this to
Expand All@@ -1524,12 +1523,7 @@ pub fn do_test<Out: Output + MaybeSend + MaybeSync>(data: &[u8], out: Out) {
value: Amount::from_sat(MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS),
script_pubkey: wallet.get_change_script().unwrap(),
}];
funding_template.splice_out_sync(
outputs,
feerate,
FeeRate::MAX,
&WalletSync::new(wallet, logger.clone()),
)
funding_template.splice_out(outputs, feerate, FeeRate::MAX)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Bug: splice_out now has additive output semantics when a prior contribution exists (via with_prior_contribution). On RBF calls, the builder is seeded with the prior's outputs, then add_outputs(outputs) appends the same withdrawal again — doubling the withdrawal amount.

Trace for the RBF case:

  1. Prior contribution has outputs = [TxOut { value: 546, ... }], no inputs (splice-out)
  2. Builder seeds from prior: outputs = [withdrawal]
  3. splice_out calls add_outputs([withdrawal])outputs = [withdrawal, withdrawal]
  4. build()amend_without_coin_selection constructs a splice-out with doubled outputs
  5. compute_feerate_adjustment checks fee + 1092 <= holder_balance — which passes (capacity > 20K sat)
  6. Result: the RBF splice-out silently withdraws 2 × 546 = 1092 sat instead of 546 sat

The old splice_out_sync destructured the template with .. which discarded the prior contribution, so outputs were always treated as absolute. The new splice_out is additive.

Fix: use without_prior_contribution in the closure when a prior exists, or use rbf_prior_contribution_sync for the RBF case. For example:

let has_prior = funding_template.prior_contribution().is_some();if has_prior {
funding_template.rbf_prior_contribution_sync(Some(feerate),FeeRate::MAX, wallet)}else{
funding_template.splice_out(outputs, feerate,FeeRate::MAX)}

(The splice_in closure has the analogous issue — already flagged in a prior review pass.)

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.

@wpaulino Should we address this given the earlier comment?

// We conditionally splice out `MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS` only when the node// has double the balance required to send a payment upon a `0xff` byte. We do this to// ensure there's always liquidity available for a payment to succeed then.

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.

Discussed offline. We'll wait for #4550 to land.

});
};

Expand DownExpand Up@@ -2450,30 +2444,26 @@ pub fn do_test<Out: Output + MaybeSend + MaybeSync>(data: &[u8], out: Out) {
0xa4 => {
let cp_node_id = nodes[1].get_our_node_id();
let wallet = &wallets[0];
let logger = Arc::clone(&loggers[0]);
let feerate_sat_per_kw = fee_estimators[0].feerate_sat_per_kw();
splice_out(&nodes[0], &cp_node_id, &chan_a_id, wallet, logger, feerate_sat_per_kw);
splice_out(&nodes[0], &cp_node_id, &chan_a_id, wallet, feerate_sat_per_kw);
},
0xa5 => {
let cp_node_id = nodes[0].get_our_node_id();
let wallet = &wallets[1];
let logger = Arc::clone(&loggers[1]);
let feerate_sat_per_kw = fee_estimators[1].feerate_sat_per_kw();
splice_out(&nodes[1], &cp_node_id, &chan_a_id, wallet, logger, feerate_sat_per_kw);
splice_out(&nodes[1], &cp_node_id, &chan_a_id, wallet, feerate_sat_per_kw);
},
0xa6 => {
let cp_node_id = nodes[2].get_our_node_id();
let wallet = &wallets[1];
let logger = Arc::clone(&loggers[1]);
let feerate_sat_per_kw = fee_estimators[1].feerate_sat_per_kw();
splice_out(&nodes[1], &cp_node_id, &chan_b_id, wallet, logger, feerate_sat_per_kw);
splice_out(&nodes[1], &cp_node_id, &chan_b_id, wallet, feerate_sat_per_kw);
},
0xa7 => {
let cp_node_id = nodes[1].get_our_node_id();
let wallet = &wallets[2];
let logger = Arc::clone(&loggers[2]);
let feerate_sat_per_kw = fee_estimators[2].feerate_sat_per_kw();
splice_out(&nodes[2], &cp_node_id, &chan_b_id, wallet, logger, feerate_sat_per_kw);
splice_out(&nodes[2], &cp_node_id, &chan_b_id, wallet, feerate_sat_per_kw);
},

// Sync node by 1 block to cover confirmation of a transaction.
Expand Down
28 changes: 12 additions & 16 deletions fuzz/src/full_stack.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -1083,13 +1083,9 @@ pub fn do_test(mut data: &[u8], logger: &Arc<dyn Logger + MaybeSend + MaybeSync>
value: Amount::from_sat(splice_out_sats),
script_pubkey: wallet.get_change_script().unwrap(),
}];
let wallet_sync = WalletSync::new(&wallet, Arc::clone(&logger));
if let Ok(contribution) = funding_template.splice_out_sync(
outputs,
feerate,
FeeRate::MAX,
&wallet_sync,
) {
if let Ok(contribution) =
funding_template.splice_out(outputs, feerate, FeeRate::MAX)
{
let _ = channelmanager.funding_contributed(
&chan_id,
&counterparty,
Expand DownExpand Up@@ -1890,8 +1886,8 @@ fn splice_seed() -> Vec<u8> {
// CommitmentSigned message with proper signature (r=f7, s=01...) and funding_txid TLV
// signature r encodes sighash first byte f7, s follows the pattern from funding_created
// TLV type 1 (odd/optional) for funding_txid as per impl_writeable_msg!(CommitmentSigned, ...)
// Note: txid is encoded in reverse byte order (Bitcoin standard), so to get display 0000...0033, encode 3300...0000
ext_from_hex("0084 c000000000000000000000000000000000000000000000000000000000000000 00000000000000000000000000000000000000000000000000000000000000f7 0100000000000000000000000000000000000000000000000000000000000000 0000 01 20 3300000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);
// Note: txid is encoded in reverse byte order (Bitcoin standard), so to get display 0000...0031, encode 3100...0000
ext_from_hex("0084 c000000000000000000000000000000000000000000000000000000000000000 00000000000000000000000000000000000000000000000000000000000000f7 0100000000000000000000000000000000000000000000000000000000000000 0000 01 20 3100000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);

// After commitment_signed exchange, we need to exchange tx_signatures.
// Message type IDs: TxSignatures = 71 (0x0047)
Expand All@@ -1904,19 +1900,19 @@ fn splice_seed() -> Vec<u8> {
// inbound read from peer id 0 of len 150 (134 message + 16 MAC)
ext_from_hex("030096", &mut test);
// TxSignatures message with shared_input_signature TLV (type 0)
// txid must match the splice funding txid (0x33 in reverse byte order)
// txid must match the splice funding txid (0x31 in reverse byte order)
// shared_input_signature: 64-byte fuzz signature for the shared input
ext_from_hex("0047 c000000000000000000000000000000000000000000000000000000000000000 3300000000000000000000000000000000000000000000000000000000000000 0000 00 40 00000000000000000000000000000000000000000000000000000000000000dc 0100000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);
ext_from_hex("0047 c000000000000000000000000000000000000000000000000000000000000000 3100000000000000000000000000000000000000000000000000000000000000 0000 00 40 00000000000000000000000000000000000000000000000000000000000000dc 0100000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);

// Connect a block with the splice funding transaction to confirm it
// The splice funding tx: version(4) + input_count(1) + txid(32) + vout(4) + script_len(1) + sequence(4)
// + output_count(1) + value(8) + script_len(1) + script(34) + locktime(4) = 94 bytes = 0x5e
// Transaction structure from FundingTransactionReadyForSigning:
// - Input: spending c000...00:0 with sequence 0xfffffffd
// - Output: 115536 sats to OP_0 PUSH32 6e00...00
// - Output: 115538 sats to OP_0 PUSH32 6e00...00
// - Locktime: 13
ext_from_hex("0c005e", &mut test);
ext_from_hex("02000000 01 c000000000000000000000000000000000000000000000000000000000000000 00000000 00 fdffffff 01 50c3010000000000 22 00206e00000000000000000000000000000000000000000000000000000000000000 0d000000", &mut test);
ext_from_hex("02000000 01 c000000000000000000000000000000000000000000000000000000000000000 00000000 00 fdffffff 01 52c3010000000000 22 00206e00000000000000000000000000000000000000000000000000000000000000 0d000000", &mut test);

// Connect additional blocks to reach minimum_depth confirmations
for _ in 0..5 {
Expand All@@ -1933,8 +1929,8 @@ fn splice_seed() -> Vec<u8> {
// inbound read from peer id 0 of len 82 (66 message + 16 MAC)
ext_from_hex("030052", &mut test);
// SpliceLocked message (type 77 = 0x004d): channel_id + splice_txid + mac
// splice_txid must match the splice funding txid (0x33 in reverse byte order)
ext_from_hex("004d c000000000000000000000000000000000000000000000000000000000000000 3300000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);
// splice_txid must match the splice funding txid (0x31 in reverse byte order)
ext_from_hex("004d c000000000000000000000000000000000000000000000000000000000000000 3100000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);

test
}
Expand DownExpand Up@@ -2064,6 +2060,6 @@ mod tests {

// Splice locked
assert_eq!(log_entries.get(&("lightning::ln::peer_handler".to_string(), "Handling SendSpliceLocked event in peer_handler for node 030000000000000000000000000000000000000000000000000000000000000002 for channel c000000000000000000000000000000000000000000000000000000000000000".to_string())), Some(&1));
assert_eq!(log_entries.get(&("lightning::ln::channel".to_string(), "Promoting splice funding txid 0000000000000000000000000000000000000000000000000000000000000033".to_string())), Some(&1));
assert_eq!(log_entries.get(&("lightning::ln::channel".to_string(), "Promoting splice funding txid 0000000000000000000000000000000000000000000000000000000000000031".to_string())), Some(&1));
}
}
57 changes: 28 additions & 29 deletions lightning/src/ln/channel.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -12324,7 +12324,25 @@ where
);
let min_rbf_feerate = prev_feerate.map(min_rbf_feerate);
let prior = if pending_splice.last_funding_feerate_sat_per_1000_weight.is_some() {
self.build_prior_contribution()
if let Some(prior) = self
.pending_splice
.as_ref()
.and_then(|pending_splice| pending_splice.contributions.last())
{
let holder_balance = self
.get_holder_counterparty_balances_floor_incl_fee(&self.funding)
.map(|(h, _)| h)
.map_err(|e| APIError::ChannelUnavailable {
err: format!(
"Channel {} cannot be spliced at this time: {}",
self.context.channel_id(),
e
),
})?;
Comment thread
wpaulino marked this conversation as resolved.
Some(PriorContribution::new(prior.clone(), holder_balance))
} else {
None
}
} else {
None
};
Expand All@@ -12346,21 +12364,6 @@ where
Ok(FundingTemplate::new(Some(shared_input), min_rbf_feerate, prior_contribution))
}

/// Clones the prior contribution and fetches the holder balance for deferred feerate
/// adjustment.
fn build_prior_contribution(&self) -> Option<PriorContribution> {
debug_assert!(
self.pending_splice.is_some(),
"build_prior_contribution requires pending_splice"
);
let prior = self.pending_splice.as_ref()?.contributions.last()?;
let holder_balance = self
.get_holder_counterparty_balances_floor_incl_fee(&self.funding)
.map(|(h, _)| h)
.ok();
Some(PriorContribution::new(prior.clone(), holder_balance))
}

/// Returns whether this channel can ever RBF, independent of splice state.
fn is_rbf_compatible(&self) -> Result<(), String> {
if self.context.minimum_depth(&self.funding) == Some(0) {
Expand DownExpand Up@@ -12532,14 +12535,12 @@ where
};
}

if let Err(e) = contribution.validate().and_then(|()| {
// For splice-out, our_funding_contribution is adjusted to cover fees if there
// aren't any inputs.
let our_funding_contribution = contribution.net_value();
let our_funding_contribution = contribution.net_value();

if let Err(e) =
self.validate_splice_contributions(our_funding_contribution, SignedAmount::ZERO)
}) {
{
log_error!(logger, "Channel {} cannot be funded: {}", self.context.channel_id(), e);

return Err(QuiescentError::FailSplice(self.splice_funding_failed_for(contribution)));
}

Expand DownExpand Up@@ -14101,13 +14102,11 @@ where
// funding_contributed and quiescence, reducing the holder's
// balance. If invalid, disconnect and return the contribution so
// the user can reclaim their inputs.
if let Err(e) = contribution.validate().and_then(|()| {
let our_funding_contribution = contribution.net_value();
self.validate_splice_contributions(
our_funding_contribution,
SignedAmount::ZERO,
)
}) {
let our_funding_contribution = contribution.net_value();
if let Err(e) = self.validate_splice_contributions(
our_funding_contribution,
SignedAmount::ZERO,
) {
let failed = self.splice_funding_failed_for(contribution);
return Err((
ChannelError::WarnAndDisconnect(format!(
Expand Down
28 changes: 20 additions & 8 deletions lightning/src/ln/channelmanager.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -6638,20 +6638,32 @@ impl<
/// The splice initiator is responsible for paying fees for common fields, shared inputs, and
/// shared outputs along with any contributed inputs and outputs. When building a
/// [`FundingContribution`], fees are estimated at `min_feerate` assuming initiator
/// responsibility and must be covered by the supplied inputs for splice-in or the channel
/// balance for splice-out. If the counterparty also initiates a splice and wins the
/// tie-break, they become the initiator and choose the feerate. The fee is then
/// re-estimated at the counterparty's feerate for only our contributed inputs and outputs,
/// which may be higher or lower than the original estimate. The contribution is dropped and
/// the splice proceeds without it when:
/// responsibility. Contributions fall into two cases:
/// - **input-backed contributions**: when wallet inputs are selected, those inputs pay for both
/// the requested value added to the channel and any explicit withdrawal outputs. For
/// example, a 60,000 sat input might add 50,000 sat to the channel, pay a 2,000 sat fee,
/// and return 8,000 sat as change. A later RBF first tries to preserve that 50,000 sat
/// value added and cover any higher fee or newly requested withdrawal from the original
/// 10,000 sat fee buffer (2,000 sat fee + 8,000 sat change). If that buffer is not enough,
/// the prior contribution cannot be reused without selecting new wallet inputs.
/// - **input-less contributions**: when no wallet inputs are selected, fees and explicit
/// withdrawal outputs are paid from the channel balance. For example, a pure splice-out that
/// withdraws 20,000 sat from a 100,000 sat holder balance leaves up to 80,000 sat available
/// for fees. A later RBF keeps the 20,000 sat withdrawal only while that remaining balance
/// can still cover the re-estimated fee.
///
/// If the counterparty also initiates a splice and wins the tie-break, they become the
/// initiator and choose the feerate. The fee is then re-estimated at the counterparty's
/// feerate for only our contributed inputs and outputs, which may be higher or lower than the
/// original estimate. The contribution is dropped and the splice proceeds without it when:
/// - the counterparty's feerate is below `min_feerate`
/// - the counterparty's feerate is above `max_feerate` and the re-estimated fee exceeds the
/// original fee estimate
/// - the re-estimated fee exceeds the *fee buffer* regardless of `max_feerate`
Comment thread
wpaulino marked this conversation as resolved.
///
/// The fee buffer is the maximum fee that can be accommodated:
/// - **splice-in**: the selected inputs' value minus the contributed amount
/// - **splice-out**: the channel balance minus the withdrawal outputs
/// - **input-backed contributions**: the original fee plus any change output value
/// - **input-less contributions**: the channel balance minus the withdrawal outputs
///
/// # Events
///
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
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
20 changes: 5 additions & 15 deletions fuzz/src/chanmon_consistency.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -1504,7 +1504,6 @@ pub fn do_test<Out: Output + MaybeSend + MaybeSync>(data: &[u8], out: Out) {
counterparty_node_id: &PublicKey,
channel_id: &ChannelId,
wallet: &TestWalletSource,
logger: Arc<dyn Logger + MaybeSend + MaybeSync>,
funding_feerate_sat_per_kw: FeeRate| {
// We conditionally splice out `MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS` only when the node
// has double the balance required to send a payment upon a `0xff` byte. We do this to
Expand All@@ -1524,12 +1523,7 @@ pub fn do_test<Out: Output + MaybeSend + MaybeSync>(data: &[u8], out: Out) {
value: Amount::from_sat(MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS),
script_pubkey: wallet.get_change_script().unwrap(),
}];
funding_template.splice_out_sync(
outputs,
feerate,
FeeRate::MAX,
&WalletSync::new(wallet, logger.clone()),
)
funding_template.splice_out(outputs, feerate, FeeRate::MAX)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Bug: splice_out now has additive output semantics when a prior contribution exists (via with_prior_contribution). On RBF calls, the builder is seeded with the prior's outputs, then add_outputs(outputs) appends the same withdrawal again — doubling the withdrawal amount.

Trace for the RBF case:

  1. Prior contribution has outputs = [TxOut { value: 546, ... }], no inputs (splice-out)
  2. Builder seeds from prior: outputs = [withdrawal]
  3. splice_out calls add_outputs([withdrawal])outputs = [withdrawal, withdrawal]
  4. build()amend_without_coin_selection constructs a splice-out with doubled outputs
  5. compute_feerate_adjustment checks fee + 1092 <= holder_balance — which passes (capacity > 20K sat)
  6. Result: the RBF splice-out silently withdraws 2 × 546 = 1092 sat instead of 546 sat

The old splice_out_sync destructured the template with .. which discarded the prior contribution, so outputs were always treated as absolute. The new splice_out is additive.

Fix: use without_prior_contribution in the closure when a prior exists, or use rbf_prior_contribution_sync for the RBF case. For example:

let has_prior = funding_template.prior_contribution().is_some();if has_prior {
funding_template.rbf_prior_contribution_sync(Some(feerate),FeeRate::MAX, wallet)}else{
funding_template.splice_out(outputs, feerate,FeeRate::MAX)}

(The splice_in closure has the analogous issue — already flagged in a prior review pass.)

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.

@wpaulino Should we address this given the earlier comment?

// We conditionally splice out `MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS` only when the node// has double the balance required to send a payment upon a `0xff` byte. We do this to// ensure there's always liquidity available for a payment to succeed then.

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.

Discussed offline. We'll wait for #4550 to land.

});
};

Expand DownExpand Up@@ -2450,30 +2444,26 @@ pub fn do_test<Out: Output + MaybeSend + MaybeSync>(data: &[u8], out: Out) {
0xa4 => {
let cp_node_id = nodes[1].get_our_node_id();
let wallet = &wallets[0];
let logger = Arc::clone(&loggers[0]);
let feerate_sat_per_kw = fee_estimators[0].feerate_sat_per_kw();
splice_out(&nodes[0], &cp_node_id, &chan_a_id, wallet, logger, feerate_sat_per_kw);
splice_out(&nodes[0], &cp_node_id, &chan_a_id, wallet, feerate_sat_per_kw);
},
0xa5 => {
let cp_node_id = nodes[0].get_our_node_id();
let wallet = &wallets[1];
let logger = Arc::clone(&loggers[1]);
let feerate_sat_per_kw = fee_estimators[1].feerate_sat_per_kw();
splice_out(&nodes[1], &cp_node_id, &chan_a_id, wallet, logger, feerate_sat_per_kw);
splice_out(&nodes[1], &cp_node_id, &chan_a_id, wallet, feerate_sat_per_kw);
},
0xa6 => {
let cp_node_id = nodes[2].get_our_node_id();
let wallet = &wallets[1];
let logger = Arc::clone(&loggers[1]);
let feerate_sat_per_kw = fee_estimators[1].feerate_sat_per_kw();
splice_out(&nodes[1], &cp_node_id, &chan_b_id, wallet, logger, feerate_sat_per_kw);
splice_out(&nodes[1], &cp_node_id, &chan_b_id, wallet, feerate_sat_per_kw);
},
0xa7 => {
let cp_node_id = nodes[1].get_our_node_id();
let wallet = &wallets[2];
let logger = Arc::clone(&loggers[2]);
let feerate_sat_per_kw = fee_estimators[2].feerate_sat_per_kw();
splice_out(&nodes[2], &cp_node_id, &chan_b_id, wallet, logger, feerate_sat_per_kw);
splice_out(&nodes[2], &cp_node_id, &chan_b_id, wallet, feerate_sat_per_kw);
},

// Sync node by 1 block to cover confirmation of a transaction.
Expand Down
28 changes: 12 additions & 16 deletions fuzz/src/full_stack.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -1083,13 +1083,9 @@ pub fn do_test(mut data: &[u8], logger: &Arc<dyn Logger + MaybeSend + MaybeSync>
value: Amount::from_sat(splice_out_sats),
script_pubkey: wallet.get_change_script().unwrap(),
}];
let wallet_sync = WalletSync::new(&wallet, Arc::clone(&logger));
if let Ok(contribution) = funding_template.splice_out_sync(
outputs,
feerate,
FeeRate::MAX,
&wallet_sync,
) {
if let Ok(contribution) =
funding_template.splice_out(outputs, feerate, FeeRate::MAX)
{
let _ = channelmanager.funding_contributed(
&chan_id,
&counterparty,
Expand DownExpand Up@@ -1890,8 +1886,8 @@ fn splice_seed() -> Vec<u8> {
// CommitmentSigned message with proper signature (r=f7, s=01...) and funding_txid TLV
// signature r encodes sighash first byte f7, s follows the pattern from funding_created
// TLV type 1 (odd/optional) for funding_txid as per impl_writeable_msg!(CommitmentSigned, ...)
// Note: txid is encoded in reverse byte order (Bitcoin standard), so to get display 0000...0033, encode 3300...0000
ext_from_hex("0084 c000000000000000000000000000000000000000000000000000000000000000 00000000000000000000000000000000000000000000000000000000000000f7 0100000000000000000000000000000000000000000000000000000000000000 0000 01 20 3300000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);
// Note: txid is encoded in reverse byte order (Bitcoin standard), so to get display 0000...0031, encode 3100...0000
ext_from_hex("0084 c000000000000000000000000000000000000000000000000000000000000000 00000000000000000000000000000000000000000000000000000000000000f7 0100000000000000000000000000000000000000000000000000000000000000 0000 01 20 3100000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);

// After commitment_signed exchange, we need to exchange tx_signatures.
// Message type IDs: TxSignatures = 71 (0x0047)
Expand All@@ -1904,19 +1900,19 @@ fn splice_seed() -> Vec<u8> {
// inbound read from peer id 0 of len 150 (134 message + 16 MAC)
ext_from_hex("030096", &mut test);
// TxSignatures message with shared_input_signature TLV (type 0)
// txid must match the splice funding txid (0x33 in reverse byte order)
// txid must match the splice funding txid (0x31 in reverse byte order)
// shared_input_signature: 64-byte fuzz signature for the shared input
ext_from_hex("0047 c000000000000000000000000000000000000000000000000000000000000000 3300000000000000000000000000000000000000000000000000000000000000 0000 00 40 00000000000000000000000000000000000000000000000000000000000000dc 0100000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);
ext_from_hex("0047 c000000000000000000000000000000000000000000000000000000000000000 3100000000000000000000000000000000000000000000000000000000000000 0000 00 40 00000000000000000000000000000000000000000000000000000000000000dc 0100000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);

// Connect a block with the splice funding transaction to confirm it
// The splice funding tx: version(4) + input_count(1) + txid(32) + vout(4) + script_len(1) + sequence(4)
// + output_count(1) + value(8) + script_len(1) + script(34) + locktime(4) = 94 bytes = 0x5e
// Transaction structure from FundingTransactionReadyForSigning:
// - Input: spending c000...00:0 with sequence 0xfffffffd
// - Output: 115536 sats to OP_0 PUSH32 6e00...00
// - Output: 115538 sats to OP_0 PUSH32 6e00...00
// - Locktime: 13
ext_from_hex("0c005e", &mut test);
ext_from_hex("02000000 01 c000000000000000000000000000000000000000000000000000000000000000 00000000 00 fdffffff 01 50c3010000000000 22 00206e00000000000000000000000000000000000000000000000000000000000000 0d000000", &mut test);
ext_from_hex("02000000 01 c000000000000000000000000000000000000000000000000000000000000000 00000000 00 fdffffff 01 52c3010000000000 22 00206e00000000000000000000000000000000000000000000000000000000000000 0d000000", &mut test);

// Connect additional blocks to reach minimum_depth confirmations
for _ in 0..5 {
Expand All@@ -1933,8 +1929,8 @@ fn splice_seed() -> Vec<u8> {
// inbound read from peer id 0 of len 82 (66 message + 16 MAC)
ext_from_hex("030052", &mut test);
// SpliceLocked message (type 77 = 0x004d): channel_id + splice_txid + mac
// splice_txid must match the splice funding txid (0x33 in reverse byte order)
ext_from_hex("004d c000000000000000000000000000000000000000000000000000000000000000 3300000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);
// splice_txid must match the splice funding txid (0x31 in reverse byte order)
ext_from_hex("004d c000000000000000000000000000000000000000000000000000000000000000 3100000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);

test
}
Expand DownExpand Up@@ -2064,6 +2060,6 @@ mod tests {

// Splice locked
assert_eq!(log_entries.get(&("lightning::ln::peer_handler".to_string(), "Handling SendSpliceLocked event in peer_handler for node 030000000000000000000000000000000000000000000000000000000000000002 for channel c000000000000000000000000000000000000000000000000000000000000000".to_string())), Some(&1));
assert_eq!(log_entries.get(&("lightning::ln::channel".to_string(), "Promoting splice funding txid 0000000000000000000000000000000000000000000000000000000000000033".to_string())), Some(&1));
assert_eq!(log_entries.get(&("lightning::ln::channel".to_string(), "Promoting splice funding txid 0000000000000000000000000000000000000000000000000000000000000031".to_string())), Some(&1));
}
}
57 changes: 28 additions & 29 deletions lightning/src/ln/channel.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -12324,7 +12324,25 @@ where
);
let min_rbf_feerate = prev_feerate.map(min_rbf_feerate);
let prior = if pending_splice.last_funding_feerate_sat_per_1000_weight.is_some() {
self.build_prior_contribution()
if let Some(prior) = self
.pending_splice
.as_ref()
.and_then(|pending_splice| pending_splice.contributions.last())
{
let holder_balance = self
.get_holder_counterparty_balances_floor_incl_fee(&self.funding)
.map(|(h, _)| h)
.map_err(|e| APIError::ChannelUnavailable {
err: format!(
"Channel {} cannot be spliced at this time: {}",
self.context.channel_id(),
e
),
})?;
Comment thread
wpaulino marked this conversation as resolved.
Some(PriorContribution::new(prior.clone(), holder_balance))
} else {
None
}
} else {
None
};
Expand All@@ -12346,21 +12364,6 @@ where
Ok(FundingTemplate::new(Some(shared_input), min_rbf_feerate, prior_contribution))
}

/// Clones the prior contribution and fetches the holder balance for deferred feerate
/// adjustment.
fn build_prior_contribution(&self) -> Option<PriorContribution> {
debug_assert!(
self.pending_splice.is_some(),
"build_prior_contribution requires pending_splice"
);
let prior = self.pending_splice.as_ref()?.contributions.last()?;
let holder_balance = self
.get_holder_counterparty_balances_floor_incl_fee(&self.funding)
.map(|(h, _)| h)
.ok();
Some(PriorContribution::new(prior.clone(), holder_balance))
}

/// Returns whether this channel can ever RBF, independent of splice state.
fn is_rbf_compatible(&self) -> Result<(), String> {
if self.context.minimum_depth(&self.funding) == Some(0) {
Expand DownExpand Up@@ -12532,14 +12535,12 @@ where
};
}

if let Err(e) = contribution.validate().and_then(|()| {
// For splice-out, our_funding_contribution is adjusted to cover fees if there
// aren't any inputs.
let our_funding_contribution = contribution.net_value();
let our_funding_contribution = contribution.net_value();

if let Err(e) =
self.validate_splice_contributions(our_funding_contribution, SignedAmount::ZERO)
}) {
{
log_error!(logger, "Channel {} cannot be funded: {}", self.context.channel_id(), e);

return Err(QuiescentError::FailSplice(self.splice_funding_failed_for(contribution)));
}

Expand DownExpand Up@@ -14101,13 +14102,11 @@ where
// funding_contributed and quiescence, reducing the holder's
// balance. If invalid, disconnect and return the contribution so
// the user can reclaim their inputs.
if let Err(e) = contribution.validate().and_then(|()| {
let our_funding_contribution = contribution.net_value();
self.validate_splice_contributions(
our_funding_contribution,
SignedAmount::ZERO,
)
}) {
let our_funding_contribution = contribution.net_value();
if let Err(e) = self.validate_splice_contributions(
our_funding_contribution,
SignedAmount::ZERO,
) {
let failed = self.splice_funding_failed_for(contribution);
return Err((
ChannelError::WarnAndDisconnect(format!(
Expand Down
28 changes: 20 additions & 8 deletions lightning/src/ln/channelmanager.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -6638,20 +6638,32 @@ impl<
/// The splice initiator is responsible for paying fees for common fields, shared inputs, and
/// shared outputs along with any contributed inputs and outputs. When building a
/// [`FundingContribution`], fees are estimated at `min_feerate` assuming initiator
/// responsibility and must be covered by the supplied inputs for splice-in or the channel
/// balance for splice-out. If the counterparty also initiates a splice and wins the
/// tie-break, they become the initiator and choose the feerate. The fee is then
/// re-estimated at the counterparty's feerate for only our contributed inputs and outputs,
/// which may be higher or lower than the original estimate. The contribution is dropped and
/// the splice proceeds without it when:
/// responsibility. Contributions fall into two cases:
/// - **input-backed contributions**: when wallet inputs are selected, those inputs pay for both
/// the requested value added to the channel and any explicit withdrawal outputs. For
/// example, a 60,000 sat input might add 50,000 sat to the channel, pay a 2,000 sat fee,
/// and return 8,000 sat as change. A later RBF first tries to preserve that 50,000 sat
/// value added and cover any higher fee or newly requested withdrawal from the original
/// 10,000 sat fee buffer (2,000 sat fee + 8,000 sat change). If that buffer is not enough,
/// the prior contribution cannot be reused without selecting new wallet inputs.
/// - **input-less contributions**: when no wallet inputs are selected, fees and explicit
/// withdrawal outputs are paid from the channel balance. For example, a pure splice-out that
/// withdraws 20,000 sat from a 100,000 sat holder balance leaves up to 80,000 sat available
/// for fees. A later RBF keeps the 20,000 sat withdrawal only while that remaining balance
/// can still cover the re-estimated fee.
///
/// If the counterparty also initiates a splice and wins the tie-break, they become the
/// initiator and choose the feerate. The fee is then re-estimated at the counterparty's
/// feerate for only our contributed inputs and outputs, which may be higher or lower than the
/// original estimate. The contribution is dropped and the splice proceeds without it when:
/// - the counterparty's feerate is below `min_feerate`
/// - the counterparty's feerate is above `max_feerate` and the re-estimated fee exceeds the
/// original fee estimate
/// - the re-estimated fee exceeds the *fee buffer* regardless of `max_feerate`
Comment thread
wpaulino marked this conversation as resolved.
///
/// The fee buffer is the maximum fee that can be accommodated:
/// - **splice-in**: the selected inputs' value minus the contributed amount
/// - **splice-out**: the channel balance minus the withdrawal outputs
/// - **input-backed contributions**: the original fee plus any change output value
/// - **input-less contributions**: the channel balance minus the withdrawal outputs
///
/// # Events
///
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
20 changes: 5 additions & 15 deletions fuzz/src/chanmon_consistency.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -1504,7 +1504,6 @@ pub fn do_test<Out: Output + MaybeSend + MaybeSync>(data: &[u8], out: Out) {
counterparty_node_id: &PublicKey,
channel_id: &ChannelId,
wallet: &TestWalletSource,
logger: Arc<dyn Logger + MaybeSend + MaybeSync>,
funding_feerate_sat_per_kw: FeeRate| {
// We conditionally splice out `MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS` only when the node
// has double the balance required to send a payment upon a `0xff` byte. We do this to
Expand All@@ -1524,12 +1523,7 @@ pub fn do_test<Out: Output + MaybeSend + MaybeSync>(data: &[u8], out: Out) {
value: Amount::from_sat(MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS),
script_pubkey: wallet.get_change_script().unwrap(),
}];
funding_template.splice_out_sync(
outputs,
feerate,
FeeRate::MAX,
&WalletSync::new(wallet, logger.clone()),
)
funding_template.splice_out(outputs, feerate, FeeRate::MAX)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Bug: splice_out now has additive output semantics when a prior contribution exists (via with_prior_contribution). On RBF calls, the builder is seeded with the prior's outputs, then add_outputs(outputs) appends the same withdrawal again — doubling the withdrawal amount.

Trace for the RBF case:

  1. Prior contribution has outputs = [TxOut { value: 546, ... }], no inputs (splice-out)
  2. Builder seeds from prior: outputs = [withdrawal]
  3. splice_out calls add_outputs([withdrawal])outputs = [withdrawal, withdrawal]
  4. build()amend_without_coin_selection constructs a splice-out with doubled outputs
  5. compute_feerate_adjustment checks fee + 1092 <= holder_balance — which passes (capacity > 20K sat)
  6. Result: the RBF splice-out silently withdraws 2 × 546 = 1092 sat instead of 546 sat

The old splice_out_sync destructured the template with .. which discarded the prior contribution, so outputs were always treated as absolute. The new splice_out is additive.

Fix: use without_prior_contribution in the closure when a prior exists, or use rbf_prior_contribution_sync for the RBF case. For example:

let has_prior = funding_template.prior_contribution().is_some();if has_prior {
funding_template.rbf_prior_contribution_sync(Some(feerate),FeeRate::MAX, wallet)}else{
funding_template.splice_out(outputs, feerate,FeeRate::MAX)}

(The splice_in closure has the analogous issue — already flagged in a prior review pass.)

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.

@wpaulino Should we address this given the earlier comment?

// We conditionally splice out `MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS` only when the node// has double the balance required to send a payment upon a `0xff` byte. We do this to// ensure there's always liquidity available for a payment to succeed then.

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.

Discussed offline. We'll wait for #4550 to land.

});
};

Expand DownExpand Up@@ -2450,30 +2444,26 @@ pub fn do_test<Out: Output + MaybeSend + MaybeSync>(data: &[u8], out: Out) {
0xa4 => {
let cp_node_id = nodes[1].get_our_node_id();
let wallet = &wallets[0];
let logger = Arc::clone(&loggers[0]);
let feerate_sat_per_kw = fee_estimators[0].feerate_sat_per_kw();
splice_out(&nodes[0], &cp_node_id, &chan_a_id, wallet, logger, feerate_sat_per_kw);
splice_out(&nodes[0], &cp_node_id, &chan_a_id, wallet, feerate_sat_per_kw);
},
0xa5 => {
let cp_node_id = nodes[0].get_our_node_id();
let wallet = &wallets[1];
let logger = Arc::clone(&loggers[1]);
let feerate_sat_per_kw = fee_estimators[1].feerate_sat_per_kw();
splice_out(&nodes[1], &cp_node_id, &chan_a_id, wallet, logger, feerate_sat_per_kw);
splice_out(&nodes[1], &cp_node_id, &chan_a_id, wallet, feerate_sat_per_kw);
},
0xa6 => {
let cp_node_id = nodes[2].get_our_node_id();
let wallet = &wallets[1];
let logger = Arc::clone(&loggers[1]);
let feerate_sat_per_kw = fee_estimators[1].feerate_sat_per_kw();
splice_out(&nodes[1], &cp_node_id, &chan_b_id, wallet, logger, feerate_sat_per_kw);
splice_out(&nodes[1], &cp_node_id, &chan_b_id, wallet, feerate_sat_per_kw);
},
0xa7 => {
let cp_node_id = nodes[1].get_our_node_id();
let wallet = &wallets[2];
let logger = Arc::clone(&loggers[2]);
let feerate_sat_per_kw = fee_estimators[2].feerate_sat_per_kw();
splice_out(&nodes[2], &cp_node_id, &chan_b_id, wallet, logger, feerate_sat_per_kw);
splice_out(&nodes[2], &cp_node_id, &chan_b_id, wallet, feerate_sat_per_kw);
},

// Sync node by 1 block to cover confirmation of a transaction.
Expand Down
28 changes: 12 additions & 16 deletions fuzz/src/full_stack.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -1083,13 +1083,9 @@ pub fn do_test(mut data: &[u8], logger: &Arc<dyn Logger + MaybeSend + MaybeSync>
value: Amount::from_sat(splice_out_sats),
script_pubkey: wallet.get_change_script().unwrap(),
}];
let wallet_sync = WalletSync::new(&wallet, Arc::clone(&logger));
if let Ok(contribution) = funding_template.splice_out_sync(
outputs,
feerate,
FeeRate::MAX,
&wallet_sync,
) {
if let Ok(contribution) =
funding_template.splice_out(outputs, feerate, FeeRate::MAX)
{
let _ = channelmanager.funding_contributed(
&chan_id,
&counterparty,
Expand DownExpand Up@@ -1890,8 +1886,8 @@ fn splice_seed() -> Vec<u8> {
// CommitmentSigned message with proper signature (r=f7, s=01...) and funding_txid TLV
// signature r encodes sighash first byte f7, s follows the pattern from funding_created
// TLV type 1 (odd/optional) for funding_txid as per impl_writeable_msg!(CommitmentSigned, ...)
// Note: txid is encoded in reverse byte order (Bitcoin standard), so to get display 0000...0033, encode 3300...0000
ext_from_hex("0084 c000000000000000000000000000000000000000000000000000000000000000 00000000000000000000000000000000000000000000000000000000000000f7 0100000000000000000000000000000000000000000000000000000000000000 0000 01 20 3300000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);
// Note: txid is encoded in reverse byte order (Bitcoin standard), so to get display 0000...0031, encode 3100...0000
ext_from_hex("0084 c000000000000000000000000000000000000000000000000000000000000000 00000000000000000000000000000000000000000000000000000000000000f7 0100000000000000000000000000000000000000000000000000000000000000 0000 01 20 3100000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);

// After commitment_signed exchange, we need to exchange tx_signatures.
// Message type IDs: TxSignatures = 71 (0x0047)
Expand All@@ -1904,19 +1900,19 @@ fn splice_seed() -> Vec<u8> {
// inbound read from peer id 0 of len 150 (134 message + 16 MAC)
ext_from_hex("030096", &mut test);
// TxSignatures message with shared_input_signature TLV (type 0)
// txid must match the splice funding txid (0x33 in reverse byte order)
// txid must match the splice funding txid (0x31 in reverse byte order)
// shared_input_signature: 64-byte fuzz signature for the shared input
ext_from_hex("0047 c000000000000000000000000000000000000000000000000000000000000000 3300000000000000000000000000000000000000000000000000000000000000 0000 00 40 00000000000000000000000000000000000000000000000000000000000000dc 0100000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);
ext_from_hex("0047 c000000000000000000000000000000000000000000000000000000000000000 3100000000000000000000000000000000000000000000000000000000000000 0000 00 40 00000000000000000000000000000000000000000000000000000000000000dc 0100000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);

// Connect a block with the splice funding transaction to confirm it
// The splice funding tx: version(4) + input_count(1) + txid(32) + vout(4) + script_len(1) + sequence(4)
// + output_count(1) + value(8) + script_len(1) + script(34) + locktime(4) = 94 bytes = 0x5e
// Transaction structure from FundingTransactionReadyForSigning:
// - Input: spending c000...00:0 with sequence 0xfffffffd
// - Output: 115536 sats to OP_0 PUSH32 6e00...00
// - Output: 115538 sats to OP_0 PUSH32 6e00...00
// - Locktime: 13
ext_from_hex("0c005e", &mut test);
ext_from_hex("02000000 01 c000000000000000000000000000000000000000000000000000000000000000 00000000 00 fdffffff 01 50c3010000000000 22 00206e00000000000000000000000000000000000000000000000000000000000000 0d000000", &mut test);
ext_from_hex("02000000 01 c000000000000000000000000000000000000000000000000000000000000000 00000000 00 fdffffff 01 52c3010000000000 22 00206e00000000000000000000000000000000000000000000000000000000000000 0d000000", &mut test);

// Connect additional blocks to reach minimum_depth confirmations
for _ in 0..5 {
Expand All@@ -1933,8 +1929,8 @@ fn splice_seed() -> Vec<u8> {
// inbound read from peer id 0 of len 82 (66 message + 16 MAC)
ext_from_hex("030052", &mut test);
// SpliceLocked message (type 77 = 0x004d): channel_id + splice_txid + mac
// splice_txid must match the splice funding txid (0x33 in reverse byte order)
ext_from_hex("004d c000000000000000000000000000000000000000000000000000000000000000 3300000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);
// splice_txid must match the splice funding txid (0x31 in reverse byte order)
ext_from_hex("004d c000000000000000000000000000000000000000000000000000000000000000 3100000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);

test
}
Expand DownExpand Up@@ -2064,6 +2060,6 @@ mod tests {

// Splice locked
assert_eq!(log_entries.get(&("lightning::ln::peer_handler".to_string(), "Handling SendSpliceLocked event in peer_handler for node 030000000000000000000000000000000000000000000000000000000000000002 for channel c000000000000000000000000000000000000000000000000000000000000000".to_string())), Some(&1));
assert_eq!(log_entries.get(&("lightning::ln::channel".to_string(), "Promoting splice funding txid 0000000000000000000000000000000000000000000000000000000000000033".to_string())), Some(&1));
assert_eq!(log_entries.get(&("lightning::ln::channel".to_string(), "Promoting splice funding txid 0000000000000000000000000000000000000000000000000000000000000031".to_string())), Some(&1));
}
}
57 changes: 28 additions & 29 deletions lightning/src/ln/channel.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -12324,7 +12324,25 @@ where
);
let min_rbf_feerate = prev_feerate.map(min_rbf_feerate);
let prior = if pending_splice.last_funding_feerate_sat_per_1000_weight.is_some() {
self.build_prior_contribution()
if let Some(prior) = self
.pending_splice
.as_ref()
.and_then(|pending_splice| pending_splice.contributions.last())
{
let holder_balance = self
.get_holder_counterparty_balances_floor_incl_fee(&self.funding)
.map(|(h, _)| h)
.map_err(|e| APIError::ChannelUnavailable {
err: format!(
"Channel {} cannot be spliced at this time: {}",
self.context.channel_id(),
e
),
})?;
Comment thread
wpaulino marked this conversation as resolved.
Some(PriorContribution::new(prior.clone(), holder_balance))
} else {
None
}
} else {
None
};
Expand All@@ -12346,21 +12364,6 @@ where
Ok(FundingTemplate::new(Some(shared_input), min_rbf_feerate, prior_contribution))
}

/// Clones the prior contribution and fetches the holder balance for deferred feerate
/// adjustment.
fn build_prior_contribution(&self) -> Option<PriorContribution> {
debug_assert!(
self.pending_splice.is_some(),
"build_prior_contribution requires pending_splice"
);
let prior = self.pending_splice.as_ref()?.contributions.last()?;
let holder_balance = self
.get_holder_counterparty_balances_floor_incl_fee(&self.funding)
.map(|(h, _)| h)
.ok();
Some(PriorContribution::new(prior.clone(), holder_balance))
}

/// Returns whether this channel can ever RBF, independent of splice state.
fn is_rbf_compatible(&self) -> Result<(), String> {
if self.context.minimum_depth(&self.funding) == Some(0) {
Expand DownExpand Up@@ -12532,14 +12535,12 @@ where
};
}

if let Err(e) = contribution.validate().and_then(|()| {
// For splice-out, our_funding_contribution is adjusted to cover fees if there
// aren't any inputs.
let our_funding_contribution = contribution.net_value();
let our_funding_contribution = contribution.net_value();

if let Err(e) =
self.validate_splice_contributions(our_funding_contribution, SignedAmount::ZERO)
}) {
{
log_error!(logger, "Channel {} cannot be funded: {}", self.context.channel_id(), e);

return Err(QuiescentError::FailSplice(self.splice_funding_failed_for(contribution)));
}

Expand DownExpand Up@@ -14101,13 +14102,11 @@ where
// funding_contributed and quiescence, reducing the holder's
// balance. If invalid, disconnect and return the contribution so
// the user can reclaim their inputs.
if let Err(e) = contribution.validate().and_then(|()| {
let our_funding_contribution = contribution.net_value();
self.validate_splice_contributions(
our_funding_contribution,
SignedAmount::ZERO,
)
}) {
let our_funding_contribution = contribution.net_value();
if let Err(e) = self.validate_splice_contributions(
our_funding_contribution,
SignedAmount::ZERO,
) {
let failed = self.splice_funding_failed_for(contribution);
return Err((
ChannelError::WarnAndDisconnect(format!(
Expand Down
28 changes: 20 additions & 8 deletions lightning/src/ln/channelmanager.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -6638,20 +6638,32 @@ impl<
/// The splice initiator is responsible for paying fees for common fields, shared inputs, and
/// shared outputs along with any contributed inputs and outputs. When building a
/// [`FundingContribution`], fees are estimated at `min_feerate` assuming initiator
/// responsibility and must be covered by the supplied inputs for splice-in or the channel
/// balance for splice-out. If the counterparty also initiates a splice and wins the
/// tie-break, they become the initiator and choose the feerate. The fee is then
/// re-estimated at the counterparty's feerate for only our contributed inputs and outputs,
/// which may be higher or lower than the original estimate. The contribution is dropped and
/// the splice proceeds without it when:
/// responsibility. Contributions fall into two cases:
/// - **input-backed contributions**: when wallet inputs are selected, those inputs pay for both
/// the requested value added to the channel and any explicit withdrawal outputs. For
/// example, a 60,000 sat input might add 50,000 sat to the channel, pay a 2,000 sat fee,
/// and return 8,000 sat as change. A later RBF first tries to preserve that 50,000 sat
/// value added and cover any higher fee or newly requested withdrawal from the original
/// 10,000 sat fee buffer (2,000 sat fee + 8,000 sat change). If that buffer is not enough,
/// the prior contribution cannot be reused without selecting new wallet inputs.
/// - **input-less contributions**: when no wallet inputs are selected, fees and explicit
/// withdrawal outputs are paid from the channel balance. For example, a pure splice-out that
/// withdraws 20,000 sat from a 100,000 sat holder balance leaves up to 80,000 sat available
/// for fees. A later RBF keeps the 20,000 sat withdrawal only while that remaining balance
/// can still cover the re-estimated fee.
///
/// If the counterparty also initiates a splice and wins the tie-break, they become the
/// initiator and choose the feerate. The fee is then re-estimated at the counterparty's
/// feerate for only our contributed inputs and outputs, which may be higher or lower than the
/// original estimate. The contribution is dropped and the splice proceeds without it when:
/// - the counterparty's feerate is below `min_feerate`
/// - the counterparty's feerate is above `max_feerate` and the re-estimated fee exceeds the
/// original fee estimate
/// - the re-estimated fee exceeds the *fee buffer* regardless of `max_feerate`
Comment thread
wpaulino marked this conversation as resolved.
///
/// The fee buffer is the maximum fee that can be accommodated:
/// - **splice-in**: the selected inputs' value minus the contributed amount
/// - **splice-out**: the channel balance minus the withdrawal outputs
/// - **input-backed contributions**: the original fee plus any change output value
/// - **input-less contributions**: the channel balance minus the withdrawal outputs
///
/// # Events
///
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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
20 changes: 5 additions & 15 deletions fuzz/src/chanmon_consistency.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -1504,7 +1504,6 @@ pub fn do_test<Out: Output + MaybeSend + MaybeSync>(data: &[u8], out: Out) {
counterparty_node_id: &PublicKey,
channel_id: &ChannelId,
wallet: &TestWalletSource,
logger: Arc<dyn Logger + MaybeSend + MaybeSync>,
funding_feerate_sat_per_kw: FeeRate| {
// We conditionally splice out `MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS` only when the node
// has double the balance required to send a payment upon a `0xff` byte. We do this to
Expand All@@ -1524,12 +1523,7 @@ pub fn do_test<Out: Output + MaybeSend + MaybeSync>(data: &[u8], out: Out) {
value: Amount::from_sat(MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS),
script_pubkey: wallet.get_change_script().unwrap(),
}];
funding_template.splice_out_sync(
outputs,
feerate,
FeeRate::MAX,
&WalletSync::new(wallet, logger.clone()),
)
funding_template.splice_out(outputs, feerate, FeeRate::MAX)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Bug: splice_out now has additive output semantics when a prior contribution exists (via with_prior_contribution). On RBF calls, the builder is seeded with the prior's outputs, then add_outputs(outputs) appends the same withdrawal again — doubling the withdrawal amount.

Trace for the RBF case:

  1. Prior contribution has outputs = [TxOut { value: 546, ... }], no inputs (splice-out)
  2. Builder seeds from prior: outputs = [withdrawal]
  3. splice_out calls add_outputs([withdrawal])outputs = [withdrawal, withdrawal]
  4. build()amend_without_coin_selection constructs a splice-out with doubled outputs
  5. compute_feerate_adjustment checks fee + 1092 <= holder_balance — which passes (capacity > 20K sat)
  6. Result: the RBF splice-out silently withdraws 2 × 546 = 1092 sat instead of 546 sat

The old splice_out_sync destructured the template with .. which discarded the prior contribution, so outputs were always treated as absolute. The new splice_out is additive.

Fix: use without_prior_contribution in the closure when a prior exists, or use rbf_prior_contribution_sync for the RBF case. For example:

let has_prior = funding_template.prior_contribution().is_some();if has_prior {
funding_template.rbf_prior_contribution_sync(Some(feerate),FeeRate::MAX, wallet)}else{
funding_template.splice_out(outputs, feerate,FeeRate::MAX)}

(The splice_in closure has the analogous issue — already flagged in a prior review pass.)

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.

@wpaulino Should we address this given the earlier comment?

// We conditionally splice out `MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS` only when the node// has double the balance required to send a payment upon a `0xff` byte. We do this to// ensure there's always liquidity available for a payment to succeed then.

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.

Discussed offline. We'll wait for #4550 to land.

});
};

Expand DownExpand Up@@ -2450,30 +2444,26 @@ pub fn do_test<Out: Output + MaybeSend + MaybeSync>(data: &[u8], out: Out) {
0xa4 => {
let cp_node_id = nodes[1].get_our_node_id();
let wallet = &wallets[0];
let logger = Arc::clone(&loggers[0]);
let feerate_sat_per_kw = fee_estimators[0].feerate_sat_per_kw();
splice_out(&nodes[0], &cp_node_id, &chan_a_id, wallet, logger, feerate_sat_per_kw);
splice_out(&nodes[0], &cp_node_id, &chan_a_id, wallet, feerate_sat_per_kw);
},
0xa5 => {
let cp_node_id = nodes[0].get_our_node_id();
let wallet = &wallets[1];
let logger = Arc::clone(&loggers[1]);
let feerate_sat_per_kw = fee_estimators[1].feerate_sat_per_kw();
splice_out(&nodes[1], &cp_node_id, &chan_a_id, wallet, logger, feerate_sat_per_kw);
splice_out(&nodes[1], &cp_node_id, &chan_a_id, wallet, feerate_sat_per_kw);
},
0xa6 => {
let cp_node_id = nodes[2].get_our_node_id();
let wallet = &wallets[1];
let logger = Arc::clone(&loggers[1]);
let feerate_sat_per_kw = fee_estimators[1].feerate_sat_per_kw();
splice_out(&nodes[1], &cp_node_id, &chan_b_id, wallet, logger, feerate_sat_per_kw);
splice_out(&nodes[1], &cp_node_id, &chan_b_id, wallet, feerate_sat_per_kw);
},
0xa7 => {
let cp_node_id = nodes[1].get_our_node_id();
let wallet = &wallets[2];
let logger = Arc::clone(&loggers[2]);
let feerate_sat_per_kw = fee_estimators[2].feerate_sat_per_kw();
splice_out(&nodes[2], &cp_node_id, &chan_b_id, wallet, logger, feerate_sat_per_kw);
splice_out(&nodes[2], &cp_node_id, &chan_b_id, wallet, feerate_sat_per_kw);
},

// Sync node by 1 block to cover confirmation of a transaction.
Expand Down
28 changes: 12 additions & 16 deletions fuzz/src/full_stack.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -1083,13 +1083,9 @@ pub fn do_test(mut data: &[u8], logger: &Arc<dyn Logger + MaybeSend + MaybeSync>
value: Amount::from_sat(splice_out_sats),
script_pubkey: wallet.get_change_script().unwrap(),
}];
let wallet_sync = WalletSync::new(&wallet, Arc::clone(&logger));
if let Ok(contribution) = funding_template.splice_out_sync(
outputs,
feerate,
FeeRate::MAX,
&wallet_sync,
) {
if let Ok(contribution) =
funding_template.splice_out(outputs, feerate, FeeRate::MAX)
{
let _ = channelmanager.funding_contributed(
&chan_id,
&counterparty,
Expand DownExpand Up@@ -1890,8 +1886,8 @@ fn splice_seed() -> Vec<u8> {
// CommitmentSigned message with proper signature (r=f7, s=01...) and funding_txid TLV
// signature r encodes sighash first byte f7, s follows the pattern from funding_created
// TLV type 1 (odd/optional) for funding_txid as per impl_writeable_msg!(CommitmentSigned, ...)
// Note: txid is encoded in reverse byte order (Bitcoin standard), so to get display 0000...0033, encode 3300...0000
ext_from_hex("0084 c000000000000000000000000000000000000000000000000000000000000000 00000000000000000000000000000000000000000000000000000000000000f7 0100000000000000000000000000000000000000000000000000000000000000 0000 01 20 3300000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);
// Note: txid is encoded in reverse byte order (Bitcoin standard), so to get display 0000...0031, encode 3100...0000
ext_from_hex("0084 c000000000000000000000000000000000000000000000000000000000000000 00000000000000000000000000000000000000000000000000000000000000f7 0100000000000000000000000000000000000000000000000000000000000000 0000 01 20 3100000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);

// After commitment_signed exchange, we need to exchange tx_signatures.
// Message type IDs: TxSignatures = 71 (0x0047)
Expand All@@ -1904,19 +1900,19 @@ fn splice_seed() -> Vec<u8> {
// inbound read from peer id 0 of len 150 (134 message + 16 MAC)
ext_from_hex("030096", &mut test);
// TxSignatures message with shared_input_signature TLV (type 0)
// txid must match the splice funding txid (0x33 in reverse byte order)
// txid must match the splice funding txid (0x31 in reverse byte order)
// shared_input_signature: 64-byte fuzz signature for the shared input
ext_from_hex("0047 c000000000000000000000000000000000000000000000000000000000000000 3300000000000000000000000000000000000000000000000000000000000000 0000 00 40 00000000000000000000000000000000000000000000000000000000000000dc 0100000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);
ext_from_hex("0047 c000000000000000000000000000000000000000000000000000000000000000 3100000000000000000000000000000000000000000000000000000000000000 0000 00 40 00000000000000000000000000000000000000000000000000000000000000dc 0100000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);

// Connect a block with the splice funding transaction to confirm it
// The splice funding tx: version(4) + input_count(1) + txid(32) + vout(4) + script_len(1) + sequence(4)
// + output_count(1) + value(8) + script_len(1) + script(34) + locktime(4) = 94 bytes = 0x5e
// Transaction structure from FundingTransactionReadyForSigning:
// - Input: spending c000...00:0 with sequence 0xfffffffd
// - Output: 115536 sats to OP_0 PUSH32 6e00...00
// - Output: 115538 sats to OP_0 PUSH32 6e00...00
// - Locktime: 13
ext_from_hex("0c005e", &mut test);
ext_from_hex("02000000 01 c000000000000000000000000000000000000000000000000000000000000000 00000000 00 fdffffff 01 50c3010000000000 22 00206e00000000000000000000000000000000000000000000000000000000000000 0d000000", &mut test);
ext_from_hex("02000000 01 c000000000000000000000000000000000000000000000000000000000000000 00000000 00 fdffffff 01 52c3010000000000 22 00206e00000000000000000000000000000000000000000000000000000000000000 0d000000", &mut test);

// Connect additional blocks to reach minimum_depth confirmations
for _ in 0..5 {
Expand All@@ -1933,8 +1929,8 @@ fn splice_seed() -> Vec<u8> {
// inbound read from peer id 0 of len 82 (66 message + 16 MAC)
ext_from_hex("030052", &mut test);
// SpliceLocked message (type 77 = 0x004d): channel_id + splice_txid + mac
// splice_txid must match the splice funding txid (0x33 in reverse byte order)
ext_from_hex("004d c000000000000000000000000000000000000000000000000000000000000000 3300000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);
// splice_txid must match the splice funding txid (0x31 in reverse byte order)
ext_from_hex("004d c000000000000000000000000000000000000000000000000000000000000000 3100000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);

test
}
Expand DownExpand Up@@ -2064,6 +2060,6 @@ mod tests {

// Splice locked
assert_eq!(log_entries.get(&("lightning::ln::peer_handler".to_string(), "Handling SendSpliceLocked event in peer_handler for node 030000000000000000000000000000000000000000000000000000000000000002 for channel c000000000000000000000000000000000000000000000000000000000000000".to_string())), Some(&1));
assert_eq!(log_entries.get(&("lightning::ln::channel".to_string(), "Promoting splice funding txid 0000000000000000000000000000000000000000000000000000000000000033".to_string())), Some(&1));
assert_eq!(log_entries.get(&("lightning::ln::channel".to_string(), "Promoting splice funding txid 0000000000000000000000000000000000000000000000000000000000000031".to_string())), Some(&1));
}
}
57 changes: 28 additions & 29 deletions lightning/src/ln/channel.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -12324,7 +12324,25 @@ where
);
let min_rbf_feerate = prev_feerate.map(min_rbf_feerate);
let prior = if pending_splice.last_funding_feerate_sat_per_1000_weight.is_some() {
self.build_prior_contribution()
if let Some(prior) = self
.pending_splice
.as_ref()
.and_then(|pending_splice| pending_splice.contributions.last())
{
let holder_balance = self
.get_holder_counterparty_balances_floor_incl_fee(&self.funding)
.map(|(h, _)| h)
.map_err(|e| APIError::ChannelUnavailable {
err: format!(
"Channel {} cannot be spliced at this time: {}",
self.context.channel_id(),
e
),
})?;
Comment thread
wpaulino marked this conversation as resolved.
Some(PriorContribution::new(prior.clone(), holder_balance))
} else {
None
}
} else {
None
};
Expand All@@ -12346,21 +12364,6 @@ where
Ok(FundingTemplate::new(Some(shared_input), min_rbf_feerate, prior_contribution))
}

/// Clones the prior contribution and fetches the holder balance for deferred feerate
/// adjustment.
fn build_prior_contribution(&self) -> Option<PriorContribution> {
debug_assert!(
self.pending_splice.is_some(),
"build_prior_contribution requires pending_splice"
);
let prior = self.pending_splice.as_ref()?.contributions.last()?;
let holder_balance = self
.get_holder_counterparty_balances_floor_incl_fee(&self.funding)
.map(|(h, _)| h)
.ok();
Some(PriorContribution::new(prior.clone(), holder_balance))
}

/// Returns whether this channel can ever RBF, independent of splice state.
fn is_rbf_compatible(&self) -> Result<(), String> {
if self.context.minimum_depth(&self.funding) == Some(0) {
Expand DownExpand Up@@ -12532,14 +12535,12 @@ where
};
}

if let Err(e) = contribution.validate().and_then(|()| {
// For splice-out, our_funding_contribution is adjusted to cover fees if there
// aren't any inputs.
let our_funding_contribution = contribution.net_value();
let our_funding_contribution = contribution.net_value();

if let Err(e) =
self.validate_splice_contributions(our_funding_contribution, SignedAmount::ZERO)
}) {
{
log_error!(logger, "Channel {} cannot be funded: {}", self.context.channel_id(), e);

return Err(QuiescentError::FailSplice(self.splice_funding_failed_for(contribution)));
}

Expand DownExpand Up@@ -14101,13 +14102,11 @@ where
// funding_contributed and quiescence, reducing the holder's
// balance. If invalid, disconnect and return the contribution so
// the user can reclaim their inputs.
if let Err(e) = contribution.validate().and_then(|()| {
let our_funding_contribution = contribution.net_value();
self.validate_splice_contributions(
our_funding_contribution,
SignedAmount::ZERO,
)
}) {
let our_funding_contribution = contribution.net_value();
if let Err(e) = self.validate_splice_contributions(
our_funding_contribution,
SignedAmount::ZERO,
) {
let failed = self.splice_funding_failed_for(contribution);
return Err((
ChannelError::WarnAndDisconnect(format!(
Expand Down
28 changes: 20 additions & 8 deletions lightning/src/ln/channelmanager.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -6638,20 +6638,32 @@ impl<
/// The splice initiator is responsible for paying fees for common fields, shared inputs, and
/// shared outputs along with any contributed inputs and outputs. When building a
/// [`FundingContribution`], fees are estimated at `min_feerate` assuming initiator
/// responsibility and must be covered by the supplied inputs for splice-in or the channel
/// balance for splice-out. If the counterparty also initiates a splice and wins the
/// tie-break, they become the initiator and choose the feerate. The fee is then
/// re-estimated at the counterparty's feerate for only our contributed inputs and outputs,
/// which may be higher or lower than the original estimate. The contribution is dropped and
/// the splice proceeds without it when:
/// responsibility. Contributions fall into two cases:
/// - **input-backed contributions**: when wallet inputs are selected, those inputs pay for both
/// the requested value added to the channel and any explicit withdrawal outputs. For
/// example, a 60,000 sat input might add 50,000 sat to the channel, pay a 2,000 sat fee,
/// and return 8,000 sat as change. A later RBF first tries to preserve that 50,000 sat
/// value added and cover any higher fee or newly requested withdrawal from the original
/// 10,000 sat fee buffer (2,000 sat fee + 8,000 sat change). If that buffer is not enough,
/// the prior contribution cannot be reused without selecting new wallet inputs.
/// - **input-less contributions**: when no wallet inputs are selected, fees and explicit
/// withdrawal outputs are paid from the channel balance. For example, a pure splice-out that
/// withdraws 20,000 sat from a 100,000 sat holder balance leaves up to 80,000 sat available
/// for fees. A later RBF keeps the 20,000 sat withdrawal only while that remaining balance
/// can still cover the re-estimated fee.
///
/// If the counterparty also initiates a splice and wins the tie-break, they become the
/// initiator and choose the feerate. The fee is then re-estimated at the counterparty's
/// feerate for only our contributed inputs and outputs, which may be higher or lower than the
/// original estimate. The contribution is dropped and the splice proceeds without it when:
/// - the counterparty's feerate is below `min_feerate`
/// - the counterparty's feerate is above `max_feerate` and the re-estimated fee exceeds the
/// original fee estimate
/// - the re-estimated fee exceeds the *fee buffer* regardless of `max_feerate`
Comment thread
wpaulino marked this conversation as resolved.
///
/// The fee buffer is the maximum fee that can be accommodated:
/// - **splice-in**: the selected inputs' value minus the contributed amount
/// - **splice-out**: the channel balance minus the withdrawal outputs
/// - **input-backed contributions**: the original fee plus any change output value
/// - **input-less contributions**: the channel balance minus the withdrawal outputs
///
/// # Events
///
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
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
20 changes: 5 additions & 15 deletions fuzz/src/chanmon_consistency.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -1504,7 +1504,6 @@ pub fn do_test<Out: Output + MaybeSend + MaybeSync>(data: &[u8], out: Out) {
counterparty_node_id: &PublicKey,
channel_id: &ChannelId,
wallet: &TestWalletSource,
logger: Arc<dyn Logger + MaybeSend + MaybeSync>,
funding_feerate_sat_per_kw: FeeRate| {
// We conditionally splice out `MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS` only when the node
// has double the balance required to send a payment upon a `0xff` byte. We do this to
Expand All@@ -1524,12 +1523,7 @@ pub fn do_test<Out: Output + MaybeSend + MaybeSync>(data: &[u8], out: Out) {
value: Amount::from_sat(MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS),
script_pubkey: wallet.get_change_script().unwrap(),
}];
funding_template.splice_out_sync(
outputs,
feerate,
FeeRate::MAX,
&WalletSync::new(wallet, logger.clone()),
)
funding_template.splice_out(outputs, feerate, FeeRate::MAX)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Bug: splice_out now has additive output semantics when a prior contribution exists (via with_prior_contribution). On RBF calls, the builder is seeded with the prior's outputs, then add_outputs(outputs) appends the same withdrawal again — doubling the withdrawal amount.

Trace for the RBF case:

  1. Prior contribution has outputs = [TxOut { value: 546, ... }], no inputs (splice-out)
  2. Builder seeds from prior: outputs = [withdrawal]
  3. splice_out calls add_outputs([withdrawal])outputs = [withdrawal, withdrawal]
  4. build()amend_without_coin_selection constructs a splice-out with doubled outputs
  5. compute_feerate_adjustment checks fee + 1092 <= holder_balance — which passes (capacity > 20K sat)
  6. Result: the RBF splice-out silently withdraws 2 × 546 = 1092 sat instead of 546 sat

The old splice_out_sync destructured the template with .. which discarded the prior contribution, so outputs were always treated as absolute. The new splice_out is additive.

Fix: use without_prior_contribution in the closure when a prior exists, or use rbf_prior_contribution_sync for the RBF case. For example:

let has_prior = funding_template.prior_contribution().is_some();if has_prior {
funding_template.rbf_prior_contribution_sync(Some(feerate),FeeRate::MAX, wallet)}else{
funding_template.splice_out(outputs, feerate,FeeRate::MAX)}

(The splice_in closure has the analogous issue — already flagged in a prior review pass.)

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.

@wpaulino Should we address this given the earlier comment?

// We conditionally splice out `MAX_STD_OUTPUT_DUST_LIMIT_SATOSHIS` only when the node// has double the balance required to send a payment upon a `0xff` byte. We do this to// ensure there's always liquidity available for a payment to succeed then.

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.

Discussed offline. We'll wait for #4550 to land.

});
};

Expand DownExpand Up@@ -2450,30 +2444,26 @@ pub fn do_test<Out: Output + MaybeSend + MaybeSync>(data: &[u8], out: Out) {
0xa4 => {
let cp_node_id = nodes[1].get_our_node_id();
let wallet = &wallets[0];
let logger = Arc::clone(&loggers[0]);
let feerate_sat_per_kw = fee_estimators[0].feerate_sat_per_kw();
splice_out(&nodes[0], &cp_node_id, &chan_a_id, wallet, logger, feerate_sat_per_kw);
splice_out(&nodes[0], &cp_node_id, &chan_a_id, wallet, feerate_sat_per_kw);
},
0xa5 => {
let cp_node_id = nodes[0].get_our_node_id();
let wallet = &wallets[1];
let logger = Arc::clone(&loggers[1]);
let feerate_sat_per_kw = fee_estimators[1].feerate_sat_per_kw();
splice_out(&nodes[1], &cp_node_id, &chan_a_id, wallet, logger, feerate_sat_per_kw);
splice_out(&nodes[1], &cp_node_id, &chan_a_id, wallet, feerate_sat_per_kw);
},
0xa6 => {
let cp_node_id = nodes[2].get_our_node_id();
let wallet = &wallets[1];
let logger = Arc::clone(&loggers[1]);
let feerate_sat_per_kw = fee_estimators[1].feerate_sat_per_kw();
splice_out(&nodes[1], &cp_node_id, &chan_b_id, wallet, logger, feerate_sat_per_kw);
splice_out(&nodes[1], &cp_node_id, &chan_b_id, wallet, feerate_sat_per_kw);
},
0xa7 => {
let cp_node_id = nodes[1].get_our_node_id();
let wallet = &wallets[2];
let logger = Arc::clone(&loggers[2]);
let feerate_sat_per_kw = fee_estimators[2].feerate_sat_per_kw();
splice_out(&nodes[2], &cp_node_id, &chan_b_id, wallet, logger, feerate_sat_per_kw);
splice_out(&nodes[2], &cp_node_id, &chan_b_id, wallet, feerate_sat_per_kw);
},

// Sync node by 1 block to cover confirmation of a transaction.
Expand Down
28 changes: 12 additions & 16 deletions fuzz/src/full_stack.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -1083,13 +1083,9 @@ pub fn do_test(mut data: &[u8], logger: &Arc<dyn Logger + MaybeSend + MaybeSync>
value: Amount::from_sat(splice_out_sats),
script_pubkey: wallet.get_change_script().unwrap(),
}];
let wallet_sync = WalletSync::new(&wallet, Arc::clone(&logger));
if let Ok(contribution) = funding_template.splice_out_sync(
outputs,
feerate,
FeeRate::MAX,
&wallet_sync,
) {
if let Ok(contribution) =
funding_template.splice_out(outputs, feerate, FeeRate::MAX)
{
let _ = channelmanager.funding_contributed(
&chan_id,
&counterparty,
Expand DownExpand Up@@ -1890,8 +1886,8 @@ fn splice_seed() -> Vec<u8> {
// CommitmentSigned message with proper signature (r=f7, s=01...) and funding_txid TLV
// signature r encodes sighash first byte f7, s follows the pattern from funding_created
// TLV type 1 (odd/optional) for funding_txid as per impl_writeable_msg!(CommitmentSigned, ...)
// Note: txid is encoded in reverse byte order (Bitcoin standard), so to get display 0000...0033, encode 3300...0000
ext_from_hex("0084 c000000000000000000000000000000000000000000000000000000000000000 00000000000000000000000000000000000000000000000000000000000000f7 0100000000000000000000000000000000000000000000000000000000000000 0000 01 20 3300000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);
// Note: txid is encoded in reverse byte order (Bitcoin standard), so to get display 0000...0031, encode 3100...0000
ext_from_hex("0084 c000000000000000000000000000000000000000000000000000000000000000 00000000000000000000000000000000000000000000000000000000000000f7 0100000000000000000000000000000000000000000000000000000000000000 0000 01 20 3100000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);

// After commitment_signed exchange, we need to exchange tx_signatures.
// Message type IDs: TxSignatures = 71 (0x0047)
Expand All@@ -1904,19 +1900,19 @@ fn splice_seed() -> Vec<u8> {
// inbound read from peer id 0 of len 150 (134 message + 16 MAC)
ext_from_hex("030096", &mut test);
// TxSignatures message with shared_input_signature TLV (type 0)
// txid must match the splice funding txid (0x33 in reverse byte order)
// txid must match the splice funding txid (0x31 in reverse byte order)
// shared_input_signature: 64-byte fuzz signature for the shared input
ext_from_hex("0047 c000000000000000000000000000000000000000000000000000000000000000 3300000000000000000000000000000000000000000000000000000000000000 0000 00 40 00000000000000000000000000000000000000000000000000000000000000dc 0100000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);
ext_from_hex("0047 c000000000000000000000000000000000000000000000000000000000000000 3100000000000000000000000000000000000000000000000000000000000000 0000 00 40 00000000000000000000000000000000000000000000000000000000000000dc 0100000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);

// Connect a block with the splice funding transaction to confirm it
// The splice funding tx: version(4) + input_count(1) + txid(32) + vout(4) + script_len(1) + sequence(4)
// + output_count(1) + value(8) + script_len(1) + script(34) + locktime(4) = 94 bytes = 0x5e
// Transaction structure from FundingTransactionReadyForSigning:
// - Input: spending c000...00:0 with sequence 0xfffffffd
// - Output: 115536 sats to OP_0 PUSH32 6e00...00
// - Output: 115538 sats to OP_0 PUSH32 6e00...00
// - Locktime: 13
ext_from_hex("0c005e", &mut test);
ext_from_hex("02000000 01 c000000000000000000000000000000000000000000000000000000000000000 00000000 00 fdffffff 01 50c3010000000000 22 00206e00000000000000000000000000000000000000000000000000000000000000 0d000000", &mut test);
ext_from_hex("02000000 01 c000000000000000000000000000000000000000000000000000000000000000 00000000 00 fdffffff 01 52c3010000000000 22 00206e00000000000000000000000000000000000000000000000000000000000000 0d000000", &mut test);

// Connect additional blocks to reach minimum_depth confirmations
for _ in 0..5 {
Expand All@@ -1933,8 +1929,8 @@ fn splice_seed() -> Vec<u8> {
// inbound read from peer id 0 of len 82 (66 message + 16 MAC)
ext_from_hex("030052", &mut test);
// SpliceLocked message (type 77 = 0x004d): channel_id + splice_txid + mac
// splice_txid must match the splice funding txid (0x33 in reverse byte order)
ext_from_hex("004d c000000000000000000000000000000000000000000000000000000000000000 3300000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);
// splice_txid must match the splice funding txid (0x31 in reverse byte order)
ext_from_hex("004d c000000000000000000000000000000000000000000000000000000000000000 3100000000000000000000000000000000000000000000000000000000000000 03000000000000000000000000000000", &mut test);

test
}
Expand DownExpand Up@@ -2064,6 +2060,6 @@ mod tests {

// Splice locked
assert_eq!(log_entries.get(&("lightning::ln::peer_handler".to_string(), "Handling SendSpliceLocked event in peer_handler for node 030000000000000000000000000000000000000000000000000000000000000002 for channel c000000000000000000000000000000000000000000000000000000000000000".to_string())), Some(&1));
assert_eq!(log_entries.get(&("lightning::ln::channel".to_string(), "Promoting splice funding txid 0000000000000000000000000000000000000000000000000000000000000033".to_string())), Some(&1));
assert_eq!(log_entries.get(&("lightning::ln::channel".to_string(), "Promoting splice funding txid 0000000000000000000000000000000000000000000000000000000000000031".to_string())), Some(&1));
}
}
57 changes: 28 additions & 29 deletions lightning/src/ln/channel.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -12324,7 +12324,25 @@ where
);
let min_rbf_feerate = prev_feerate.map(min_rbf_feerate);
let prior = if pending_splice.last_funding_feerate_sat_per_1000_weight.is_some() {
self.build_prior_contribution()
if let Some(prior) = self
.pending_splice
.as_ref()
.and_then(|pending_splice| pending_splice.contributions.last())
{
let holder_balance = self
.get_holder_counterparty_balances_floor_incl_fee(&self.funding)
.map(|(h, _)| h)
.map_err(|e| APIError::ChannelUnavailable {
err: format!(
"Channel {} cannot be spliced at this time: {}",
self.context.channel_id(),
e
),
})?;
Comment thread
wpaulino marked this conversation as resolved.
Some(PriorContribution::new(prior.clone(), holder_balance))
} else {
None
}
} else {
None
};
Expand All@@ -12346,21 +12364,6 @@ where
Ok(FundingTemplate::new(Some(shared_input), min_rbf_feerate, prior_contribution))
}

/// Clones the prior contribution and fetches the holder balance for deferred feerate
/// adjustment.
fn build_prior_contribution(&self) -> Option<PriorContribution> {
debug_assert!(
self.pending_splice.is_some(),
"build_prior_contribution requires pending_splice"
);
let prior = self.pending_splice.as_ref()?.contributions.last()?;
let holder_balance = self
.get_holder_counterparty_balances_floor_incl_fee(&self.funding)
.map(|(h, _)| h)
.ok();
Some(PriorContribution::new(prior.clone(), holder_balance))
}

/// Returns whether this channel can ever RBF, independent of splice state.
fn is_rbf_compatible(&self) -> Result<(), String> {
if self.context.minimum_depth(&self.funding) == Some(0) {
Expand DownExpand Up@@ -12532,14 +12535,12 @@ where
};
}

if let Err(e) = contribution.validate().and_then(|()| {
// For splice-out, our_funding_contribution is adjusted to cover fees if there
// aren't any inputs.
let our_funding_contribution = contribution.net_value();
let our_funding_contribution = contribution.net_value();

if let Err(e) =
self.validate_splice_contributions(our_funding_contribution, SignedAmount::ZERO)
}) {
{
log_error!(logger, "Channel {} cannot be funded: {}", self.context.channel_id(), e);

return Err(QuiescentError::FailSplice(self.splice_funding_failed_for(contribution)));
}

Expand DownExpand Up@@ -14101,13 +14102,11 @@ where
// funding_contributed and quiescence, reducing the holder's
// balance. If invalid, disconnect and return the contribution so
// the user can reclaim their inputs.
if let Err(e) = contribution.validate().and_then(|()| {
let our_funding_contribution = contribution.net_value();
self.validate_splice_contributions(
our_funding_contribution,
SignedAmount::ZERO,
)
}) {
let our_funding_contribution = contribution.net_value();
if let Err(e) = self.validate_splice_contributions(
our_funding_contribution,
SignedAmount::ZERO,
) {
let failed = self.splice_funding_failed_for(contribution);
return Err((
ChannelError::WarnAndDisconnect(format!(
Expand Down
28 changes: 20 additions & 8 deletions lightning/src/ln/channelmanager.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -6638,20 +6638,32 @@ impl<
/// The splice initiator is responsible for paying fees for common fields, shared inputs, and
/// shared outputs along with any contributed inputs and outputs. When building a
/// [`FundingContribution`], fees are estimated at `min_feerate` assuming initiator
/// responsibility and must be covered by the supplied inputs for splice-in or the channel
/// balance for splice-out. If the counterparty also initiates a splice and wins the
/// tie-break, they become the initiator and choose the feerate. The fee is then
/// re-estimated at the counterparty's feerate for only our contributed inputs and outputs,
/// which may be higher or lower than the original estimate. The contribution is dropped and
/// the splice proceeds without it when:
/// responsibility. Contributions fall into two cases:
/// - **input-backed contributions**: when wallet inputs are selected, those inputs pay for both
/// the requested value added to the channel and any explicit withdrawal outputs. For
/// example, a 60,000 sat input might add 50,000 sat to the channel, pay a 2,000 sat fee,
/// and return 8,000 sat as change. A later RBF first tries to preserve that 50,000 sat
/// value added and cover any higher fee or newly requested withdrawal from the original
/// 10,000 sat fee buffer (2,000 sat fee + 8,000 sat change). If that buffer is not enough,
/// the prior contribution cannot be reused without selecting new wallet inputs.
/// - **input-less contributions**: when no wallet inputs are selected, fees and explicit
/// withdrawal outputs are paid from the channel balance. For example, a pure splice-out that
/// withdraws 20,000 sat from a 100,000 sat holder balance leaves up to 80,000 sat available
/// for fees. A later RBF keeps the 20,000 sat withdrawal only while that remaining balance
/// can still cover the re-estimated fee.
///
/// If the counterparty also initiates a splice and wins the tie-break, they become the
/// initiator and choose the feerate. The fee is then re-estimated at the counterparty's
/// feerate for only our contributed inputs and outputs, which may be higher or lower than the
/// original estimate. The contribution is dropped and the splice proceeds without it when:
/// - the counterparty's feerate is below `min_feerate`
/// - the counterparty's feerate is above `max_feerate` and the re-estimated fee exceeds the
/// original fee estimate
/// - the re-estimated fee exceeds the *fee buffer* regardless of `max_feerate`
Comment thread
wpaulino marked this conversation as resolved.
///
/// The fee buffer is the maximum fee that can be accommodated:
/// - **splice-in**: the selected inputs' value minus the contributed amount
/// - **splice-out**: the channel balance minus the withdrawal outputs
/// - **input-backed contributions**: the original fee plus any change output value
/// - **input-less contributions**: the channel balance minus the withdrawal outputs
///
/// # Events
///
Expand Down
Loading
Loading