Two Critical Consensus-Divergence Vulnerabilities in Zcash's Zebra Node: Catching Unauthenticated Chain Splits Before Release
How two Critical consensus-divergence vulnerabilities in Zebra's zcash_primitives transaction refactor (#10461)—an unauthenticated nExpiryHeight bypass across 2.14 billion wire values and an orphaned {2..100}-byte coinbase scriptSig check—would have let any user or miner split Zcash mainnet, and how we caught both on main before they shipped in a release.
Following the end-of-life of zcashd on July 18, 2026 and the activation of Network Upgrade 6.3 (Ironwood) at mainnet block 3,428,143 on July 28, 2026, Zcash mainnet has been secured by two independent full-node implementations: Zebra (maintained by the Zcash Foundation) and Zakura (zakura-core/zakura v1.3.1, a consensus-compatible fork of Zebra). On August 22, 2026, Zcash Foundation merged PR #10461 into Zebra’s main branch, replacing Zebra’s native Transaction enum (V1 through V6) with a newtype wrapper around librustzcash (zcash_primitives 0.30.0 and zcash_transparent 0.10.0). Ten days later, on September 2, 2026, while conducting a differential consensus audit between Zebra’s main branch and Zakura v1.3.1, we uncovered two Critical consensus-divergence vulnerabilities that silently disabled consensus rules mandated by Zcash Protocol Specification §7.1.2 and ZIP-203.
Had PR #10461 shipped in the next tagged Zebra release without a fix, either vulnerability would have enabled a deterministic mainnet chain split—partitioning upgraded Zebra nodes, mining pools, Zaino indexers, and Zallet wallets from Zakura nodes and prior Zebra releases (v6.0.0–v6.3.0). Worse still, the primary finding (C-01) required zero mining hashpower: any unauthenticated user could broadcast a single V4, V5, or V6 non-coinbase transaction with nExpiryHeight in [2^31, 2^32 - 1], which Zebra’s mempool would retain indefinitely as “never expires” and automatically feed into honest Zebra miners’ block templates via getblocktemplate. Because we caught and reported both regressions on main while the refactor was still unreleased, Zcash Foundation triaged them as release blockers in Issue #11383, patched them in PR #11387 alongside two sibling refactor regressions (Issue #11386 and PR #11400), and credited @ouicate in the ZF Engineering Update (7–20 September 2026)—eliminating two catastrophic chain-split primitives before a single production node was exposed.
The Architectural Seam: Delegating Consensus Parsing to librustzcash (#10461)
Before PR #10461 (v6.0.0 through v6.3.0, as well as Zakura v1.3.1), zebra_chain::transaction::Transaction was a native Rust enum (V1 through V6) whose wire parser lived entirely inside zebra-chain/src/transaction/serialize.rs and zebra-chain/src/transparent/serialize.rs. That parser enforced several consensus bounds directly while reading bytes off the wire:
- Every transparent input was deserialized through
Input::zcash_deserialize, which checkedMIN_COINBASE_SCRIPT_LEN(2) andMAX_COINBASE_SCRIPT_LEN(100) on any coinbase input before allocating the script buffer. - Every Overwinter+
nExpiryHeightfield was read as a rawu32and stored directly inside the enum variant via tuple construction (block::Height(reader.read_u32::<LittleEndian>()?)attransaction/serialize.rs:956, 997, 1105, 1164), bypassingHeight::try_fromso all 32 bits reachedzebra-consensus.
In PR #10461, Zebra replaced that enum with a newtype wrapper around zcash_primitives::transaction::Transaction:
// ZcashFoundation/zebra@0881709ff zebra-chain/src/transaction.rs:52-53
#[derive(Debug)]
pub struct Transaction(pub(crate) zp_tx::Transaction);
Because zcash_primitives is designed as a shared protocol and wallet library rather than a drop-in replacement for Zebra’s parse-time consensus verifier, zp_tx::Transaction::read omits several checks that Zebra previously enforced during deserialization. The refactor attempted to bridge that gap in two places:
Transaction::zcash_deserialize(zebra-chain/src/transaction.rs:847-933) calledzp_tx::Transaction::readand manually re-applied three post-parse checks:parse_coinbase_height(:884-891), coinbase-must-not-have-Sapling-spends (:893-913), and the V4 empty-SaplingvalueBalanceSapling == 0rule (:915-931).zebra-chain/src/transaction/compat.rsadded conversion helpers to translate innerlibrustzcashtypes (zcash_protocol::consensus::BlockHeight,zcash_transparent::bundle::TxIn) back into Zebra domain types (zebra_chain::block::Height,zebra_chain::transparent::Input) so downstream code inzebra-consensusdid not have to change its function signatures.
Both vulnerabilities emerged directly from that seam: one consensus check in zebra-consensus was starved by a lossy .ok() conversion in compat.rs, and one parse-time check in zebra-chain was orphaned in dead code while its unit tests stayed green.
Divergence 1 (C-01): Unauthenticated Chain Split via Lossy .ok() in nExpiryHeight
Under Zcash Protocol Specification §7.1.2 and ZIP-203, non-coinbase Overwinter+ transactions (V4, V5, and V6 at NU6.3) must satisfy nExpiryHeight <= 499,999,999 (Height::MAX_EXPIRY_HEIGHT), where 0 encodes “no expiry.” On Zebra’s main branch, a five-link chain silently disabled this rule for the entire upper half of the 32-bit integer space ([2^31, 2^32 - 1]):
1. Links 1–4: How compat::block_height_to_height(bh).ok() Collapses 2^31 Invalid Values into None
First (Link 1), zcash_primitives-0.30.0/src/transaction/mod.rs (read_header_fragment for V5/V6, and read_v4 at lines 757-759) reads nExpiryHeight straight off the wire as an unbounded u32 wrapped in zcash_protocol::consensus::BlockHeight(u32). Second (Link 2), Transaction::zcash_deserialize (zebra-chain/src/transaction.rs:847-933) performs no expiry-height check after zp_tx::Transaction::read returns.
The bug fires in Link 3, inside Transaction::expiry_height():
// ZcashFoundation/zebra@0881709ff zebra-chain/src/transaction.rs:162-173
pub fn expiry_height(&self) -> Option<block::Height> {
match self.tx_version() {
TxVersion::Sprout(_) => None,
_ => {
let bh = self.0.expiry_height();
if bh == zcash_protocol::consensus::BlockHeight::from_u32(0) {
None
} else {
compat::block_height_to_height(bh).ok() // <-- Err collapsed to None!
}
}
}
}
What does compat::block_height_to_height(bh) (zebra-chain/src/transaction/compat.rs:160-165) do? It delegates to block::Height::try_from(bh), which in zebra-chain/src/block/height.rs:67, 133-149 enforces Height::MAX = Height(u32::MAX / 2) (2,147,483,647, or 2^31 - 1). For any wire value >= 2^31 (0x8000_0000 ..= 0xFFFF_FFFF), Height::try_from returns Err("heights must be less than or equal to Height::MAX")—and .ok() converts that error into None.
Because the bh == 0 branch also returns None, a transaction with nExpiryHeight = 0xFFFF_FFFF (4,294,967,295) becomes indistinguishable from a transaction with nExpiryHeight = 0 (“no expiry”). When non_coinbase_expiry_height calls validate_expiry_height_max (Link 4, zebra-consensus/src/transaction/check.rs:583-601), the check is a complete no-op on None:
// ZcashFoundation/zebra@0881709ff zebra-consensus/src/transaction/check.rs:583-601
fn validate_expiry_height_max(
expiry_height: Option<Height>,
is_coinbase: bool,
block_height: &Height,
transaction: &Transaction,
) -> Result<(), TransactionError> {
if let Some(expiry_height) = expiry_height {
if expiry_height > Height::MAX_EXPIRY_HEIGHT { // 499_999_999
Err(TransactionError::MaximumExpiryHeight { /* ... */ })?;
}
}
Ok(()) // None -> Ok(())
}
Meanwhile, Zakura v1.3.1 (crates/zakura-chain/src/transaction/serialize.rs:1024, 1132, 1190 and crates/zakura-consensus/src/transaction/check.rs:525, 561-568) runs the exact same validate_expiry_height_max function body, but because Zakura kept the pre-#10461 enum that constructs Some(block::Height(raw)) without .ok(), it sees 0xFFFF_FFFF > 499_999_999 and rejects the transaction with TransactionError::MaximumExpiryHeight:
Raw Wire nExpiryHeight (u32) |
Height::try_from(bh) |
Zebra tx.expiry_height() |
Unpatched Zebra main |
Zakura v1.3.1 / Zebra v6.3.0 |
|---|---|---|---|---|
0 |
(skipped) | None |
Accepted (no expiry) | Accepted (no expiry) |
1 ..= 499,999,999 |
Ok(Height(n)) |
Some(Height(n)) |
Accepted (legal expiry) | Accepted (legal expiry) |
500,000,000 ..= 2,147,483,647 |
Ok(Height(n)) |
Some(Height(n)) |
Rejected (MaximumExpiryHeight) |
Rejected (MaximumExpiryHeight) |
2,147,483,648 ..= 4,294,967,295 ([2^31, 2^32 - 1]) |
Err (> Height::MAX) |
None (via .ok()) |
BYPASSED — Accepted as “no expiry”! | Rejected (MaximumExpiryHeight) |
2. Link 5: Why an Unauthenticated User Can Trigger a Chain Split Without Mining Hardware
Why doesn’t an attacker need mining hashpower to turn this transaction-level bug into a full block-level chain split? Because of how Link 5 (check_common_consensus_rules at zebra-consensus/src/transaction.rs:791-795) interacts with Zebra’s mempool storage and RPC block-template builder:
- Dual verifier reachability:
check_common_consensus_rulesis the shared gate for bothBlockTxVerifier(transaction.rs:308) andMempoolTxVerifier(transaction.rs:488). A non-coinbase transaction withnExpiryHeight = 0xFFFF_FFFFsubmitted viasendrawtransaction(zebra-rpc/src/methods.rs:1281) or P2P gossip (zebra-network/src/protocol/external/codec.rs) passesMempoolTxVerifierimmediately. - “Sticky mempool” amplification: Mempool expiration eviction in
zebrad/src/components/mempool/storage.rs:900is also guarded byif let Some(expiry_height) = tx.expiry_height(). Becausetx.expiry_height()returnsNone, Zebra treats the malicious transaction as never expiring, keeping it in the mempool and re-gossiping it across Zebra peers indefinitely. - Automatic miner self-inclusion (
getblocktemplate): When a Zebra-backed mining pool callsgetblocktemplate,zebra-rpc/src/methods/types/get_block_template.rs:953pulls candidate transactions directly from the mempool viamempool::Request::FullTransactionswith no expiry re-filter. The honest Zebra miner automatically packages the attacker’s transaction into its next block. Once solved, every upgraded Zebra node accepts and extends the block, while every Zakura node and prior Zebra release rejects the block withMaximumExpiryHeight—splitting the network and all downstream Zaino indexers and Zallet wallets onto two incompatible chains.
Divergence 2 (C-02): Block-Level Split via Orphaned Coinbase scriptSig Bounds
While C-01 starved a consensus check through a lossy type conversion, C-02 orphaned a parse-time check by assuming zcash_transparent enforced the same wire bounds as Zebra’s old deserializer.
Zcash Protocol Specification §7.1.2 requires: “A coinbase transaction script MUST have length in {2 .. 100} bytes.” When PR #10461 switched transaction parsing to zp_tx::Transaction::read, transparent inputs were deserialized via zcash_transparent::bundle::TxIn::read:
// zcash_transparent@0.10.0 src/bundle.rs:263-272 (TxIn::read)
pub fn read<R: Read>(mut reader: &mut R) -> io::Result<Self> {
let prevout = OutPoint::read(&mut reader)?;
let script_sig = Script::read(&mut reader)?; // unbounded Vector::read — no {2..100} check!
let sequence = reader.read_u32_le()?;
Ok(TxIn::from_parts(prevout, script_sig, sequence))
}
In zcash_transparent 0.10.0, MIN_COINBASE_SCRIPT_LEN = 2 and MAX_COINBASE_SCRIPT_LEN = 100 (src/coinbase.rs:10, 17) are only enforced inside the TxIn::coinbase builder (src/bundle.rs:282-351) when constructing a coinbase in memory—never in TxIn::read when parsing raw block bytes off the network.
After zp_tx::Transaction::read returned, Transaction::zcash_deserialize (zebra-chain/src/transaction.rs:884-891) called parse_coinbase_height(&script_bytes) on every coinbase input (*txin.prevout() == OutPoint::NULL). However, parse_coinbase_height (zebra-chain/src/transparent/serialize.rs:58-98) only validated the 1-to-5 byte canonical BIP-34 height prefix at the start of script_sig and returned the remaining slice script_sig[len..].to_vec() as inert miner data without checking script_sig.len():
// ZcashFoundation/zebra@0881709ff zebra-chain/src/transparent/serialize.rs:58-98 (parse_coinbase_height)
if script_sig
.get(..len)
.ok_or(parse_err("Coinbase script too short"))?
!= pattern::push_num(h).to_bytes().as_slice()
{
return Err(parse_err("Non-canonical coinbase height encoding"));
}
let h = u32::try_from(h).map_err(|_| parse_err("Negative coinbase height"))?;
let height =
Height::try_from(h).map_err(|_| parse_err("Coinbase height exceeds Height::MAX"))?;
Ok((height, script_sig[len..].to_vec())) // <-- no total script_sig.len() check in {2..100}!
Where was Zebra’s {2..100} check? It was still sitting 100 lines lower in the exact same file, inside Input::zcash_deserialize (zebra-chain/src/transparent/serialize.rs:194-200)—which no longer had a single production caller! Because the existing unit tests (transparent/tests/vectors.rs, transaction/tests/vectors.rs:443) called Input::zcash_deserialize directly rather than Transaction::zcash_deserialize, CI stayed completely green while testing dead code. (Even worse, ZcashDeserializeWithContext::zcash_deserialize_with_context at transaction.rs:816-826 called zp_tx::Transaction::read and returned Ok(Transaction(inner)) without running any post-parse checks at all.)
By contrast, Zakura v1.3.1 (crates/zakura-chain/src/transaction/serialize.rs:1012, 1135, 1193 -> transparent/serialize.rs:191-198) continued to deserialize block inputs through Input::zcash_deserialize, rejecting any coinbase scriptSig longer than 100 bytes with SerializationError::Parse("Coinbase script is too long").
┌────────────────────────────────────────────────────────────────────────────────────┐
│ TWO PRE-RELEASE CHAIN-SPLIT VECTORS (ZEBRA main POST-#10461 vs. ZAKURA) │
├────────────────────────────────────────────────────────────────────────────────────┤
│ │
│ [Vector 1: Unauthenticated User (C-01)] [Vector 2: Miner / Pool Block (C-02)] │
│ Broadcast V4/V5/V6 non-coinbase tx: Mine block at height H with coinbase │
│ nExpiryHeight = 0xFFFF_FFFF (> 499,999,999) scriptSig = BIP34(H) + 200B pad (204B)│
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────────────────────────────┐ ┌──────────────────────────────┐ │
│ │ Unpatched Zebra main │ │ Unpatched Zebra main │ │
│ │ • Height::try_from(0xFFFF_FFFF) -> Err │ │ • TxIn::read -> Script::read │ │
│ │ • block_height_to_height(bh).ok() -> None │ │ (no {2..100} length bound) │ │
│ │ • validate_expiry_height_max(None) -> Ok(())│ │ • parse_coinbase_height: │ │
│ │ • Sticky mempool retains tx ("no expiry") │ │ validates 4B height prefix,│ │
│ │ • getblocktemplate mines tx into block │ │ ignores 200 trailing bytes │ │
│ │ • RESULT: BLOCK ACCEPTED & EXTENDED │ │ • RESULT: BLOCK ACCEPTED │ │
│ └──────────────────────┬──────────────────────┘ └──────────────┬───────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────────────────────────────┐ │
│ │ Zakura v1.3.1 / Released Zebra v6.3.0 / zcashd │ │
│ │ • Vector 1: Preserves Some(Height(0xFFFF_FFFF)) > 499,999,999 │ │
│ │ -> REJECTED (TransactionError::MaximumExpiryHeight) │ │
│ │ • Vector 2: Input::zcash_deserialize checks 204 > MAX_COINBASE_SCRIPT_LEN │ │
│ │ -> REJECTED (SerializationError::Parse("Coinbase script...")) │ │
│ │ • NETWORK OUTCOME: Sustained Chain Split (Zebra vs. Zakura + Zaino/Zallet) │ │
│ └──────────────────────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────────────────────┘
Deterministic Proof of Concept: Zebra vs. Zakura on Mainnet NU6.3
Both consensus divergences are 100% deterministic and reproducible offline by feeding identical wire bytes into Zebra and Zakura:
1. PoC for C-01 (nExpiryHeight = 0xFFFF_FFFF on a Mainnet NU6.3 V5 Transaction)
At mainnet NU6.3 (activated at block 3,428,143 with consensus branch ID 0x37A5165B), transaction versions V4, V5, and V6 are all active. Feeding the exact same V5 non-coinbase wire bytes with nExpiryHeight = 0xFFFF_FFFF into Zebra’s and Zakura’s deserializers and non_coinbase_expiry_height checks produces opposite consensus verdicts:
// Shared wire-bytes builder: V5 non-coinbase tx at NU6.3 with nExpiryHeight = 0xFFFF_FFFF.
fn poison_tx_bytes() -> Vec<u8> {
let mut bytes = Vec::new();
bytes.extend_from_slice(&[0x05, 0x00, 0x00, 0x80]); // header: overwintered | version 5
bytes.extend_from_slice(&0x26A7270Au32.to_le_bytes()); // nVersionGroupId (V5)
bytes.extend_from_slice(&0x37A5165Bu32.to_le_bytes()); // nConsensusBranchId = NU6.3
bytes.extend_from_slice(&0u32.to_le_bytes()); // nLockTime = 0
bytes.extend_from_slice(&0xFFFF_FFFFu32.to_le_bytes()); // nExpiryHeight = 4_294_967_295 (> 499_999_999)
bytes.push(0x01); // tx_in count = 1
bytes.extend_from_slice(&[0x01; 32]); // non-null prevout hash (non-coinbase)
bytes.extend_from_slice(&0u32.to_le_bytes()); // prevout index = 0
bytes.push(0x00); // scriptSig length = 0
bytes.extend_from_slice(&0xFFFF_FFFFu32.to_le_bytes()); // nSequence
bytes.push(0x01); // tx_out count = 1
bytes.extend_from_slice(&1000u64.to_le_bytes()); // value = 1000 zatoshis
bytes.push(0x00); // scriptPubKey length = 0
bytes.extend_from_slice(&[0x00, 0x00, 0x00]); // 0 Sapling spends/outputs, 0 Orchard actions
bytes
}
// 1. ZEBRA (unpatched main, zebra-consensus): collapses 0xFFFF_FFFF to None and ACCEPTS.
#[test]
fn zebra_accepts_over_range_expiry() {
let tx = Transaction::zcash_deserialize(&poison_tx_bytes()[..]).expect("parses");
assert_eq!(tx.expiry_height(), None); // Link 3: collapsed to None
crate::transaction::check::non_coinbase_expiry_height(&Height(3_000_000), &tx)
.expect("BUG: Zebra accepts the over-range expiry");
}
// 2. ZAKURA (v1.3.1, crates/zakura-consensus): preserves Some(Height(0xFFFF_FFFF)) and REJECTS.
#[test]
fn zakura_rejects_over_range_expiry() {
let tx = Transaction::zcash_deserialize(&poison_tx_bytes()[..]).expect("parses");
assert_eq!(tx.expiry_height(), Some(Height(0xFFFF_FFFF)));
let err = crate::transaction::check::non_coinbase_expiry_height(&Height(3_000_000), &tx)
.unwrap_err();
assert!(matches!(err, TransactionError::MaximumExpiryHeight { .. }));
}
2. PoC for C-02 (204-Byte Padded Coinbase scriptSig at Height 3,000,000)
Because parse_coinbase_height still requires a valid BIP-34 height prefix, the < 2-byte undersized case can only encode block heights 1..=16 (OP_1..=OP_16, deep inside the checkpointed range). At tip heights (H = 3,000,000 = 0x2DC6C0, encoded as the 4-byte push 0x03 C0 C6 2D), padding the coinbase scriptSig with 200 trailing zero bytes (204 bytes total > 100) passes Zebra’s production parser while Zakura rejects it immediately:
fn oversized_coinbase_script() -> Vec<u8> {
let mut s = vec![0x03, 0xC0, 0xC6, 0x2D]; // canonical BIP-34 push for height 3_000_000
s.extend(std::iter::repeat(0x00).take(200)); // 204 bytes total > MAX_COINBASE_SCRIPT_LEN (100)
s
}
// 1. ZEBRA (unpatched main): production parse path accepts 204-byte coinbase scriptSig.
#[test]
fn zebra_accepts_oversized_coinbase() {
let (h, _data) = zebra_chain::transparent::serialize::parse_coinbase_height(
&oversized_coinbase_script(),
)
.expect("BUG: Zebra accepts an oversized coinbase scriptSig on the production path");
assert_eq!(h, zebra_chain::block::Height(3_000_000));
}
// 2. ZAKURA (v1.3.1, crates/zakura-chain): Input::zcash_deserialize rejects at parse time.
#[test]
fn zakura_rejects_oversized_coinbase() {
let script = oversized_coinbase_script();
let mut wire = Vec::new();
wire.extend_from_slice(&[0u8; 32]); // null prevout hash
wire.extend_from_slice(&0xFFFF_FFFFu32.to_le_bytes()); // prevout index = 0xFFFFFFFF
wire.push(0xFD); // CompactSize 16-bit length prefix
wire.extend_from_slice(&(script.len() as u16).to_le_bytes());
wire.extend_from_slice(&script);
wire.extend_from_slice(&0xFFFF_FFFFu32.to_le_bytes()); // nSequence
let err = transparent::Input::zcash_deserialize(&wire[..]).unwrap_err();
assert!(format!("{err:?}").contains("too long")); // "Coinbase script is too long"
}
Pre-Release Remediation, Disclosure Context, and Engineering Takeaways
When we reported both findings on September 2, 2026, Zcash Foundation’s security and engineering team verified the commit history and confirmed that because v6.3.0 had been cut prior to PR #10461, we had caught both consensus splits in the narrow window before they shipped in a release. In Issue #11383 and PR #11387 (merged September 8, 2026), Zcash Foundation restored both consensus checks at their chokepoints, unified ZcashDeserialize and ZcashDeserializeWithContext into a single deserialize_and_check gate, and audited all 16 parse-time consensus rules from v6.3.0 (which also uncovered and fixed Issue #11386 in sprout_value_balance() and PR #11400 in is_valid_non_coinbase()):
--- a/zebra-chain/src/transaction.rs
+++ b/zebra-chain/src/transaction.rs
@@ -162,14 +162,10 @@ impl Transaction {
pub fn expiry_height(&self) -> Option<block::Height> {
match self.tx_version() {
TxVersion::Sprout(_) => None,
- _ => {
- let bh = self.0.expiry_height();
- if bh == zcash_protocol::consensus::BlockHeight::from_u32(0) {
- None
- } else {
- compat::block_height_to_height(bh).ok()
- }
- }
+ _ => match u32::from(self.0.expiry_height()) {
+ 0 => None,
+ raw => Some(block::Height(raw)),
+ },
}
}
--- a/zebra-chain/src/transparent/serialize.rs
+++ b/zebra-chain/src/transparent/serialize.rs
@@ -60,6 +63,13 @@ pub(crate) fn parse_coinbase_height(
) -> Result<(Height, Vec<u8>), SerializationError> {
let parse_err = SerializationError::Parse;
+ // Length bound from the doc comment above; `zcash_transparent::TxIn::read` doesn't enforce it.
+ if script_sig.len() < MIN_COINBASE_SCRIPT_LEN {
+ return Err(parse_err("Coinbase script is too short"));
+ } else if script_sig.len() > MAX_COINBASE_SCRIPT_LEN {
+ return Err(parse_err("Coinbase script is too long"));
+ }
As @ouicate emphasized in Issue #11383 and in the ZF Engineering Update (7–20 September 2026), catching a consensus-broadening bug before release is strictly superior to waiting for a tagged release to claim a live CVE—because once a permissive bug ships to mainnet nodes, fixing it requires a consensus tightening (soft fork) that risks splitting upgraded and un-upgraded Zebra nodes during rollout. Three structural lessons from this audit are now permanently codified in The Zebra Book’s new chapter, Refactoring Consensus-Critical Code (book/src/dev/consensus-refactors.md):
- Absence is invisible in a diff—inventory every rejected input before replacing a parser: When migrating from an internal type to an upstream library (
librustzcash), a consensus rule can vanish without a single deleted check appearing in the diff. Always inventory everyErrreturn in the old deserializer and verify its exact enforcement site in the pinnedCargo.lockdependency—never assume an upstream wire reader (TxIn::read) enforces the same bounds as an upstream builder (TxIn::coinbase). - Never pipe wire integers through
.ok()into anOptionsentinel consumed by consensus checks: ConvertingHeight::try_from(bh)via.ok()collapsed two opposite states—“field is0(legitimately no expiry)” and “field is>= 2^31(illegally out of range)”—intoNone. Any consensus check that skipsNoneis sound only ifNonecannot be produced by a failed conversion. - Test through the production entry point at both type boundaries and spec boundaries: A check whose only callers are its own unit tests (
Input::zcash_deserialize) is dead code with green CI, and testing only spec boundaries (499,999,999/500,000,000) misses bugs that trigger at intermediate type boundaries (Height::MAX = 2^31 - 1vs.2^31andu32::MAX).