Include to_self_delay size in DelayedPaymentOutput weight calculation
What changed, and why it matters
This commit fixes a small accounting bug in how the Lightning wallet estimates the size (and therefore transaction fee) of a special Bitcoin transaction that sweeps funds back to the user after a channel closes. The old code always assumed the largest possible 4-byte encoding of a delay value, even when the real value used only 1 byte. That could make the fee estimate slightly too high and, in rare cases with a short digital signature, trigger an internal debug-only assertion failure. The fix computes the exact size based on the actual delay value and adds a regression test. It is not a remote exploit and does not risk loss of funds.
No urgent security action required. Reviewers should verify that max_witness_length() is used consistently wherever DelayedPaymentOutputDescriptor::MAX_WITNESS_LENGTH was previously referenced, and confirm the regression test covers the relevant to_self_delay ranges. Downstream users relying on the removed MAX_WITNESS_LENGTH constant should migrate to the new method.
Security signals we found
debug assertion failure possible in development/testing builds
transaction weight/fee estimate overestimation up to 3 WU
constant replaced with per-descriptor length computation
regression test added covering 1-byte through 4-byte OP_CSV encodings
Evidence from the diff
DelayedPaymentOutputDescriptor::MAX_WITNESS_LENGTH was a constant that assumed a 4-byte OP_CSV push for to_self_delay. The real push length is 1–4 bytes depending on the u16 value, so create_spendable_outputs_psbt could overestimate the witness weight by up to 3 WU. If that overestimate coincided with a signature shorter than MAX_STANDARD_SIGNATURE_SIZE, the debug_assert in KeysManager::spend_spendable_outputs comparing estimated and actual weights could fail. The patch introduces revokeable_redeemscript_len(contest_delay) and a per-descriptor max_witness_length() method that uses the actual to_self_delay, then updates the PSBT weight estimate and adds a regression test. The assertion is debug-only, so production builds would not panic; the practical effect is a more accurate fee estimate and no spurious debug failures.
Changed components
lightning/src/ln/chan_utils.rslightning/src/sign/mod.rsSpendableOutputDescriptor::create_spendable_outputs_psbtKeysManager::spend_spendable_outputsDelayedPaymentOutputDescriptorInspect captured patch +97 / −8
diff --git a/lightning/src/ln/chan_utils.rs b/lightning/src/ln/chan_utils.rs
index dd33477..781baec 100644
--- a/lightning/src/ln/chan_utils.rs
+++ b/lightning/src/ln/chan_utils.rs
@@ -668,6 +668,18 @@ impl TxCreationKeys {
// on-chain funds.
pub const REVOKEABLE_REDEEMSCRIPT_MAX_LENGTH: usize = 6 + 4 + 34 * 2;
+/// The exact length of the script returned by [`get_revokeable_redeemscript`] for a given
+/// `contest_delay`.
+///
+/// This is always at most [`REVOKEABLE_REDEEMSCRIPT_MAX_LENGTH`], and shorter when `contest_delay`
+/// encodes to fewer than the maximum 4 bytes.
+pub fn revokeable_redeemscript_len(contest_delay: u16) -> usize {
+ // 6 bytes of opcodes + the `OP_CSV` value push + two 33-byte public keys (each with a 1-byte
+ // push).
+ let contest_delay_push_len = Builder::new().push_int(contest_delay as i64).into_script().len();
+ 6 + contest_delay_push_len + 34 * 2
+}
+
/// A script either spendable by the revocation
/// key or the broadcaster_delayed_payment_key and satisfying the relative-locktime OP_CSV constrain.
/// Encumbering a `to_holder` output on a commitment transaction or 2nd-stage HTLC transactions.
@@ -683,7 +695,7 @@ pub fn get_revokeable_redeemscript(revocation_key: &RevocationKey, contest_delay
.push_opcode(opcodes::all::OP_ENDIF)
.push_opcode(opcodes::all::OP_CHECKSIG)
.into_script();
- debug_assert!(res.len() <= REVOKEABLE_REDEEMSCRIPT_MAX_LENGTH);
+ debug_assert_eq!(res.len(), revokeable_redeemscript_len(contest_delay));
res
}
diff --git a/lightning/src/sign/mod.rs b/lightning/src/sign/mod.rs
index b81d382..f2907ae 100644
--- a/lightning/src/sign/mod.rs
+++ b/lightning/src/sign/mod.rs
@@ -109,14 +109,21 @@ pub struct DelayedPaymentOutputDescriptor {
impl DelayedPaymentOutputDescriptor {
/// The maximum length a well-formed witness spending one of these should have.
///
+ /// This depends on the descriptor's [`to_self_delay`], whose `OP_CSV` push in the revocable
+ /// redeemscript varies in length.
+ ///
/// Note: If you have the `grind_signatures` feature enabled, this will be at least 1 byte
/// shorter.
- pub const MAX_WITNESS_LENGTH: u64 = (1 /* witness items */
- + 1 /* sig push */
- + MAX_STANDARD_SIGNATURE_SIZE
- + 1 /* empty vec push */
- + 1 /* redeemscript push */
- + chan_utils::REVOKEABLE_REDEEMSCRIPT_MAX_LENGTH) as u64;
+ ///
+ /// [`to_self_delay`]: Self::to_self_delay
+ pub fn max_witness_length(&self) -> u64 {
+ (1 /* witness items */
+ + 1 /* sig push */
+ + MAX_STANDARD_SIGNATURE_SIZE
+ + 1 /* empty vec push */
+ + 1 /* redeemscript push */
+ + chan_utils::revokeable_redeemscript_len(self.to_self_delay)) as u64
+ }
}
impl_ser_tlv_based!(DelayedPaymentOutputDescriptor, {
@@ -502,7 +509,7 @@ impl SpendableOutputDescriptor {
sequence: Sequence(descriptor.to_self_delay as u32),
witness: Witness::new(),
});
- witness_weight += DelayedPaymentOutputDescriptor::MAX_WITNESS_LENGTH;
+ witness_weight += descriptor.max_witness_length();
#[cfg(feature = "grind_signatures")]
{
// Guarantees a low R signature
@@ -2717,6 +2724,71 @@ pub fn dyn_sign() {
let _signer: Box<dyn EcdsaChannelSigner>;
}
+// Regression test: the sweep-weight estimate for a `to_local` (`DelayedPaymentOutput`) output must
+// reflect the channel's `to_self_delay`.
+//
+// The revocable redeemscript encodes `to_self_delay` with an `OP_CSV` push that can vary in size
+// from 1 byte (for `to_self_delay <= 16`) up to 4 bytes. `create_spendable_outputs_psbt` used to
+// estimate every such output with the maximum 4-byte push, overshooting the real sweep weight by up
+// to 3 WU for a small `to_self_delay`. If this occurred along with a short signature, an assertion
+// would fail in `KeysManager::spend_spendable_outputs`.
+#[test]
+fn sweep_weight_estimate_accounts_for_to_self_delay() {
+ let secp_ctx = Secp256k1::new();
+ let per_commitment_point =
+ PublicKey::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[1u8; 32]).unwrap());
+ let delayed_payment_key = DelayedPaymentKey(PublicKey::from_secret_key(
+ &secp_ctx,
+ &SecretKey::from_slice(&[3u8; 32]).unwrap(),
+ ));
+ let revocation_pubkey = RevocationKey(PublicKey::from_secret_key(
+ &secp_ctx,
+ &SecretKey::from_slice(&[2u8; 32]).unwrap(),
+ ));
+ let change_script = ScriptBuf::new_p2wpkh(&WPubkeyHash::from_byte_array([7u8; 20]));
+
+ let estimate = |to_self_delay: u16| {
+ let witness_script =
+ get_revokeable_redeemscript(&revocation_pubkey, to_self_delay, &delayed_payment_key);
+ let descriptor =
+ SpendableOutputDescriptor::DelayedPaymentOutput(DelayedPaymentOutputDescriptor {
+ outpoint: OutPoint { txid: Txid::from_byte_array([1u8; 32]), index: 0 },
+ per_commitment_point,
+ to_self_delay,
+ output: TxOut {
+ value: Amount::from_sat(1_000_000),
+ script_pubkey: witness_script.to_p2wsh(),
+ },
+ revocation_pubkey,
+ channel_keys_id: [1u8; 32],
+ channel_value_satoshis: 1_000_000,
+ channel_transaction_parameters: None,
+ });
+ SpendableOutputDescriptor::create_spendable_outputs_psbt(
+ &secp_ctx,
+ &[&descriptor],
+ vec![],
+ change_script.clone(),
+ 253,
+ None,
+ )
+ .unwrap()
+ .1
+ };
+
+ // The estimate should adjust according to the `to_self_delay` push length.
+ let max_estimate = estimate(65_535); // 4-byte `OP_CSV` push
+ for (to_self_delay, push_len) in
+ [(0u16, 1u64), (16, 1), (17, 2), (127, 2), (128, 3), (32_767, 3), (32_768, 4), (65_535, 4)]
+ {
+ assert_eq!(
+ estimate(to_self_delay),
+ max_estimate - (4 - push_len),
+ "wrong sweep-weight estimate for to_self_delay={to_self_delay}",
+ );
+ }
+}
+
#[cfg(ldk_bench)]
pub mod benches {
use crate::sign::{EntropySource, KeysManager};
diff --git a/pending_changelog/4833-delayed-payment-max-witness-length.txt b/pending_changelog/4833-delayed-payment-max-witness-length.txt
new file mode 100644
index 0000000..e97f07f
--- /dev/null
+++ b/pending_changelog/4833-delayed-payment-max-witness-length.txt
@@ -0,0 +1,5 @@
+# API Updates
+ * `DelayedPaymentOutputDescriptor::MAX_WITNESS_LENGTH` was removed in favor of
+ the new `DelayedPaymentOutputDescriptor::max_witness_length` method, which
+ returns a tighter witness weight by accounting for the descriptor's
+ `to_self_delay` (#4833).
Why this scored 26/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.