Skip to content

test(taproot): assert the serialized transaction, not just the signature - #209

Merged
BitHighlander merged 2 commits into
reconcile/upstream-syncfrom
test/taproot-serialized-tx-coverage
Aug 4, 2026
Merged

test(taproot): assert the serialized transaction, not just the signature#209
BitHighlander merged 2 commits into
reconcile/upstream-syncfrom
test/taproot-serialized-tx-coverage

Conversation

@BitHighlander

Copy link
Copy Markdown
Contributor

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.

signature and serialized_tx are separate nanopb fields with independent presence flags, and signature was populated correctly throughout. Every taproot test read only that field:

test assertion why it missed the bug
test_send_p2tr (signatures, _) discarded serialized
test_send_mixed_p2tr_and_legacy (signatures, _) discarded serialized
test_send_p2tr_with_change assertIn(EXPECTED_CHANGE_SCRIPT, serialized) that scriptPubKey is a phase-1 output byte, transmitted well before any witness, so it survives a truncated suffix

What this adds

assertCompleteSegwitTx() — a strict BIP-144 parse that must consume exactly len(raw). This mirrors Trezor upstream, which funnels every success-path taproot test through assert_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 0x00 witness.

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() treated 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 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_MODULES converts a skip in those modules into a skipped-but-required failure.

Verification

  • fixed firmware: 9 passed across test_msg_signtx_taproot.py, test_msg_getaddress_taproot.py, test_taproot_screens.py
  • mutation (truncate to 83 bytes — the exact shape that shipped): AssertionError: tx truncated at offset 83: wanted 1 more byte(s) of 83 total
  • report gate both ways: taproot passing validates clean; taproot skipping yields six failures (B21–B26) where it previously reported success

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.
@BitHighlander
BitHighlander merged commit 1f2eecd into reconcile/upstream-sync Aug 4, 2026
4 checks passed
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.

1 participant