Introduction
On June 8th, 2026, zkSecurity started a security audit of the Zcash Orchard and Halo2 repositories on behalf of the Tachyon Foundation. The audit lasted one week with two consultants, and was a focused, high-priority review aimed at reducing tail risk in the Orchard and Halo2 code.
Scope
The audit targeted the following release tags:
A primary focus was constraint soundness: the large Orchard Action circuit, together with the Halo2 gadgets it depends on. The review also covered the Halo2 proof system verifier and the non-circuit, verifier-facing surface of the Orchard crate. In concrete terms, the review spanned four areas:
- The Orchard Action circuit and its sub-circuits: note commitment (
NoteCommit), incoming-viewing-key commitment (CommitIvk), value commitment, nullifier derivation, and Sinsemilla Merkle-path verification.
- The reusable Halo2 gadgets it builds on: the
halo2_gadgets elliptic-curve, Sinsemilla, and utility gadgets, and the halo2_poseidon Poseidon circuits.
- The
halo2_proofs PLONK verifier, transcript, and polynomial-commitment verification paths.
- The Orchard crate’s non-circuit surface: bundle, action, and public-instance handling; value, nullifier, and note-commitment binding; key, signature, and address parsing; and the PCZT partial-transaction verification and extraction flow.
The Halo2 crates in scope also contain gadgets that Orchard does not use, most notably the SHA-256 circuit. At the client’s direction, these components were excluded from the review. This report therefore makes no claim about their correctness; the SHA-256 gadgets are in fact known to contain serious soundness issues, outside the scope of this report.
Given the one-week timeframe relative to the size of the scope, the review was deliberately prioritized: effort was directed at the areas of highest risk and the most likely sources of soundness bugs, rather than at exhaustive, uniform coverage of every component. The findings in this report reflect the issues surfaced within that window, and the absence of findings in a given area should not be read as a guarantee that it is free of issues.
Summary
A substantial part of the engagement went into an MVP formal verification of the entire Orchard Action circuit in Lean, built with Clean, zkSecurity’s framework for formally verifying zero-knowledge circuits. This formalization, described below, is the most significant outcome of the audit: it gives us high confidence about circuit soundness against the Zcash protocol spec.
The manual review and accompanying proofs of concept surfaced no issue affecting the security of the deployed Orchard protocol. The circuit-level findings concentrate on gadget-usage paths that are hard to maintain and dangerous to change, where a future modification could silently break a soundness assumption; rather than on live bugs in the current code.
zkao, zkSecurity’s AI security scanner for cryptographic code, was used during the engagement and flagged the empty-Sinsemilla and fixed-base-multiplication findings.
A note on Halo2 gadget usage
Several observations during the review stem from general characteristics of the Halo2 constraint system rather than from defects specific to a single gadget. These four recur as footguns for anyone integrating the halo2_gadgets primitives:
- Values are not cells. A
Value<F> passed into a gadget is used only during witness generation; it is never part of the constraint system. Unless the gadget copies it into an assigned cell and constrains that cell, nothing binds the value a caller supplies to the value the gates actually enforce. An honest prover always makes the two agree, so a missing binding is invisible on the happy path and only a malicious prover diverges.
- Constraints apply only where the selector is enabled. A gate constrains a row only when its selector is enabled on that row. Result cells written on rows where the selector is not enabled are assigned but unconstrained. A single missed enablement, for instance an off-by-one loop bound, silently leaves those cells free for a malicious prover to choose.
- Semantically same cells are sometimes implicitly referred to by offset. Similarly, a cell that’s constrained on the chip configuration step is sometimes referred to in the synthesis step only by the documented offset. An offsetting error, such as an off-by-one, can miss those cells.
- Risky APIs that are only used by tests are not gated. Some APIs, such as
witness_decompose are only used by tests. While their naming and usages are clear, they increase the risk surface.
The audited code uses the gadgets correctly.
To rule out correctness gaps in the Orchard circuits systematically, we formalized them in Clean, see https://github.com/zksecurity/clean-orchard. The work comes with end-to-end soundness and completeness proofs for the top-level Action circuit, covering all of its gadget dependencies. Proofs depend on zero sorries and a single benign axiom that establishes the Pallas curve order.
Limitations. The formalization was done within the circuit model of Clean, using raw constraints and a linear witness tape, which is a more primitive representation than Halo2 produces with its regions, custom gates and row-column layout. Because of that mismatch, an “approximate” rather than fully faithful style of porting circuits was chosen. The approach is documented in detail in the repository.
The main limitation, which follows from that approximation, is that our formalization does not mechanically connect the ported circuits to their Rust source, in any way. The translation of circuit code was done “by hand”, in an AI-assisted manner, with strong instructions to use source-conformant circuits but no waterproof mechanism to enforce such conformance. Therefore, we could accidentally have formalized different circuits than Orchard actually uses.
A second limitation is that not all circuit specs and interfaces were human-written or reviewed, and such a review is essential for guaranteeing the proof gives us the properties we want. We did review and co-author the top-level Action circuit spec and have reasonable confidence of its appropiateness, but this does not rule out gaps in lower-level gadgets that the Action circuit does not happen to exercise.
As concrete evidence of how this can hide soundness bugs, consider our finding showing that the Sinsemilla hash circuit is unsound for empty input messages. The ported Clean circuit that LLMs produced works around this issue (which is standing in the way of the required soundness proof) by simply requiring the input message to be a Vector of length n + 1. The Action circuit only uses inputs of length greater than zero.
Despite these limitations, we believe that the process we used would have exposed any end-to-end soundness gap with very high probability. As part of ongoing collaboration with Tachyon Foundation, zkSecurity is working on lifting both limitations, producing a circuit formalization that is mechanically checked to match the Halo2 constraint system, and where specs and circuit input signatures are carefully reviewed at each level.
Below are listed the findings found during the engagement. High severity findings can be seen as
so-called
"priority 0" issues that need fixing (potentially urgently). Medium severity findings are most often
serious
findings that have less impact (or are harder to exploit) than high-severity findings. Low severity
findings
are most often exploitable in contrived scenarios, if at all, but still warrant reflection. Findings
marked
as informational are general comments that did not fit any of the other criteria.
Description. hash_to_point accepts a Message with zero pieces. The Message constructor only bounds the number of words from above, so an empty message passes:
fn from(pieces: Vec<MessagePiece<F, K>>) -> Self {
// A message cannot contain more than `MAX_WORDS` words.
assert!(pieces.iter().map(|piece| piece.num_words()).sum::<usize>() < MAX_WORDS);
Message(pieces)
}
When there are no pieces, the hashing loop in hash_all_pieces never runs, so hash_piece is never called and no q_sinsemilla1 row (nor the generator-table lookup or transition gate) is enabled. The returned y-coordinate is then assigned as plain advice into lambda_1, constrained only by the initialization gate:
// Hash each piece in the message.
for (idx, piece) in message.iter().enumerate() {
let final_piece = idx == message.len() - 1;
let (x, y, zs) = self.hash_piece(region, offset, piece, x_a, y_a, final_piece)?;
offset += piece.num_words();
x_a = x;
y_a = y;
zs_sum.push(zs);
}
// Assign the final y_a.
let y_a = {
let y_a_cell =
region.assign_advice(|| "y_a", config.double_and_add.lambda_1, offset, || y_a.0)?;
// dummy lambda2 / x_p assigned as zero, since they are multiplied by zero
y_a_cell
};
The initialization gate pins the output x-coordinate to Q.x and relates the y-coordinate only through a single equation:
init_y_q_check: 2·y_Q − Y_A = 0, Y_A = (λ₁ + λ₂)·(x_a − x_R), x_R = λ₁² − x_a − x_p
Here x_a is fixed to the constant x_Q and y_Q to a fixed column, but λ₁ (the returned y-coordinate), λ₂, and x_p are free advice.
A prover can pick any target y for the output and set the auxiliary cells x_p = y² − 2·x_Q + 1 and λ₂ = 2·y_Q − y, which give x_a − x_R = 1 and Y_A = 2·y_Q, satisfying the gate. We developed a proof-of-concept that drives a real SinsemillaChip on an empty message and returns (x_Q, −y_Q) = −Q, even though the specification defines SinsemillaHashToPoint(D, []) = Q.
Impact. This is an exploitable soundness gap for consumers of the Sinsemilla gadget that permit empty messages and use the returned point as a full curve point. It has no impact on Orchard, which does not hash empty Sinsemilla messages. Callers that only use the x-coordinate via extract are unaffected.
Recommendation. If empty messages are meant to be supported, constrain the empty case to return the domain point Q = (x_Q, y_Q). The initialization already pins the returned x-coordinate to the constant x_Q (via assign_advice_from_constant); the empty branch should bind the returned y-coordinate to the constant y_Q in the same way, instead of assigning it as free advice. This fix lives entirely in the empty-message code path: it reuses the existing fixed_y_q and permutation machinery and adds no gate or column at configure time, so the constraint system that Orchard synthesizes is unchanged.
Alternatively, if empty messages need not be supported, reject them in Message::from / Message::from_pieces / hash_all_pieces.
Description. Full-width fixed-base scalar multiplication decomposes a ScalarFixed into windows and computes [windows]·B, but it never constrains those windows to equal the witnessed scalar value. A ScalarFixed is created through the public constructor:
pub fn new(
chip: EccChip,
mut layouter: impl Layouter<pallas::Base>,
value: Value<pallas::Scalar>,
) -> Result<Self, Error> {
// ...
}
The value here is a host-side Value, used only during witness generation to derive the window assignments. Under the hood new calls witness_scalar_fixed, which ignores its layouter and assigns nothing, returning EccScalarFixed { value, windows: None }; the only cells are the window digits created later inside .mul, and they are range- and coordinate-checked only, never tied back to value. No equality constraint links the two. The relation the gadget actually enforces is therefore existential: result = [windows]·B for prover-chosen windows, i.e. “the output is B multiplied by some scalar the prover knows.” It does not enforce that two multiplications sharing a single ScalarFixed object use the same scalar.
A caller who passes one ScalarFixed to two multiplications intending them to share an exponent gets no such guarantee. We developed a proof-of-concept that reuses a single ScalarFixed across P = [α]·G and Q = [α]·H, a discrete-log-equality (DLEQ) relationship. Because neither multiplication binds its windows to the scalar value, a prover can desynchronize them: the circuit accepts P = [5]·G and Q = [11]·H — two points that do not share a discrete log — even though a single ScalarFixed was passed to both.
This is an instance of a “value, not cell” gap: the API takes a Value and uses it to compute witnesses, while a caller might assume that passing the same value binds the two results to the same scalar. An honest prover always makes the windows agree with the value, so happy-path tests and witness-time self-checks pass; only a misuse combined with a malicious prover diverges.
Impact. The gadget is used correctly in the audited code. Orchard’s spend-authority block reads as if it binds the scalar to a cell, but the same-scalar guarantee it visually suggests does not exist:
let alpha = ScalarFixed::new(ecc_chip, layouter.namespace(|| "alpha"), self.alpha)?;
let (alpha_commitment, _) = spend_auth_g.mul(layouter, alpha)?; // [alpha]·SpendAuthG
Here alpha is a private prover hint, so the existential contract (“the output is the base multiplied by some scalar the prover knows”) is exactly what Orchard needs, and no cross-multiplication scalar equality is relied upon. The issue is a latent footgun for other callers — such as a DLEQ construction — that would need two fixed-base multiplications to be pinned to the same scalar.
Recommendation. Document that full-width fixed-base multiplication enforces only an existential relation on the scalar and does not bind a reused ScalarFixed to a single value. Callers requiring a shared scalar across multiplications must bind it themselves — e.g. by copying the scalar cell into the running-sum decomposition (copy_decompose) rather than witnessing the windows from a Value.