test(taproot): assert the serialized transaction, not just the signature - #209
Merged
BitHighlander merged 2 commits intoAug 4, 2026
Merged
Conversation
Every signing test read the `signature` protobuf field and either discarded `serialized_tx` or checked a substring of it. `signature` and `serialized_tx` are separate nanopb fields with independent presence flags, so a firmware path that populated one and not the other passed the whole suite -- which is exactly what shipped: the taproot branch omitted has_serialized_tx and the host silently lost the 66-byte witness and the 4-byte locktime footer. The substring check in test_send_p2tr_with_change could not have caught it either: the change scriptPubKey it looked for is serialized in phase 1, well before any witness, so it survives a truncated suffix. assertCompleteSegwitTx() parses the transaction per BIP-144 and requires it to consume exactly len(raw): a segwit marker promises witness data, so a dropped witness now runs the stream off the end instead of passing unnoticed. It returns the per-input witness stacks, letting the tests assert that a key-path spend carries exactly one 64-byte element and that a legacy input still serializes its empty 0x00 witness. Each test now also pins the full serialization. Those goldens were captured from a fixed-firmware emulator run and independently rederived from the inputs and the existing EXPECTED_* witnesses; both agree.
validate_junit() accepted 'skip' as a waiver. That is right for build-flag-gated features (bitcoin-only, zcash-privacy), where a skip genuinely means "not in this build". It is wrong for a capability the build claims to have: every taproot test opens with requires_taproot(), so if that capability regressed, all six would skip and the report would still certify a green run -- coverage it never actually obtained. MUST_RUN_MODULES lists the modules that must really execute; a skip there is now a 'skipped-but-required' failure. Verified both ways against the catalogue: taproot passing validates clean, taproot skipping produces six failures (B21-B26) where it previously reported success. B21/B22/B23 prose now states what the tests prove after the serialized-tx coverage change -- that the full transaction is parsed as BIP-144 and must consume every byte, so the witness and locktime footer are known to have reached the host, not just the signature field.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Supports firmware PR BitHighlander/keepkey-firmware#337.
Why
rc25 shipped a taproot signing path that dropped the witness and the locktime footer on the wire — 70 bytes — leaving a transaction that declares the segwit marker/flag but carries no witness stack and no locktime. The host would have broadcast it verbatim.
This whole suite passed the entire time.
signatureandserialized_txare separate nanopb fields with independent presence flags, andsignaturewas populated correctly throughout. Every taproot test read only that field:test_send_p2tr(signatures, _)serializedtest_send_mixed_p2tr_and_legacy(signatures, _)serializedtest_send_p2tr_with_changeassertIn(EXPECTED_CHANGE_SCRIPT, serialized)What this adds
assertCompleteSegwitTx()— a strict BIP-144 parse that must consume exactlylen(raw). This mirrors Trezor upstream, which funnels every success-path taproot test throughassert_tx_matches()→CTransaction.deserialize(). A segwit marker promises witness data, so a dropped witness now runs the stream off the end instead of passing unnoticed. It needs no goldens and no seed knowledge, and it catches the whole bug class rather than this one instance.It returns the per-input witness stacks, so the tests can additionally assert that a key-path spend carries exactly one 64-byte element and that a legacy input still serializes its empty
0x00witness.Each test also pins the full serialization. Those goldens were captured from a fixed-firmware emulator run and, independently, rederived from the tests' own inputs plus the existing
EXPECTED_*witnesses — both agree byte-for-byte (153 / 196 / 301 bytes).Report gate
validate_junit()treatedskipas a waiver. That is right for build-flag-gated features (bitcoin-only, zcash-privacy), where a skip genuinely means "not in this build". It is wrong for a capability the build claims to have: every taproot test opens withrequires_taproot(), so had that capability regressed, all six would have skipped and the PDF would still have certified a green run — reporting coverage it never obtained.MUST_RUN_MODULESconverts a skip in those modules into askipped-but-requiredfailure.Verification
test_msg_signtx_taproot.py,test_msg_getaddress_taproot.py,test_taproot_screens.pyAssertionError: tx truncated at offset 83: wanted 1 more byte(s) of 83 total