fix(taproot): send the witness and tx footer to the host - #337
Conversation
signing_sign_segwit_input()'s taproot branch signs with bip340_sign() directly instead of signing_sign_hash(), which is where every other input type sets resp.serialized.has_serialized_tx. signing_txack() memsets resp on every message, so the flag never carried over and nanopb omitted serialized_tx from the wire entirely. The host therefore lost the 66-byte witness (01 40 <64-byte schnorr sig>) and the 4-byte locktime footer appended after it -- 70 bytes -- leaving a transaction that declares the segwit marker/flag but carries no witness stack and no locktime. Reproduced on hardware: a P2TR spend returned a valid signature alongside an 83-byte unparseable serialized tx, which the host would have broadcast verbatim. The signature field was unaffected, which is why the existing taproot tests passed: they assert signatures and discard the serialized tx.
Picks up the BIP-144 completeness assertions that catch this bug and the MUST_RUN_MODULES report gate, so CI exercises both.
…s fixes Eight emulator captures on the abandon test seed covering both defects rc25 shipped: the BIP-86 and P2WSH address screens now rendering complete rather than cut at 42 characters, and the confirm screens for the three taproot signing flows, showing the amount/destination/fee are unaffected by the serialization fix. Emulator captures do not satisfy Gate-3 on their own -- the README records what is still owed on device.
|
Gate-3 evidence added: The address frame is the one worth looking at. rc25 rendered Frames 03-08 are the confirm screens for the three signing flows, establishing that amount, destination and fee are unchanged by the serialization fix. These are emulator captures and do not satisfy Gate-3 on their own; the README records what is still owed on device (compare a |
The bug
signing_sign_segwit_input()'s taproot branch signs withbip340_sign()directly instead ofsigning_sign_hash(). That helper is where every other input type sets its nanopb presence flags:signing_txack()memsetsrespon every message, so nothing carried it over. nanopb therefore omittedserialized_txfrom the wire entirely, and the host lost the 66-byte witness (01 40 <64-byte schnorr sig>) plus the 4-byte locktime footer appended after it — 70 bytes.The result declares the segwit marker/flag but carries no witness stack and no locktime. It is unparseable and every node rejects it.
Reproduced on hardware
A P2TR spend on rc25 (
740e65b5,KeepKeyZcash) returned a valid Schnorr signature alongside an 83-byte serialized transaction:This is not cosmetic: the vault broadcasts
serializedTxverbatim (txbuilder/index.ts:824,828), so a real taproot spend would have pushed a truncated transaction to the network.Why CI missed it
signatureandserialized_txare separate nanopb fields with independent presence flags, andsignaturewas populated correctly the whole time. Every taproot test read only that field:test_send_p2tr(signatures, _)— discardedserializedtest_send_mixed_p2tr_and_legacy(signatures, _)— discardedserializedtest_send_p2tr_with_changeassertIn(EXPECTED_CHANGE_SCRIPT, serialized)— a phase-1 output byte, transmitted well before any witness, so it survives a truncated suffixThe fix
One line, plus the comment explaining why the branch is special.
Test coverage (python-keepkey
test/taproot-serialized-tx-coverage)Following Trezor upstream, which funnels every success-path taproot test through
assert_tx_matches()→CTransaction.deserialize(), this addsassertCompleteSegwitTx(): a strict BIP-144 parse that must consume exactlylen(raw). A segwit marker promises witness data, so a dropped witness now runs the stream off the end instead of passing unnoticed. It returns per-input witness stacks, letting the tests assert a key-path spend carries exactly one 64-byte element and a legacy input still serializes its empty0x00witness.Each test also pins the full serialization. Goldens were captured from a fixed-firmware emulator run and independently rederived from the inputs and the existing
EXPECTED_*witnesses; both agree byte-for-byte.Verified both directions:
AssertionError: tx truncated at offset 83: wanted 1 more byte(s) of 83 totalReport gate
validate_junit()acceptedskipas a waiver. Correct for build-flag-gated features; 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.MUST_RUN_MODULESmakes a skip in those modules askipped-but-requiredfailure. Verified: taproot passing validates clean; taproot skipping yields six failures (B21–B26) where it previously reported success. B21/B22/B23 prose now states what is actually proven.Same-class findings (not fixed here)
The bug class is a hand-rolled branch duplicating a shared helper and forgetting one of its side effects. A sweep found more, filed for follow-up rather than widening this PR:
if (idx1 == inputs_count - 1)block extendsserialized_tx.sizewithout setting the flag, inheriting whatever the branch above decided. This is why the locktime vanished with the witness, and the next witness type added reproduces this identically.sha256_Init'd only inside theelseofif (overwintered), but updated/finalised undercoin->has_taproot && coin->taprootalone, andoverwinteredhas no coin-family check.SignTx(coin_name="Bitcoin", overwintered=true)with a SPENDTAPROOT input would sign over uninitialised hash state. Inferred, not demonstrated — the signature would not verify on chain, but state crosses transactions.coins.c account_prefix()has nom/86'case — degrades the BIP-86 confirm label to a raw path.fsm_msgGetPublicKeyhas noSPENDTAPROOTbranch — BIP-86 account xpub requests hard-fail. Reads as a gap, not policy. Worked around host-side in hdwallet (keepkey/hdwalletfix/taproot-account-xpub-wire-type); worth deciding explicitly and pinning with a test.Verification