Date: June 4, 2026 Status: Devnet research note Scope: Orchard-style shielded accounting, the June 2026 Zcash Orchard remediation, and the corresponding Post Fiat devnet verifier boundary.
Post Fiat has been researching Orchard-style shielded execution in a devnet environment. That distinction matters. This is not a launch announcement, not a mainnet incident report, and not a statement that public Post Fiat funds were exposed. It is a technical note about a devnet privacy path that used Orchard/Halo2 components and therefore needed to be reviewed after the June 2026 Zcash Orchard vulnerability disclosure.
The relevant code path is the one every Orchard-style implementation must get right:
serialized shielded action
-> bundle reconstruction
-> Halo2 proof verification
-> nullifier / commitment / ciphertext validation
-> value-balance accounting
-> state transition
The Zcash incident is therefore not an abstract cryptography headline for us. It touches the precise interface where private accounting becomes consensus accounting: the verifier accepts a proof, and the node applies a hidden state transition it cannot otherwise inspect.
This note explains:
what Orchard-style accounting is trying to prove;
how an underconstrained circuit can become a double-spend or counterfeit risk;
why a turnstile constrains pool-level supply but cannot repair a bad proof;
what changed upstream in the Orchard/Halo2 remediation;
what changed in the Post Fiat devnet verifier boundary;
what the patched devnet profile now rejects.
We do not publish an exploit witness, counterfeit transaction, or proof payload. The useful public artifact is the accounting model and the remediation boundary, not an operational exploit.
Quick Read
- What happened: Zcash disclosed a real Orchard circuit soundness bug in June 2026; a verifier could accept a proof for a hidden relation the circuit had not fully constrained.
- Why it matters: in a shielded pool, the proof system is the accounting layer. Nullifiers and value balances still matter, but they cannot make a false private proof true.
- What this says about Post Fiat: our exposure was a devnet dependency intersection, not known exploitation and not public funds at risk.
- What fixed the circuit: the actual soundness repair came from the upstream Orchard/Halo2 update; our local work is verifier-boundary hygiene around that fix.
- What to take away: Orchard-style privacy remains viable only if circuit dependencies, proof profiles, verifying keys, and migration boundaries are treated as consensus-security objects.
At a glance
On May 29, 2026, Taylor Hornby disclosed a critical soundness vulnerability in Zcash’s Orchard zero-knowledge proof circuit during protocol audit work for Shielded Labs. ZODL engineers confirmed the issue, and the ecosystem response happened in two consensus steps: Zebra 4.5.3 temporarily disabled Orchard actions, and Zebra 5.0.0 / NU6.2 re-enabled Orchard using the corrected circuit and updated verifying-key path. (Zcash Foundation)
Public Zcash statements say the vulnerability was caught before any known exploitation, that no unauthorized value creation was detected, that the total ZEC supply remained intact under the turnstile accounting mechanism, that user privacy was not affected, and that Sapling and transparent transactions continued operating while Orchard was temporarily disabled. (Zcash Foundation)
The disclosed affected Rust dependency ranges were:
halo2_gadgets < 0.5.0
orchard < 0.14.0
zcash_primitives < 0.28.0
Zcash Foundation also listed affected node ranges, including zcashd v5.0.0–v6.12.3 and zebrad versions below v4.5.1. (Zcash Foundation)
The Post Fiat devnet issue was dependency intersection, not known exploitation. The legacy devnet profile accepted v1 Orchard-style actions and used orchard 0.13.1 with halo2_gadgets 0.4.0, which placed that research path inside the affected upstream range. The patched devnet profile moves to orchard 0.14.0 and halo2_gadgets 0.5.0, rejects legacy v1 profile identifiers, and enforces strict proof-size reconstruction before state application.
The accounting model
An Orchard action is not publicly represented as “Alice sends X to Bob.” The public chain sees structured commitments and verification objects; the private witness contains the note data, keys, openings, and values.
An Orchard transaction contains a bundle of actions, and each action is both a spend and an output. This gives arity hiding while allowing value commitments to balance the transaction. (Zcash)
A simplified public action surface is:
anchor
nullifier
note commitment
encrypted output
value commitment
public value balance
binding signature
Halo2 proof
The hidden witness includes, schematically:
old note
old note opening
old note position / Merkle path
spending authority
new note
new note opening
input and output values
value-commitment randomness
The proof is meant to establish that there exists a witness w satisfying a statement R(x, w) = 1, where x is the public transaction data:
Verify(vk, x, π) = 1
should imply:
∃ w such that R(x, w) = 1
Except with negligible probability in the security parameter. In proof-system language, this is soundness:
Pr[ ∃(x, π) : Verify(vk, x, π) = 1 and x ∉ L_R ] ≤ negl(λ)
where L_R is the language of public statements that have valid private witnesses.
That definition is not academic decoration. It is the reason the ledger can safely apply a shielded state transition without learning the witness. If soundness fails, the node may accept a proof for a state transition that never should have existed.
Nullifiers: why uniqueness is not enough by itself
In Orchard, a nullifier is the public anti-double-spend tag for a hidden note. The Orchard Book gives the nullifier design as:
nf = Extract_P( ([F_nk(ρ) + ψ mod p] G) + cm )
where F is a circuit-efficient keyed PRF, ρ is unique to the output, ψ is sender-controlled randomness, G is a fixed independent base, and cm is the note commitment. (Zcash)
The node’s public nullifier rule is simple:
if nf ∈ SeenNullifiers:
reject
else:
SeenNullifiers ← SeenNullifiers ∪ {nf}
That rule prevents the same valid note from being spent twice with the same nullifier. But it does not, by itself, prove that the nullifier came from a valid hidden note. The proof must establish that link.
The important distinction is:
nullifier uniqueness prevents reuse of an accepted public tag;
proof soundness proves the tag came from a valid private spend.
If the proof system accepts a false statement, the nullifier set is still doing its job locally, but it is guarding the wrong boundary. It can tell the node, “I have not seen this nullifier before.” It cannot tell the node, “this nullifier was honestly derived from a valid note,” unless the circuit actually enforces that relation.
Value balance: the useful math
Orchard uses homomorphic value commitments. The Orchard Book describes value commitments as Pedersen-style homomorphic commitments:
cv = HomomorphicCommit_rcv(v)
with perfect hiding and binding reducible to discrete logarithm assumptions. (Zcash)
Schematic Orchard value accounting can be written as:
v_i^net = v_i^old - v_i^new
cv_i^net = [v_i^net] V + [r_i] R
For a bundle of n actions, the hidden values should balance against the public Orchard value balance:
Σ_i v_i^net = v_balanceOrchard
Because the commitments are homomorphic:
Σ_i cv_i^net - [v_balanceOrchard] V
= [Σ_i r_i] R
The binding signature then ties the transaction hash to the aggregate commitment randomness. The verifier does not learn the individual values, but it checks that the commitments and public balance are mutually consistent.
This is why a circuit soundness bug is so dangerous. The chain intentionally does not see the private values. It sees commitments, signatures, nullifiers, and a proof. If the proof fails to constrain the hidden arithmetic or hidden curve relations correctly, downstream accounting can still look syntactically normal.
A bad shielded action can therefore pass the public shape tests:
proof verifies
binding signature verifies
nullifier is new
commitments are well-formed
public value_balance is in range
while the private relation that made those objects meaningful was never actually enforced.
What failed upstream
The public root cause was specific. The halo2_gadgets-0.5.0 release says the bug was in variable-base scalar multiplication, halo2_gadgets::ecc::chip::mul. The incomplete double-and-add loop kept a per-iteration base constant across rows, but did not anchor that value back to the real base. As a result, a malicious prover could use a free base B' ≠ base, making the gadget output:
[a] base + [b] B'
instead of the intended:
[scalar] base
The fix anchors the base into the first incomplete-addition row and propagates equality across the loop rows; this changes the verifying key. (GitHub)
In circuit terms, the intended relation was roughly:
Q = [s]B
but the underconstrained circuit admitted witnesses satisfying a weaker relation:
Q = [a]B + [b]B'
where B' was not forced to equal B.
That is the entire class of failure in one line: the proof verified a relation, but not the relation the protocol needed.
Double-spend risk versus counterfeiting risk
Two terms tend to get collapsed in public discussion: double spending and counterfeiting. They are related, but not identical.
A double-spend-style failure means the system accepts a spend relation it should reject. The attacker is not necessarily presenting “the same note twice” in the naive public sense. The attacker is presenting a proof whose public objects look like a valid action:
I know a valid note,
I know its opening,
I know the spending authority,
this nullifier is correctly derived,
this commitment is correctly formed,
and the value equation balances.
If that statement is false but the proof verifies, the nullifier database only proves that the public tag is fresh. It does not prove that the hidden spend was legitimate.
A counterfeit-style failure is the value side of the same soundness problem. If the hidden arithmetic or commitment relations are not fully constrained, the pool may accept value claims not backed by legitimate inputs or transparent deposits.
The Zcash Foundation characterized this incident as a vulnerability that could have allowed invalid Orchard state transitions and potentially double spending within Orchard, while stating that the total ZEC supply was protected by the turnstile mechanism. (Zcash Foundation)
That distinction is exactly right and should be preserved.
What the turnstile proves, and what it does not prove
The turnstile is real, important, and scoped.
ZIP 209 defines the Orchard chain value pool balance as:
ChainValuePoolBalance_Orchard(h)
= - Σ_{tx up to height h} valueBalanceOrchard(tx)
and requires nodes to reject a block if accepting it would make any shielded pool balance negative. (Zcash Zips)
ZIP 224 explains that valueBalanceOrchard, combined with non-negative pool-balance checks, creates a transparent turnstile for value moving into and out of Orchard. That contains counterfeiting bugs to the affected pool boundary: a broken Orchard pool should not be able to inflate the total ZEC supply across the whole system. (Zcash Zips)
But the turnstile is not a retroactive witness extractor. It cannot inspect every private Orchard note lineage after the fact. It can say, at the pool boundary:
more value cannot publicly leave this pool than publicly entered it
It cannot say, for every private note inside the pool:
this note descends from a valid private spend path
That is why proof soundness remains the accounting layer. The turnstile constrains aggregate cross-pool movement; it does not repair an invalid private proof.
Why we reviewed the Post Fiat devnet path
The Post Fiat devnet research implementation had a real Orchard-style verifier path:
Post Fiat serialized Orchard action
-> Post Fiat adapter
-> upstream Orchard bundle reconstruction
-> Orchard/Halo2 proof verification
-> Post Fiat shielded state application
The relevant legacy node-level variants were:
ShieldedAction::OrchardV1
ShieldedAction::OrchardWithdrawV1
ShieldedAction::OrchardDepositV1
The pre-remediation devnet snapshot used:
orchard 0.13.1
halo2_gadgets 0.4.0
That put the devnet research stack inside the disclosed affected crate range. Again: devnet. This is exactly what devnet privacy research is for. You want this kind of dependency and verifier-boundary review before a public testnet or production chain treats shielded state as real money.
Defensive reproduction of the legacy behavior
This reproduction demonstrates exposure to the affected dependency profile and the legacy proof path. It does not exploit the circuit.
Use a scratch worktree against the pre-patch local commit:
git worktree add /tmp/postfiat-orchard-v1 c1311381
cd /tmp/postfiat-orchard-v1
Confirm the legacy dependency graph:
cargo tree -p postfiat-node | rg "orchard v|halo2_gadgets v|zcash_primitives"
Expected legacy shape:
postfiat-privacy-orchard
orchard v0.13.1
halo2_gadgets v0.4.0
Run the local Orchard adapter proof path:
cargo test -p postfiat-privacy-orchard \
verify_adapter_accepts_generated_output_bundle_and_rejects_wrong_domain \
-- --nocapture
That test constructs an Orchard output bundle, creates a proof, serializes the Post Fiat action, reconstructs the Orchard bundle, verifies it, scans encrypted outputs, and confirms that wrong-domain binding fails.
The legacy profile generated:
proof_system_id = postfiat.privacy.orchard-halo2.v1
circuit_id = orchard.action.v1
The conclusion is intentionally narrow:
the old devnet profile accepted v1 Post Fiat Orchard actions;
the old devnet profile used orchard 0.13.1 / halo2_gadgets 0.4.0;
that upstream stack was in the disclosed affected range.
That is a narrow defensive reproduction. It proves intersection with the disclosed vulnerability class without publishing an exploit witness.
Before and after the Orchard fix
The upstream remediation has two layers: containment and correction.
First, Zebra 4.5.3 implemented a soft fork that temporarily rejected transactions containing Orchard actions. This reduced exposure while the corrected circuit and network upgrade were prepared. (GitHub)
Second, Zebra 5.0.0 activated NU6.2, re-enabled Orchard actions with the fixed Orchard Action circuit, routed proofs to the appropriate per-circuit verifying key, and added a consensus rule rejecting Orchard bundles with non-canonical proof size. (GitHub)
For Post Fiat devnet, the corresponding boundary change is:
| Boundary | Before | After | Why it matters |
|---|---|---|---|
| Dependency stack | orchard 0.13.1, halo2_gadgets 0.4.0 | orchard 0.14.0, halo2_gadgets 0.5.0 | Moves outside the disclosed affected crate range. |
| Proof profile | postfiat.privacy.orchard-halo2.v1 | postfiat.privacy.orchard-halo2.v2 | Prevents silent acceptance of proofs from the old circuit profile. |
| Circuit ID | orchard.action.v1 | orchard.action.v2 | Makes the circuit-soundness boundary explicit in serialized actions. |
| Bundle reconstruction | Legacy permissive path | Strict upstream constructor with proof-size enforcement | Rejects malformed or non-canonical proof shape before state application. |
| State policy | v1 devnet state existed under affected profile | v1 state is not automatically portable into v2 | Avoids pretending a circuit-soundness boundary is a routine software upgrade. |
The important design decision is not merely “upgrade the crate.” The verifier must also reject stale proof identities so a legacy devnet action is not routed ambiguously through the new verifier boundary.
Post Fiat devnet remediation
The devnet remediation has three layers.
First, the upstream soundness fix is inherited by moving to the corrected Orchard/Halo2 stack:
orchard = "0.14.0"
with the lockfile resolving:
orchard 0.14.0
halo2_gadgets 0.5.0
Second, the serialized proof profile is versioned:
postfiat.privacy.orchard-halo2.v2
orchard.action.v2
and the parser rejects legacy v1 identifiers:
pub const ORCHARD_PROOF_SYSTEM_ID: &str =
"postfiat.privacy.orchard-halo2.v2";
pub const ORCHARD_ACTION_CIRCUIT_ID: &str =
"orchard.action.v2";
Third, untrusted serialized actions go through the checked upstream bundle constructor with strict proof-size enforcement:
Bundle::try_from_parts(
actions,
action.flags.to_orchard()?,
action.value_balance,
action.anchor.to_orchard()?,
Authorized::from_parts(
Proof::new(action.proof.to_bytes()?),
action.binding_signature.to_orchard()?,
),
ProofSizeEnforcement::Strict,
)
This is the intended verifier boundary. A node should not loosely assemble an authorized bundle from untrusted proof bytes and then hope later checks catch the problem. The canonical proof shape should be enforced before verification, and verification should happen before state application.
Boundary tests
These tests do not prove the algebraic soundness of the Orchard circuit. That assurance comes from the corrected upstream circuit and its review. These tests check the Post Fiat devnet boundary behavior around that fix:
legacy v1 proof identifiers are rejected;
legacy v1 circuit identifiers are rejected;
non-canonical proof bytes are rejected before verification;
valid v2 Orchard actions still verify and apply;
duplicate nullifiers are still rejected at the node state layer.
Regression for legacy profile rejection:
#[test]
fn legacy_v1_profile_ids_are_rejected() {
assert_eq!(
OrchardProofSystemId::parse("postfiat.privacy.orchard-halo2.v1")
.expect_err("legacy proof system id")
.code(),
"unsupported_proof_system"
);
assert_eq!(
OrchardCircuitId::parse("orchard.action.v1")
.expect_err("legacy circuit id")
.code(),
"unsupported_circuit"
);
}
Regression for non-canonical proof shape:
let mut padded_proof_action = parsed.clone();
let mut padded_proof = padded_proof_action
.proof
.to_bytes()
.expect("proof bytes");
padded_proof.push(0);
padded_proof_action.proof =
OrchardProofBytes::from_bytes(&padded_proof)
.expect("bounded padded proof");
assert_eq!(
orchard_bundle_from_action(&padded_proof_action)
.expect_err("padded proof must fail before verification")
.code(),
"invalid_orchard_bundle"
);
Focused privacy crate result:
cargo test -p postfiat-privacy-orchard
15 passed
0 failed
finished in 86.80s
Node-level devnet integration result:
timeout 600s cargo test -p postfiat-node \
orchard_action_gate_verifies_applies_and_rejects_duplicate_nullifiers \
--lib -- --nocapture
1 passed
0 failed
finished in 335.45s
That node test verifies and applies an Orchard action, checks wallet scanning, exports view keys, rejects duplicate nullifiers, creates a wallet-generated action, and applies it under the patched verifier.
Additional checks:
cargo check -p postfiat-node
cargo check -p postfiat-fuzz
cargo tree -p postfiat-node \
| rg "orchard v|halo2_gadgets v|zcash_primitives"
cargo tree --workspace \
| rg "orchard v0\.13|halo2_gadgets v0\.4|zcash_primitives v0\.(2[0-7]|1[0-9])" \
|| true
Current node graph:
postfiat-privacy-orchard
orchard v0.14.0
halo2_gadgets v0.5.0
The vulnerable-version scan returned no active orchard 0.13, no active halo2_gadgets 0.4, and no vulnerable zcash_primitives dependency in the active workspace graph.
Lessons for implementers
The lesson is not “use a turnstile and relax.”
The lesson is that shielded accounting has two distinct layers:
private validity:
enforced by the circuit and proof system
public conservation:
enforced by value balances, signatures, nullifiers, and pool accounting
Both are necessary. They are not substitutes.
For implementers, the practical rules are:
version proof profiles when circuit soundness changes;
reject old profile identifiers explicitly;
treat verifying-key changes as consensus-boundary changes;
reconstruct bundles through checked constructors;
enforce canonical proof size before verification;
apply state only after proof and binding checks pass;
test malformed proof shape directly;
test duplicate-nullifier rejection separately;
do not carry shielded devnet state across a soundness boundary silently.
The state-transition order should look like this:
parse action
-> reject unsupported proof_system_id / circuit_id
-> reconstruct bundle strictly
-> verify proof and binding signature
-> check nullifier freshness
-> apply nullifiers and commitments
-> update value-pool accounting
Not this:
parse loosely
-> partially apply state
-> rely on later accounting to notice proof-system failure
Downstream accounting cannot repair a false proof. Once the verifier accepts π for a public statement x, the state machine treats x as authorized. If x ∉ L_R, the damage happened at the proof boundary.
Post Fiat devnet policy
For this devnet branch, v1 shielded state is not treated as automatically portable into the v2 profile.
Any future devnet migration should be explicit. The options are:
drain through a documented turnstile boundary;
reset the devnet shielded state;
or publish a migration record stating exactly how old shielded state was handled.
What should not happen is silent continuity across a circuit-soundness boundary. A proof-profile version is a security boundary, not a cosmetic label.
Boundary of this note
This note claims:
Post Fiat's internal devnet Orchard research path intersected the disclosed affected upstream crate range.
The legacy devnet profile accepted v1 Orchard-style actions under orchard 0.13.1 / halo2_gadgets 0.4.0.
The patched devnet profile uses orchard 0.14.0 and halo2_gadgets 0.5.0.
The patched parser rejects v1 proof-system and circuit identifiers.
The patched adapter rejects padded or non-canonical proof bytes before verification.
The patched node-level Orchard path still verifies, applies, scans, and rejects duplicate nullifiers.
This note does not claim:
that Post Fiat shipped a public unsafe shielded-money system;
that Post Fiat mainnet or public testnet funds were exposed;
that we can independently prove or disprove historical Zcash exploitation;
that the turnstile repairs an invalid proof;
that publishing a counterfeit witness would help users.
The practical conclusion is simple: Orchard-style privacy remains viable, but only under a strict operating model. In a shielded pool, the proof system is the accounting layer. Circuit soundness, dependency tracking, profile versioning, verifying-key hygiene, and explicit migration boundaries are not optional infrastructure. They are the difference between private accounting and unverifiable state mutation.