Skip to content

plan: fix underreported witness and scriptSig sizes for wsh and sh() wrapping - #1046

Open
portlandhodl wants to merge 4 commits into
rust-bitcoin:masterfrom
portlandhodl:2026-09/plan-size-fixes
Open

portlandhodl wants to merge 4 commits into
rust-bitcoin:masterfrom
portlandhodl:2026-09/plan-size-fixes

Conversation

@portlandhodl

@portlandhodl portlandhodl commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Reimplements #1045, where these bugs were originally found and reported by @guggero.

Fixes two size-accounting bugs in Plan fee-estimation helpers that caused input sizes (and therefore fee estimates) to be underreported:

  • Count the witness script in witness_size for wsh and sh(wsh). Plan::satisfy() appends the witness script as the final witness element for wsh and sh(wsh) descriptors (Append witness script for P2WSH in Plan::satisfy() #897), but witness_size() only summed the template, underestimating the witness by the whole script (varint length prefix plus script bytes).

  • Fix scriptSig size for sh(wpkh) and sh(wsh). The scriptSig of a nested-segwit input is a push of the witness program, and scriptsig_size() missed the outer push opcode byte: sh(wpkh) is 24 bytes on the wire, not 23, and sh(wsh) is 36 bytes, not 35. Fee estimates were 4 WU per input too low.

  • Regression tests: hold witness_size() and scriptsig_size() to the exact wire size of a real satisfaction and satisfaction_weight() to Descriptor::max_weight_to_satisfy(), for both a sigless wsh/sh(wsh) path (no signature size variance) and — using 72-byte signatures that exactly fill the 73-byte-per-signature budget documented on types::extra_props::SatData — signature-bearing wpkh, sh(wpkh), wsh and sh(wsh) paths.

Assisted-by: Kimi kimi-latest

Plan::satisfy() appends the witness script as the final witness element
for wsh and sh(wsh) descriptors (rust-bitcoin#897), but witness_size() only summed
the template, underestimating the witness by the whole script (varint
length prefix plus script bytes) and skewing fee estimation.

Updates the affected unit tests to the corrected witness sizes.

Assisted-by: Kimi kimi-latest
The scriptSig of a nested-segwit input is a push of the witness
program, and scriptsig_size() missed that outer push opcode byte:
sh(wpkh) is 24 bytes on the wire, not 23, and sh(wsh) is 36 bytes,
not 35. Fee estimates were 4 WU per input too low.

Assisted-by: Kimi kimi-latest
Adds a regression test holding witness_size() and scriptsig_size() to
the exact wire size of a real satisfaction (a sigless wsh/sh(wsh) path,
so signature size variance does not apply) and satisfaction_weight() to
Descriptor::max_weight_to_satisfy(), plus the exact scriptSig check for
sh(wpkh).

Assisted-by: Kimi kimi-latest
@portlandhodl
portlandhodl marked this pull request as draft September 4, 2026 18:41
@apoelstra

Copy link
Copy Markdown
Member

What needs to be done to undraft this?

@portlandhodl

Copy link
Copy Markdown
Collaborator Author

Minimum: I need to squash commits 4 + 5.

Bigger picture I am investigating : while holding these sizes byte-for-byte against a real satisfaction, I found the problem doesn't stop at segwit. The legacy
sh(<miniscript>) path is worse than a size bug:

  • Plan::satisfy() never appends the redeem script for DescriptorType::Sh.
    The scriptSig it produces fails the P2SH hash check, so the spend is
    consensus-invalid, not just mispriced.
  • scriptsig_size() accordingly omits the redeem script push entirely.
  • It also uses the witness item-count varint as the scriptSig length
    prefix. Those agree for small scripts and diverge once the scriptSig
    passes 252 bytes, which is routine for P2SH miniscript.

Then this: the old push_slice path encoded OP_1 (thresh/or branch selectors) as OP_PUSHBYTES_1 0x01, non-minimal, so even sub-252-byte sh() scriptSigs could differ byte-for-byte from get_satisfaction().

@portlandhodl
portlandhodl marked this pull request as ready for review September 15, 2026 13:13
A plan budgets every ECDSA signature at 73 bytes including its length
prefix (the convention documented on types::extra_props::SatData), so a
satisfier handing out 72-byte signatures (71-byte DER plus sighash
flag, filling the budget exactly) lets wpkh, sh(wpkh), wsh and sh(wsh)
plans be held to the exact wire size of a real satisfaction, not just
the sigless paths.

Assisted-by: Kimi kimi-latest
@portlandhodl
portlandhodl force-pushed the 2026-09/plan-size-fixes branch from e3bd963 to ab32aa1 Compare September 15, 2026 13:15

@Abeeujah Abeeujah left a comment

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.

ACK ab32aa1

Comment thread src/plan.rs
Comment on lines 259 to 276
if self.descriptor.desc_type().segwit_version().is_some() {
witness_size(self.template.as_ref())
let template_size = witness_size(self.template.as_ref());
match self.descriptor.desc_type() {
DescriptorType::Wsh | DescriptorType::ShWsh => {
let script_len = self
.descriptor
.explicit_script()
.expect("wsh descriptors have explicit script")
.len();
template_size + varint_len(script_len) + script_len
- varint_len(self.template.len())
+ varint_len(self.template.len() + 1)
}
_ => template_size,
}
} else {
0
}

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.

Nit, this can be done if you have to address any other changes, for scannability, I would implement the function like this:

pub fn witness_size(&self) -> usize {
    let desc_type = self.descriptor.desc_type();

    if desc_type.segwit_version().is_none() {
        return 0;
    }

    let template_size = witness_size(self.template.as_ref());
    match desc_type {
        DescriptorType::Wsh | DescriptorType::ShWsh => {
            let script_len = self
                .descriptor
                .explicit_script()
                .expect("wsh descriptors have explicit script")
                .len();
            let template_len = self.template.len();
            let varint_diff = varint_len(template_len + 1) - varint_len(template_len);
            template_size + varint_len(script_len) + script_len + varint_diff
        }
        _ => template_size,
    }
}
  • This reduces the amount of calls to desc_type from two to one.
  • Reduces Logic nesting by early returning 0 if it's not a witness program.
  • Separates the varint arithmetic from the witness size arithmetic.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants