fix: is_ddust_tx sighash check for P2WSH multisig inputs - #46
Conversation
There was a problem hiding this comment.
Pull request overview
Fixes is_ddust_tx so it no longer falsely rejects P2WSH (and P2SH-P2WSH) multisig inputs, where witness.nth(0) is the empty OP_0 placeholder rather than a signature. The new logic walks every witness item, skips the trailing item (assumed to be the witness script), and verifies the sighash byte of each remaining signature.
Changes:
- Replace single
nth(0)sighash check with a loop over all witness items. - Skip the last witness item (treated as the witness script) and tolerate items of unrecognized lengths (
_ => continueinstead ofreturn false). - Combine 64- and 65-byte cases into a single Taproot match arm.
Comments suppressed due to low confidence (3)
src/main.rs:680
- Changing the fallback arm from
return falseto_ => continuesilently relaxes validation. Previously, any unrecognized witness item shape on a segwit input caused the transaction to be rejected as not matching the ddust pattern; now any item whose length is outside 64/65/70..=73 is ignored. While this is necessary to skip the emptyOP_0placeholder in P2WSH multisig and the witness script itself, in combination with skipping only the last item it also means signatures with unexpected lengths (e.g., truncated/odd encodings) are no longer treated as a mismatch. Consider explicitly skipping only known non-signature items (empty pushes, the witness script/control block) and keeping the strict default for everything else, or at minimum document why permissive behavior is now safe.
_ => continue,
src/main.rs:678
- A 64-byte Taproot signature has no appended sighash byte and implies SIGHASH_DEFAULT (semantically
ALL, notALL|ANYONECANPAY). Including64in this match arm and then comparing the last byte of the signature againstTapSighashType::AllPlusAnyoneCanPaytreats the final byte of the schnorr signature as if it were a sighash flag. While this will almost always still result inreturn false(because that byte is effectively random), the logic is misleading: such inputs should be unconditionally rejected as not matching ALL|ANYONECANPAY rather than checked byte-wise. Consider matching only65here and rejecting bare 64-byte schnorr sigs explicitly.
// Taproot signature
64 | 65 => {
if *item.last().unwrap() != TapSighashType::AllPlusAnyoneCanPay as u8 {
return false;
}
src/main.rs:696
- No tests appear to accompany this fix. The PR addresses a bug specifically demonstrated by a 2-of-2 P2WSH multisig transaction (per the linked issue), but no regression test for P2WSH multisig (or for P2WPKH and Taproot inputs that previously worked) is added. Consider adding unit tests for
is_ddust_txcovering: P2WPKH, P2WSH single-sig, P2WSH multisig with OP_0, P2SH-P2WSH multisig, Taproot key-path, and Taproot script-path — both with and without ALL|ANYONECANPAY — to lock in the intended behavior and guard against regressions like the key-path skip described above.
for input in &tx.input {
if !input.witness.is_empty() {
// segwit input: check sighash byte of each signature in the witness stack
// skip the last item (witness script) and empty OP_0
let witness_len = input.witness.len();
for (i, item) in input.witness.iter().enumerate() {
if i == witness_len - 1 {
continue;
}
match item.len() {
// ECDSA signature
70..=73 => {
if *item.last().unwrap() != EcdsaSighashType::AllPlusAnyoneCanPay as u8 {
return false;
}
}
// Taproot signature
64 | 65 => {
if *item.last().unwrap() != TapSighashType::AllPlusAnyoneCanPay as u8 {
return false;
}
}
_ => continue,
}
}
}
// legacy input: check sighash byte from scriptSig
else if input.script_sig.is_p2pkh() || input.script_sig.is_p2sh() {
for instruction in input.script_sig.instructions() {
if let Ok(Instruction::PushBytes(data)) = instruction
&& let Ok(sig) = Signature::from_slice(data.as_bytes())
&& sig.sighash_type != EcdsaSighashType::AllPlusAnyoneCanPay
{
return false;
}
}
}
}
true
| let witness_len = input.witness.len(); | ||
| for (i, item) in input.witness.iter().enumerate() { | ||
| if i == witness_len - 1 { | ||
| continue; | ||
| } | ||
| // ECDSA (P2WPKH/P2WSH) — low-R/low-S sigs (with sighash byte) are typically | ||
| // 71 B, but can be 70 when s has a leading 0x00, or 72 in non-grinded paths. | ||
| 70..=73 => { | ||
| if *sig.last().unwrap() != EcdsaSighashType::AllPlusAnyoneCanPay as u8 { | ||
| return false; | ||
| match item.len() { | ||
| // ECDSA signature | ||
| 70..=73 => { | ||
| if *item.last().unwrap() != EcdsaSighashType::AllPlusAnyoneCanPay as u8 { | ||
| return false; | ||
| } | ||
| } | ||
| // Taproot signature | ||
| 64 | 65 => { | ||
| if *item.last().unwrap() != TapSighashType::AllPlusAnyoneCanPay as u8 { | ||
| return false; | ||
| } | ||
| } | ||
| _ => continue, | ||
| } | ||
| // Taproot default sighash (64 bytes) or unknown | ||
| _ => return false, | ||
| } |
4581f2b to
8f8752f
Compare
|
Squashed to single commit. The taproot fix was a Copilot suggestion I verified and combined both related fixes for cleaner history. |
7aec88c to
d150576
Compare
|
All requested changes complete:
Run Ready for review. |
|
utACK. I reviewed briefly, looks good, would like to test it locally and re-ack |
harismuzaffer
left a comment
There was a problem hiding this comment.
Thanks for the work, lets address the pending comments
d150576 to
f1e30ed
Compare
There was a problem hiding this comment.
Looks good to me. @SIDHARTH20K4 create a separate issue for P2tr script path or feel free to fix it as part of #22
|
@SIDHARTH20K4 please sign your commit and make sure it is rebased on the latest commits from the |
f1e30ed to
8f83a48
Compare
|
@bubb1es71 done, rebased on latest main and signed the commit. |
@SIDHARTH20K4 the commit is unverified, please check why it is so |
I don't think you rebased this correctly, make sure your local |
8f83a48 to
401c07e
Compare
rebased on |
|
I ran this commit through Claude and it found some problems. Please take a look and see if these are real issues you should fix:
Summary & RecommendationsCritical Issues:
|
|
thanks for the detailed review, this is really helping me understand the witness structure better. will fix these issues and update the PR. |
401c07e to
e092f45
Compare
|
added |
Replace manual signature length checks and byte extraction with rust-bitcoin's built-in signature parsing functions. Added TODO comments to replace script type huristic and add support for taproot script-path spend validation.
|
@bubb1es71 |
fixes #45
what was wrong
input.witness.nth(0)always grabs the emptyOP_0item for P2WSH inputs, so the sighash check was failing for valid P2WSH and P2SH-P2WSH multisig transactions.what I changed:
loop through all witness items, skip the last item (witness script) and check each signature's sighash byte individually.