@@ -356,6 +356,85 @@ skip-proof feature containment (`pnpm check:invariants`, baselines in
356356 binds the chain, Interfold address, E3 ID, scheme ID, BFV parameter hash, committee public key,
357357 output hash, and SAFE commitment. The E3 program verifies application rules separately and cannot
358358 create a decryption duty by itself. — ` flow-trace/04 ` ; INDEX Z-15
359+ - ** The compute path carries no external audit.** The 2026-08-17 Zenith audit covered six Solidity
360+ files and no Rust. ` crates/compute-provider ` , the RISC Zero guest, ` crates/zk-helpers ` , and
361+ ` Risc0BfvCiphertextVerifier.sol ` were outside both the audit and its mitigation review, so a
362+ ` Resolved ` ` Z- ` row is this repository's remediation rather than a re-reviewed one. Treat changes
363+ in these areas as unaudited by default. — ` flow-trace/00 ` ;
364+ ` packages/interfold-contracts/audits/README.md `
365+ - ** A Secure Process derives its input root; it never receives it.** ` ComputeInput ` holds only
366+ ` fhe_inputs ` , and ` ComputeInput::process ` derives the leaves from the ciphertexts it processed.
367+ The protocol verifier takes the input root from the proof envelope and does not constrain it, so
368+ the E3 program's comparison against its own on-chain root is the only check — and that comparison
369+ is worthless if the guest can be handed leaves that disagree with the ciphertexts it consumed.
370+ Publication is unpermissioned and one-shot, with no dispute path, so any party could otherwise
371+ publish a tally over ciphertexts that were never submitted. ` MerkleTreeBuilder::with_leaf_hashes `
372+ is ` #[cfg(test)] ` to keep it out of that path. — ` flow-trace/04 `
373+ - ** Every E3 program must compare the proof's input root against its own root.**
374+ ` Risc0BfvCiphertextVerifier ` takes no ` inputRoot ` argument and constrains none. A program that
375+ skips the comparison accepts a result computed over any input set. — ` flow-trace/04 `
376+ - ** A Secure Process derives its leaves; it never receives them, and never drops one.**
377+ ` MerkleTreeBuilder::compute_leaf_hashes ` builds every leaf from the ciphertexts it was given and
378+ pushes one per published input, whatever the E3 program's policy decides about computing over it.
379+ Both rules are applied by ` e3-compute-provider ` rather than delegated: a received root can
380+ disagree with the data it claims to describe, and a missing leaf changes the root and makes the
381+ result unpublishable. — ` flow-trace/04 `
382+ - ** The leaf layout and input selection are the E3 program's, not the crate's.** They are supplied
383+ as an ` InputPolicy ` , because a leaf must match whatever that program builds on chain and no two
384+ programs need agree, and because "what does a second input for the same participant mean?" has no
385+ universal answer. ` InputPolicy::default ` is the historical behaviour — leaf is the ciphertext's
386+ own commitment, every input counts — which matches the starter template. Every E3 program exports
387+ ` policy() ` beside ` fhe_processor ` . — ` flow-trace/04 `
388+ - ** CRISP binds bytes, commitment, slot and parent into its leaf, and selects the end of each slot's
389+ chain.** ` CRISPProgram.inputLeaf ` is
390+ ` sha256(sha256(bytes) || commitment || slot || parentIndexPlusOne) mod SNARK_SCALAR_FIELD ` and
391+ ` e3_user_program::policy ` rebuilds it byte for byte; a divergence makes every root mismatch and
392+ nothing else would catch it, so both sides pin the same vector (` program/tests/input_leaf.rs ` ,
393+ ` tests/input-leaf.test.ts ` ) and ` onchain_root_agreement.rs ` asserts Rust reproduces a root a real
394+ contract produced. The tree is append-only because the mask path checks no signature, so anyone
395+ can write to any census member's slot and update-in-place would let a third party erase a counted
396+ vote. — ` flow-trace/04 `
397+ - ** A slot's head must be openable by anyone, so selection follows a parent chain rather than a
398+ mutable pointer.** ` chain_head_per_slot ` takes an entry only when its bytes reproduce its
399+ commitment _ and_ the entry it names is that slot's current head. ` CRISPProgram ` cannot check the
400+ first — the commitment is a Poseidon sponge over CRT limbs and the circuit never sees the
401+ serialization — so with one mutable head per slot, anyone could publish a valid proof beside
402+ unusable bytes and leave a head only they can open. A slot nobody can mask is a slot where every
403+ later input is provably its owner voting again, which is a coercion receipt. Because an unusable
404+ entry is never the head, it is never a valid parent, and the next honest input names the same
405+ parent it did.
406+
407+ The rule takes the ** first** usable entry to extend a parent, so a later sibling is dropped and an
408+ input can be front-run into not counting. Keep it that way: a stale parent cannot be told apart
409+ from a sibling built a moment earlier, because only the circuit knows whether an entry replaces
410+ the slot or adds to it. Preferring the later sibling would let a mask on a superseded ciphertext
411+ restore it over a vote — a silent tally corruption, against a dropped re-vote the voter can see
412+ and retry. — ` flow-trace/04 `
413+
414+ - ** CRISP's three ballot operations prove one relation and publish one shape.** Voting, updating,
415+ and masking all prove ` published = addend + ballot ` , with the addend selected by the private
416+ ` is_mask_vote ` and derived as ` keep_previous = is_mask_vote & !is_first_vote ` . The circuit returns
417+ ` sum_ct_commitment ` on every path, the SDK has one code path, and ` CrispSDK.prepareBallot ` makes
418+ the same server request either way. Branching any of these apart — a different published
419+ ciphertext, a different commitment for the digest, a different request — makes the three
420+ distinguishable on chain, which is what masks exist to prevent. Deriving the selector rather than
421+ witnessing it is what stops a voter counting their old ballot twice and a masker erasing a vote. —
422+ ` flow-trace/04 `
423+ - ** CRISP constrains every coefficient of the ballot plaintext, at the real BFV degree.** The
424+ witness generator reverses the message over the full degree, so the payload sits at
425+ ` k1[D - MAX_MSG_NON_ZERO_COEFFS ..] ` with the options back to front;
426+ ` crisp_lib::utils::ballot_layout ` derives that offset and both checkers use it. Coefficients
427+ inside an option segment must be binary, everything outside the ballot region must be zero, and a
428+ mask's plaintext must be zero everywhere. Indexing as if the polynomial were the message width
429+ makes both checks read only padding: every vote passes any balance bound, and a mask — which needs
430+ no signature and may be written to any eligible slot — can carry an arbitrary payload into someone
431+ else's ballot. Tests must build ` k1 ` at the compiled degree, not at ` MAX_MSG_NON_ZERO_COEFFS ` . —
432+ ` flow-trace/04 `
433+ - ** The SAFE ciphertext commitment requires exactly two components.** It covers ` c[0] ` and ` c[1] `
434+ only, matching the Noir circuit, so ` bfv_ciphertext_to_greco ` rejects any other component count. A
435+ padded ciphertext would otherwise share a commitment with its two-component prefix while threshold
436+ decryption rejects it, failing the round as a ` DecryptionTimeout ` billed to the ciphernodes. —
437+ ` flow-trace/04 `
359438- ** Client PK commitment binding (C-01):** serialized PK event bytes are an untrusted transport
360439 hint; indexers store the decoded key only when its recomputed commitment equals the on-chain
361440 (C5-proven) value. Proof-backed committee publication never accepts key bytes. Public-key
@@ -438,7 +517,20 @@ skip-proof feature containment (`pnpm check:invariants`, baselines in
438517
439518- Committee four-file sync (above) — ` scripts/check-committee.sh ` , pre-push + CI.
440519- ** Never hand-edit generated files:** parity matrices, ` utils.ts ` H/T values, verifier contracts
441- (` generate-verifiers.ts ` output), ` .active-preset.json ` .
520+ (` generate-verifiers.ts ` output), ` .active-preset.json ` , ` crates/support/contracts/ImageID.sol ` ,
521+ ` crates/support/tests/Elf.sol ` .
522+ - ** Generated verifiers must match the built VKs** — ` pnpm check:verifiers ` , pre-push + CI
523+ (` build_circuits ` ). A drift means the deployed verifier accepts a different circuit from the tree.
524+ - ** ` Elf.sol ` is never committed.** ` crates/support/methods/build.rs ` writes it with a machine-local
525+ guest ELF path, so it is generated per checkout and ` .gitignore ` d.
526+ - ** A release publishes a complete provenance manifest** — ` pnpm provenance:manifest ` . It ties
527+ source commit, lockfile digests, pinned revisions, RISC Zero version, builder image tag ** and
528+ digest** (the builder tag is mutable and ` RISC0_DOCKER_CONTAINER_TAG ` overrides it), guest ELF
529+ SHA-256, image ID, and the deployed verifier to one record. The generator reports
530+ ` complete: false ` with the unresolved fields rather than emitting a partial record that reads as
531+ verified. The ELF SHA-256 is ** not** the image ID: SHA-256 checks binary integrity, the image ID
532+ is computed from the loaded memory image. Procedure:
533+ ` docs/pages/verifying-the-compute-provider.mdx ` .
442534- Upgradeable-contract storage baselines are committed and CI-gated (missing baselines, compiler
443535 drift, layout incompatibility, bad gap consumption all fail); baseline creation is an explicit
444536 maintainer command. — INDEX concern #27
0 commit comments