Summary
The zcash_primitives transaction newtype refactor (PR #10461, merged 2026-08-22) moved production transaction parsing to zcash_primitives/zcash_transparent, which do not enforce several of Zebra's parse-time consensus checks. The refactor re-added most of those checks explicitly in Transaction::zcash_deserialize, but two were missed, so on main two consensus rules are no longer enforced on the path that released nodes actually run:
- Coinbase
scriptSig length must be in {2 .. 100} bytes (protocol spec 7.1.2).
- Non-coinbase
nExpiryHeight must be less than or equal to 499,999,999 (ZIP-203, spec 7.1.2).
Both are the same shape: a check that lived in Zebra's own enum-based deserializer is not reproduced on the zcash_primitives path, so main now accepts transactions the spec requires rejecting. Both were reported by @ouicate.
No released version is affected. v6.0.0 through v6.3.0 parse inputs through Zebra's own deserializer and enforce both bounds. This is a release blocker for the next release cut from main.
Details
Verified on main and on tags v6.3.0 and v6.0.0 (fetched 2026-09-02).
On main, production parsing goes through zp_tx::Transaction::read (zebra-chain/src/transaction.rs:823, 872, 876). The refactor re-added three parse-time checks after that call, each with a comment noting that zcash_primitives does not enforce it: coinbase height encoding (transaction.rs:884-891, via parse_coinbase_height), coinbase-must-not-have-Sapling-spends (transaction.rs:893-913, citing GHSA-rgwx-8r98-p34c), and the V4 empty-Sapling valueBalance rule (transaction.rs:915-931). The two rules below were not re-added.
1. Coinbase scriptSig length {2 .. 100}
parse_coinbase_height (zebra-chain/src/transparent/serialize.rs, the function around lines 55-97) validates only the canonical height prefix and returns the remaining bytes as inert data. It applies no total-length bound. So a coinbase input whose scriptSig begins with a canonical height push but runs past 100 bytes (or is a single OP_N byte, length 1) parses successfully.
The {2 .. 100} check still exists, but only inside Input::zcash_deserialize (transparent/serialize.rs:168, bound at :194-201, constants imported at :7 from zcash_transparent::coinbase). On main the transaction serializer no longer calls Vec<transparent::Input>::zcash_deserialize, so that check and its regression tests have no production callers. Nothing in zebra-consensus re-checks the length. Upstream zcash_transparent-0.10.0 enforces the bound only in the TxIn::coinbase builder, not in TxIn::read.
On v6.3.0 (and back to v6.0.0), inputs are parsed via Vec<transparent::Input> = Vec::zcash_deserialize(...) (transaction/serialize.rs:985, 1108, 1167), which runs the {2 .. 100} check per input, so releases are unaffected.
2. Non-coinbase nExpiryHeight <= 499,999,999
On main, Transaction is a newtype over zcash_primitives (transaction.rs:53), and Transaction::expiry_height() converts the inner value through compat::block_height_to_height(bh).ok() (transaction/compat.rs:160-165). That conversion is block::Height::try_from(bh), which errors for any value above Height::MAX = u32::MAX / 2 = 2_147_483_647 (block/height.rs:67, 133-149); .ok() then maps the error to None. Because validate_expiry_height_max skips None (zebra-consensus/src/transaction/check.rs, fn validate_expiry_height_max), a raw nExpiryHeight in [2^31, 2^32-1] is treated as "no expiry" and the 499,999,999 bound is not applied. The coinbase path is unaffected (it requires expiry_height == Some(block_height), so None is rejected).
On v6.3.0, Transaction is an enum storing expiry_height: block::Height verbatim (transaction.rs:76 onward), the deserializer tuple-constructs block::Height(read_u32()) preserving the raw value (transaction/serialize.rs:956, 997, 1105, 1164), the accessor returns Some(block::Height(raw)) (transaction.rs:510), and validate_expiry_height_max rejects the over-range value with MaximumExpiryHeight (transaction/check.rs:571-589). Releases are unaffected.
Conformance reference
The pre-refactor architecture, which released Zebra still uses, rejects both classes of transaction at parse time, matching the protocol spec. The independent Zebra-derived implementation Zakura (v1.3.1), which did not adopt the newtype refactor, also enforces both bounds. Restoring these checks brings main back into parity with the spec and with released Zebra.
Suggested fix
Re-add both checks on the production parse path, alongside the checks the refactor already reinstated in Transaction::zcash_deserialize.
-
Coinbase length: enforce {2 .. 100} on the production path. The cleanest single site is parse_coinbase_height: check the total script_sig length against MIN_COINBASE_SCRIPT_LEN/MAX_COINBASE_SCRIPT_LEN (already imported) before parsing the height. That covers both production callers (transaction.rs:888 and the txin_to_input conversion in compat.rs:39) in one place. If instead the check is added at the transaction.rs:884-891 block, mirror it in compat.rs so both callers are covered.
-
Expiry max: enforce the bound on the raw u32, independent of the Height-range conversion, so an over-range value is rejected rather than collapsed to None. Either compare the inner expiry as a u32 against 499,999,999 in validate_expiry_height_max/non_coinbase_expiry_height, or reject at parse time in Transaction::zcash_deserialize next to the coinbase-height re-check. Keep 0 meaning "no expiry"; only values above 499,999,999 are rejected.
Audit result (the reason both defects exist)
These two are instances of one pattern: the refactor manually re-added parse-time consensus checks but missed some. The full inventory of parse-time consensus rejections in v6.3.0's transaction, transparent, Sapling, Orchard, Sprout, and lock-time deserializers has been traced against main, the pinned upstream crates (zcash_primitives 0.30.0, zcash_transparent 0.10.0, orchard 0.15.3, sapling-crypto 0.7.0, reddsa 0.5.1), and main's consensus-layer checks. See the appendix. Result: the two rules above are the complete set of dropped parse-time consensus rules. Every other rule is either re-added on main, enforced by upstream at parse, or enforced by a later verification/consensus step (rejected regardless, at a different layer).
Still required in the fix: audit accessors that convert an inner zcash_primitives field through a fallible compat:: conversion with .ok() or a default. Any that turns an out-of-range wire value into None/default can hide a consensus-relevant value the way expiry_height() does. A parse-rejection inventory does not cover this class; the expiry defect is the found instance.
Scope guardrails
- Parse-time consensus enforcement only. Do not change consensus validation semantics elsewhere; the rules are the spec's {2 .. 100} and
<= 499,999,999, nothing stricter.
- Do not conflate "no expiry" (
nExpiryHeight == 0, legal) with over-range. Only 0 maps to "no expiry".
- Preserve the genesis coinbase special-case.
compat.rs special-cases GENESIS_COINBASE_SCRIPT_SIG (77 bytes, within bounds); the transaction.rs:884-891 block does not, and only works on the genesis coinbase because its bytes happen to parse as a valid height push. Confirm the genesis block still parses after the change, and consider aligning the two sites.
Regression tests
Add tests that exercise the production path (through Transaction::zcash_deserialize or block deserialization), not only Input::zcash_deserialize:
- Coinbase length: reject an oversized coinbase (canonical height push padded past 100 bytes) and an undersized coinbase (single-byte
OP_N); accept the boundary lengths 2 and 100; confirm the genesis coinbase still parses.
- Expiry max: reject
nExpiryHeight = 0xFFFFFFFF for non-coinbase V4, V5, and V6; reject 500,000,000; accept 499,999,999 and 0; confirm coinbase acceptance is unchanged.
Also add end-to-end rejection tests for the rules that moved from parse to a later layer (see appendix), so that enforcement is pinned by a test rather than by the current upstream implementation and does not silently move again on a dependency bump:
- A V5 transaction with a pre-NU5 branch ID and a V6 transaction with a pre-NU6.3 branch ID are rejected by the verifier (
WrongConsensusBranchId).
- An input with a null prevout hash and index other than
0xFFFFFFFF is rejected (as the first transaction of a block, and as a later transaction).
- A Sapling spend whose
rk is a small-order point, and a Sapling output whose epk is a small-order point, are rejected by the verifier.
Affected version
No released version. main only, since PR #10461 (merged 2026-08-22). Release blocker for the next release cut from main.
Related
Credit
Both findings identified and responsibly disclosed by @ouicate. We appreciate your contribution in identifying and mitigating what would have been a nasty consensus bug in Zebra before we shipped it.
Appendix: parse-time consensus check inventory (v6.3.0 vs main)
Every consensus rejection performed by Zebra's own deserializers on v6.3.0, and where the same rule is enforced on main. Verified against source on 2026-09-02.
| Rule (enforced at parse on v6.3.0) |
v6.3.0 site |
Enforcement on main |
Coinbase scriptSig length in {2 .. 100} |
transparent/serialize.rs:194-201 |
Not enforced. Restored by this issue. |
Non-coinbase nExpiryHeight <= 499,999,999 |
value preserved by enum, checked in check.rs:571-589 |
Not enforced (over-range collapsed to None). Restored by this issue. |
| Coinbase height encoding is canonical |
transparent/serialize.rs parse_coinbase_height |
Re-added: transaction.rs:884-891 |
| Coinbase MUST NOT have Sapling spends (V4, V5) |
transaction/serialize.rs:215, 1013 |
Re-added: transaction.rs:893-913 |
V4 with no spends/outputs: valueBalanceSapling = 0 |
transaction/serialize.rs:1051 |
Re-added: transaction.rs:915-931 |
nVersionGroupId matches version (V3, V4, V5, V6) |
transaction/serialize.rs:941, 968, 1082, 1146 |
Upstream zcash_primitives rejects at parse |
nConsensusBranchId is a known branch ID |
transaction/serialize.rs:1088, 1150 |
Upstream BranchId::try_from rejects at parse |
| Unknown version / header combination |
transaction/serialize.rs:1220 |
Upstream rejects at parse |
Canonical jubjub::Fq, pallas::Scalar, pallas::Base |
transaction/serialize.rs:36, 50, 64 |
Upstream field parsers reject non-canonical encodings |
Orchard flags: reserved bits zero; V6 enableCrossAddress permitted |
orchard/shielded_data.rs:338 |
Upstream, version-aware: orchard 0.15.3 Flags::from_byte(byte, bundle_version), FLAG_V6_CROSS_ADDRESS_ENABLED; zcash_primitives read_flags with ironwood_v3 |
Orchard action rk is not the identity |
orchard/action.rs:86 |
Upstream orchard::Action::from_parts rejects |
Sapling cv is not small order |
Zebra commitment type |
Upstream ValueCommitment::from_bytes_not_small_order at parse (zcash_primitives sapling.rs:93) |
| V5 requires NU5+ branch ID; V6 requires NU6.3+ branch ID |
transaction/serialize.rs:1096, 1156 |
Layer shift: check::consensus_branch_id (check.rs:885-901) in check_structure_and_network_rules, reached from both the block and mempool verifiers |
Coinbase input index must be 0xFFFFFFFF |
transparent/serialize.rs:177 |
Layer shift: a null-hash input with another index is a non-coinbase input on main (prevout != OutPoint::NULL, matching zcashd), rejected by the coinbase-position rule or the missing-UTXO check |
Sapling spend rk is not small order |
sapling/spend.rs:217-220 via ValidatingKey::try_from (sapling/keys.rs) |
Layer shift: sapling-crypto check_spend (verifier.rs:48-49), run by BatchValidator::check_bundle, which main routes through at zebra-consensus/src/transaction.rs:1339 |
Sapling output epk is not small order |
Zebra output parser |
Layer shift: sapling-crypto check_output (verifier.rs:108) |
The Sprout and lock-time deserializers on v6.3.0 contain no parse-time consensus rejections.
Rows marked "layer shift" are rejected on main with the same outcome, at verification rather than at parse. Rejection therefore happens after the transaction has been allocated and queued rather than during read; the pre-allocation bounds for that are tracked at #10554 and are out of scope here. The regression tests above pin these rules at their new layer.
Summary
The
zcash_primitivestransaction newtype refactor (PR #10461, merged 2026-08-22) moved production transaction parsing tozcash_primitives/zcash_transparent, which do not enforce several of Zebra's parse-time consensus checks. The refactor re-added most of those checks explicitly inTransaction::zcash_deserialize, but two were missed, so onmaintwo consensus rules are no longer enforced on the path that released nodes actually run:scriptSiglength must be in {2 .. 100} bytes (protocol spec 7.1.2).nExpiryHeightmust be less than or equal to 499,999,999 (ZIP-203, spec 7.1.2).Both are the same shape: a check that lived in Zebra's own enum-based deserializer is not reproduced on the
zcash_primitivespath, somainnow accepts transactions the spec requires rejecting. Both were reported by @ouicate.No released version is affected. v6.0.0 through v6.3.0 parse inputs through Zebra's own deserializer and enforce both bounds. This is a release blocker for the next release cut from
main.Details
Verified on
mainand on tags v6.3.0 and v6.0.0 (fetched 2026-09-02).On
main, production parsing goes throughzp_tx::Transaction::read(zebra-chain/src/transaction.rs:823, 872, 876). The refactor re-added three parse-time checks after that call, each with a comment noting thatzcash_primitivesdoes not enforce it: coinbase height encoding (transaction.rs:884-891, viaparse_coinbase_height), coinbase-must-not-have-Sapling-spends (transaction.rs:893-913, citing GHSA-rgwx-8r98-p34c), and the V4 empty-SaplingvalueBalancerule (transaction.rs:915-931). The two rules below were not re-added.1. Coinbase scriptSig length {2 .. 100}
parse_coinbase_height(zebra-chain/src/transparent/serialize.rs, the function around lines 55-97) validates only the canonical height prefix and returns the remaining bytes as inert data. It applies no total-length bound. So a coinbase input whosescriptSigbegins with a canonical height push but runs past 100 bytes (or is a singleOP_Nbyte, length 1) parses successfully.The {2 .. 100} check still exists, but only inside
Input::zcash_deserialize(transparent/serialize.rs:168, bound at:194-201, constants imported at:7fromzcash_transparent::coinbase). Onmainthe transaction serializer no longer callsVec<transparent::Input>::zcash_deserialize, so that check and its regression tests have no production callers. Nothing inzebra-consensusre-checks the length. Upstreamzcash_transparent-0.10.0enforces the bound only in theTxIn::coinbasebuilder, not inTxIn::read.On v6.3.0 (and back to v6.0.0), inputs are parsed via
Vec<transparent::Input> = Vec::zcash_deserialize(...)(transaction/serialize.rs:985, 1108, 1167), which runs the {2 .. 100} check per input, so releases are unaffected.2. Non-coinbase nExpiryHeight <= 499,999,999
On
main,Transactionis a newtype overzcash_primitives(transaction.rs:53), andTransaction::expiry_height()converts the inner value throughcompat::block_height_to_height(bh).ok()(transaction/compat.rs:160-165). That conversion isblock::Height::try_from(bh), which errors for any value aboveHeight::MAX = u32::MAX / 2 = 2_147_483_647(block/height.rs:67, 133-149);.ok()then maps the error toNone. Becausevalidate_expiry_height_maxskipsNone(zebra-consensus/src/transaction/check.rs,fn validate_expiry_height_max), a rawnExpiryHeightin [2^31, 2^32-1] is treated as "no expiry" and the 499,999,999 bound is not applied. The coinbase path is unaffected (it requiresexpiry_height == Some(block_height), soNoneis rejected).On v6.3.0,
Transactionis an enum storingexpiry_height: block::Heightverbatim (transaction.rs:76onward), the deserializer tuple-constructsblock::Height(read_u32())preserving the raw value (transaction/serialize.rs:956, 997, 1105, 1164), the accessor returnsSome(block::Height(raw))(transaction.rs:510), andvalidate_expiry_height_maxrejects the over-range value withMaximumExpiryHeight(transaction/check.rs:571-589). Releases are unaffected.Conformance reference
The pre-refactor architecture, which released Zebra still uses, rejects both classes of transaction at parse time, matching the protocol spec. The independent Zebra-derived implementation Zakura (v1.3.1), which did not adopt the newtype refactor, also enforces both bounds. Restoring these checks brings
mainback into parity with the spec and with released Zebra.Suggested fix
Re-add both checks on the production parse path, alongside the checks the refactor already reinstated in
Transaction::zcash_deserialize.Coinbase length: enforce {2 .. 100} on the production path. The cleanest single site is
parse_coinbase_height: check the totalscript_siglength againstMIN_COINBASE_SCRIPT_LEN/MAX_COINBASE_SCRIPT_LEN(already imported) before parsing the height. That covers both production callers (transaction.rs:888and thetxin_to_inputconversion incompat.rs:39) in one place. If instead the check is added at thetransaction.rs:884-891block, mirror it incompat.rsso both callers are covered.Expiry max: enforce the bound on the raw
u32, independent of theHeight-range conversion, so an over-range value is rejected rather than collapsed toNone. Either compare the inner expiry as au32against 499,999,999 invalidate_expiry_height_max/non_coinbase_expiry_height, or reject at parse time inTransaction::zcash_deserializenext to the coinbase-height re-check. Keep0meaning "no expiry"; only values above 499,999,999 are rejected.Audit result (the reason both defects exist)
These two are instances of one pattern: the refactor manually re-added parse-time consensus checks but missed some. The full inventory of parse-time consensus rejections in v6.3.0's
transaction,transparent, Sapling, Orchard, Sprout, and lock-time deserializers has been traced againstmain, the pinned upstream crates (zcash_primitives0.30.0,zcash_transparent0.10.0,orchard0.15.3,sapling-crypto0.7.0,reddsa0.5.1), andmain's consensus-layer checks. See the appendix. Result: the two rules above are the complete set of dropped parse-time consensus rules. Every other rule is either re-added onmain, enforced by upstream at parse, or enforced by a later verification/consensus step (rejected regardless, at a different layer).Still required in the fix: audit accessors that convert an inner
zcash_primitivesfield through a falliblecompat::conversion with.ok()or a default. Any that turns an out-of-range wire value intoNone/default can hide a consensus-relevant value the wayexpiry_height()does. A parse-rejection inventory does not cover this class; the expiry defect is the found instance.Scope guardrails
<= 499,999,999, nothing stricter.nExpiryHeight == 0, legal) with over-range. Only0maps to "no expiry".compat.rsspecial-casesGENESIS_COINBASE_SCRIPT_SIG(77 bytes, within bounds); thetransaction.rs:884-891block does not, and only works on the genesis coinbase because its bytes happen to parse as a valid height push. Confirm the genesis block still parses after the change, and consider aligning the two sites.Regression tests
Add tests that exercise the production path (through
Transaction::zcash_deserializeor block deserialization), not onlyInput::zcash_deserialize:OP_N); accept the boundary lengths 2 and 100; confirm the genesis coinbase still parses.nExpiryHeight = 0xFFFFFFFFfor non-coinbase V4, V5, and V6; reject 500,000,000; accept 499,999,999 and 0; confirm coinbase acceptance is unchanged.Also add end-to-end rejection tests for the rules that moved from parse to a later layer (see appendix), so that enforcement is pinned by a test rather than by the current upstream implementation and does not silently move again on a dependency bump:
WrongConsensusBranchId).0xFFFFFFFFis rejected (as the first transaction of a block, and as a later transaction).rkis a small-order point, and a Sapling output whoseepkis a small-order point, are rejected by the verifier.Affected version
No released version.
mainonly, since PR #10461 (merged 2026-08-22). Release blocker for the next release cut frommain.Related
Halo2Proofandtransparent::Scriptdeserializers #10554: per-field allocation bounds onScript. Related surface (the coinbase script bytes are allocated by upstreamScript::readbefore the length can be checked, bounded only by the outerMAX_BLOCK_BYTESreader cap), but a different observable. This issue restores the consensus length bound only; the pre-allocation bound is tracked there.Credit
Both findings identified and responsibly disclosed by @ouicate. We appreciate your contribution in identifying and mitigating what would have been a nasty consensus bug in Zebra before we shipped it.
Appendix: parse-time consensus check inventory (v6.3.0 vs
main)Every consensus rejection performed by Zebra's own deserializers on v6.3.0, and where the same rule is enforced on
main. Verified against source on 2026-09-02.mainscriptSiglength in {2 .. 100}transparent/serialize.rs:194-201nExpiryHeight<= 499,999,999check.rs:571-589None). Restored by this issue.transparent/serialize.rsparse_coinbase_heighttransaction.rs:884-891transaction/serialize.rs:215, 1013transaction.rs:893-913valueBalanceSapling= 0transaction/serialize.rs:1051transaction.rs:915-931nVersionGroupIdmatches version (V3, V4, V5, V6)transaction/serialize.rs:941, 968, 1082, 1146zcash_primitivesrejects at parsenConsensusBranchIdis a known branch IDtransaction/serialize.rs:1088, 1150BranchId::try_fromrejects at parsetransaction/serialize.rs:1220jubjub::Fq,pallas::Scalar,pallas::Basetransaction/serialize.rs:36, 50, 64enableCrossAddresspermittedorchard/shielded_data.rs:338orchard0.15.3Flags::from_byte(byte, bundle_version),FLAG_V6_CROSS_ADDRESS_ENABLED;zcash_primitivesread_flagswithironwood_v3rkis not the identityorchard/action.rs:86orchard::Action::from_partsrejectscvis not small orderValueCommitment::from_bytes_not_small_orderat parse (zcash_primitivessapling.rs:93)transaction/serialize.rs:1096, 1156check::consensus_branch_id(check.rs:885-901) incheck_structure_and_network_rules, reached from both the block and mempool verifiers0xFFFFFFFFtransparent/serialize.rs:177main(prevout != OutPoint::NULL, matching zcashd), rejected by the coinbase-position rule or the missing-UTXO checkrkis not small ordersapling/spend.rs:217-220viaValidatingKey::try_from(sapling/keys.rs)sapling-cryptocheck_spend(verifier.rs:48-49), run byBatchValidator::check_bundle, whichmainroutes through atzebra-consensus/src/transaction.rs:1339epkis not small ordersapling-cryptocheck_output(verifier.rs:108)The Sprout and lock-time deserializers on v6.3.0 contain no parse-time consensus rejections.
Rows marked "layer shift" are rejected on
mainwith the same outcome, at verification rather than at parse. Rejection therefore happens after the transaction has been allocated and queued rather than during read; the pre-allocation bounds for that are tracked at #10554 and are out of scope here. The regression tests above pin these rules at their new layer.