diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d211e26d8e..d03731a604 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -281,6 +281,8 @@ jobs: - name: Run ZK Prover Integration Tests (with network downloads) run: 'cargo test -p e3-zk-prover --features integration-tests --test integration_tests -- --nocapture' + # Guards the RISC Zero guest artifact. The on-chain imageId is immutable, so a guest change + # that leaves ImageID.sol untouched ships a verifier that no longer matches this tree. build_e3_support_risc0: needs: [detect_changes] if: needs.detect_changes.outputs.docker_support == 'true' @@ -658,19 +660,51 @@ jobs: crisp_unit: needs: [detect_changes, build_crisp_sdk] if: needs.detect_changes.outputs.crisp == 'true' - timeout-minutes: 30 + # Per leg, because they are not remotely the same size. Anything that generates a ballot proves + # five Noir circuits, which a two-vCPU runner takes minutes to do; everything else is seconds. + timeout-minutes: ${{ matrix.timeout }} runs-on: 'ubuntu-latest' strategy: matrix: include: - test-suite: test:circuits command: pnpm test:circuits + timeout: 15 - test-suite: test:sdk command: pnpm test:sdk - - test-suite: test:contracts - command: pnpm test:contracts + timeout: 45 + needs-sdk: true + # The contract suite is split by proving cost, not by subject. Run whole it took 27m54s + # against a 30m cap and started dying to ordinary runner variance, and it grows by minutes + # with every ballot a new test needs. Split, the critical path is the largest single leg + # rather than the sum, and the checks that prove nothing report in seconds instead of + # queueing behind twenty minutes of proving. + # + # Balanced by ballots, which is what the time is: input-tree 7, ballots 5, unit 0. + # `pnpm test:contracts` still runs all of it in one go for local use, and + # `crisp_contract_leg_coverage` fails if a test file ever belongs to no leg. + # + # Every contract leg needs both capabilities: `hardhat.config.ts` imports + # `@interfold/contracts/tasks/*`, which only resolves once `evm:build` has emitted `dist/`, + # and each leg contains at least one test that imports `@crisp-e3/sdk`. + - test-suite: test:contracts:input-tree + command: pnpm test:contracts:input-tree + timeout: 45 + needs-evm: true + needs-sdk: true + - test-suite: test:contracts:ballots + command: pnpm test:contracts:ballots + timeout: 45 + needs-evm: true + needs-sdk: true + - test-suite: test:contracts:unit + command: pnpm test:contracts:unit + timeout: 15 + needs-evm: true + needs-sdk: true - test-suite: cargo test command: cargo test + timeout: 20 fail-fast: false steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 @@ -724,13 +758,16 @@ jobs: working-directory: . run: pnpm install --frozen-lockfile + # Gated on a declared capability rather than on the leg's name. Matching the name made the + # label load-bearing: splitting `test:contracts` into three renamed legs silently skipped this + # step, and the suite failed on a missing `@interfold/contracts/dist` two steps later. - name: Compile Interfold contracts - if: matrix.test-suite == 'test:contracts' + if: matrix.needs-evm working-directory: . run: pnpm evm:build - name: Download Crisp SDK artifacts - if: matrix.test-suite == 'test:sdk' || matrix.test-suite == 'test:contracts' + if: matrix.needs-sdk uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: crisp-sdk-artifacts @@ -941,6 +978,36 @@ jobs: - name: Build circuits run: pnpm build:circuits + # The generated verifier contracts must match the freshly built verification keys. A drift + # means the deployed verifier accepts a different circuit from the one in this tree. + # + # `--no-compile` is load-bearing, not an optimisation. Without it the checker deletes every + # nargo target directory first — its guard against comparing stale artifacts — and recompiles + # only what it needs, which is the `.json` and the evm `.vk`. That silently discards the + # `.vk_recursive` files `build:circuits` had just written, and the upload below then ships an + # artifact `zk_prover_e2e` cannot use. Nothing here fails; the damage surfaces two jobs later. + # The step above has just built these circuits, so there is nothing stale to guard against. + - name: Check generated verifiers are in sync + run: pnpm check:verifiers --no-compile + + # Asserted before the upload, so a step that consumes or clears a build output fails in the + # job that did it rather than in the job that needed it. Same list `zk_prover_e2e` checks. + - name: Verify recursive aggregation artifacts survived + run: | + for artifact in \ + circuits/bin/recursive_aggregation/dkg_aggregator/target/dkg_aggregator.json \ + circuits/bin/recursive_aggregation/dkg_aggregator/target/dkg_aggregator.vk_recursive \ + circuits/bin/recursive_aggregation/decryption_aggregator/target/decryption_aggregator.json \ + circuits/bin/recursive_aggregation/decryption_aggregator/target/decryption_aggregator.vk_recursive + do + test -f "$artifact" || { + echo "Missing before upload: $artifact" + echo "A step between 'Build circuits' and here removed it." + exit 1 + } + echo " ok $artifact" + done + - name: Upload compiled circuit artifacts uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: diff --git a/.github/workflows/releases.yml b/.github/workflows/releases.yml index c239db16b2..f27be67651 100644 --- a/.github/workflows/releases.yml +++ b/.github/workflows/releases.yml @@ -450,16 +450,45 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 0 + # This job holds `contents: write` and runs `pnpm install`, which executes lifecycle + # scripts from every dependency. Persisting the token in .git/config would leave it on + # disk for all of them. The one step that needs it takes it explicitly instead. + persist-credentials: false - name: Download all binary artifacts uses: actions/download-artifact@v4 with: path: dist/ + # Pinned to immutable SHAs: this job has `contents: write` and force-pushes `stable`, so a + # mutable tag could introduce unreviewed code into the release. + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '22' + + - uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # A RISC Zero proof names a guest image; it does not name the source that produced it. This + # record is what lets a third party tie the released source to the deployed imageId. + - name: Generate compute-provider provenance manifest + run: | + pnpm provenance:manifest --out compute-provider-provenance.json + # `complete: false` means a field could not be resolved. Publishing it would put a + # partial record beside the release as though it were a verification. + if ! node -e "process.exit(require('./compute-provider-provenance.json').complete ? 0 : 1)"; then + echo "provenance manifest is incomplete; refusing to publish it" >&2 + node -e "console.error(require('./compute-provider-provenance.json').unresolved.join('\n'))" + exit 1 + fi + - name: Prepare release assets run: | mkdir -p release-assets find dist/ -name "*.tar.gz" -exec cp {} release-assets/ \; + cp compute-provider-provenance.json release-assets/ # `interfold config check` fetches this asset from the latest release, # so a release without it leaves operators unable to detect a redeploy. if [ ! -f deployments/manifest.json ]; then @@ -609,7 +638,15 @@ jobs: - name: Update stable tag (for stable releases only) if: needs.validate-and-prepare.outputs.is_prerelease == 'false' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | git tag -f stable + # Through a credential helper, so the token never reaches a process argument list. Every + # process on a runner shares one user, so anything left running by `pnpm install` can read + # /proc/*/cmdline; it cannot read another process's environment as easily. The config + # stores the helper only — the token is read from the environment when git invokes it. + git config --local credential.helper '!f() { echo username=x-access-token; echo "password=$GITHUB_TOKEN"; }; f' git push origin stable --force + git config --local --unset credential.helper echo "✅ Updated 'stable' tag to point to v${{ needs.validate-and-prepare.outputs.version }}" diff --git a/.gitignore b/.gitignore index eb04b88b84..62c899d4c4 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,7 @@ __pycache__/ # circuit benchmarks circuits/benchmarks/results_*/raw/ + +# Generated by crates/support/methods/build.rs. Holds a machine-local guest ELF path, +# so it is never committed. +crates/support/tests/Elf.sol diff --git a/.husky/pre-push b/.husky/pre-push index c0c9733c5e..a9c723051f 100644 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -6,3 +6,5 @@ pnpm check:license pnpm check:committee pnpm check:docs pnpm check:invariants +pnpm check:image-id +pnpm check:verifiers diff --git a/Cargo.lock b/Cargo.lock index 9e7898598d..509419e3af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3489,14 +3489,20 @@ dependencies = [ "ark-ff 0.4.2", "e3-bfv-client", "e3-fhe-params", + "fhe", + "fhe-traits", "hex", "lean-imt", "light-poseidon", "num-bigint 0.4.6", "num-traits", - "rayon", + "rand 0.9.2", + "rand_chacha 0.9.0", "serde", + "serde_json", + "sha2 0.10.9", "sha3", + "thiserror 1.0.69", "zk-kit-imt", ] diff --git a/agent/CRATES_ARCHITECTURE.md b/agent/CRATES_ARCHITECTURE.md index 4c0a138bac..cdccdf0808 100644 --- a/agent/CRATES_ARCHITECTURE.md +++ b/agent/CRATES_ARCHITECTURE.md @@ -573,9 +573,9 @@ semantic replay domain across deferred, in-flight, and completed submissions. Re failures release their key. Successful or known-benign terminal results retain it. Every node reads the proof-type policy after a fault quorum. A disabled policy produces a durable, -E3-scoped `CommitteeMemberExcluded` fact instead of a transaction that must revert. This fact is -not an on-chain expulsion: it changes only the current E3's collectors and aggregator selection. -The canonical N-member roster remains unchanged for proof binding, rewards, and registry state. +E3-scoped `CommitteeMemberExcluded` fact instead of a transaction that must revert. This fact is not +an on-chain expulsion: it changes only the current E3's collectors and aggregator selection. The +canonical N-member roster remains unchanged for proof binding, rewards, and registry state. The gate is deliberately described as in-memory: there is no durable external-effect outbox or persisted transaction intent. A crash after snapshot advancement but before receipt classification diff --git a/agent/INVARIANTS.md b/agent/INVARIANTS.md index 53d5028c52..d6618cbbe9 100644 --- a/agent/INVARIANTS.md +++ b/agent/INVARIANTS.md @@ -356,6 +356,85 @@ skip-proof feature containment (`pnpm check:invariants`, baselines in binds the chain, Interfold address, E3 ID, scheme ID, BFV parameter hash, committee public key, output hash, and SAFE commitment. The E3 program verifies application rules separately and cannot create a decryption duty by itself. — `flow-trace/04`; INDEX Z-15 +- **The compute path carries no external audit.** The 2026-08-17 Zenith audit covered six Solidity + files and no Rust. `crates/compute-provider`, the RISC Zero guest, `crates/zk-helpers`, and + `Risc0BfvCiphertextVerifier.sol` were outside both the audit and its mitigation review, so a + `Resolved` `Z-` row is this repository's remediation rather than a re-reviewed one. Treat changes + in these areas as unaudited by default. — `flow-trace/00`; + `packages/interfold-contracts/audits/README.md` +- **A Secure Process derives its input root; it never receives it.** `ComputeInput` holds only + `fhe_inputs`, and `ComputeInput::process` derives the leaves from the ciphertexts it processed. + The protocol verifier takes the input root from the proof envelope and does not constrain it, so + the E3 program's comparison against its own on-chain root is the only check — and that comparison + is worthless if the guest can be handed leaves that disagree with the ciphertexts it consumed. + Publication is unpermissioned and one-shot, with no dispute path, so any party could otherwise + publish a tally over ciphertexts that were never submitted. `MerkleTreeBuilder::with_leaf_hashes` + is `#[cfg(test)]` to keep it out of that path. — `flow-trace/04` +- **Every E3 program must compare the proof's input root against its own root.** + `Risc0BfvCiphertextVerifier` takes no `inputRoot` argument and constrains none. A program that + skips the comparison accepts a result computed over any input set. — `flow-trace/04` +- **A Secure Process derives its leaves; it never receives them, and never drops one.** + `MerkleTreeBuilder::compute_leaf_hashes` builds every leaf from the ciphertexts it was given and + pushes one per published input, whatever the E3 program's policy decides about computing over it. + Both rules are applied by `e3-compute-provider` rather than delegated: a received root can + disagree with the data it claims to describe, and a missing leaf changes the root and makes the + result unpublishable. — `flow-trace/04` +- **The leaf layout and input selection are the E3 program's, not the crate's.** They are supplied + as an `InputPolicy`, because a leaf must match whatever that program builds on chain and no two + programs need agree, and because "what does a second input for the same participant mean?" has no + universal answer. `InputPolicy::default` is the historical behaviour — leaf is the ciphertext's + own commitment, every input counts — which matches the starter template. Every E3 program exports + `policy()` beside `fhe_processor`. — `flow-trace/04` +- **CRISP binds bytes, commitment, slot and parent into its leaf, and selects the end of each slot's + chain.** `CRISPProgram.inputLeaf` is + `sha256(sha256(bytes) || commitment || slot || parentIndexPlusOne) mod SNARK_SCALAR_FIELD` and + `e3_user_program::policy` rebuilds it byte for byte; a divergence makes every root mismatch and + nothing else would catch it, so both sides pin the same vector (`program/tests/input_leaf.rs`, + `tests/input-leaf.test.ts`) and `onchain_root_agreement.rs` asserts Rust reproduces a root a real + contract produced. The tree is append-only because the mask path checks no signature, so anyone + can write to any census member's slot and update-in-place would let a third party erase a counted + vote. — `flow-trace/04` +- **A slot's head must be openable by anyone, so selection follows a parent chain rather than a + mutable pointer.** `chain_head_per_slot` takes an entry only when its bytes reproduce its + commitment _and_ the entry it names is that slot's current head. `CRISPProgram` cannot check the + first — the commitment is a Poseidon sponge over CRT limbs and the circuit never sees the + serialization — so with one mutable head per slot, anyone could publish a valid proof beside + unusable bytes and leave a head only they can open. A slot nobody can mask is a slot where every + later input is provably its owner voting again, which is a coercion receipt. Because an unusable + entry is never the head, it is never a valid parent, and the next honest input names the same + parent it did. + + The rule takes the **first** usable entry to extend a parent, so a later sibling is dropped and an + input can be front-run into not counting. Keep it that way: a stale parent cannot be told apart + from a sibling built a moment earlier, because only the circuit knows whether an entry replaces + the slot or adds to it. Preferring the later sibling would let a mask on a superseded ciphertext + restore it over a vote — a silent tally corruption, against a dropped re-vote the voter can see + and retry. — `flow-trace/04` + +- **CRISP's three ballot operations prove one relation and publish one shape.** Voting, updating, + and masking all prove `published = addend + ballot`, with the addend selected by the private + `is_mask_vote` and derived as `keep_previous = is_mask_vote & !is_first_vote`. The circuit returns + `sum_ct_commitment` on every path, the SDK has one code path, and `CrispSDK.prepareBallot` makes + the same server request either way. Branching any of these apart — a different published + ciphertext, a different commitment for the digest, a different request — makes the three + distinguishable on chain, which is what masks exist to prevent. Deriving the selector rather than + witnessing it is what stops a voter counting their old ballot twice and a masker erasing a vote. — + `flow-trace/04` +- **CRISP constrains every coefficient of the ballot plaintext, at the real BFV degree.** The + witness generator reverses the message over the full degree, so the payload sits at + `k1[D - MAX_MSG_NON_ZERO_COEFFS ..]` with the options back to front; + `crisp_lib::utils::ballot_layout` derives that offset and both checkers use it. Coefficients + inside an option segment must be binary, everything outside the ballot region must be zero, and a + mask's plaintext must be zero everywhere. Indexing as if the polynomial were the message width + makes both checks read only padding: every vote passes any balance bound, and a mask — which needs + no signature and may be written to any eligible slot — can carry an arbitrary payload into someone + else's ballot. Tests must build `k1` at the compiled degree, not at `MAX_MSG_NON_ZERO_COEFFS`. — + `flow-trace/04` +- **The SAFE ciphertext commitment requires exactly two components.** It covers `c[0]` and `c[1]` + only, matching the Noir circuit, so `bfv_ciphertext_to_greco` rejects any other component count. A + padded ciphertext would otherwise share a commitment with its two-component prefix while threshold + decryption rejects it, failing the round as a `DecryptionTimeout` billed to the ciphernodes. — + `flow-trace/04` - **Client PK commitment binding (C-01):** serialized PK event bytes are an untrusted transport hint; indexers store the decoded key only when its recomputed commitment equals the on-chain (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 - Committee four-file sync (above) — `scripts/check-committee.sh`, pre-push + CI. - **Never hand-edit generated files:** parity matrices, `utils.ts` H/T values, verifier contracts - (`generate-verifiers.ts` output), `.active-preset.json`. + (`generate-verifiers.ts` output), `.active-preset.json`, `crates/support/contracts/ImageID.sol`, + `crates/support/tests/Elf.sol`. +- **Generated verifiers must match the built VKs** — `pnpm check:verifiers`, pre-push + CI + (`build_circuits`). A drift means the deployed verifier accepts a different circuit from the tree. +- **`Elf.sol` is never committed.** `crates/support/methods/build.rs` writes it with a machine-local + guest ELF path, so it is generated per checkout and `.gitignore`d. +- **A release publishes a complete provenance manifest** — `pnpm provenance:manifest`. It ties + source commit, lockfile digests, pinned revisions, RISC Zero version, builder image tag **and + digest** (the builder tag is mutable and `RISC0_DOCKER_CONTAINER_TAG` overrides it), guest ELF + SHA-256, image ID, and the deployed verifier to one record. The generator reports + `complete: false` with the unresolved fields rather than emitting a partial record that reads as + verified. The ELF SHA-256 is **not** the image ID: SHA-256 checks binary integrity, the image ID + is computed from the loaded memory image. Procedure: + `docs/pages/verifying-the-compute-provider.mdx`. - Upgradeable-contract storage baselines are committed and CI-gated (missing baselines, compiler drift, layout incompatibility, bad gap consumption all fail); baseline creation is an explicit maintainer command. — INDEX concern #27 diff --git a/agent/flow-trace/00_INDEX.md b/agent/flow-trace/00_INDEX.md index 63ee09a884..03e1f5b4dc 100644 --- a/agent/flow-trace/00_INDEX.md +++ b/agent/flow-trace/00_INDEX.md @@ -203,6 +203,19 @@ _Found during source-code cross-referencing of these trace documents._ | **IF-003 — decryption proof phase and party binding** | Fixed | `decryption_aggregator` requires 1-indexed, strictly increasing party IDs, while `BfvDecryptionVerifier` checks the surfaced SK/ESM commitments against the E3's registry-backed DKG anchors. | | **IF-004 — ciphertext commitment binding** | Fixed | The off-chain SAFE commitment is stored at ciphertext publication, propagated as a final decryption-proof public input, and compared on-chain without attempting BFV decoding or Poseidon2 execution in Solidity. | +### CRISP Ballot Remediations + +Found while making voting, updating, and masking work as one indistinguishable operation. All of +them are in the reference app (`examples/CRISP`), not the protocol. + +| Finding | Status | Implemented behavior | +| ----------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Ballot rules read only zero padding** | Fixed | `check_coefficient_values_with_balance` and `check_coefficient_zero` indexed `k1` as if it were `MAX_MSG_NON_ZERO_COEFFS` wide, while the circuit receives the full reversed BFV degree. Both read padding, so every vote passed any balance bound and a mask could carry an arbitrary payload. `ballot_layout` now derives the real offset. | +| **Coefficients outside the ballot are unconstrained** | Fixed | The whole plaintext is bound: binary inside an option segment, zero everywhere else, and zero everywhere for a mask. An unconstrained coefficient survives to the decrypted tally, so it was both a way to corrupt a slot and a channel a coercer could read a receipt from. | +| **Re-votes are accepted and never tallied** | Fixed | The witness published `previous + new` while the circuit committed to `new`. One generator now publishes exactly what the circuit commits to on every branch, and the SDK exposes one commitment for the ballot digest. | +| **Occupied-slot masks cannot be published** | Fixed | The client built the digest from the ballot commitment while the contract rebuilt it from the stored one, which differ for a mask over an occupied slot. `PreparedBallot.ctCommitment` is now the published ciphertext's commitment on every branch. | +| **A valid input can freeze a slot** | Fixed | Publishing a real proof beside unusable bytes advanced the slot head to a commitment only the submitter could open, so nobody could mask that slot — and a slot that cannot be masked is one where every later input is provably its owner voting again. Inputs now name the entry they extend and the guest follows the chain. | + ### Protocol Design Concerns | # | Concern | Severity | Detail | @@ -297,3 +310,32 @@ _Found during source-code cross-referencing of these trace documents._ | Z-16 | **Safe slashing-manager retirement** | Resolved | Retiring managers remain authorized for their assigned E3s, bans, slash locks, proposals, and pending routes. `closeE3` now also waits for the objective accusation submission deadline. Revocation requires every canonical obligation counter to be zero. | | Z-24 | **Build-bound crypto configuration** | Resolved | The generated configuration ID binds the encryption scheme, exact parameter hash, and circuit version. Requests accept only that append-only configuration and snapshot its verifier addresses. Rust validates the emitted ID against its local build, and the indexer uses local immutable parameters plus the E3's frozen ID instead of querying mutable live parameter bytes. | | Z-45 | **In-call request fee bound** | Resolved | Each request supplies its expected fee token, expected crypto configuration, and maximum fee. Any change between quote and inclusion reverts before escrow transfer. The SDK obtains a fresh quote when the caller does not provide an explicit maximum. | + +### Scope of the Zenith `Z-` entries + +The `Z-` rows above come from the 2026-08-17 Zenith protocol audit. Read its scope before treating a +`Resolved` row as external assurance. + +The audit reviewed **six Solidity files and no Rust**, at scope commit `c2097da6`, with a mitigation +review at `c64bcfb8` over the same six files: + +``` +E3RefundManager.sol Interfold.sol lib/ExitQueueLib.sol +lib/InterfoldPricing.sol registry/BondingRegistry.sol registry/CiphernodeRegistryOwnable.sol +``` + +Outside both file lists: `crates/compute-provider` and the RISC Zero guest, `crates/zk-helpers`, +`verifiers/bfv/Risc0BfvCiphertextVerifier.sol`, `lib/InterfoldLifecycle.sol`, +`lib/Risc0ComputeProof.sol`, `lib/FailurePayerLib.sol`, and `examples/CRISP/**`. + +Two consequences worth holding onto: + +- **A `Resolved` row describes this repository's remediation, not an auditor's re-review of it.** + Several entries were remediated by adding files that are themselves outside both lists — Z-15's + protocol verifier among them — so the remediation code did not pass through the mitigation review. +- **The compute path was never audited.** The input-root binding defect fixed in + `crates/compute-provider` (see `04_DKG_AND_COMPUTATION.md`) sat in unaudited code, which is the + simplest explanation for how it survived. Zenith's §2.5 Security Note recommends a follow-up audit + before deploying significant capital. + +Full scope detail: `packages/interfold-contracts/audits/README.md`. diff --git a/agent/flow-trace/04_DKG_AND_COMPUTATION.md b/agent/flow-trace/04_DKG_AND_COMPUTATION.md index 89447c67c5..290feac82f 100644 --- a/agent/flow-trace/04_DKG_AND_COMPUTATION.md +++ b/agent/flow-trace/04_DKG_AND_COMPUTATION.md @@ -813,6 +813,44 @@ smallest binary Poseidon tree that can hold the submitted SAFE ciphertext commit minimum depth of one. The compute provider and E3 program must use this same leaf value, order, zero value, and depth rule. +The guest derives the input root from the ciphertexts it processed. `ComputeInput` holds only +`fhe_inputs`, and `ComputeInput::process` calls `MerkleTreeBuilder::compute_leaf_hashes` over those +ciphertexts before it builds the tree (`crates/compute-provider/src/compute_input.rs`). The leaves +are therefore a function of the processed set, not a separate prover-supplied value. + +This binding matters because nothing else supplies it. `Risc0BfvCiphertextVerifier` takes the input +root from the proof envelope and never constrains it, so the only check on the root is the +comparison an E3 program performs against its own on-chain root (`CRISPProgram.verify`, +`MyProgram.verify`). If the guest accepted the leaves as an independent input, that comparison would +pass for a tally computed over ciphertexts that were never submitted: a prover would replay the +genuine on-chain leaves while processing its own ciphertexts. Publication is unpermissioned +(`Interfold.publishCiphertextOutput` has no authorization modifier) and one-shot, so any party could +do this, and no dispute path exists. + +Two rules follow for anyone changing this path: + +- **An E3 program must compare the proof's input root against its own root.** The protocol verifier + will not do it. +- **A Secure Process must derive its leaves, never receive them.** + `MerkleTreeBuilder::with_leaf_hashes` is `#[cfg(test)]` for that reason. + +`ComputeManager` proves the whole input set in one guest run. The former `start_parallel` path +proved per chunk and set the final leaves to sub-tree roots, which produced a tree of sub-tree roots +rather than the flat input root an E3 program compares against. It was unreachable — every call site +passed `use_parallel = false` — and it is removed. Restoring batching requires first defining what a +leaf means on that path. + +### Ciphertext component count + +The SAFE ciphertext commitment covers `c[0]` and `c[1]` only, matching the Noir circuit. +`bfv_ciphertext_to_greco` rejects any ciphertext whose component count is not exactly two +(`crates/zk-helpers/src/circuits/threshold/user_data_encryption/utils.rs`). Without that check a +ciphertext padded with a third polynomial commits to the same value as its two-component prefix, so +the input root and the output commitment stay identical while threshold decryption rejects the +padded output — `ShareManager` requires exactly two components. The round would then fail as a +`DecryptionTimeout`, which `FailurePayerLib` bills to the ciphernodes. Seed-compressed ciphertexts +are unaffected: `TryConvertFrom` expands the seed into `c[1]` before the converter sees it. + ``` Compute provider runs computation on encrypted data: │ @@ -1259,3 +1297,166 @@ During restart, `ComputeEffectGate` observes replay before compute workers are e buffers and deduplicates `ComputeRequest`s, prefers the newest regenerated request, cancels terminal E3 work, and releases pending jobs only after `EffectsEnabled`. The gate changes effect timing, not durable event order or audit state. + +### What the compute-provider crate guarantees, and what an E3 program decides + +`e3-compute-provider` is shared by every E3 program, so it holds only what is true for all of them: + +- leaves are **derived** from the ciphertexts the Secure Process consumed, never received alongside + them; +- **every** published input contributes a leaf, whatever is computed over. + +The leaf layout and which inputs are computed over come from the program, as an `InputPolicy`: + +```rust +pub struct InputPolicy { + pub leaf: fn(&PublishedInput) -> Result, + pub select: fn(&[PublishedInput]) -> Vec, +} +``` + +Both are program-specific. A leaf must equal what that program builds on-chain, and no two programs +need agree. Selection answers "what does a second input for the same participant mean?", which CRISP +answers differently from a program where every input counts. + +`InputPolicy::default()` is the behaviour that predates policies — the leaf is the ciphertext's own +commitment and every input is computed over — and matches the starter template, whose +`MyProgram.publishInput` inserts the commitment directly. Every E3 program exports `policy()` beside +`fhe_processor`, so the guest and the dev runner need not know which program they are running. + +`PublishedData` carries what the program published per input: the stored commitment, and opaque +`metadata` the crate never interprets. CRISP puts its 20-byte slot address and the 5-byte parent +index there, laid out as `abi.encodePacked(address, uint40)`. + +### Input leaf binding and per-slot selection + +An E3 program verifies a proof over the ciphertext **commitment** when an input is published. The +proof never sees the serialized ciphertext the event carries, so the two can disagree, and neither +the contract nor the circuit can tell: the commitment is a Poseidon sponge over the ciphertext's CRT +limbs (~49k field elements at the secure preset), and the circuit cannot reproduce the fhe.rs +serialization. The guest is the first place both representations exist at once. + +`CRISPProgram.inputLeaf` therefore binds four values: + +```text +leaf = sha256(sha256(encryptedVote) || encryptedVoteCommitment || slotAddress || parentIndexPlusOne) + mod SNARK_SCALAR_FIELD +``` + +- the **bytes**, so a submitter cannot publish a valid commitment beside unrelated data; +- the **commitment**, so any commitment cannot be paired with any ciphertext; +- the **slot**, because the tree is append-only and the guest selects per slot — an unbound slot + would let a prover re-group entries and change which one wins; +- the **parent**, because the guest walks each slot's chain by it — an unbound parent would let a + prover re-point entries and change which one holds the slot. + +`MerkleTreeBuilder::compute_leaf_hashes` rebuilds exactly that layout. Both sides pin the same test +vector (`program/tests/input_leaf.rs` and `tests/input-leaf.test.ts`), and +`examples/CRISP/program/tests/onchain_root_agreement.rs` asserts Rust reproduces a root a real +contract produced, from a fixture generated by `tests/input-tree-e2e.test.ts`. A one-byte divergence +would make every root mismatch and nothing else would detect it. + +SHA-256 rather than Keccak: the zkVM accelerates SHA-256 inline, while its Keccak accelerator emits +a proof assumption the host must prove separately and compose. The extra on-chain cost is about 67k +gas on a transaction that already carries the ciphertext — a secure-preset ciphertext is about 348 +KB, so calldata and log data dominate by orders of magnitude. + +**The input tree is append-only.** `_processVote` always inserts and never updates in place. That is +a security property, not a storage choice: the mask path requires no signature, so anyone can write +to any census member's slot. With update-in-place, a third party could replace the bytes of a vote +that had already been counted and erase it silently while the round still completed. Appending +leaves the earlier entry in the tree. + +Every input names the entry it extends. `publishInput` takes `parentIndexPlusOne` in its calldata, +reads that entry's commitment out of the per-slot history, and hands it to the circuit as +`prev_ct_commitment`; zero means the input extends nothing, which the circuit reads as +`is_first_vote`. + +For each slot the Secure Process computes over the **end of that slot's chain of usable entries** +(`chain_head_per_slot`). Walking in index order, an entry becomes the slot's head only when both +hold: + +- its bytes reproduce its commitment, so it is a ciphertext anyone can read; and +- the entry it names is the slot's current head. + +Entries that fail either rule keep their leaf — removing one would change the root — but are +skipped. The rule is a function of values the root binds, so any prover holding the same published +data reaches the same set and none can choose what to drop. + +Why the chain rather than "the most recent usable entry": `CRISPProgram` cannot tell that a +submitter's bytes disagree with the commitment they published — only the Secure Process can, and +only after the input window closes. With a single mutable slot head, anyone could therefore leave a +slot whose head only they can open, and a slot nobody can mask is a slot where every later input is +provably its owner voting again. That is a coercion receipt, and a cleaner one than the receipt +masks exist to destroy. Because an unusable entry is never the head, it is never a valid parent +either, so the next honest input names the same parent it did and masking continues. + +The resulting properties: + +- a malformed input costs one entry, not the round; +- an append with bad bytes cannot erase a counted vote, and cannot freeze the slot against masking; +- an entry naming a stale parent is dropped, so a mask cannot restore a superseded ciphertext over a + later vote; +- an honest re-vote still replaces the earlier ballot, because it extends the head and adds to + nothing; +- a mask preserves the tally, since the circuit forces its plaintext to zero everywhere + (`check_coefficient_zero`) and proves `sum = head + zero`. + +**What the rule costs.** Two entries naming the same parent are siblings, and the first usable one +takes the head; the second is dropped. So an input can be front-run into being dropped — an attacker +who sees a re-vote in the mempool can land a mask on the same parent first, and the re-vote is not +counted. + +That is the deliberate side of a genuine trade-off, not an oversight. A stale parent is +indistinguishable from a sibling that was simply built a moment earlier: both name an entry that is +no longer the head, and only the circuit knows whether an entry _replaces_ the slot or _adds_ to it +— which is precisely what `is_mask_vote` keeps private. Favour the earlier sibling and a re-vote can +be delayed; favour the later one and a mask built on a superseded ciphertext can restore it over a +vote. The first is visible to the voter (the server resolves the chain, so the client can see its +input was not taken) and is fixed by submitting again. The second is a silent tally corruption that +nobody can detect or undo. Submitting through the CRISP server's relayer also keeps the transaction +out of a public mempool, which is where the race would be won. + +Closing the gap entirely would need the guest to tell a replace from an add, which means publishing +that distinction — the thing the whole design exists to hide. + +Capacity: `TREE_DEPTH = 20` gives 2^20 entries, against a physical ceiling of roughly three writes +per block at the secure preset — append-only is not capacity-bound. + +A round where _every_ entry is unusable fails at the output commitment, because the processor's +empty ciphertext does not deserialize. That is only reachable when no honest input exists, and is +indistinguishable from a round that received none, which the protocol resolves as +`NoInputsReceived`. + +`InputPublished` carries the slot, the commitment, and the parent alongside the bytes. All three +were already public — the slot and the parent are plaintext `publishInput` arguments, and +`getSlotIndex` and `inputCommitmentOf` expose the rest — so emitting them leaks nothing and saves +every consumer from parsing transaction calldata. + +### One relation for voting, updating, and masking + +The ballot circuit proves the same statement for all three operations: + +```text +published ciphertext = addend + ballot ciphertext +``` + +The ballot is a fresh BFV encryption of `k1`, covered by the recursive `user_data_encryption` proof. +The addend is the slot's current head for a mask, and the zero ciphertext for a vote, a re-vote, or +any input to an empty slot. `is_mask_vote` chooses between them and is **private**, and the selector +is derived (`keep_previous = is_mask_vote & !is_first_vote`) rather than taken as a witness — so a +voter cannot add their new ballot on top of their old one and count twice, and a masker cannot +discard the head and erase a vote. + +The circuit returns `sum_ct_commitment` on every path, so the public inputs, the stored commitment, +the ballot digest, and the published ciphertext have the same shape whichever operation ran. Telling +them apart would mean distinguishing a fresh BFV ciphertext from a sum, which the encryption scheme +hides. The SDK has one code path for all three, and `CrispSDK.prepareBallot` makes the same +`state/previous-ciphertext` request either way, so the request pattern says nothing either. + +The plaintext is fully constrained on both branches. `check_coefficient_values_with_balance` binds +every coefficient of `k1`: those inside an option segment must be binary, and every coefficient +outside the ballot region must be zero. `check_coefficient_zero` requires the whole polynomial to be +zero for a mask. Both read the payload at `k1[D - MAX_MSG_NON_ZERO_COEFFS ..]`, because the witness +generator reverses the message over the full BFV degree — the ballot occupies the **last** 100 +coefficients, and the options appear back to front. diff --git a/agent/flow-trace/05_FAILURE_REFUND_SLASHING.md b/agent/flow-trace/05_FAILURE_REFUND_SLASHING.md index c4e0843730..4e7d018975 100644 --- a/agent/flow-trace/05_FAILURE_REFUND_SLASHING.md +++ b/agent/flow-trace/05_FAILURE_REFUND_SLASHING.md @@ -1301,13 +1301,13 @@ When a proof-fault quorum is reached while its on-chain policy is disabled, the separate `CommitteeMemberExcluded` event. Sortition resolves its stable `party_id` from the same immutable `CommitteeFinalized` roster and republishes the enriched event. The keyshare collectors, public-key aggregator, plaintext aggregator, and active-aggregator selector then treat that party as -unavailable for this E3. The final DKG proof still receives all N canonical committee addresses -from `CommitteeFinalized`; it never derives the proof-bound roster from the reduced keyshare set. +unavailable for this E3. The final DKG proof still receives all N canonical committee addresses from +`CommitteeFinalized`; it never derives the proof-bound roster from the reduced keyshare set. This fallback is availability-only. The excluded operator remains an active on-chain committee member, can remain eligible for future E3s, and can receive any reward that the contracts still -assign to it. Enable the matching slash policy when the deployment requires economic punishment, -an on-chain reward hold, or registry expulsion. +assign to it. Enable the matching slash policy when the deployment requires economic punishment, an +on-chain reward hold, or registry expulsion. --- diff --git a/crates/compute-provider/Cargo.toml b/crates/compute-provider/Cargo.toml index f97b0f4f52..8ae44e2e49 100644 --- a/crates/compute-provider/Cargo.toml +++ b/crates/compute-provider/Cargo.toml @@ -10,13 +10,22 @@ repository.workspace = true serde = { workspace = true, features = ["derive", "std"] } lean-imt = "=0.1.2" sha3 = "=0.10.8" +sha2 = "=0.10.9" num-bigint = "=0.4.6" num-traits = "=0.2.19" hex = "=0.4.3" light-poseidon = "=0.2.0" ark-ff = "=0.4.2" ark-bn254 = "=0.4.0" -rayon = "=1.10.0" zk-kit-imt = "0.0.7" e3-bfv-client = { workspace = true } +fhe = { workspace = true } e3-fhe-params = { workspace = true, features = ["abi-encoding"] } +thiserror = { workspace = true } + +[dev-dependencies] +fhe = { workspace = true } +fhe-traits = { workspace = true } +rand = { workspace = true } +rand_chacha = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/compute-provider/src/ciphertext_output.rs b/crates/compute-provider/src/ciphertext_output.rs index 330b973ed5..353ba6628e 100644 --- a/crates/compute-provider/src/ciphertext_output.rs +++ b/crates/compute-provider/src/ciphertext_output.rs @@ -5,11 +5,20 @@ // or FITNESS FOR A PARTICULAR PURPOSE. use crate::compute_input::ComputeInput; +use crate::policy::InputPolicy; pub trait ComputeProvider { type Output: Send + Sync; - fn prove(&self, input: &ComputeInput) -> Self::Output; + /// Proves the computation over `input`, under the same [`InputPolicy`] the caller published + /// with. + /// + /// The policy is passed rather than chosen here. A prover that picked its own would decide a + /// different leaf layout and a different selected input set from the one + /// [`crate::ComputeManager::start`] returned the ciphertext for, and the two only disagree + /// where it matters: an E3 program hashes the published ciphertext into the digest it rebuilds, + /// so any divergence makes the round unpublishable and names no cause. + fn prove(&self, input: &ComputeInput, policy: InputPolicy) -> Self::Output; } #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] diff --git a/crates/compute-provider/src/compute_input.rs b/crates/compute-provider/src/compute_input.rs index 6498abde95..eba7b85b93 100644 --- a/crates/compute-provider/src/compute_input.rs +++ b/crates/compute-provider/src/compute_input.rs @@ -6,6 +6,7 @@ use crate::ciphertext_output::ComputeResult; use crate::merkle_tree_builder::MerkleTreeBuilder; +use crate::policy::InputPolicy; use e3_bfv_client::client::compute_ct_commitment; use e3_fhe_params::decode_bfv_params; use sha3::{Digest, Keccak256}; @@ -14,49 +15,430 @@ pub type FHEProcessor = fn(&FHEInputs) -> Vec; #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct FHEInputs { + /// The serialized ciphertexts to compute over, each paired with its on-chain index. + /// + /// The index is not the authority on order — a leaf's position in the tree is its position in + /// this vector. **A caller assembling this from chain events must sort by the index**, because + /// event delivery is not ordered, and getting it wrong produces a root the E3 program rejects. pub ciphertexts: Vec<(Vec, u64)>, pub params: Vec, } +/// What an E3 program published alongside a ciphertext, in the same order as `ciphertexts`. +/// +/// Separate from `FHEInputs` because a Secure Process never computes over it: it decides leaves and +/// selection, which is the [`InputPolicy`]'s business, not the processor's. +#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)] +pub struct PublishedData { + /// The commitment the E3 program stored for this input, when it stores one. + pub commitment: Option<[u8; 32]>, + /// Anything else the program published per input. Opaque here; CRISP carries its slot address. + #[serde(default)] + pub metadata: Vec, +} + +/// The full input to the Secure Process. +/// +/// This type holds only the values the Secure Process computes over. Every field the journal +/// publishes is derived from these values inside the compute environment. A prover cannot supply +/// the input Merkle root or the output hash as separate values, because a separate value can +/// disagree with the ciphertexts the Secure Process actually consumed. #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct ComputeInput { pub fhe_inputs: FHEInputs, - pub ciphertext_hash: Vec, - pub leaf_hashes: Vec, + /// One entry per ciphertext, in the same order. Empty when the E3 program publishes nothing + /// beyond the ciphertexts, which is what [`InputPolicy::default`] expects. + #[serde(default)] + pub published: Vec, +} + +/// A failure inside the Secure Process. +/// +/// These arise from malformed inputs, which a Secure Process cannot assume away: an E3 program +/// accepts inputs from untrusted parties, and a single unusable one reaches the compute environment +/// like any other. Returning the reason lets a compute provider report which input was bad rather +/// than aborting the process with a panic. +#[derive(Debug, thiserror::Error)] +pub enum ComputeError { + #[error("failed to decode BFV parameters: {0}")] + DecodeParams(String), + + #[error("failed to compute the commitment of ciphertext {index}: {reason}")] + LeafCommitment { index: usize, reason: String }, + + #[error("failed to compute the commitment of the output ciphertext: {0}")] + OutputCommitment(String), + + #[error("failed to build the input Merkle tree: {0}")] + MerkleTree(String), } impl ComputeInput { - pub fn process(&self, fhe_processor: FHEProcessor) -> ComputeResult { - let processed_ciphertext = (fhe_processor)(&self.fhe_inputs); + /// Runs the Secure Process under the E3 program's [`InputPolicy`]. + /// + /// The policy decides the leaf layout and which inputs are computed over. What it cannot do is + /// supply a root or drop an input from the tree — leaves are derived here, from the ciphertexts + /// actually consumed, and every published input contributes one. + pub fn process( + &self, + fhe_processor: FHEProcessor, + policy: InputPolicy, + ) -> Result { + self.run(fhe_processor, policy).map(|(result, _)| result) + } + + /// As [`Self::process`], and also returns the output ciphertext. + /// + /// A caller that publishes the ciphertext must take it from here rather than running the + /// processor itself. The two are not interchangeable once a policy excludes anything: an E3 + /// program hashes the published bytes into the digest it rebuilds, so a ciphertext computed + /// over a different input set makes the receipt unverifiable and the round unpublishable. + pub fn run( + &self, + fhe_processor: FHEProcessor, + policy: InputPolicy, + ) -> Result<(ComputeResult, Vec), ComputeError> { + let params = decode_bfv_params(&self.fhe_inputs.params) + .map_err(|e| ComputeError::DecodeParams(e.to_string()))?; + + if !self.published.is_empty() && self.published.len() != self.fhe_inputs.ciphertexts.len() { + return Err(ComputeError::MerkleTree(format!( + "{} ciphertexts but {} published entries", + self.fhe_inputs.ciphertexts.len(), + self.published.len() + ))); + } + + let mut tree_builder = MerkleTreeBuilder::new(self.fhe_inputs.ciphertexts.len()); + let selected = + tree_builder.compute_leaf_hashes(&self.fhe_inputs, &self.published, ¶ms, policy)?; + let merkle_root = tree_builder + .build_tree() + .map_err(|e| ComputeError::MerkleTree(e.to_string()))? + .root() + .ok_or_else(|| ComputeError::MerkleTree("the tree has no root".into()))?; + + // The processor sees only what the policy selected. Both the root above and this set are + // functions of values the root binds, so any prover over the same published inputs reaches + // the same result. + let processed_ciphertext = (fhe_processor)(&FHEInputs { + ciphertexts: selected, + params: self.fhe_inputs.params.clone(), + }); let processed_hash = Keccak256::digest(&processed_ciphertext).to_vec(); - let params = - decode_bfv_params(&self.fhe_inputs.params).expect("Failed to decode BFV params"); let ciphertext_commitment = compute_ct_commitment( processed_ciphertext.clone(), params.degree(), params.plaintext(), params.moduli().to_vec(), ) - .expect("Failed to compute ciphertext commitment") + .map_err(|e| ComputeError::OutputCommitment(e.to_string()))? .to_vec(); let params_hash = Keccak256::digest(&self.fhe_inputs.params).to_vec(); - assert_eq!( - processed_hash, self.ciphertext_hash, - "Ciphertext hash mismatch" + Ok(( + ComputeResult { + ciphertext_hash: processed_hash, + ciphertext_commitment, + params_hash, + merkle_root: hex::decode(merkle_root) + .map_err(|e| ComputeError::MerkleTree(e.to_string()))?, + }, + processed_ciphertext, + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::policy::{all_inputs, commitment_leaf, PublishedInput}; + use e3_fhe_params::{build_pair_for_preset, encode_bfv_params, BfvPreset}; + use fhe::bfv::{Ciphertext, Encoding, Plaintext, PublicKey, SecretKey}; + use fhe_traits::{FheEncoder, FheEncrypter, Serialize as FheSerialize}; + use rand::SeedableRng; + use rand_chacha::ChaCha8Rng; + use std::sync::Arc; + + fn sum_processor(inputs: &FHEInputs) -> Vec { + let params = Arc::new(decode_bfv_params(&inputs.params).unwrap()); + let mut sum = Ciphertext::zero(¶ms); + for (bytes, _) in &inputs.ciphertexts { + use fhe_traits::DeserializeParametrized; + sum += &Ciphertext::from_bytes(bytes, ¶ms).unwrap(); + } + sum.to_bytes() + } + + fn encrypted_inputs(values: &[u64]) -> FHEInputs { + let (params, _) = build_pair_for_preset(BfvPreset::InsecureThreshold512).unwrap(); + let mut rng = ChaCha8Rng::seed_from_u64(7); + let secret_key = SecretKey::random(¶ms, &mut rng); + let public_key = PublicKey::new(&secret_key, &mut rng); + + let ciphertexts = values + .iter() + .enumerate() + .map(|(index, value)| { + let plaintext = + Plaintext::try_encode(&[*value], Encoding::poly(), ¶ms).unwrap(); + let ciphertext = public_key.try_encrypt(&plaintext, &mut rng).unwrap(); + (ciphertext.to_bytes(), index as u64) + }) + .collect(); + + FHEInputs { + ciphertexts, + params: encode_bfv_params(¶ms), + } + } + + fn process(inputs: FHEInputs, policy: InputPolicy) -> Result { + ComputeInput { + fhe_inputs: inputs, + published: Vec::new(), + } + .process(sum_processor, policy) + } + + /// The journal's input root must be a function of the ciphertexts consumed. Before this, the + /// root arrived as a separate field, so a prover could publish a tally over one input set while + /// proving the root of another. + #[test] + fn the_root_is_derived_from_the_processed_ciphertexts() { + let inputs = encrypted_inputs(&[1, 1, 1]); + let params = decode_bfv_params(&inputs.params).unwrap(); + + let result = process(inputs.clone(), InputPolicy::default()).unwrap(); + + let mut builder = MerkleTreeBuilder::new(3); + builder + .compute_leaf_hashes(&inputs, &[], ¶ms, InputPolicy::default()) + .unwrap(); + let expected = hex::decode(builder.build_tree().unwrap().root().unwrap()).unwrap(); + + assert_eq!(result.merkle_root, expected); + } + + /// Changing the consumed ciphertexts must change the published root, so an E3 program's + /// comparison rejects a substituted set. + #[test] + fn substituted_ciphertexts_produce_a_different_root() { + let honest = encrypted_inputs(&[1, 1, 1]); + let mut forged = honest.clone(); + forged.ciphertexts[2] = encrypted_inputs(&[9]).ciphertexts[0].clone(); + + assert_ne!( + process(honest, InputPolicy::default()).unwrap().merkle_root, + process(forged, InputPolicy::default()).unwrap().merkle_root ); + } - let merkle_root = MerkleTreeBuilder::new(self.leaf_hashes.len()) - .with_leaf_hashes(self.leaf_hashes.clone()) - .build_tree() - .root() + /// The default policy is what every E3 program had before policies existed. + #[test] + fn the_default_policy_uses_the_ciphertext_commitment_and_keeps_every_input() { + let inputs = encrypted_inputs(&[4, 5]); + let params = decode_bfv_params(&inputs.params).unwrap(); + + let mut builder = MerkleTreeBuilder::new(2); + let selected = builder + .compute_leaf_hashes(&inputs, &[], ¶ms, InputPolicy::default()) + .unwrap(); + + assert_eq!(selected.len(), 2, "every input is computed over"); + let commitment = compute_ct_commitment( + inputs.ciphertexts[0].0.clone(), + params.degree(), + params.plaintext(), + params.moduli().to_vec(), + ) + .unwrap(); + assert_eq!(builder.leaf_hashes[0], hex::encode(commitment)); + } + + /// A policy chooses what is computed over; it cannot shrink the tree. Dropping a leaf would + /// change the root and make the result unpublishable, so the crate applies this rather than + /// trusting each program to. + #[test] + fn a_policy_cannot_drop_an_input_from_the_tree() { + fn select_nothing(_: &[PublishedInput]) -> Vec { + Vec::new() + } + let inputs = encrypted_inputs(&[1, 2, 3]); + let params = decode_bfv_params(&inputs.params).unwrap(); + + let mut builder = MerkleTreeBuilder::new(3); + let selected = builder + .compute_leaf_hashes( + &inputs, + &[], + ¶ms, + InputPolicy { + leaf: commitment_leaf, + select: select_nothing, + }, + ) .unwrap(); - ComputeResult { - ciphertext_hash: processed_hash, - ciphertext_commitment, - params_hash, - merkle_root: hex::decode(merkle_root).unwrap(), + assert!(selected.is_empty(), "the policy selected nothing"); + assert_eq!( + builder.leaf_hashes.len(), + 3, + "every leaf is still in the tree" + ); + } + + /// A policy returning an index that does not exist is a bug in the program, not a silent skip. + #[test] + fn an_out_of_range_selection_is_rejected() { + fn select_beyond_the_end(_: &[PublishedInput]) -> Vec { + vec![99] + } + let inputs = encrypted_inputs(&[1]); + let params = decode_bfv_params(&inputs.params).unwrap(); + + let error = MerkleTreeBuilder::new(1) + .compute_leaf_hashes( + &inputs, + &[], + ¶ms, + InputPolicy { + leaf: commitment_leaf, + select: select_beyond_the_end, + }, + ) + .unwrap_err(); + + assert!( + matches!(error, ComputeError::MerkleTree(_)), + "got {error:?}" + ); + } + + /// Published data of the wrong length would silently mis-pair inputs with their commitments. + #[test] + fn mismatched_published_data_is_rejected() { + let error = ComputeInput { + fhe_inputs: encrypted_inputs(&[1, 2]), + published: vec![PublishedData::default()], + } + .process(sum_processor, InputPolicy::default()) + .unwrap_err(); + + assert!( + matches!(error, ComputeError::MerkleTree(_)), + "got {error:?}" + ); + } + + /// Under the default policy an undecodable ciphertext names the index that failed, rather than + /// aborting the process. + #[test] + fn the_default_policy_reports_the_index_of_an_undecodable_input() { + let mut inputs = encrypted_inputs(&[1, 1]); + inputs.ciphertexts[1].0 = vec![0xff; 8]; + let params = decode_bfv_params(&inputs.params).unwrap(); + + let error = MerkleTreeBuilder::new(2) + .compute_leaf_hashes(&inputs, &[], ¶ms, InputPolicy::default()) + .unwrap_err(); + + assert!( + matches!(error, ComputeError::LeafCommitment { index: 1, .. }), + "got {error:?}" + ); + } + + /// `matches_commitment` is what a policy uses to spot a published ciphertext that disagrees + /// with what the E3 program proved. + #[test] + fn matches_commitment_reports_the_three_cases() { + let bytes = vec![1u8, 2, 3]; + let commitment = [7u8; 32]; + + let agreeing = PublishedInput { + index: 0, + ciphertext: &bytes, + commitment: Some(&commitment), + metadata: &[], + recomputed: Some(commitment), + }; + let disagreeing = PublishedInput { + recomputed: Some([8u8; 32]), + ..agreeing + }; + let undecodable = PublishedInput { + recomputed: None, + ..agreeing + }; + let unpublished = PublishedInput { + commitment: None, + recomputed: None, + ..agreeing + }; + + assert!(agreeing.matches_commitment()); + assert!(!disagreeing.matches_commitment()); + assert!( + !undecodable.matches_commitment(), + "an undecodable input cannot match" + ); + assert!( + unpublished.matches_commitment(), + "with no commitment published there is nothing to contradict" + ); + } + + #[test] + fn all_inputs_selects_everything() { + let bytes = vec![0u8]; + let entries: Vec = (0..3) + .map(|index| PublishedInput { + index, + ciphertext: &bytes, + commitment: None, + metadata: &[], + recomputed: None, + }) + .collect(); + assert_eq!(all_inputs(&entries), vec![0, 1, 2]); + } + + /// The ciphertext a caller publishes must be the one the journal describes. + /// + /// An E3 program hashes the published bytes into the digest it rebuilds, so if the two are + /// computed over different input sets the receipt never verifies. That is exactly what happens + /// when a policy excludes anything and the caller runs the processor itself. + #[test] + fn the_returned_ciphertext_is_the_one_the_journal_describes() { + fn drop_the_first(inputs: &[PublishedInput]) -> Vec { + (1..inputs.len()).collect() + } + + let inputs = encrypted_inputs(&[1, 2, 3]); + let policy = InputPolicy { + leaf: commitment_leaf, + select: drop_the_first, + }; + + let (result, ciphertext) = ComputeInput { + fhe_inputs: inputs.clone(), + published: Vec::new(), } + .run(sum_processor, policy) + .unwrap(); + + assert_eq!( + result.ciphertext_hash, + Keccak256::digest(&ciphertext).to_vec(), + "the journal must describe the ciphertext the caller publishes" + ); + + // And it is genuinely the selected subset, not the whole set. + let over_everything = sum_processor(&inputs); + assert_ne!( + ciphertext, over_everything, + "the excluded input must not be in the published ciphertext" + ); } } diff --git a/crates/compute-provider/src/compute_manager.rs b/crates/compute-provider/src/compute_manager.rs index 314a18f05c..b13426a194 100644 --- a/crates/compute-provider/src/compute_manager.rs +++ b/crates/compute-provider/src/compute_manager.rs @@ -5,12 +5,9 @@ // or FITNESS FOR A PARTICULAR PURPOSE. use crate::ciphertext_output::ComputeProvider; -use crate::compute_input::{ComputeInput, FHEInputs}; -use crate::merkle_tree_builder::MerkleTreeBuilder; +use crate::compute_input::{ComputeError, ComputeInput, FHEInputs, PublishedData}; +use crate::policy::InputPolicy; use crate::FHEProcessor; -use rayon::prelude::*; -use sha3::{Digest, Keccak256}; -use std::sync::Arc; pub struct ComputeManager

where @@ -19,121 +16,47 @@ where input: ComputeInput, provider: P, processor: FHEProcessor, - use_parallel: bool, - batch_size: Option, } impl

ComputeManager

where P: ComputeProvider + Send + Sync, { - pub fn new( + pub fn new(provider: P, fhe_inputs: FHEInputs, fhe_processor: FHEProcessor) -> Self { + Self::with_published(provider, fhe_inputs, Vec::new(), fhe_processor) + } + + /// Carries what the E3 program published alongside each ciphertext, which its + /// [`crate::InputPolicy`] reads to build leaves and select inputs. + pub fn with_published( provider: P, fhe_inputs: FHEInputs, + published: Vec, fhe_processor: FHEProcessor, - use_parallel: bool, - batch_size: Option, ) -> Self { Self { provider, input: ComputeInput { fhe_inputs, - ciphertext_hash: Vec::new(), - leaf_hashes: Vec::new(), + published, }, processor: fhe_processor, - use_parallel, - batch_size, - } - } - - pub fn start(&mut self) -> (P::Output, Vec) { - if self.use_parallel { - self.start_parallel() - } else { - self.start_sequential() } } - fn start_sequential(&mut self) -> (P::Output, Vec) { - let num_leaves = self.input.fhe_inputs.ciphertexts.len(); - let mut tree_builder = MerkleTreeBuilder::new(num_leaves); - - tree_builder.compute_leaf_hashes( - &self.input.fhe_inputs.ciphertexts, - &self.input.fhe_inputs.params, - ); - self.input.leaf_hashes = tree_builder.leaf_hashes.clone(); - - // Compute the ciphertext - let ciphertext = (self.processor)(&self.input.fhe_inputs); - - // Compute the hash of the ciphertext - self.input.ciphertext_hash = Keccak256::digest(&ciphertext).to_vec(); - - (self.provider.prove(&self.input), ciphertext) - } - - fn start_parallel(&self) -> (P::Output, Vec) { - let batch_size = self.batch_size.unwrap_or(2); - - let ciphertexts = Arc::new(self.input.fhe_inputs.ciphertexts.clone()); - let params = Arc::new(self.input.fhe_inputs.params.clone()); - - let chunks: Vec, u64)>> = ciphertexts - .chunks(batch_size) - .map(|chunk| chunk.to_vec()) - .collect(); - - let tally_results: Vec<(P::Output, Vec, String)> = chunks - .into_par_iter() - .map(|chunk| { - let mut tree_builder = MerkleTreeBuilder::new(chunk.len()); - - tree_builder.compute_leaf_hashes(&chunk, params.as_slice()); - let merkle_root = tree_builder.build_tree().root().unwrap(); - - let fhe_inputs = FHEInputs { - ciphertexts: chunk.clone(), - params: params.to_vec(), - }; - - let ciphertext = (self.processor)(&fhe_inputs); - let ciphertext_hash = Keccak256::digest(&ciphertext).to_vec(); - - let input = ComputeInput { - fhe_inputs, - ciphertext_hash, - leaf_hashes: tree_builder.leaf_hashes.clone(), - }; - - (self.provider.prove(&input), ciphertext, merkle_root) - }) - .collect(); - - // The leaf hashes are the hashes of the merkle roots of the parallel trees - let leaf_hashes: Vec = tally_results - .iter() - .map(|result| result.2.clone()) - .collect(); - - let fhe_inputs = FHEInputs { - ciphertexts: tally_results - .iter() - .map(|result| (result.1.clone(), 0u64)) // The index is not used for the final computation in the parallel case - .collect(), - params: params.to_vec(), - }; - - let ciphertext = (self.processor)(&fhe_inputs); - let ciphertext_hash = Keccak256::digest(&ciphertext).to_vec(); - - let final_input = ComputeInput { - fhe_inputs, - ciphertext_hash, - leaf_hashes: leaf_hashes.clone(), - }; - - (self.provider.prove(&final_input), ciphertext) + /// Proves the computation and returns the ciphertext to publish. + /// + /// The ciphertext comes from the same selection the proof covers. Running the processor over + /// the full input set here instead would publish bytes the receipt does not describe: an E3 + /// program hashes the published ciphertext into the digest it rebuilds, so any excluded input + /// would make every round unpublishable. + /// + /// One policy reaches both, from this one argument. Letting the provider choose its own would + /// reopen the same gap one layer down: the ciphertext returned here and the one the receipt + /// describes would be selected by different rules, and nothing would compare them. + pub fn start(&mut self, policy: InputPolicy) -> Result<(P::Output, Vec), ComputeError> { + let (_, ciphertext) = self.input.run(self.processor, policy)?; + + Ok((self.provider.prove(&self.input, policy), ciphertext)) } } diff --git a/crates/compute-provider/src/lib.rs b/crates/compute-provider/src/lib.rs index 8671259269..adb1b81bbc 100644 --- a/crates/compute-provider/src/lib.rs +++ b/crates/compute-provider/src/lib.rs @@ -8,7 +8,9 @@ mod ciphertext_output; mod compute_input; mod compute_manager; mod merkle_tree_builder; +pub mod policy; pub use ciphertext_output::*; pub use compute_input::*; pub use compute_manager::*; +pub use policy::{InputPolicy, PublishedInput}; diff --git a/crates/compute-provider/src/merkle_tree_builder.rs b/crates/compute-provider/src/merkle_tree_builder.rs index fb8566eee2..9e1ba4e457 100644 --- a/crates/compute-provider/src/merkle_tree_builder.rs +++ b/crates/compute-provider/src/merkle_tree_builder.rs @@ -4,10 +4,12 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. +use crate::compute_input::{ComputeError, FHEInputs, PublishedData}; +use crate::policy::{InputPolicy, PublishedInput}; use ark_bn254::Fr; use ark_ff::{BigInt, BigInteger}; use e3_bfv_client::client::compute_ct_commitment; -use e3_fhe_params::decode_bfv_params; +use fhe::bfv::BfvParameters; use light_poseidon::{Poseidon, PoseidonHasher}; use num_bigint::BigUint; use num_traits::Num; @@ -31,25 +33,79 @@ impl MerkleTreeBuilder { } } + /// Sets the leaves directly, for tests that need a known tree. + /// + /// Never use this to build a tree the journal publishes. A Secure Process must derive its + /// leaves from the ciphertexts it consumed, with [`Self::compute_leaf_hashes`]. Leaves that + /// arrive as a separate value can disagree with those ciphertexts. + #[cfg(test)] pub fn with_leaf_hashes(mut self, leaf_hashes: Vec) -> Self { self.leaf_hashes = leaf_hashes; self } - pub fn compute_leaf_hashes(&mut self, data: &[(Vec, u64)], params_bytes: &[u8]) { - let params = decode_bfv_params(params_bytes).expect("Failed to decode BFV params"); + /// Derives one leaf per published input and returns the ciphertexts the policy selected. + /// + /// Two guarantees hold whatever the policy does, because they are applied here rather than + /// delegated: + /// + /// - **every input contributes a leaf**, so the root covers the whole published set and a + /// policy cannot make the result unpublishable by omitting one; + /// - **leaves are derived from the ciphertexts given**, never accepted alongside them. + pub fn compute_leaf_hashes( + &mut self, + inputs: &FHEInputs, + published: &[PublishedData], + params: &BfvParameters, + policy: InputPolicy, + ) -> Result, u64)>, ComputeError> { let degree = params.degree(); let plaintext_modulus = params.plaintext(); let moduli = params.moduli().to_vec(); - - for item in data { - let commitment = - compute_ct_commitment(item.0.clone(), degree, plaintext_modulus, moduli.clone()) - .expect("Failed to compute ciphertext commitment"); - - let commitment_hex = hex::encode(commitment); - self.leaf_hashes.push(commitment_hex); + let empty = PublishedData::default(); + + let entries: Vec = inputs + .ciphertexts + .iter() + .enumerate() + .map(|(index, (ciphertext, _))| { + let entry = published.get(index).unwrap_or(&empty); + PublishedInput { + index, + ciphertext, + commitment: entry.commitment.as_ref(), + metadata: &entry.metadata, + // Recomputed here rather than by the policy: it is the one value that ties the + // published bytes back to what the E3 program proved, and it needs the BFV + // parameters. A ciphertext that does not deserialize yields `None`, which is an + // unusable input rather than a failure — the bytes are untrusted. + recomputed: compute_ct_commitment( + ciphertext.clone(), + degree, + plaintext_modulus, + moduli.clone(), + ) + .ok(), + } + }) + .collect(); + + for entry in &entries { + self.leaf_hashes.push((policy.leaf)(entry)?); } + + let mut selected = (policy.select)(&entries); + selected.sort_unstable(); + selected.dedup(); + + selected + .into_iter() + .map(|index| { + inputs.ciphertexts.get(index).cloned().ok_or_else(|| { + ComputeError::MerkleTree(format!("selected index {index} is out of range")) + }) + }) + .collect() } fn poseidon_hash(nodes: Vec) -> String { @@ -69,7 +125,7 @@ impl MerkleTreeBuilder { hex::encode(result_hash.to_bytes_be()) } - pub fn build_tree(&self) -> IMT { + pub fn build_tree(&self) -> Result { let mut tree = IMT::new( Self::poseidon_hash, self.depth, @@ -77,13 +133,14 @@ impl MerkleTreeBuilder { self.arity, vec![], ) - .unwrap(); + .map_err(|e| ComputeError::MerkleTree(e.to_string()))?; for leaf in &self.leaf_hashes { - tree.insert(leaf.clone()).unwrap(); + tree.insert(leaf.clone()) + .map_err(|e| ComputeError::MerkleTree(e.to_string()))?; } - tree + Ok(tree) } } @@ -110,6 +167,7 @@ mod tests { let root = MerkleTreeBuilder::new(1) .with_leaf_hashes(vec!["0".to_string()]) .build_tree() + .unwrap() .root() .unwrap(); diff --git a/crates/compute-provider/src/policy.rs b/crates/compute-provider/src/policy.rs new file mode 100644 index 0000000000..d69d42ae42 --- /dev/null +++ b/crates/compute-provider/src/policy.rs @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// +// This file is provided WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. + +//! How an E3 program's inputs become tree leaves, and which of them the computation sees. +//! +//! Both are program-specific. A leaf must match whatever the E3 program builds on chain, and no +//! two programs need agree; selection is the program's answer to "what does a second input for the +//! same participant mean?", which CRISP answers differently from a program where every input +//! counts. +//! +//! What is **not** program-specific, and is enforced by this crate rather than delegated: +//! +//! - leaves are derived from the ciphertexts the Secure Process consumed, never received alongside +//! them — a received root can disagree with the data it claims to describe; +//! - every published input contributes a leaf, whatever the policy decides about computing over it +//! — dropping one changes the root and makes the result unpublishable. + +use crate::compute_input::ComputeError; +use num_bigint::BigUint; +use num_traits::Num; + +/// The BN254 scalar field. Every leaf must reduce into it before the tree will accept it. +pub const SNARK_SCALAR_FIELD: &str = + "21888242871839275222246405745257275088548364400416034343698204186575808495617"; + +/// One published input, as the Secure Process sees it. +pub struct PublishedInput<'a> { + /// Position in the input set, which is also this leaf's position in the tree. + pub index: usize, + /// The serialized ciphertext the E3 program published. + pub ciphertext: &'a [u8], + /// The commitment the E3 program stored, when it publishes one. + /// + /// The proof an E3 program checks at input time typically constrains this and never sees the + /// serialized bytes, so the two can disagree. Comparing it against `recomputed` is the only + /// place that mismatch can be detected. + pub commitment: Option<&'a [u8; 32]>, + /// Whatever else the E3 program published per input, opaque to this crate. + /// + /// CRISP carries the 20-byte slot address here, because its tree is append-only and it selects + /// per slot. A program with no such notion leaves this empty. + pub metadata: &'a [u8], + /// The commitment recomputed from `ciphertext`, or `None` when it does not deserialize. + pub recomputed: Option<[u8; 32]>, +} + +impl PublishedInput<'_> { + /// Whether the published bytes reproduce the commitment the E3 program stored. + /// + /// `false` when the two disagree or the ciphertext does not deserialize. Always `true` when + /// the program publishes no commitment, since there is then nothing to check against. + pub fn matches_commitment(&self) -> bool { + match (self.commitment, self.recomputed) { + (Some(stored), Some(recomputed)) => *stored == recomputed, + (Some(_), None) => false, + (None, _) => true, + } + } +} + +/// Builds a leaf, which must equal what the E3 program builds on chain for the same input. +pub type LeafFn = fn(&PublishedInput) -> Result; + +/// Chooses which inputs the computation runs over, by index. +/// +/// Must be a function of data the input root binds, or two provers over the same published inputs +/// would disagree and a prover could choose what to leave out. +pub type SelectFn = fn(&[PublishedInput]) -> Vec; + +/// An E3 program's answers to both questions. +#[derive(Clone, Copy)] +pub struct InputPolicy { + pub leaf: LeafFn, + pub select: SelectFn, +} + +impl Default for InputPolicy { + /// The behaviour every E3 program had before policies existed: the leaf is the ciphertext's + /// own commitment, and every input is computed over. + fn default() -> Self { + Self { + leaf: commitment_leaf, + select: all_inputs, + } + } +} + +/// A leaf that is the ciphertext's own SAFE commitment. +/// +/// Matches an E3 program that inserts the commitment directly, as the starter template does. It +/// cannot detect a published ciphertext that disagrees with its commitment, because the leaf is +/// derived from the bytes alone — a program that needs that must bind both, as CRISP does. +pub fn commitment_leaf(input: &PublishedInput) -> Result { + input + .recomputed + .map(hex::encode) + .ok_or_else(|| ComputeError::LeafCommitment { + index: input.index, + reason: "ciphertext could not be deserialized".to_string(), + }) +} + +/// Every input is computed over, in published order. +pub fn all_inputs(inputs: &[PublishedInput]) -> Vec { + (0..inputs.len()).collect() +} + +/// Reduces a digest into the scalar field and hex-encodes it, ready to be a leaf. +/// +/// Exposed because reducing into BN254 is a property of the tree rather than of any one program, +/// so a program building its own leaf should not have to restate the modulus. +pub fn leaf_from_digest(digest: &[u8]) -> String { + let field = BigUint::from_str_radix(SNARK_SCALAR_FIELD, 10).expect("field constant"); + hex::encode((BigUint::from_bytes_be(digest) % field).to_bytes_be()) +} diff --git a/crates/program-server/src/lib.rs b/crates/program-server/src/lib.rs index 9c363fdc3f..51df53e62e 100644 --- a/crates/program-server/src/lib.rs +++ b/crates/program-server/src/lib.rs @@ -10,7 +10,7 @@ use actix_web::{middleware::Logger, web, App, HttpResponse, HttpServer, Result a use alloy_primitives::U256; use anyhow::{Context, Result}; use e3_bfv_client::compute_ct_commitment; -use e3_compute_provider::FHEInputs; +use e3_compute_provider::{FHEInputs, PublishedData}; use e3_fhe_params::decode_bfv_params_arc; use serde::Serialize; use std::{future::Future, pin::Pin, sync::Arc, time::Duration}; @@ -64,6 +64,15 @@ impl ComputeDomain { } } +/// The width of one slot address in published metadata: a Solidity `address`. +const SLOT_BYTES: usize = 20; + +/// The width of one parent index in published metadata: a Solidity `uint40`. +const PARENT_BYTES: usize = 5; + +/// The largest parent index that fits in [`PARENT_BYTES`]. +const MAX_PARENT: u64 = (1 << (8 * PARENT_BYTES as u64)) - 1; + fn fixed(value: &[u8], name: &str) -> Result<[u8; N], String> { value .try_into() @@ -72,6 +81,9 @@ fn fixed(value: &[u8], name: &str) -> Result<[u8; N], String> { pub struct ComputeJob { pub inputs: FHEInputs, + /// What the E3 program published alongside each ciphertext, in the same order. Empty when the + /// program publishes nothing beyond the ciphertexts. + pub published: Vec, pub domain: ComputeDomain, } @@ -215,6 +227,102 @@ pub struct AppConfig { jobs: Arc, } +/// Whether callbacks to addresses only reachable from inside the deployment are permitted. +/// +/// Off by default. Local development legitimately posts to a host on the same machine, so there has +/// to be a way in, but it must be a deliberate one rather than the default. +fn allow_private_callbacks() -> bool { + matches!( + std::env::var("ALLOW_PRIVATE_CALLBACKS") + .unwrap_or_default() + .as_str(), + "1" | "true" | "TRUE" | "yes" | "YES" + ) +} + +/// Rejects a callback host that is not reachable from the public internet. +/// +/// This endpoint takes a URL from the network and then makes a request to it, which is a +/// server-side request forgery primitive: without this, a caller can aim the callback at a cloud +/// metadata service (169.254.169.254), at loopback, or at anything inside the private network the +/// server sits in, and read the effect through the response or the side effects. +/// +/// Literal addresses are checked exhaustively. A hostname is checked by name only — a name that +/// resolves to a private address still passes, and DNS rebinding remains possible. Closing that +/// needs resolution at connect time and a pinned socket; this guard is the cheap part, not the +/// whole answer. +fn ensure_public_callback_host(url: &reqwest::Url) -> Result<()> { + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + + if allow_private_callbacks() { + return Ok(()); + } + + // Loopback is deliberately NOT treated as internal. + // + // The escalation that makes SSRF worth guarding is reaching hosts the caller cannot reach + // itself: cloud metadata at 169.254.169.254, RFC1918 services on the deployment's LAN, names + // under .internal. Loopback is the machine this server already runs on, and anyone who can post + // to this endpoint can address that host directly, so bouncing a request off its own loopback + // adds little reach. It is not zero risk — a localhost-only admin port is still a target — but + // it is a different class from pivoting into a private network. + // + // It is also how every local and CI deployment works: the callback is a webhook on the same + // host, and `with_localhost_rewrite` is unset by default. Rejecting it broke the CRISP + // end-to-end run for no security gain that a caller could not already obtain. + fn v4_is_internal(ip: Ipv4Addr) -> bool { + if ip.is_loopback() { + return false; + } + ip.is_private() + || ip.is_link_local() + || ip.is_broadcast() + || ip.is_documentation() + || ip.is_unspecified() + || ip.octets()[0] == 0 + // 100.64.0.0/10, carrier-grade NAT. + || (ip.octets()[0] == 100 && (64..128).contains(&ip.octets()[1])) + } + + fn v6_is_internal(ip: Ipv6Addr) -> bool { + if let Some(mapped) = ip.to_ipv4_mapped() { + return v4_is_internal(mapped); + } + if ip.is_loopback() { + return false; + } + ip.is_unspecified() + // fc00::/7 unique-local, fe80::/10 link-local. + || (ip.segments()[0] & 0xfe00) == 0xfc00 + || (ip.segments()[0] & 0xffc0) == 0xfe80 + } + + let internal = match url.host_str() { + Some(host) => { + // `host_str` keeps the brackets on an IPv6 literal, which `IpAddr` will not parse. + let bare = host.trim_start_matches('[').trim_end_matches(']'); + match bare.parse::() { + Ok(IpAddr::V4(ip)) => v4_is_internal(ip), + Ok(IpAddr::V6(ip)) => v6_is_internal(ip), + Err(_) => { + // `localhost` resolves to loopback, and is allowed for the same reason. + let lowered = bare.to_ascii_lowercase(); + lowered.ends_with(".local") || lowered.ends_with(".internal") + } + } + } + None => true, + }; + + anyhow::ensure!( + !internal, + "callback URL must not point at a private, loopback or link-local address; \ + set ALLOW_PRIVATE_CALLBACKS=1 to permit it for local development" + ); + + Ok(()) +} + fn parse_http_url(value: &str, label: &str) -> Result { let url = reqwest::Url::parse(value).with_context(|| format!("invalid {label}"))?; anyhow::ensure!( @@ -234,17 +342,27 @@ fn validated_callback_url( localhost_rewrite: Option<&str>, ) -> Result { let mut callback = parse_http_url(callback_url, "callback URL")?; + let mut rewritten = false; if matches!(callback.host_str(), Some("localhost" | "127.0.0.1")) { if let Some(rewrite) = localhost_rewrite { callback .set_host(Some(rewrite)) .map_err(|_| anyhow::anyhow!("invalid localhost rewrite host"))?; + rewritten = true; } } anyhow::ensure!( callback.fragment().is_none(), "callback URL must not contain a fragment" ); + + // The rewrite target is operator configuration rather than caller input, and it exists so a + // local deployment can post to its own host. Exempting it keeps that path working without + // opening the general case: a caller who did not name an exact local host is still checked. + if !rewritten { + ensure_public_callback_host(&callback)?; + } + Ok(callback) } @@ -367,6 +485,78 @@ async fn handle_compute( .clone() .ok_or_else(|| actix_web::error::ErrorBadRequest("callback_url is required"))?; + let published = req + .input_commitments + .iter() + .enumerate() + .map(|(index, hex_commitment)| { + let raw = hex::decode(hex_commitment.trim_start_matches("0x")) + .map_err(|e| actix_web::error::ErrorBadRequest(format!("bad commitment: {e}")))?; + let commitment = <[u8; 32]>::try_from(raw.as_slice()).map_err(|_| { + actix_web::error::ErrorBadRequest("each commitment must be 32 bytes") + })?; + + // The metadata is opaque here — what a policy reads out of it is the E3 program's + // business. It is assembled in the order the program packs it, which for CRISP is + // `abi.encodePacked(address, uint40)`. + // + // Both widths are checked rather than coerced. This endpoint takes JSON from the + // network, and the packing is fixed-width: a slot of the wrong length shifts every + // byte after it, and a parent above `uint40` would be truncated into a different, + // valid-looking index. Either produces metadata the E3 program never published, and + // the only symptom is an input root the guest derives and the contract rejects. + let mut metadata = Vec::new(); + if let Some(hex_slot) = req.input_slots.get(index) { + let slot = hex::decode(hex_slot.trim_start_matches("0x")) + .map_err(|e| actix_web::error::ErrorBadRequest(format!("bad slot: {e}")))?; + if slot.len() != SLOT_BYTES { + return Err(actix_web::error::ErrorBadRequest(format!( + "each slot must be {SLOT_BYTES} bytes, got {}", + slot.len() + ))); + } + metadata.extend_from_slice(&slot); + + let parent = req.input_parents.get(index).copied().unwrap_or_default(); + if parent > MAX_PARENT { + return Err(actix_web::error::ErrorBadRequest(format!( + "each parent must fit in {PARENT_BYTES} bytes (at most {MAX_PARENT}), got {parent}" + ))); + } + metadata.extend_from_slice(&parent.to_be_bytes()[8 - PARENT_BYTES..]); + } + + Ok(PublishedData { + commitment: Some(commitment), + metadata, + }) + }) + .collect::>>()?; + + // One-sided metadata is a caller bug that would otherwise fall through to the default policy + // and silently produce a root the E3 program rejects. + if req.input_commitments.is_empty() != req.input_slots.is_empty() { + return Err(actix_web::error::ErrorBadRequest( + "input_commitments and input_slots must be supplied together", + )); + } + if req.input_slots.len() != req.input_parents.len() { + return Err(actix_web::error::ErrorBadRequest( + "input_slots and input_parents must have the same length", + )); + } + + if !published.is_empty() && published.len() != req.ciphertext_inputs.len() { + return Err(actix_web::error::ErrorBadRequest( + "input_commitments must have one entry per ciphertext input", + )); + } + if !req.input_slots.is_empty() && req.input_slots.len() != req.ciphertext_inputs.len() { + return Err(actix_web::error::ErrorBadRequest( + "input_slots must have one entry per ciphertext input", + )); + } + let fhe_inputs = FHEInputs { params: req.params.clone(), ciphertexts: req.ciphertext_inputs.clone(), @@ -381,6 +571,7 @@ async fn handle_compute( .map_err(actix_web::error::ErrorBadRequest)?; let job = ComputeJob { inputs: fhe_inputs, + published, domain, }; @@ -446,10 +637,24 @@ mod server_tests { #[test] fn callback_validation_accepts_http_origins_and_rejects_unsafe_urls() { assert!(validated_callback_url("https://callback.example:8443/results/1", None).is_ok()); - assert!(validated_callback_url("https://metadata.internal:8443/latest", None).is_ok()); assert!(validated_callback_url("file:///etc/passwd", None).is_err()); assert!(validated_callback_url("https://user:pass@example.com/result", None).is_err()); assert!(validated_callback_url("https://example.com/result#fragment", None).is_err()); + + // This endpoint dials whatever it is given, so a caller must not be able to aim it inside + // the deployment. `metadata.internal` was previously accepted; it is the canonical target. + assert!(validated_callback_url("https://metadata.internal:8443/latest", None).is_err()); + assert!(validated_callback_url("http://169.254.169.254/latest/meta-data", None).is_err()); + assert!(validated_callback_url("http://10.0.0.5/hook", None).is_err()); + assert!(validated_callback_url("http://192.168.1.10/hook", None).is_err()); + assert!(validated_callback_url("http://[fd00::1]/hook", None).is_err()); + assert!(validated_callback_url("http://100.64.0.1/hook", None).is_err()); + + // Loopback is allowed: it reaches only the host this server already runs on, and it is how + // every local and CI deployment delivers its webhook. + assert!(validated_callback_url("http://127.0.0.1/hook", None).is_ok()); + assert!(validated_callback_url("http://[::1]/hook", None).is_ok()); + assert!(validated_callback_url("http://localhost:4000/hook", None).is_ok()); } #[test] diff --git a/crates/program-server/src/types.rs b/crates/program-server/src/types.rs index 22a24235c4..f753c6310f 100644 --- a/crates/program-server/src/types.rs +++ b/crates/program-server/src/types.rs @@ -28,6 +28,26 @@ pub struct ComputeRequest { pub params: Vec, #[serde(deserialize_with = "deserialize_hex_tuple")] pub ciphertext_inputs: Vec<(Vec, u64)>, + /// The commitment the E3 program stored for each input, hex encoded, in the same order as + /// `ciphertext_inputs`. + /// + /// Optional so an older caller still deserializes. Omitting it means the Secure Process cannot + /// check a published ciphertext against the commitment that was actually proven, so a single + /// unusable input costs the whole round. + #[serde(default)] + pub input_commitments: Vec, + /// The slot each input was published to, hex encoded, in the same order. Required alongside + /// `input_commitments`: the tree is append-only, so the Secure Process groups by slot. + #[serde(default)] + pub input_slots: Vec, + /// The entry each input names as the one it extends, plus one, in the same order; zero means it + /// extends nothing. Required alongside `input_slots`, because a policy that groups by slot may + /// also order within one, and CRISP's does. + /// + /// Carried as `u64` because JSON has no narrower integer, but the published width is a Solidity + /// `uint40`. The handler refuses anything wider rather than truncating it. + #[serde(default)] + pub input_parents: Vec, pub callback_url: Option, } diff --git a/crates/support-scripts/dev/src/main.rs b/crates/support-scripts/dev/src/main.rs index 4f7c451a42..0b495ac988 100644 --- a/crates/support-scripts/dev/src/main.rs +++ b/crates/support-scripts/dev/src/main.rs @@ -5,7 +5,9 @@ // or FITNESS FOR A PARTICULAR PURPOSE. use anyhow::Result; -use e3_compute_provider::{ComputeInput, ComputeManager, ComputeProvider, ComputeResult}; +use e3_compute_provider::{ + ComputeInput, ComputeManager, ComputeProvider, ComputeResult, InputPolicy, +}; use e3_program_server::E3ProgramServer; use e3_user_program::fhe_processor; @@ -14,8 +16,16 @@ struct MockProofProvider; impl ComputeProvider for MockProofProvider { type Output = ComputeResult; - fn prove(&self, input: &ComputeInput) -> Self::Output { - input.process(fhe_processor) + fn prove(&self, input: &ComputeInput, policy: InputPolicy) -> Self::Output { + // The policy comes from the caller rather than from `e3_user_program::policy()` here. + // Reading it twice would let the ciphertext this run publishes and the one it proves be + // selected by different rules, which is the divergence the argument exists to remove. + // + // This dev provider stands in for the zkVM, where a failure aborts the guest. Panicking + // with the reason keeps that behaviour while naming the input that could not be used. + input + .process(fhe_processor, policy) + .expect("the Secure Process rejected its inputs") } } @@ -46,9 +56,15 @@ fn encode_mock_compute_proof(seal: &[u8], result: &ComputeResult) -> Result Result<()> { let server = E3ProgramServer::builder(|job| async move { - let mut manager = - ComputeManager::new(MockProofProvider, job.inputs, fhe_processor, false, None); - let (result, ciphertext) = manager.start(); + // `with_published`, not `new`: a program whose policy reads the commitment or the slot + // needs what the E3 program published, and `new` supplies none of it. + let mut manager = ComputeManager::with_published( + MockProofProvider, + job.inputs, + job.published, + fhe_processor, + ); + let (result, ciphertext) = manager.start(e3_user_program::policy())?; let proof = encode_mock_compute_proof(&[3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5], &result)?; Ok((proof, ciphertext)) diff --git a/crates/support/Cargo.lock b/crates/support/Cargo.lock index e6ffb02259..d9464160aa 100644 --- a/crates/support/Cargo.lock +++ b/crates/support/Cargo.lock @@ -65,7 +65,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" dependencies = [ "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -182,7 +182,7 @@ dependencies = [ "actix-router", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -634,7 +634,7 @@ checksum = "64b728d511962dda67c1bc7ea7c03736ec275ed2cf4c35d9585298ac9ccf3b73" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -769,7 +769,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -786,7 +786,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", "syn-solidity", "tiny-keccak", ] @@ -805,7 +805,7 @@ dependencies = [ "proc-macro2", "quote", "serde_json", - "syn 2.0.111", + "syn 2.0.119", "syn-solidity", ] @@ -895,7 +895,7 @@ dependencies = [ "darling 0.21.3", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -914,7 +914,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" dependencies = [ "anstyle", - "anstyle-parse", + "anstyle-parse 0.2.7", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse 1.0.0", "anstyle-query", "anstyle-wincon", "colorchoice", @@ -937,13 +952,22 @@ dependencies = [ "utf8parse", ] +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + [[package]] name = "anstyle-query" version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -954,7 +978,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1016,7 +1040,7 @@ checksum = "e7e89fe77d1f0f4fe5b96dfc940923d88d17b6a773808124f21e764dfb063c6a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -1142,7 +1166,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" dependencies = [ "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -1180,7 +1204,7 @@ dependencies = [ "num-traits", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -1309,7 +1333,7 @@ checksum = "213888f660fddcca0d257e88e54ac05bca01885f258ccdf695bafd77031bb69d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -1571,7 +1595,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -1588,7 +1612,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -1605,7 +1629,7 @@ checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -2164,7 +2188,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -2261,7 +2285,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -2385,9 +2409,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.41" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be92d32e80243a54711e5d7ce823c35c41c9d929dc4ab58e1276f625841aadf9" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" dependencies = [ "clap_builder", "clap_derive", @@ -2395,11 +2419,11 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.41" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707eab41e9622f9139419d573eca0900137718000c517d47da73045f54331c3d" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ - "anstream", + "anstream 1.0.0", "anstyle", "clap_lex", "strsim", @@ -2407,21 +2431,21 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.41" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] name = "clap_lex" -version = "0.7.6" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cmake" @@ -2684,7 +2708,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -2699,7 +2723,7 @@ dependencies = [ "quote", "serde", "strsim", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -2710,7 +2734,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -2721,7 +2745,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core 0.21.3", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -2804,7 +2828,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -2814,7 +2838,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -2834,7 +2858,7 @@ checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", "unicode-xid", ] @@ -2887,7 +2911,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2909,7 +2933,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -2961,8 +2985,8 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "e3-bfv-client" -version = "0.4.0" -source = "git+https://github.com/theinterfold/interfold?rev=c2097da61b4d07c4ce83840393ff4e9f171eefb4#c2097da61b4d07c4ce83840393ff4e9f171eefb4" +version = "0.7.0" +source = "git+https://github.com/theinterfold/interfold?rev=5b2ca6619a07aec6104f9b877bff948bd976b6cf#5b2ca6619a07aec6104f9b877bff948bd976b6cf" dependencies = [ "anyhow", "e3-fhe-params", @@ -2976,28 +3000,30 @@ dependencies = [ [[package]] name = "e3-compute-provider" -version = "0.4.0" -source = "git+https://github.com/theinterfold/interfold?rev=c2097da61b4d07c4ce83840393ff4e9f171eefb4#c2097da61b4d07c4ce83840393ff4e9f171eefb4" +version = "0.7.0" +source = "git+https://github.com/theinterfold/interfold?rev=5b2ca6619a07aec6104f9b877bff948bd976b6cf#5b2ca6619a07aec6104f9b877bff948bd976b6cf" dependencies = [ "ark-bn254 0.4.0", "ark-ff 0.4.2", "e3-bfv-client", "e3-fhe-params", + "fhe", "hex", "lean-imt", "light-poseidon", "num-bigint", "num-traits", - "rayon", "serde", + "sha2", "sha3", + "thiserror 1.0.69", "zk-kit-imt", ] [[package]] name = "e3-fhe-params" -version = "0.4.0" -source = "git+https://github.com/theinterfold/interfold?rev=c2097da61b4d07c4ce83840393ff4e9f171eefb4#c2097da61b4d07c4ce83840393ff4e9f171eefb4" +version = "0.7.0" +source = "git+https://github.com/theinterfold/interfold?rev=5b2ca6619a07aec6104f9b877bff948bd976b6cf#5b2ca6619a07aec6104f9b877bff948bd976b6cf" dependencies = [ "alloy-dyn-abi", "alloy-primitives", @@ -3014,8 +3040,8 @@ dependencies = [ [[package]] name = "e3-parity-matrix" -version = "0.4.0" -source = "git+https://github.com/theinterfold/interfold?rev=c2097da61b4d07c4ce83840393ff4e9f171eefb4#c2097da61b4d07c4ce83840393ff4e9f171eefb4" +version = "0.7.0" +source = "git+https://github.com/theinterfold/interfold?rev=5b2ca6619a07aec6104f9b877bff948bd976b6cf#5b2ca6619a07aec6104f9b877bff948bd976b6cf" dependencies = [ "num-bigint", "num-traits", @@ -3025,8 +3051,8 @@ dependencies = [ [[package]] name = "e3-polynomial" -version = "0.4.0" -source = "git+https://github.com/theinterfold/interfold?rev=c2097da61b4d07c4ce83840393ff4e9f171eefb4#c2097da61b4d07c4ce83840393ff4e9f171eefb4" +version = "0.7.0" +source = "git+https://github.com/theinterfold/interfold?rev=5b2ca6619a07aec6104f9b877bff948bd976b6cf#5b2ca6619a07aec6104f9b877bff948bd976b6cf" dependencies = [ "fhe-math", "num-bigint", @@ -3037,8 +3063,8 @@ dependencies = [ [[package]] name = "e3-safe" -version = "0.4.0" -source = "git+https://github.com/theinterfold/interfold?rev=c2097da61b4d07c4ce83840393ff4e9f171eefb4#c2097da61b4d07c4ce83840393ff4e9f171eefb4" +version = "0.7.0" +source = "git+https://github.com/theinterfold/interfold?rev=5b2ca6619a07aec6104f9b877bff948bd976b6cf#5b2ca6619a07aec6104f9b877bff948bd976b6cf" dependencies = [ "ark-bn254 0.5.0", "ark-ff 0.5.0", @@ -3120,8 +3146,8 @@ dependencies = [ [[package]] name = "e3-zk-helpers" -version = "0.4.0" -source = "git+https://github.com/theinterfold/interfold?rev=c2097da61b4d07c4ce83840393ff4e9f171eefb4#c2097da61b4d07c4ce83840393ff4e9f171eefb4" +version = "0.7.0" +source = "git+https://github.com/theinterfold/interfold?rev=5b2ca6619a07aec6104f9b877bff948bd976b6cf#5b2ca6619a07aec6104f9b877bff948bd976b6cf" dependencies = [ "anyhow", "ark-bn254 0.5.0", @@ -3184,7 +3210,7 @@ dependencies = [ "enum-ordinalize", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -3289,7 +3315,7 @@ checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -3308,7 +3334,7 @@ version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" dependencies = [ - "anstream", + "anstream 0.6.21", "anstyle", "env_filter", "jiff", @@ -3332,7 +3358,7 @@ checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -3348,7 +3374,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3590,7 +3616,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -3695,7 +3721,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -4322,7 +4348,7 @@ checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -4450,7 +4476,7 @@ checksum = "980af8b43c3ad5d8d349ace167ec8170839f753a42d233ba19e08afe1850fa69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -4572,7 +4598,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -4712,7 +4738,7 @@ checksum = "1b27834086c65ec3f9387b096d66e99f221cf081c2b738042aa252bcd41204e3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -4751,7 +4777,7 @@ checksum = "5cf92c10c7e361d6b99666ec1c6f9805b0bea2c3bd8c78dc6fe98ac5bd78db11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -4905,7 +4931,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5051,7 +5077,7 @@ checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -5121,7 +5147,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -5190,7 +5216,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -5306,7 +5332,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -5447,7 +5473,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -5500,14 +5526,14 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -5520,7 +5546,7 @@ checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", "version_check", ] @@ -5570,7 +5596,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ "heck", - "itertools 0.11.0", + "itertools 0.14.0", "log", "multimap", "petgraph 0.8.3", @@ -5578,7 +5604,7 @@ dependencies = [ "prost 0.14.4", "prost-types", "regex", - "syn 2.0.111", + "syn 2.0.119", "tempfile", ] @@ -5589,10 +5615,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools 0.11.0", + "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -5602,10 +5628,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", - "itertools 0.11.0", + "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -5703,9 +5729,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.42" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -5892,7 +5918,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -6430,7 +6456,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6767,19 +6793,20 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] name = "serde_json" -version = "1.0.141" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ "itoa", "memchr", "ryu", "serde", + "serde_core", ] [[package]] @@ -6842,7 +6869,7 @@ dependencies = [ "darling 0.21.3", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -7044,7 +7071,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d904e7009df136af5297832a3ace3370cd14ff1546a232f4f185036c2736fcac" dependencies = [ "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -7095,7 +7122,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -7117,9 +7144,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.111" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -7135,7 +7162,7 @@ dependencies = [ "paste", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -7155,7 +7182,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -7208,7 +7235,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -7259,7 +7286,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -7270,7 +7297,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -7384,7 +7411,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -7592,7 +7619,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -7771,7 +7798,7 @@ checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -7902,7 +7929,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", "wasm-bindgen-shared", ] @@ -7993,7 +8020,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8023,7 +8050,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -8034,7 +8061,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -8294,7 +8321,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", "synstructure", ] @@ -8315,7 +8342,7 @@ checksum = "cf955aa904d6040f70dc8e9384444cb1030aed272ba3cb09bbc4ab9e7c1f34f5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -8335,7 +8362,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", "synstructure", ] @@ -8356,7 +8383,7 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -8389,7 +8416,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] diff --git a/crates/support/Cargo.toml b/crates/support/Cargo.toml index ff951c6566..18634aaf48 100644 --- a/crates/support/Cargo.toml +++ b/crates/support/Cargo.toml @@ -13,7 +13,7 @@ e3-support-app = { path = "./app" } e3-support-host = { path = "./host" } e3-user-program = { path = "./program" } e3-support-types = { path = "./types" } -e3-fhe-params = { git = "https://github.com/theinterfold/interfold", rev = "c2097da61b4d07c4ce83840393ff4e9f171eefb4" } +e3-fhe-params = { git = "https://github.com/theinterfold/interfold", rev = "5b2ca6619a07aec6104f9b877bff948bd976b6cf" } methods = { path = "./methods" } alloy-primitives = { version = "1.3", default-features = false, features = [ @@ -37,12 +37,41 @@ risc0-ethereum-contracts = { git = "https://github.com/risc0/risc0-ethereum", ta risc0-zkvm = { version = "=3.0.3" } risc0-zkp = { version = "=3.0.2", default-features = false } serde = { version = "1.0", features = ["derive", "std"] } -serde_json = "=1.0.141" +serde_json = "=1.0.145" # The Interfold revision must match for all Interfold dependencies in this workspace. +# +# The Interfold crates below are pinned by revision rather than by path because this workspace is +# excluded from the root workspace on purpose (see the root Cargo.toml): a client must be able to +# build crates/support on its own, and the RISC Zero Docker build context root is crates/support, +# so anything outside that directory is unreachable from the guest build. +# +# The pin therefore decides which compute-provider code the guest runs, and moving it changes the +# image ID. Bump it only together with a rebuilt crates/support/contracts/ImageID.sol and a +# refreshed ImageID.stamp.json. `pnpm check:image-id` asserts that every pin here names one +# revision and warns when that revision is not an ancestor of HEAD. +# +# Why this revision: 5b2ca661 is the tip of the branch that fixed compute-input binding, and the +# guest has to run that code for the fix to mean anything on chain. +# +# It replaces c2097da6, the scope commit of the 2026-08-17 Zenith protocol audit. Sitting at the +# audited baseline read as the conservative choice and was not: the audit covered six Solidity +# files and no Rust at all, so crates/compute-provider and the guest were outside its scope, and +# outside the mitigation review at c64bcfb8 as well. See packages/interfold-contracts/audits/README.md. +# Holding the old pin bought reproducibility rather than assurance, while excluding real fixes to +# the code the guest runs — among them 5213e23d, which derives the guest input root from the +# processed ciphertexts. `pnpm check:image-id` still warns that the pin has moved off the audit +# baseline, which is true and worth seeing; it does not mean the pin is wrong. +# +# This revision is interim. It is a branch tip, and this repository squash-merges, so the commit +# stops being referenced by anything once the branch is deleted and eventually stops resolving — +# at which point the guest cannot be built at all. Re-pin to the commit a release tag names as +# soon as one contains this code, and record the tag here. `pnpm check:image-id` warns until then. +# It is deliberately a `rev` and not a `tag`: a tag can be moved, and the pin decides which code +# the guest runs, so it should be content-addressed even when it names a release. fhe = { package = "fhe", git = "https://github.com/gnosisguild/fhe.rs", tag = "v0.2.2" } fhe-traits = { git = "https://github.com/gnosisguild/fhe.rs", tag = "v0.2.2" } fhe-util = { git = "https://github.com/gnosisguild/fhe.rs", tag = "v0.2.2" } -e3-compute-provider = { git = "https://github.com/theinterfold/interfold", rev = "c2097da61b4d07c4ce83840393ff4e9f171eefb4" } +e3-compute-provider = { git = "https://github.com/theinterfold/interfold", rev = "5b2ca6619a07aec6104f9b877bff948bd976b6cf" } tokio = { version = "=1.46.1", features = ["full"] } rand = { version = "=0.9.2" } tracing-subscriber = { version = "=0.3.20", features = ["env-filter"] } diff --git a/crates/support/Dockerfile b/crates/support/Dockerfile index 937037c1fb..53aa28420c 100644 --- a/crates/support/Dockerfile +++ b/crates/support/Dockerfile @@ -1,6 +1,6 @@ ARG RUST_VERSION=1.91.1 ARG RISC0_VERSION=3.0.3 -ARG RISC0_TOOLCHAIN=1.88.0 +ARG RISC0_TOOLCHAIN=1.91.1 ARG SKIP_SOLIDITY=0 FROM rust:${RUST_VERSION}-slim-bookworm AS base-dev @@ -35,6 +35,19 @@ ENV SHELL=/bin/bash ENV PATH="/home/${USERNAME}/.risc0/bin:${PATH}" RUN curl -L https://risczero.com/install | bash + +# Re-declared inside the stage. An ARG before the first FROM is only in scope for FROM lines, so +# these expanded to nothing here and `rzup install` silently took its own default — the opposite of +# what pinning them is for. A bare ARG picks up the default declared at the top of the file. +ARG RISC0_VERSION +ARG RISC0_TOOLCHAIN + +# Empty is the failure this guards against, and it is silent: `rzup install rust` with no version +# installs whatever rzup considers current, and the image still builds. +RUN set -eu; \ + : "${RISC0_TOOLCHAIN:?RISC0_TOOLCHAIN is empty; declare the ARG inside this stage}"; \ + : "${RISC0_VERSION:?RISC0_VERSION is empty; declare the ARG inside this stage}" + RUN rzup install rust ${RISC0_TOOLCHAIN} RUN rzup install r0vm ${RISC0_VERSION} RUN rzup install cargo-risczero ${RISC0_VERSION} diff --git a/crates/support/README.md b/crates/support/README.md index 4e5607a4d0..3efc06cd69 100644 --- a/crates/support/README.md +++ b/crates/support/README.md @@ -116,6 +116,42 @@ interfold program compile This builds the guest ELF binary inside the Docker container. Output goes to `./target/riscv-guest/methods/guests/riscv32im-risc0-zkvm-elf/release/program.bin`. +### Changing the guest, and the pin that decides which code it runs + +The guest does **not** compile `crates/compute-provider` from this tree. `crates/support` is a +separate Cargo workspace, excluded from the root one on purpose so a client can build it +independently, and it reads `e3-compute-provider` and `e3-fhe-params` through a git pin to a +published revision (`crates/support/Cargo.toml`, `crates/support/methods/guest/Cargo.toml`). + +A change to `crates/compute-provider` therefore has no effect on the guest until that pin moves, and +the pin can only move to a pushed commit. Moving it changes the image ID, and +`Risc0BfvCiphertextVerifier.imageId` is immutable — so a guest change is a redeployment, not a +patch. + +The order matters: + +1. Merge the change to `crates/compute-provider`, then push. +2. Bump every Interfold pin to the merge commit. There are three, and they must all name the same + revision — the guest and host workspaces have to compile the same sources: + - `e3-fhe-params` in `crates/support/Cargo.toml` + - `e3-compute-provider` in `crates/support/Cargo.toml` + - `e3-compute-provider` in `crates/support/methods/guest/Cargo.toml` + + Then refresh both lockfiles (`crates/support/Cargo.lock` and + `crates/support/methods/guest/Cargo.lock`). The Docker guest build passes `--locked`, so a + lockfile one line behind its manifest stops the reproducible build before it starts. + +3. Update the `crates/support` call sites that track the crate's API — the compiler will point at + them, since they built against the old revision until now. +4. Rebuild the guest against the pinned code with the RISC Zero Docker builder, and commit the + regenerated `crates/support/contracts/ImageID.sol`. +5. Redeploy `Risc0BfvCiphertextVerifier`, and every E3 program that stores its own image ID. + +Skipping step 4 leaves a deployed verifier that accepts a guest no longer matching this tree, and +nothing in the repository detects it: there is no longer an automated check that the committed image +ID is the one the current sources produce, so this order is a convention rather than something CI +enforces. The reviewer-facing procedure is `docs/pages/verifying-the-compute-provider.mdx`. + ### Step 3: Upload Program to IPFS (Pinata) ```bash diff --git a/crates/support/app/src/main.rs b/crates/support/app/src/main.rs index 7e1ac0b6f6..870b2a3ce4 100644 --- a/crates/support/app/src/main.rs +++ b/crates/support/app/src/main.rs @@ -6,6 +6,9 @@ use actix_web::{middleware::Logger, web, App, HttpResponse, HttpServer, Result as ActixResult}; use e3_compute_provider::FHEInputs; +use e3_compute_provider::PublishedData; +use std::sync::{Arc, OnceLock}; +use tokio::sync::{OwnedSemaphorePermit, Semaphore}; use e3_support_types::{ComputeDomain, ComputeRequest, WebhookPayload}; use serde::Serialize; @@ -66,11 +69,13 @@ async fn call_webhook(callback_url: &str, payload: &WebhookPayload) -> anyhow::R async fn run_computation_async( fhe_inputs: FHEInputs, domain: ComputeDomain, + published: Vec, ) -> anyhow::Result<(Vec, Vec, Vec)> { println!("running computation..."); - let result = - tokio::task::spawn_blocking(move || e3_support_host::run_compute(fhe_inputs, domain)) - .await?; + let result = tokio::task::spawn_blocking(move || { + e3_support_host::run_compute(fhe_inputs, domain, published) + }) + .await?; match result { Ok((boundless_output, ciphertext)) => match boundless_output { @@ -107,8 +112,13 @@ async fn process_computation_background( callback_url: &str, fhe_inputs: FHEInputs, domain: ComputeDomain, + published: Vec, + // Held for the whole computation and dropped with it, which is what frees the slot. Taking it + // by value rather than borrowing is deliberate: the task is detached, so nothing else is alive + // to own it. + _permit: OwnedSemaphorePermit, ) -> anyhow::Result<()> { - match run_computation_async(fhe_inputs, domain).await { + match run_computation_async(fhe_inputs, domain, published).await { Ok((proof, ciphertext, ciphertext_commitment)) => { println!("computation finished!"); println!("handling webhook delivery..."); @@ -137,6 +147,205 @@ async fn process_computation_background( } } +/// Whether callbacks to addresses only reachable from inside the deployment are permitted. +/// +/// Off by default. Local development legitimately posts to a host on the same machine, so there has +/// to be a way in, but it must be a deliberate one rather than the default. +fn allow_private_callbacks() -> bool { + matches!( + std::env::var("ALLOW_PRIVATE_CALLBACKS") + .unwrap_or_default() + .as_str(), + "1" | "true" | "TRUE" | "yes" | "YES" + ) +} + +/// Validates a caller-supplied callback URL before this server makes a request to it. +/// +/// Without this, `callback_url` is a server-side request forgery primitive: the caller chooses a +/// destination and this server dials it, which reaches cloud metadata (169.254.169.254), loopback, +/// and anything else inside the network the server sits in. +/// +/// Literal addresses are checked exhaustively. A hostname is checked by name only, so a name that +/// resolves to a private address still passes and DNS rebinding remains possible — closing that +/// needs resolution at connect time and a pinned socket. +fn validate_callback_url(raw: &str) -> ActixResult<()> { + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + + let url = reqwest::Url::parse(raw) + .map_err(|e| actix_web::error::ErrorBadRequest(format!("invalid callback_url: {e}")))?; + + if !matches!(url.scheme(), "http" | "https") { + return Err(actix_web::error::ErrorBadRequest( + "callback_url must use http or https", + )); + } + if !url.username().is_empty() || url.password().is_some() { + return Err(actix_web::error::ErrorBadRequest( + "callback_url must not contain credentials", + )); + } + + if allow_private_callbacks() { + return Ok(()); + } + + // Loopback is deliberately NOT treated as internal. The escalation worth guarding is reaching + // hosts the caller cannot reach itself — cloud metadata, RFC1918 services, .internal names. + // Loopback is the machine this server already runs on, and it is how every local deployment + // posts its webhook. Note this runs BEFORE the localhost -> host.local rewrite below, so that + // rewrite is unaffected by `.local` remaining blocked. + fn v4_is_internal(ip: Ipv4Addr) -> bool { + if ip.is_loopback() { + return false; + } + ip.is_private() + || ip.is_link_local() + || ip.is_broadcast() + || ip.is_documentation() + || ip.is_unspecified() + || ip.octets()[0] == 0 + || (ip.octets()[0] == 100 && (64..128).contains(&ip.octets()[1])) + } + + fn v6_is_internal(ip: Ipv6Addr) -> bool { + if let Some(mapped) = ip.to_ipv4_mapped() { + return v4_is_internal(mapped); + } + if ip.is_loopback() { + return false; + } + ip.is_unspecified() + || (ip.segments()[0] & 0xfe00) == 0xfc00 + || (ip.segments()[0] & 0xffc0) == 0xfe80 + } + + let internal = match url.host_str() { + Some(host) => { + let bare = host.trim_start_matches('[').trim_end_matches(']'); + match bare.parse::() { + Ok(IpAddr::V4(ip)) => v4_is_internal(ip), + Ok(IpAddr::V6(ip)) => v6_is_internal(ip), + Err(_) => { + // `localhost` resolves to loopback, and is allowed for the same reason. + let lowered = bare.to_ascii_lowercase(); + lowered.ends_with(".local") || lowered.ends_with(".internal") + } + } + } + None => true, + }; + + if internal { + return Err(actix_web::error::ErrorBadRequest( + "callback_url must not point at a private, loopback or link-local address; \ + set ALLOW_PRIVATE_CALLBACKS=1 to permit it for local development", + )); + } + + Ok(()) +} + +/// How many computations may be in flight at once. +/// +/// Proving is the most expensive thing this process does, and the handler previously spawned one +/// detached task per request with nothing bounding them: a caller could open as many as they liked +/// and exhaust CPU, memory, blocking workers and Boundless submissions together. One at a time by +/// default, because a single proof already saturates the machine. +fn max_concurrent_computations() -> usize { + std::env::var("MAX_CONCURRENT_COMPUTATIONS") + .ok() + .and_then(|value| value.parse().ok()) + .filter(|value| *value > 0) + .unwrap_or(1) +} + +/// Permits for in-flight computations, sized once on first use. +static COMPUTE_SLOTS: OnceLock> = OnceLock::new(); + +fn compute_slots() -> &'static Arc { + COMPUTE_SLOTS.get_or_init(|| Arc::new(Semaphore::new(max_concurrent_computations()))) +} + +/// Width of the slot an E3 program packs into its published metadata, in bytes. +/// +/// Mirrors `abi.encodePacked(address, uint40)` — the convention the starter contract uses and the +/// one `crates/program-server` implements. Duplicated rather than shared because this workspace +/// builds standalone, outside the root workspace, so it cannot depend on that crate. +const SLOT_BYTES: usize = 20; +/// Width of the parent index in the same packing. +const PARENT_BYTES: usize = 5; +/// Largest parent index that fits `PARENT_BYTES`. +const MAX_PARENT: u64 = (1u64 << (8 * PARENT_BYTES as u64)) - 1; + +/// Rebuilds what the E3 program published alongside each ciphertext. +/// +/// Both widths are checked rather than coerced. This endpoint takes JSON from the network and the +/// packing is fixed-width: a slot of the wrong length shifts every byte after it, and a parent +/// above `uint40` would be truncated into a different, valid-looking index. Either produces +/// metadata the E3 program never published, and the only symptom is an input root the guest +/// derives and the contract rejects. +fn published_from(req: &ComputeRequest) -> ActixResult> { + if req.input_commitments.is_empty() && req.input_slots.is_empty() && req.input_parents.is_empty() + { + return Ok(Vec::new()); + } + + if req.input_commitments.len() != req.ciphertext_inputs.len() { + return Err(actix_web::error::ErrorBadRequest( + "input_commitments must have one entry per ciphertext input", + )); + } + if !req.input_slots.is_empty() && req.input_slots.len() != req.input_commitments.len() { + return Err(actix_web::error::ErrorBadRequest( + "input_slots must have one entry per ciphertext input", + )); + } + if req.input_slots.len() != req.input_parents.len() { + return Err(actix_web::error::ErrorBadRequest( + "input_slots and input_parents must have the same length", + )); + } + + req.input_commitments + .iter() + .enumerate() + .map(|(index, hex_commitment)| { + let bytes = hex::decode(hex_commitment.trim_start_matches("0x")) + .map_err(|e| actix_web::error::ErrorBadRequest(format!("bad commitment: {e}")))?; + let commitment: [u8; 32] = bytes.try_into().map_err(|_| { + actix_web::error::ErrorBadRequest("each commitment must be 32 bytes") + })?; + + let mut metadata = Vec::new(); + if let Some(hex_slot) = req.input_slots.get(index) { + let slot = hex::decode(hex_slot.trim_start_matches("0x")) + .map_err(|e| actix_web::error::ErrorBadRequest(format!("bad slot: {e}")))?; + if slot.len() != SLOT_BYTES { + return Err(actix_web::error::ErrorBadRequest(format!( + "each slot must be {SLOT_BYTES} bytes, got {}", + slot.len() + ))); + } + metadata.extend_from_slice(&slot); + + let parent = req.input_parents.get(index).copied().unwrap_or_default(); + if parent > MAX_PARENT { + return Err(actix_web::error::ErrorBadRequest(format!( + "each parent must fit in {PARENT_BYTES} bytes (at most {MAX_PARENT}), got {parent}" + ))); + } + metadata.extend_from_slice(&parent.to_be_bytes()[8 - PARENT_BYTES..]); + } + + Ok(PublishedData { + commitment: Some(commitment), + metadata, + }) + }) + .collect() +} + async fn handle_compute(req: web::Json) -> ActixResult { println!("Processing computation..."); let e3_id = req @@ -147,10 +356,22 @@ async fn handle_compute(req: web::Json) -> ActixResult) -> ActixResult { println!("Success! RISC0 computation completed"); println!("Output result: {:?}", output.result); diff --git a/crates/support/host/src/lib.rs b/crates/support/host/src/lib.rs index 62b8efdd4d..03fce59644 100644 --- a/crates/support/host/src/lib.rs +++ b/crates/support/host/src/lib.rs @@ -19,9 +19,11 @@ use boundless_market::{ storage::storage_provider_from_env, Client, }; -use e3_compute_provider::{ComputeInput, ComputeManager, ComputeProvider, FHEInputs}; +use e3_compute_provider::{ + ComputeInput, ComputeManager, ComputeProvider, FHEInputs, InputPolicy, PublishedData, +}; use e3_support_types::{ComputeDomain, ComputeGuestInput, ComputeJournal}; -use e3_user_program::fhe_processor; +use e3_user_program::{fhe_processor, policy}; use methods::PROGRAM_ELF; use risc0_ethereum_contracts::groth16; use risc0_zkvm::{default_prover, ExecutorEnv, ProverOpts, VerifierContext}; @@ -51,16 +53,26 @@ pub enum ComputeError { Other(String), } +/// The compute provider has its own error type; this one is the host's public surface. +/// +/// Flattened to a string rather than re-exported so the host's callers do not have to depend on +/// the provider crate to match on a failure. +impl From for ComputeError { + fn from(error: e3_compute_provider::ComputeError) -> Self { + ComputeError::Other(error.to_string()) + } +} + impl ComputeProvider for BoundlessProvider { type Output = BoundlessOutput; - fn prove(&self, input: &ComputeInput) -> Self::Output { + fn prove(&self, input: &ComputeInput, policy: InputPolicy) -> Self::Output { let is_dev_mode = std::env::var("RISC0_DEV_MODE").unwrap_or_else(|_| "0".to_string()) == "1"; if is_dev_mode { println!("Dev mode: Using fake proof"); - fake_prove(input, &self.domain) + fake_prove(input, &self.domain, policy) } else { println!("Using Boundless for proving"); tokio::runtime::Handle::current().block_on(boundless_prove(input, &self.domain)) @@ -81,11 +93,21 @@ fn encode_journal(result: &ComputeJournal) -> Result, Error> { } /// Dev mode: return fake proof without executing -fn fake_prove(input: &ComputeInput, domain: &ComputeDomain) -> BoundlessOutput { +fn fake_prove( + input: &ComputeInput, + domain: &ComputeDomain, + policy: InputPolicy, +) -> BoundlessOutput { println!("Generating fake proof for dev mode"); - // Execute the program with the input - let result = match ComputeJournal::new(domain.clone(), input.process(fhe_processor)) { + // Execute the program with the input. The policy is the caller's, so dev mode computes the same + // result the guest would rather than silently falling back to the default. + let processed = match input.process(fhe_processor, policy) { + Ok(processed) => processed, + Err(error) => return to_output_error(Error::from(error)), + }; + + let result = match ComputeJournal::new(domain.clone(), processed) { Ok(result) => result, Err(error) => return to_output_error(error), }; @@ -378,7 +400,9 @@ pub struct Risc0Output { impl ComputeProvider for Risc0Provider { type Output = Risc0Output; - fn prove(&self, input: &ComputeInput) -> Self::Output { + fn prove(&self, input: &ComputeInput, _policy: InputPolicy) -> Self::Output { + // The policy is not forwarded: the guest calls the user program's own `policy()`, so + // passing one here would let host and guest disagree about the leaves and the selection. let guest_input = ComputeGuestInput { domain: self.domain.clone(), input: input.clone(), @@ -425,21 +449,24 @@ impl ComputeProvider for Risc0Provider { pub fn run_compute( params: FHEInputs, domain: ComputeDomain, + published: Vec, ) -> std::result::Result<(BoundlessOutput, Vec), ComputeError> { let boundless_provider = BoundlessProvider { domain }; - let mut provider = ComputeManager::new( + // `with_published` rather than `new`: the policy reads this to rebuild the leaves the E3 + // program's contract built. Passing an empty vec is the same as `new`, which is what a program + // using the default policy wants. + let mut provider = ComputeManager::with_published( boundless_provider, params.clone(), + published, fhe_processor, - false, - None, ); // Start timer let start_time = Instant::now(); - let output = provider.start(); + let output = provider.start(policy())?; // Capture end time and calculate the duration let elapsed_time = start_time.elapsed(); @@ -454,24 +481,23 @@ pub fn run_compute( ); // Check if the output indicates failure - match output.0 { + match &output.0 { BoundlessOutput::Success { .. } => Ok(output), - BoundlessOutput::Error { error } => Err(ComputeError::BoundlessFailed(error)), + BoundlessOutput::Error { error } => Err(ComputeError::BoundlessFailed(error.clone())), } } pub fn run_risc0_compute( params: FHEInputs, domain: ComputeDomain, + published: Vec, ) -> std::result::Result<(Risc0Output, Vec), ComputeError> { let risc0_provider = Risc0Provider { domain }; let mut provider = - ComputeManager::new(risc0_provider, params.clone(), fhe_processor, false, None); - - let output = provider.start(); + ComputeManager::with_published(risc0_provider, params.clone(), published, fhe_processor); - Ok(output) + Ok(provider.start(policy())?) } pub fn encode_compute_proof( diff --git a/crates/support/methods/build.rs b/crates/support/methods/build.rs index d818cf7d9d..560b548260 100644 --- a/crates/support/methods/build.rs +++ b/crates/support/methods/build.rs @@ -18,7 +18,11 @@ // See the License for the specific language governing permissions and // limitations under the License. -use std::{collections::HashMap, env, path::PathBuf}; +use std::{ + collections::HashMap, + env, fs, + path::{Path, PathBuf}, +}; use risc0_build::{embed_methods_with_options, DockerOptionsBuilder, GuestOptionsBuilder}; use risc0_build_ethereum::generate_solidity_files; @@ -27,16 +31,67 @@ use risc0_build_ethereum::generate_solidity_files; const SOLIDITY_IMAGE_ID_PATH: &str = "../contracts/ImageID.sol"; const SOLIDITY_ELF_PATH: &str = "../tests/Elf.sol"; +/// Reports whether the reproducible Docker guest build is selected. +/// +/// The variable is read for its value, not its presence, so `RISC0_USE_DOCKER=0` selects the +/// local build. +fn use_docker() -> bool { + matches!( + env::var("RISC0_USE_DOCKER").unwrap_or_default().as_str(), + "1" | "true" | "TRUE" | "yes" | "YES" + ) +} + +/// The guest builder image tag, derived from `ARG RISC0_TOOLCHAIN` in `crates/support/Dockerfile`. +/// +/// risc0-build does not read that Dockerfile — it generates its own and, left alone, uses its +/// compiled-in default tag. For risc0-build 3.0.3 that default is `r0.1.88.0`, which carries rustc +/// 1.88, while the guest's dependency tree pins fhe.rs at an MSRV of 1.91.1. The guest then fails +/// to compile inside the container with an MSRV error, and the Dockerfile that says 1.91.1 has no +/// bearing on it. +/// +/// Reading the tag from `ARG RISC0_TOOLCHAIN` is what ties the two together: the toolchain the +/// Dockerfile declares becomes the toolchain the ELF is actually built with, rather than the two +/// being independent values that happen to agree. +fn guest_builder_tag(support_dir: &Path) -> String { + let dockerfile = support_dir.join("Dockerfile"); + println!("cargo:rerun-if-changed={}", dockerfile.display()); + + let source = fs::read_to_string(&dockerfile).unwrap_or_else(|e| { + panic!( + "cannot read {} to resolve the guest toolchain: {e}", + dockerfile.display() + ) + }); + + let toolchain = source + .lines() + .find_map(|line| line.trim().strip_prefix("ARG RISC0_TOOLCHAIN=")) + .unwrap_or_else(|| panic!("no ARG RISC0_TOOLCHAIN in {}", dockerfile.display())) + .trim(); + + assert!( + !toolchain.is_empty(), + "ARG RISC0_TOOLCHAIN in {} is empty", + dockerfile.display() + ); + + format!("r0.{toolchain}") +} + fn main() { // Builds can be made deterministic, and thereby reproducible, by using Docker to build the - // guest. Check the RISC0_USE_DOCKER variable and use Docker to build the guest if set. + // guest. Set RISC0_USE_DOCKER to 1 (or true) to select the reproducible Docker build. Any + // other value, and an unset variable, select the local build. println!("cargo:rerun-if-env-changed=RISC0_USE_DOCKER"); println!("cargo:rerun-if-changed=build.rs"); let manifest_dir = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()); let mut builder = GuestOptionsBuilder::default(); - if env::var("RISC0_USE_DOCKER").is_ok() { + if use_docker() { + let support_dir = manifest_dir.join("../"); let docker_options = DockerOptionsBuilder::default() - .root_dir(manifest_dir.join("../")) + .root_dir(support_dir.clone()) + .docker_container_tag(guest_builder_tag(&support_dir)) .build() .unwrap(); builder.use_docker(docker_options); diff --git a/crates/support/methods/guest/Cargo.lock b/crates/support/methods/guest/Cargo.lock index 9156cbab8d..ffb4490873 100644 --- a/crates/support/methods/guest/Cargo.lock +++ b/crates/support/methods/guest/Cargo.lock @@ -114,7 +114,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -130,7 +130,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", "syn-solidity", "tiny-keccak", ] @@ -147,7 +147,7 @@ dependencies = [ "macro-string", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", "syn-solidity", ] @@ -175,9 +175,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -196,9 +196,9 @@ checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -282,7 +282,7 @@ checksum = "e7e89fe77d1f0f4fe5b96dfc940923d88d17b6a773808124f21e764dfb063c6a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -408,7 +408,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" dependencies = [ "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -446,7 +446,7 @@ dependencies = [ "num-traits", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -575,7 +575,7 @@ checksum = "213888f660fddcca0d257e88e54ac05bca01885f258ccdf695bafd77031bb69d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -634,7 +634,7 @@ checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -747,7 +747,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -773,7 +773,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -815,9 +815,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "clap" -version = "4.5.41" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be92d32e80243a54711e5d7ce823c35c41c9d929dc4ab58e1276f625841aadf9" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" dependencies = [ "clap_builder", "clap_derive", @@ -825,9 +825,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.41" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707eab41e9622f9139419d573eca0900137718000c517d47da73045f54331c3d" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -837,21 +837,21 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.41" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] name = "clap_lex" -version = "0.7.7" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cobs" @@ -1033,7 +1033,7 @@ checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", "unicode-xid", ] @@ -1078,8 +1078,8 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "e3-bfv-client" -version = "0.4.0" -source = "git+https://github.com/theinterfold/interfold?rev=c2097da61b4d07c4ce83840393ff4e9f171eefb4#c2097da61b4d07c4ce83840393ff4e9f171eefb4" +version = "0.7.0" +source = "git+https://github.com/theinterfold/interfold?rev=5b2ca6619a07aec6104f9b877bff948bd976b6cf#5b2ca6619a07aec6104f9b877bff948bd976b6cf" dependencies = [ "anyhow", "e3-fhe-params", @@ -1093,28 +1093,30 @@ dependencies = [ [[package]] name = "e3-compute-provider" -version = "0.4.0" -source = "git+https://github.com/theinterfold/interfold?rev=c2097da61b4d07c4ce83840393ff4e9f171eefb4#c2097da61b4d07c4ce83840393ff4e9f171eefb4" +version = "0.7.0" +source = "git+https://github.com/theinterfold/interfold?rev=5b2ca6619a07aec6104f9b877bff948bd976b6cf#5b2ca6619a07aec6104f9b877bff948bd976b6cf" dependencies = [ "ark-bn254 0.4.0", "ark-ff 0.4.2", "e3-bfv-client", "e3-fhe-params", + "fhe", "hex", "lean-imt", "light-poseidon", "num-bigint", "num-traits", - "rayon", "serde", + "sha2", "sha3", + "thiserror 1.0.69", "zk-kit-imt", ] [[package]] name = "e3-fhe-params" -version = "0.4.0" -source = "git+https://github.com/theinterfold/interfold?rev=c2097da61b4d07c4ce83840393ff4e9f171eefb4#c2097da61b4d07c4ce83840393ff4e9f171eefb4" +version = "0.7.0" +source = "git+https://github.com/theinterfold/interfold?rev=5b2ca6619a07aec6104f9b877bff948bd976b6cf#5b2ca6619a07aec6104f9b877bff948bd976b6cf" dependencies = [ "alloy-dyn-abi", "alloy-primitives", @@ -1131,8 +1133,8 @@ dependencies = [ [[package]] name = "e3-parity-matrix" -version = "0.4.0" -source = "git+https://github.com/theinterfold/interfold?rev=c2097da61b4d07c4ce83840393ff4e9f171eefb4#c2097da61b4d07c4ce83840393ff4e9f171eefb4" +version = "0.7.0" +source = "git+https://github.com/theinterfold/interfold?rev=5b2ca6619a07aec6104f9b877bff948bd976b6cf#5b2ca6619a07aec6104f9b877bff948bd976b6cf" dependencies = [ "num-bigint", "num-traits", @@ -1142,8 +1144,8 @@ dependencies = [ [[package]] name = "e3-polynomial" -version = "0.4.0" -source = "git+https://github.com/theinterfold/interfold?rev=c2097da61b4d07c4ce83840393ff4e9f171eefb4#c2097da61b4d07c4ce83840393ff4e9f171eefb4" +version = "0.7.0" +source = "git+https://github.com/theinterfold/interfold?rev=5b2ca6619a07aec6104f9b877bff948bd976b6cf#5b2ca6619a07aec6104f9b877bff948bd976b6cf" dependencies = [ "fhe-math", "num-bigint", @@ -1154,8 +1156,8 @@ dependencies = [ [[package]] name = "e3-safe" -version = "0.4.0" -source = "git+https://github.com/theinterfold/interfold?rev=c2097da61b4d07c4ce83840393ff4e9f171eefb4#c2097da61b4d07c4ce83840393ff4e9f171eefb4" +version = "0.7.0" +source = "git+https://github.com/theinterfold/interfold?rev=5b2ca6619a07aec6104f9b877bff948bd976b6cf#5b2ca6619a07aec6104f9b877bff948bd976b6cf" dependencies = [ "ark-bn254 0.5.0", "ark-ff 0.5.0", @@ -1168,6 +1170,7 @@ dependencies = [ name = "e3-support-types" version = "0.1.0" dependencies = [ + "alloy-primitives", "anyhow", "derivative", "e3-compute-provider", @@ -1188,8 +1191,8 @@ dependencies = [ [[package]] name = "e3-zk-helpers" -version = "0.4.0" -source = "git+https://github.com/theinterfold/interfold?rev=c2097da61b4d07c4ce83840393ff4e9f171eefb4#c2097da61b4d07c4ce83840393ff4e9f171eefb4" +version = "0.7.0" +source = "git+https://github.com/theinterfold/interfold?rev=5b2ca6619a07aec6104f9b877bff948bd976b6cf#5b2ca6619a07aec6104f9b877bff948bd976b6cf" dependencies = [ "anyhow", "ark-bn254 0.5.0", @@ -1239,7 +1242,7 @@ dependencies = [ "enum-ordinalize", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -1302,7 +1305,7 @@ checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -1322,7 +1325,7 @@ checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -1518,7 +1521,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -1665,7 +1668,7 @@ checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -1819,7 +1822,7 @@ checksum = "1b27834086c65ec3f9387b096d66e99f221cf081c2b738042aa252bcd41204e3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -2013,7 +2016,7 @@ checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -2062,7 +2065,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -2151,7 +2154,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -2204,14 +2207,14 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -2260,7 +2263,7 @@ dependencies = [ "prost", "prost-types", "regex", - "syn 2.0.111", + "syn 2.0.119", "tempfile", ] @@ -2274,7 +2277,7 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -2317,9 +2320,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quote" -version = "1.0.42" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -2869,19 +2872,20 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] name = "serde_json" -version = "1.0.141" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ "itoa", "memchr", "ryu", "serde", + "serde_core", ] [[package]] @@ -2969,7 +2973,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d904e7009df136af5297832a3ace3370cd14ff1546a232f4f185036c2736fcac" dependencies = [ "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -3003,9 +3007,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.111" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -3021,7 +3025,7 @@ dependencies = [ "paste", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -3093,7 +3097,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -3104,7 +3108,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -3207,7 +3211,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -3369,7 +3373,7 @@ checksum = "cf955aa904d6040f70dc8e9384444cb1030aed272ba3cb09bbc4ab9e7c1f34f5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] @@ -3389,7 +3393,7 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.119", ] [[package]] diff --git a/crates/support/methods/guest/Cargo.toml b/crates/support/methods/guest/Cargo.toml index cb2deb6ef9..4fd42d3f3b 100644 --- a/crates/support/methods/guest/Cargo.toml +++ b/crates/support/methods/guest/Cargo.toml @@ -13,7 +13,10 @@ path = "src/bin/program.rs" [dependencies] risc0-zkvm = { version = "=3.0.3", default-features = false, features = ['std'] } -e3-compute-provider = { git = "https://github.com/theinterfold/interfold", rev = "c2097da61b4d07c4ce83840393ff4e9f171eefb4" } +# This revision decides which compute-provider code runs inside the zkVM, so it decides the image +# ID. It must match the pin in crates/support/Cargo.toml. See that file for why the dependency is +# pinned by revision instead of by path. +e3-compute-provider = { git = "https://github.com/theinterfold/interfold", rev = "5b2ca6619a07aec6104f9b877bff948bd976b6cf" } e3-support-types = { path = "../../types" } e3-user-program = { path = "../../program" } bincode = "=1.3.3" diff --git a/crates/support/methods/guest/src/bin/program.rs b/crates/support/methods/guest/src/bin/program.rs index 96576233f0..328d3080ee 100644 --- a/crates/support/methods/guest/src/bin/program.rs +++ b/crates/support/methods/guest/src/bin/program.rs @@ -7,7 +7,7 @@ use anyhow::{Error, Result}; use bincode::deserialize; use e3_support_types::{ComputeGuestInput, ComputeJournal}; -use e3_user_program::fhe_processor; +use e3_user_program::{fhe_processor, policy}; use risc0_zkvm::guest::env; use std::io::Read; @@ -20,7 +20,9 @@ fn main() { env::stdin().read_to_end(&mut input_slice).unwrap(); let input: ComputeGuestInput = deserialize(&decode_input(&input_slice).unwrap()).unwrap(); - let result = input.input.process(fhe_processor); + // The policy comes from the user program, not from a default here: it decides the input-tree + // leaf and which inputs count, and both have to agree with what the E3 program's contract did. + let result = input.input.process(fhe_processor, policy()).unwrap(); let journal = ComputeJournal::new(input.domain, result).unwrap(); env::commit(&journal); diff --git a/crates/support/program/src/lib.rs b/crates/support/program/src/lib.rs index 839a495e5d..ac9502e8f7 100644 --- a/crates/support/program/src/lib.rs +++ b/crates/support/program/src/lib.rs @@ -4,7 +4,7 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. -use e3_compute_provider::FHEInputs; +use e3_compute_provider::{FHEInputs, InputPolicy}; use e3_fhe_params::decode_bfv_params_arc; use fhe::bfv::Ciphertext; use fhe_traits::{DeserializeParametrized, Serialize}; @@ -21,3 +21,17 @@ pub fn fhe_processor(fhe_inputs: &FHEInputs) -> Vec { sum.to_bytes() } + +/// How the Secure Process builds input-tree leaves and chooses which inputs to compute over. +/// +/// The default is the behaviour every E3 program had before policies existed: the leaf is the +/// ciphertext's own commitment, and every input is computed over. It is the right starting point +/// for a program whose contract inserts the commitment directly, as this template's does. +/// +/// A program that publishes more than the ciphertext — a slot, a parent, anything the contract +/// folds into its leaf — must return a policy that rebuilds the *same* leaf here, or the root the +/// guest derives will not match the one the contract built and the round cannot publish. See +/// CRISP's `policy()` for a worked example. +pub fn policy() -> InputPolicy { + InputPolicy::default() +} diff --git a/crates/support/tests/Elf.sol b/crates/support/tests/Elf.sol deleted file mode 100644 index f2f1590950..0000000000 --- a/crates/support/tests/Elf.sol +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright 2024 RISC Zero, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// SPDX-License-Identifier: Apache-2.0 - -// This file is automatically generated - -pragma solidity ^0.8.20; - -library Elf { - string public constant PROGRAM_PATH = - "/home/ace/main/gnosis/interfold/crates/support/target/riscv-guest/methods/guests/riscv32im-risc0-zkvm-elf/release/program.bin"; -} diff --git a/crates/support/types/src/lib.rs b/crates/support/types/src/lib.rs index 19665921a2..eb291950cc 100644 --- a/crates/support/types/src/lib.rs +++ b/crates/support/types/src/lib.rs @@ -125,6 +125,19 @@ pub struct ComputeRequest { #[serde(deserialize_with = "deserialize_hex_tuple")] pub ciphertext_inputs: Vec<(Vec, u64)>, pub callback_url: Option, + + // What the E3 program published alongside each ciphertext. A program whose contract folds more + // than the ciphertext into its input-tree leaf needs these to rebuild the same leaf; without + // them the guest derives a different root and the proof cannot be published. Optional, because + // a program using the default policy publishes none of it — but silently dropping them when + // they *are* sent is the failure this exists to prevent, so the handler rejects a partial set + // rather than computing a root the contract will reject. + #[serde(default)] + pub input_commitments: Vec, + #[serde(default)] + pub input_slots: Vec, + #[serde(default)] + pub input_parents: Vec, } /// Webhook payload for CRISP and `E3ProgramServer`. diff --git a/crates/zk-helpers/src/circuits/threshold/user_data_encryption/utils.rs b/crates/zk-helpers/src/circuits/threshold/user_data_encryption/utils.rs index a11f3127fb..6d1446ebd7 100644 --- a/crates/zk-helpers/src/circuits/threshold/user_data_encryption/utils.rs +++ b/crates/zk-helpers/src/circuits/threshold/user_data_encryption/utils.rs @@ -22,15 +22,37 @@ use fhe::bfv::{BfvParameters, Ciphertext, PublicKey}; /// A tuple of (ct0is, ct1is) where each is CrtPolynomial /// /// # Errors -/// Returns [`CrtPolynomialError::ModuliLengthMismatch`] if `moduli.len() != self.limbs.len()`. +/// Returns [`ZkHelpersUtilsError::UnexpectedCiphertextComponents`] if the ciphertext does not have +/// exactly two components. +/// Returns [`ZkHelpersUtilsError::ConversionError`] if `moduli.len() != self.limbs.len()`. pub fn bfv_ciphertext_to_greco( params: &BfvParameters, ciphertext: &Ciphertext, -) -> Result<(CrtPolynomial, CrtPolynomial), CrtPolynomialError> { +) -> Result<(CrtPolynomial, CrtPolynomial), ZkHelpersUtilsError> { + // The Greco form and the Noir circuit both cover c[0] and c[1] only. A ciphertext with more + // components would commit to the same value as its own two-component prefix, so two different + // ciphertexts would share one commitment. Reject the extra components instead. + if ciphertext.len() != 2 { + return Err(ZkHelpersUtilsError::UnexpectedCiphertextComponents( + ciphertext.len(), + )); + } + let moduli = params.moduli(); - let ct0is = fhe_poly_to_crt_centered(&ciphertext[0], moduli)?; - let ct1is = fhe_poly_to_crt_centered(&ciphertext[1], moduli)?; + // Converted separately and directly, not through a shared closure. `fhe_poly_to_crt_centered` + // takes the polynomial by reference and builds a fresh `CrtPolynomial` before reversing and + // centering it, so each call starts from an untouched component and neither centering can + // compound on the other. Spelling that out here rather than folding the two calls into one + // helper, because a reused helper reads as though it might carry state across them. + let wrap = |e: CrtPolynomialError| { + ZkHelpersUtilsError::ConversionError(format!( + "Failed to convert ciphertext polynomial: {e}" + )) + }; + + let ct0is = fhe_poly_to_crt_centered(&ciphertext[0], moduli).map_err(wrap)?; + let ct1is = fhe_poly_to_crt_centered(&ciphertext[1], moduli).map_err(wrap)?; Ok((ct0is, ct1is)) } @@ -117,6 +139,8 @@ pub fn compute_public_key_commitment( /// The commitment of the ciphertext /// /// # Errors +/// Returns [`ZkHelpersUtilsError::UnexpectedCiphertextComponents`] if the ciphertext does not have +/// exactly two components. /// Returns [`ZkHelpersUtilsError::ConversionError`] if the conversion fails. /// Returns [`ZkHelpersUtilsError::CommitmentTooLong`] if the commitment is too long. pub fn compute_ciphertext_commitment( @@ -125,12 +149,7 @@ pub fn compute_ciphertext_commitment( ) -> Result<[u8; 32], ZkHelpersUtilsError> { use crate::commitments::compute_ciphertext_commitment; - let (ct0is, ct1is) = bfv_ciphertext_to_greco(params, ciphertext).map_err(|e| { - ZkHelpersUtilsError::ConversionError(format!( - "Failed to convert ciphertext to greco: {}", - e - )) - })?; + let (ct0is, ct1is) = bfv_ciphertext_to_greco(params, ciphertext)?; let pk_bit = compute_modulus_bit(params); let commitment = compute_ciphertext_commitment(&ct0is, &ct1is, pk_bit); @@ -160,6 +179,7 @@ mod tests { use crate::threshold::user_data_encryption::UserDataEncryptionCircuitData; use e3_fhe_params::{build_pair_for_preset, BfvPreset}; use fhe_traits::DeserializeParametrized; + use num_bigint::BigInt; #[test] fn test_bfv_public_key_to_greco() { @@ -179,6 +199,82 @@ mod tests { assert_eq!(actual_pk1is, inputs.pk1is); } + /// Centering must hold for both components, and converting twice must give the same answer. + /// + /// The conversion reads the ciphertext by reference and centers a copy, so nothing it does can + /// leave a component centered-twice or half-centered. Asserted rather than reasoned about, + /// because the failure would be silent: a coefficient outside (-q/2, q/2] still commits to + /// *something*, and the circuit would reject the ballot with no indication why. + #[test] + fn conversion_centers_both_components_and_is_repeatable() { + let (threshold_params, _) = build_pair_for_preset(BfvPreset::InsecureThreshold512).unwrap(); + let sample = + UserDataEncryptionCircuitData::generate_sample(BfvPreset::InsecureThreshold512) + .unwrap(); + let inputs = Inputs::compute(BfvPreset::InsecureThreshold512, &sample).unwrap(); + let ciphertext = Ciphertext::from_bytes(&inputs.ciphertext, &threshold_params).unwrap(); + + let moduli = threshold_params.moduli(); + let (ct0is, ct1is) = bfv_ciphertext_to_greco(&threshold_params, &ciphertext).unwrap(); + + for crt in [&ct0is, &ct1is] { + assert_eq!(crt.limbs.len(), moduli.len()); + for (limb, qi) in crt.limbs.iter().zip(moduli.iter()) { + let half = BigInt::from(*qi) / BigInt::from(2u64); + let low = -half.clone(); + for coefficient in limb.coefficients() { + assert!( + *coefficient > low && *coefficient <= half, + "coefficient {coefficient} outside (-q/2, q/2] for q={qi}" + ); + } + } + } + + // Idempotent in the sense that matters: the input is untouched, so a second conversion of + // the same ciphertext produces an identical result rather than centering again. + let (again0, again1) = bfv_ciphertext_to_greco(&threshold_params, &ciphertext).unwrap(); + assert_eq!(ct0is.limbs, again0.limbs); + assert_eq!(ct1is.limbs, again1.limbs); + } + + /// The commitment covers `c[0]` and `c[1]` only. Without a component-count check, a + /// ciphertext padded with a third polynomial commits to the same value as its two-component + /// prefix, so two different serialized ciphertexts would share one commitment. Threshold + /// decryption then rejects the padded ciphertext and the round fails. + #[test] + fn ciphertext_with_more_than_two_components_is_rejected() { + let (threshold_params, _) = build_pair_for_preset(BfvPreset::InsecureThreshold512).unwrap(); + let sample = + UserDataEncryptionCircuitData::generate_sample(BfvPreset::InsecureThreshold512) + .unwrap(); + let inputs = Inputs::compute(BfvPreset::InsecureThreshold512, &sample).unwrap(); + let ciphertext = Ciphertext::from_bytes(&inputs.ciphertext, &threshold_params).unwrap(); + + let padded = Ciphertext::new( + vec![ + ciphertext[0].clone(), + ciphertext[1].clone(), + ciphertext[1].clone(), + ], + &threshold_params, + ) + .unwrap(); + assert_eq!(padded.len(), 3); + + assert!(matches!( + bfv_ciphertext_to_greco(&threshold_params, &padded), + Err(ZkHelpersUtilsError::UnexpectedCiphertextComponents(3)) + )); + assert!(matches!( + compute_ciphertext_commitment(&threshold_params, &padded), + Err(ZkHelpersUtilsError::UnexpectedCiphertextComponents(3)) + )); + + // The two-component original still converts, so the check rejects only the padding. + assert!(bfv_ciphertext_to_greco(&threshold_params, &ciphertext).is_ok()); + } + #[test] fn test_bfv_ciphertext_to_greco() { let (threshold_params, _) = build_pair_for_preset(BfvPreset::InsecureThreshold512).unwrap(); diff --git a/crates/zk-helpers/src/utils.rs b/crates/zk-helpers/src/utils.rs index da46e794a9..b71cb68d29 100644 --- a/crates/zk-helpers/src/utils.rs +++ b/crates/zk-helpers/src/utils.rs @@ -37,6 +37,19 @@ pub enum ZkHelpersUtilsError { #[error("Commitment too long: {0}")] CommitmentTooLong(usize), + + /// A ciphertext carrying more than `c[0]` and `c[1]`. + /// + /// Deliberately not phrased as advice to relinearize. A fresh BFV encryption has exactly two + /// components, and this conversion only ever runs on published bytes, so more than two means + /// the bytes were padded — the commitment covers `c[0]` and `c[1]`, so a padded ciphertext + /// commits to the same value as its own two-component prefix. Rejecting it is the point, not a + /// step on the way to accepting it. + #[error( + "Expected 2 ciphertext components, got {0}; the commitment covers c[0] and c[1] only, so \ + additional components would share a commitment with the two-component prefix" + )] + UnexpectedCiphertextComponents(usize), } pub type Result = std::result::Result; diff --git a/docs/pages/CRISP/introduction.mdx b/docs/pages/CRISP/introduction.mdx index 3a4e2191f5..02d416f493 100644 --- a/docs/pages/CRISP/introduction.mdx +++ b/docs/pages/CRISP/introduction.mdx @@ -119,7 +119,7 @@ corresponds to the correct input Merkle root, parameters hash, and ciphertext ou `decodeTally()` decodes the encrypted results into readable yes and no vote counts. The -[CRISPVerifier.sol](https://github.com/theinterfold/interfold/tree/main/examples/CRISP/packages/crisp-contracts/contracts/CRISPVerifier.sol) +[CRISPVerifier.sol](https://github.com/theinterfold/interfold/tree/main/examples/CRISP/packages/crisp-contracts/contracts/verifiers/CRISPVerifier.sol) contract is a Honk verifier generated from compiled Noir circuits that verifies zero-knowledge proofs demonstrating votes are correctly encrypted using BFV fully homomorphic encryption under the valid public key. diff --git a/docs/pages/_meta.json b/docs/pages/_meta.json index 856164356f..c35c36b65e 100644 --- a/docs/pages/_meta.json +++ b/docs/pages/_meta.json @@ -88,6 +88,9 @@ "compute-provider": { "title": "Compute Provider Setup" }, + "verifying-the-compute-provider": { + "title": "Verifying the Compute Provider" + }, "putting-it-together": { "title": "Putting It All Together" }, diff --git a/docs/pages/compute-provider.mdx b/docs/pages/compute-provider.mdx index faf487b988..b1d7293843 100644 --- a/docs/pages/compute-provider.mdx +++ b/docs/pages/compute-provider.mdx @@ -113,3 +113,10 @@ To use Boundless: [CRISP setup guide](/CRISP/setup#boundless-configuration) For more details, see the [Boundless documentation](https://docs.boundless.network/). + +## Verifying a Deployment + +Choosing a verifiable CP gives you a proof that a particular guest ran. It does not, by itself, tell +you that the guest corresponds to the source you are reading. For the procedure that ties a source +commit to the image ID a deployed verifier accepts, see +[Verifying the Compute Provider](/verifying-the-compute-provider). diff --git a/docs/pages/tutorials/custom-zk-circuits.mdx b/docs/pages/tutorials/custom-zk-circuits.mdx index e996edf443..8b359124bf 100644 --- a/docs/pages/tutorials/custom-zk-circuits.mdx +++ b/docs/pages/tutorials/custom-zk-circuits.mdx @@ -26,21 +26,34 @@ input validation circuits. ## What the Circuits Prove -CRISP's circuit system handles two distinct cases: +CRISP's circuit system proves one relation for every submission, and four things hold on every path: -**Actual vote:** +1. **Voter eligibility** — the slot's address is in a Merkle tree of eligible participants +2. **Valid encryption** — the ballot ciphertext is a well-formed BFV encryption +3. **Correct ciphertext addition** — the published ciphertext is `addend + ballot`, computed + correctly +4. **A well-formed plaintext** — every coefficient of the ballot is constrained, not only the ones + the vote rules read -1. **Voter eligibility** — the voter's address is in a Merkle tree of eligible participants -2. **Valid encryption** — the ciphertext is a well-formed BFV encryption -3. **Correct signature** — the submission was signed by the voter (ECDSA recovery) -4. **Within balance** — the vote amount does not exceed the voter's token balance +What changes between the two cases is the addend and the authorisation: + +**Actual vote** — the addend is the zero ciphertext, so the ballot replaces what the slot holds. +Additionally proves: + +1. **Correct signature** — the submission was signed by the voter (ECDSA recovery) +2. **Within balance** — the vote amount does not exceed the voter's token balance **Mask vote** (for -[receipt-freeness](https://blog.theinterfold.com/vote-masking-receipt-freeness-secret-ballots/)): +[receipt-freeness](https://blog.theinterfold.com/vote-masking-receipt-freeness-secret-ballots/)) — +the addend is the ciphertext already in the slot, so the vote there is unchanged. Additionally +proves: -1. **Encryption of zero** — the ciphertext encrypts a zero-value vote -2. **Correct ciphertext addition** — the sum of the previous slot ciphertext and the zero ciphertext - is computed correctly (`sum = prev + 0`) +1. **Encryption of zero** — the plaintext is zero in every coefficient, so a mask can neither change + the tally nor carry a payload + +No signature is checked on the mask path, which is what lets a third party mask a slot at all. The +flag that chooses between the two is private, so the two cases are not distinguishable once +published. --- @@ -95,15 +108,27 @@ fn main( ) -> pub (Field, Field, Field) // (final_ct_commitment, ct_commitment, k1_commitment) ``` -The circuit supports two modes controlled by `is_mask_vote`: +The circuit proves one relation for every operation — voting, changing a vote, and masking: + +``` +published ciphertext = addend + ballot ciphertext +``` + +`is_mask_vote` chooses the addend and is **private**: - **Actual vote**: Verifies the signature matches the slot address, checks that the vote amount - doesn't exceed the voter's balance, and validates Merkle membership -- **Mask vote**: Verifies a zero-value vote. Anyone can submit mask votes to any slot, which - provides + doesn't exceed the voter's balance, and validates Merkle membership. The addend is the zero + ciphertext, so a vote replaces what the slot holds rather than adding to it +- **Mask vote**: Verifies a zero-value ballot, in every plaintext coefficient. The addend is the + ciphertext already in the slot, so the vote there is unchanged. Anyone can submit mask votes to + any slot, which provides [receipt-freeness](https://blog.theinterfold.com/vote-masking-receipt-freeness-secret-ballots/) — voters cannot prove which ciphertext is theirs +Because both branches return the same commitment and publish the same shape of ciphertext, an +observer cannot tell them apart. Doing so would mean distinguishing a fresh BFV ciphertext from a +sum, which the encryption scheme hides. + ### Library Modules **`merkle_tree.nr`** — Poseidon-based Merkle membership proof with a 20-level tree. Proves that a @@ -112,10 +137,11 @@ leaf (voter address) exists at a specific index without revealing other leaves. **`ecdsa.nr`** — secp256k1 ECDSA signature verification and Ethereum address derivation. Recovers the signer address from the signature and verifies it matches the expected voter. -**`ciphertext_addition.nr`** — Used in mask vote mode to prove that `sum = prev + new` for BFV -ciphertexts (where `new` is an encryption of zero). Uses the Schwartz-Zippel lemma with a -Fiat-Shamir challenge point to verify the polynomial identity at a single random point, avoiding the -cost of checking every coefficient. +**`ciphertext_addition.nr`** — Proves that `sum = addend + new` for BFV ciphertexts. Asserted on +every path, not only for masks: the addend is the slot's ciphertext for a mask and the zero +ciphertext otherwise, so the same relation covers a vote, a re-vote, and a mask. Uses the +Schwartz-Zippel lemma with a Fiat-Shamir challenge point to verify the polynomial identity at a +single random point, avoiding the cost of checking every coefficient. **`utils.nr`** — Vote validation helpers. Checks that vote coefficients are binary (0 or 1) and that the total doesn't exceed the voter's balance. @@ -167,7 +193,8 @@ the API instance and SRS (Structured Reference String) to avoid re-initialisatio ## On-chain Verification The final proof is verified on-chain in `CRISPProgram.publishInput()` using an auto-generated Honk -verifier contract (`CRISPVerifier.sol`). This verifier is generated by Barretenberg and contains the +verifier contract (`contracts/verifiers//CRISPVerifier.sol`, one per BFV preset because it +encodes the circuit's verification key). This verifier is generated by Barretenberg and contains the verification key baked in. ```solidity @@ -180,7 +207,7 @@ The public inputs include: - `prev_ct_commitment` — commitment to the previous ciphertext in the slot - `merkle_root` — the eligibility Merkle root - `slot_address` — which vote slot is being written to -- `is_first_vote` — whether this is the first vote in the slot +- `is_first_vote` — whether this input extends an earlier entry in the slot, or nothing - `num_options` — number of voting options --- @@ -197,10 +224,14 @@ import { ZKInputsGenerator } from '@crisp-e3/zk-inputs' await init() const generator = ZKInputsGenerator.withDefaults() +// `previousCiphertext` is the end of the slot's chain of usable entries, or `undefined` when the +// slot holds nothing. `keepPrevious` is set only for a mask over an occupied slot: it is what makes +// the ballot add to that ciphertext rather than replace it. const { encryptedVote, inputs } = generator.generateInputs( previousCiphertext, publicKey, voteCoefficients, + keepPrevious, ) ``` diff --git a/docs/pages/tutorials/write-e3-program.mdx b/docs/pages/tutorials/write-e3-program.mdx index 0be6e86b6f..90e69822a3 100644 --- a/docs/pages/tutorials/write-e3-program.mdx +++ b/docs/pages/tutorials/write-e3-program.mdx @@ -206,27 +206,45 @@ function verify( bytes32 ciphertextOutputHash, bytes32 ciphertextCommitment, bytes memory proof -) external returns (bool) { - if (msg.sender != address(interfold)) revert CallerNotAuthorized(); - - // Reconstruct the exact RISC Zero journal from known values - bytes memory journal = new bytes(528); - _encodeLengthPrefixAndHash(journal, 0, ciphertextOutputHash); - _encodeLengthPrefixAndHash(journal, 132, ciphertextCommitment); - _encodeLengthPrefixAndHash(journal, 264, e3Data[e3Id].paramsHash); - _encodeLengthPrefixAndHash(journal, 396, bytes32(e3Data[e3Id].votes._root(TREE_DEPTH))); - - // Verify the RISC Zero proof - risc0Verifier.verify(proof, imageId, sha256(journal)); +) external view override returns (bool) { + E3 memory e3 = interfold.getE3(e3Id); + bytes32 paramsHash = getParamsHash(e3Id); + bytes32 inputRoot = bytes32(e3Data[e3Id].votes._root()); + + // The Secure Process derives this root from the ciphertexts it processed, and the proof envelope + // carries that derived value. Compare it against the root this program accumulated on chain: the + // protocol verifier does not, so this is the only check that the result covers your inputs. + Risc0ComputeProof.Proof memory computeProof = Risc0ComputeProof.decode(proof); + if (computeProof.paramsHash != paramsHash || computeProof.inputRoot != inputRoot) + revert InvalidComputeContext(); + + // Rebuild the exact RISC Zero journal from on-chain state. + bytes memory journal = Risc0ComputeProof.journal( + bytes32(block.chainid), + bytes32(uint256(uint160(address(interfold)))), + bytes32(e3Id), + e3.encryptionSchemeId, + e3.committeePublicKey, + ciphertextOutputHash, + ciphertextCommitment, + paramsHash, + inputRoot + ); + risc0Verifier.verify(computeProof.seal, imageId, sha256(journal)); return true; } ``` -The guest returns these four values as 32-byte Rust vectors. RISC Zero serializes each vector into -132 bytes, so the contract must reconstruct the same 528 bytes before it checks the proof. The -journal binds the proof to the output hash, SAFE commitment, inputs, and parameters. A front-runner -cannot replace the commitment while reusing a valid proof. +The guest commits nine values as 32-byte Rust vectors. RISC Zero serializes each vector into 132 +bytes, so the journal is 1,188 bytes. `Risc0ComputeProof.journal` builds that layout, and +`Risc0ComputeProof.FIELD_COUNT` holds the field count, so a program never hardcodes the length. + +Eight of the nine fields come from on-chain state, which is why a front-runner cannot replace the +commitment while reusing a valid proof. The ninth, `inputRoot`, arrives inside the proof envelope. +The protocol verifier does not constrain it, so **every E3 program must compare it against its own +input root**, as the check above does. A program that skips that comparison accepts a result +computed over an input set it never saw. --- diff --git a/docs/pages/verifying-the-compute-provider.mdx b/docs/pages/verifying-the-compute-provider.mdx new file mode 100644 index 0000000000..20d03b224a --- /dev/null +++ b/docs/pages/verifying-the-compute-provider.mdx @@ -0,0 +1,95 @@ +--- +title: 'Verifying the Compute Provider' +description: 'Reproduce the RISC Zero guest image ID and check it against the deployed verifier' +--- + +# Verifying the Compute Provider + +A RISC Zero proof tells you **which guest ran**. It does not tell you **which source produced that +guest**. This page closes that gap: it is the procedure for going from a source commit to the image +ID a deployed `Risc0BfvCiphertextVerifier` accepts, and for checking that the two agree. + +Read this if you are auditing an E3, operating a ciphernode, or deciding whether to trust a +deployment you did not build. + +## What the chain already gives you + +Three properties hold without any work on your part: + +1. **The verifier is frozen per E3.** Interfold snapshots the ciphertext verifier for an E3's + encryption scheme at request time. A later `setCiphertextVerifier` does not affect an E3 already + in flight. +2. **The image ID is immutable.** `Risc0BfvCiphertextVerifier.imageId` is set in the constructor and + is `public`. Read it from the deployed contract; nobody can change it. +3. **The journal is rebuilt from chain state.** The verifier reconstructs eight of the nine journal + fields — chain ID, calling program, E3 ID, scheme ID, committee public key, output hash, SAFE + commitment, parameter hash — from values the compute provider does not control. + +The ninth field, the input root, comes from the proof envelope. The protocol verifier does not +constrain it. **The E3 program must compare it against its own on-chain input root**, and both +shipped programs do. See [Writing the Secure Process](/write-secure-program). + +What none of this tells you is whether `imageId` corresponds to the source you are reading. That is +what the rest of this page is for. + +## The procedure + +### 1. Read the deployed value + +```bash +cast call "imageId()(bytes32)" --rpc-url +``` + +Compare it against `crates/support/contracts/ImageID.sol` in the released source. They must be +identical. If they are not, the deployment does not correspond to this source — stop and find out +why. + +Note that this compares the deployed value against a **committed** one. Nothing in the repository +reproduces the image ID from the guest source any more, so a match here shows the deployment agrees +with what was committed, not that what was committed is what the source builds. + +Also read `risc0Verifier()` and confirm the underlying RISC Zero verifier is the one you expect for +that chain. + +### 2. Generate the manifest + +```bash +pnpm provenance:manifest --rpc --verifier --out manifest.json +``` + +The manifest records source commit, lockfile digests, pinned revisions, RISC Zero version, builder +image tag and digest, guest ELF SHA-256, image ID, the deployed verifier address and runtime code +digest, the underlying RISC Zero verifier, and the on-chain `imageId()`. + +It prints `"complete": false` and lists what is missing when any field could not be resolved. **A +release manifest must be complete.** An incomplete manifest is a record of an unfinished +verification, not a passing result. + +## One correctness point + +**The SHA-256 of the published ELF is not the image ID.** + +SHA-256 checks that a binary arrived intact. The RISC Zero image ID is computed from the _loaded +memory image_, not from the file bytes. Two artifacts can share an image ID and differ in SHA-256, +and a matching SHA-256 does not by itself establish that a proof will verify. + +The manifest records both, for different purposes: the SHA-256 for transport integrity, the image ID +for proof acceptance. Never substitute one for the other. + +## What this does not cover + +- **Governance of the proxy.** The Safe owns Interfold's `ProxyAdmin`. The per-E3 verifier snapshot + protects an E3 in flight from a `setCiphertextVerifier` call, but it is not protection against a + proxy upgrade. That is a governance property, not a build property. +- **Application-level image IDs.** An E3 program may store its own image ID and verifier — CRISP has + `setImageId` and `setRisc0Verifier`, both `onlyOwner`. Because the protocol wrapper stays pinned, + changing those cannot replace a computation the protocol already accepted, but it can make an E3 + in flight fail. Verify the E3 program's values separately from the protocol verifier's. +- **The ciphernode image.** Reproducible builds for the ciphernode container are a separate concern. + +## Related + +- [Writing the Secure Process](/write-secure-program) — why the Secure Process derives its input + root rather than receiving it +- [Compute Provider Setup](/compute-provider) — choosing a provider +- `agent/INVARIANTS.md`, "Build / config sync" — the rules the gates enforce diff --git a/docs/pages/write-secure-program.mdx b/docs/pages/write-secure-program.mdx index 6b4faa296c..74fb333ce3 100644 --- a/docs/pages/write-secure-program.mdx +++ b/docs/pages/write-secure-program.mdx @@ -20,10 +20,23 @@ To simplify integration with the Interfold, use the provided Compute Provider pa **Benefits:** - **Handles Merkle tree construction**: Recreates the input Merkle tree inside the compute - environment. + environment, from the ciphertexts the Secure Process consumed. - **Simplifies proof generation**: Manages proof creation for computation verification. - **Abstracts complexity**: Allows you to focus on your computation logic. +> **The first point is a security property, not a convenience.** `ComputeInput` carries only +> `fhe_inputs`, and `ComputeInput::process` derives the leaves from those ciphertexts. It must stay +> that way. If the leaves ever arrive as a separate value, they can disagree with the ciphertexts +> that produced the result, and the input-root comparison your E3 program performs becomes +> meaningless — a prover could replay the genuine on-chain leaves while computing over inputs that +> were never submitted. Publishing a result is unpermissioned and one-shot, so that is not a +> hypothetical failure of a trusted party. +> +> The matching obligation on the contract side is that **your E3 program must compare the proof's +> input root against its own on-chain root**. `Risc0BfvCiphertextVerifier` takes the root from the +> proof envelope and does not constrain it. See +> [Verifying the Compute Provider](/verifying-the-compute-provider). + **Implementation:** - Import the Compute Provider package into your project. @@ -98,8 +111,8 @@ expects: - The `FHEInputs` struct that consists of the FHE parameters and the ciphertexts to use. Each ciphertext is a tuple of `(bytes, index)` where `index` is the position in the input set. - The Secure Process function `fhe_processor` -- A boolean flag `use_parallel` to indicate whether to use parallel processing. -- An optional `batch_size` that will be used for parallel processing. Must be a power of 2. +- The [`InputPolicy`](https://github.com/theinterfold/interfold/tree/main/crates/compute-provider) + your E3 program exports, which decides the leaf layout and which inputs are computed over. The `FHEInputs` struct is defined as: @@ -117,7 +130,7 @@ pub fn run_compute(params: FHEInputs) -> Result<(Risc0Output, Vec)> { let risc0_provider = Risc0Provider; // Create the ComputeManager with the provider, params, and the secure process function - let mut provider = ComputeManager::new(risc0_provider, params, fhe_processor, false, None); + let mut provider = ComputeManager::new(risc0_provider, params, fhe_processor); // Execute the program and get the output let output: (Risc0Output, Vec) = provider.start(); diff --git a/examples/CRISP/Cargo.lock b/examples/CRISP/Cargo.lock index 16fb3d3a97..63283b1734 100644 --- a/examples/CRISP/Cargo.lock +++ b/examples/CRISP/Cargo.lock @@ -2116,6 +2116,7 @@ dependencies = [ "derivative", "dialoguer", "dotenvy", + "e3-bfv-client", "e3-compute-provider", "e3-fhe-params", "e3-sdk", @@ -2466,14 +2467,16 @@ dependencies = [ "ark-ff 0.4.2", "e3-bfv-client", "e3-fhe-params", + "fhe", "hex", "lean-imt", "light-poseidon 0.2.0", "num-bigint", "num-traits", - "rayon", "serde", + "sha2", "sha3", + "thiserror 1.0.69", "zk-kit-imt", ] @@ -2603,10 +2606,17 @@ dependencies = [ name = "e3-user-program" version = "0.1.0" dependencies = [ + "e3-bfv-client", "e3-compute-provider", "e3-fhe-params", "fhe", "fhe-traits", + "hex", + "num-bigint", + "rand 0.9.2", + "serde_json", + "sha2", + "sha3", ] [[package]] diff --git a/examples/CRISP/RELEASING.md b/examples/CRISP/RELEASING.md new file mode 100644 index 0000000000..b06509b1da --- /dev/null +++ b/examples/CRISP/RELEASING.md @@ -0,0 +1,83 @@ +# Releasing the CRISP packages + +`@crisp-e3/sdk` and `@crisp-e3/contracts` are published as a matched pair on two channels, split by +BFV preset. + +| channel | tag | preset | who it is for | +| ---------- | --------- | -------------- | ---------------------------------- | +| testing | `testing` | `insecure-512` | testnets, demos, local development | +| production | `latest` | `secure-8192` | real rounds | + +## Why the channels are split by preset + +The SDK inlines the compiled circuit and the contracts package ships the Solidity verifier generated +from that same circuit's verification key. A verifier only accepts proofs from the circuit it was +generated for, so mixing presets across the two packages produces a round that rejects every ballot +— and it fails at on-chain verification, not anywhere a test would catch it. + +Each tarball therefore carries exactly one preset. Importing the other subpath fails to resolve, +which is a loud, immediate error rather than a silently wrong proof: + +```ts +// on @crisp-e3/sdk@testing +import { loadCircuits } from '@crisp-e3/sdk/insecure-512' // resolves +import { loadCircuits } from '@crisp-e3/sdk/secure-8192' // ERR_MODULE_NOT_FOUND +``` + +It also keeps the secure circuits out of every testing install. They are far larger than the +insecure ones, and shipping both would put that weight in both channels. + +## Versioning + +Testing releases carry a prerelease identifier; production releases do not. + +``` +0.18.0-insecure.0 tag: testing insecure-512 +0.18.0 tag: latest secure-8192 +``` + +The identifier is load-bearing. npm excludes prerelease versions from ordinary ranges, so a consumer +on `^0.18.0` can never drift onto a testing build through an update — reaching it takes an explicit +`@testing` or an exact version. + +## Procedure + +Both channels build from artifacts staged by `pnpm build:presets`, which compiles each preset in +turn and writes the generated verifiers to `packages/crisp-contracts/contracts/verifiers//`. +Run it once, then publish each channel: + +```sh +pnpm -C examples/CRISP build:presets # slow: compiles both presets + +# testing +cd examples/CRISP/packages/crisp-sdk +npm version 0.18.0-insecure.0 --no-git-tag-version +pnpm publish:testing +cd ../crisp-contracts +npm version 0.18.0-insecure.0 --no-git-tag-version +pnpm publish:testing + +# production +cd ../crisp-sdk +npm version 0.18.0 --no-git-tag-version +pnpm publish:prod +cd ../crisp-contracts +npm version 0.18.0 --no-git-tag-version +pnpm publish:prod +``` + +`prepublishOnly` runs `check-presets` on both packages, which refuses to publish a channel whose +artifacts do not match its preset — a missing preset, a stub bundle, an exports entry pointing at +nothing, missing verifiers, or the _other_ preset's bundle being present. + +## Deploying + +The contracts tarball carries the verifiers for both presets, because they are small and pruning +them buys nothing. Safety comes from the deploy path refusing to guess instead: `activePreset()` in +`packages/crisp-contracts/scripts/verifiers.ts` uses `CRISP_PRESET` when set, falls back only when +exactly one preset has generated verifiers, and otherwise throws. There is no default — quietly +deploying the insecure verifier is the mistake worth making impossible. + +```sh +CRISP_PRESET=secure-8192 pnpm deploy:crisp --network +``` diff --git a/examples/CRISP/circuits/bin/crisp/src/main.nr b/examples/CRISP/circuits/bin/crisp/src/main.nr index 009e570004..b12fd32a22 100644 --- a/examples/CRISP/circuits/bin/crisp/src/main.nr +++ b/examples/CRISP/circuits/bin/crisp/src/main.nr @@ -4,7 +4,7 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. -use crisp_lib::ciphertext_addition::CiphertextAddition; +use crisp_lib::ciphertext_addition::{CiphertextAddition, select_addend}; use crisp_lib::ecdsa::{address_to_field, derive_address, digest_from_halves, validate_signature}; use crisp_lib::merkle_tree::get_merkle_root; use crisp_lib::utils::{check_coefficient_values_with_balance, check_coefficient_zero}; @@ -23,9 +23,10 @@ use interfold_lib::math::{ /// /// This circuit implements the core verification logic for the CRISP (Coercion-Resistant Impartial /// Selection Protocol) voting system. It verifies that votes are properly encrypted using BFV -/// (Brakerski-Fan-Vercauteren) fully homomorphic encryption and handles two distinct use cases: -/// 1. **Actual Voting**: An eligible voter casts a vote -/// 2. **Mask Vote**: Anyone submits a zero vote to mask slot activity +/// (Brakerski-Fan-Vercauteren) fully homomorphic encryption, and it covers three operations: +/// 1. **Voting**: An eligible voter casts a ballot into an empty slot +/// 2. **Updating**: The same voter replaces the ballot in their slot +/// 3. **Masking**: Anyone adds a zero ballot to any eligible slot /// /// Votes are stored in an on-chain Merkle tree, where each vote ciphertext is stored at a specific /// index. A mapping stores the slot address (Ethereum address) to the index in the Merkle tree. @@ -39,11 +40,27 @@ use interfold_lib::math::{ /// impossible for a voter to prove which ciphertext was their actual vote, preventing them from /// providing a verifiable receipt to a coercer or briber. /// -/// The circuit verifies all inputs and returns the appropriate ciphertext commitment. -/// Specifically, it returns a Field commitment depending on the case: -/// - **Actual vote**: `ct_commitment` - commitment to the new vote ciphertext -/// - **Mask vote (first vote)**: `ct_commitment` - commitment to the zero ciphertext -/// - **Mask vote (updating slot)**: `sum_ct_commitment` - commitment to the sum of previous votes in slot +/// # One relation for all three operations +/// +/// The circuit proves the same statement whichever operation is being performed: +/// +/// ```text +/// new slot ciphertext = addend + ballot ciphertext +/// ``` +/// +/// where the ballot ciphertext is a fresh BFV encryption of `k1`, and the addend is the ciphertext +/// already in the slot for a mask, or the zero ciphertext for a vote, an update, and any input to +/// an empty slot. `is_mask_vote` chooses between them and is private, so the returned commitment, +/// the public inputs, and the published ciphertext have the same shape in every case. An observer +/// cannot tell a vote from an update from a mask; distinguishing them would mean distinguishing a +/// fresh BFV ciphertext from a sum, which is what the encryption scheme hides. +/// +/// The circuit returns: +/// - `sum_ct_commitment` - the commitment to the ciphertext that becomes the slot, and the value +/// the E3 program stores and the ballot digest is built over +/// - `ct_commitment` - the commitment to the ballot ciphertext, which ties this proof to the +/// `user_data_encryption` proof in the fold circuit +/// - `k1_commitment` - the commitment to the ballot plaintext, for the same reason /// fn main( // Ciphertext Addition Section @@ -89,6 +106,8 @@ fn main( k1, DS_USER_DATA_ENCRYPTION_COMMITMENT, ); + let sum_ct_commitment = + compute_ciphertext_commitment::(sum_ct0is, sum_ct1is); // ============================================================================ // STEP 2: Eligibility - Merkle Tree Proof @@ -99,7 +118,7 @@ fn main( // the expected root, which is stored in the contract and passed as a public input // when verifying the proof. // - // This check applies to BOTH cases: + // This check applies to every operation: // - For actual votes: verifies the voter is eligible // - For mask votes: verifies the slot address is in the eligibility tree let merkle_root_calculated = get_merkle_root( @@ -113,58 +132,51 @@ fn main( assert(merkle_root_calculated == merkle_root); // ============================================================================ - // STEP 3: Vote Type Detection and Return Logic + // STEP 3: The Previous Ciphertext // ============================================================================ - // The circuit branches into two cases based on the is_mask_vote flag: - // - // CASE 1: ACTUAL VOTE (is_mask_vote == false) - // - This is an eligible voter casting a vote - // - Verify vote amount <= balance (checked via valid_vote) - // - Validate the signature over the public digest (checked via valid_signature) - // to authenticate the voter for this ballot - // - Verify address matches slot address (checked via valid_address) - // - Return new vote ciphertext commitment (ct_commitment) - let valid_vote = - check_coefficient_values_with_balance(k1, Q_MOD_T_CENTERED, balance, num_options); - - // The digest is a public input, so the prover cannot select the message that the signature - // covers. The contract computes the digest from the round, the slot, and this ballot, which - // binds the signature to all three. - let hashed_message = digest_from_halves(digest_hi, digest_lo); - let valid_signature = validate_signature(hashed_message, public_key_x, public_key_y, signature); - let valid_address = - slot_address == address_to_field(derive_address(public_key_x, public_key_y)); + // Only a mask keeps what the slot holds. A real vote replaces it, and an empty slot holds + // nothing, so both add the ballot to the zero ciphertext. The selector is derived rather than + // taken as a witness: a voter cannot add their new ballot on top of their old one and count + // twice, and a masker cannot discard what the slot holds and erase a vote. + let keep_previous = is_mask_vote & !is_first_vote; + let addend_ct0is = select_addend(prev_ct0is, keep_previous); + let addend_ct1is = select_addend(prev_ct1is, keep_previous); - // CASE 2: MASK VOTE (is_mask_vote == true) - // - This is anyone submitting a zero vote to mask slot activity - // - Verify vote is zero: k1 must be zero (checked via valid_zero_vote) - // - If first vote in slot: return zero ciphertext commitment (ct_commitment) - // - If updating slot (is_first_vote == false): - // * Verify prev_ct_commitment matches the computed commitment from (prev_ct0is, prev_ct1is) - // to ensure the prover hasn't tampered with the previous ciphertext - // * Verify ciphertext addition: prove that sum_ct = prev_ct + ct where: - // - prev_ct = (prev_ct0is, prev_ct1is) is the previous ciphertext in the slot - // - ct = (ct0is, ct1is) is the zero vote ciphertext being added - // - sum_ct = (sum_ct0is, sum_ct1is) is the resulting ciphertext - // * The verification uses commitments and Fiat-Shamir transform with Schwartz-Zippel - // lemma to prove the homomorphic addition without decrypting - // * Mask votes add zero to the previous ciphertext, creating a different ciphertext - // with the same plaintext, preventing receipt sharing attacks - // * Return sum ciphertext commitment (sum_ct_commitment) - let sum_ct_commitment = - compute_ciphertext_commitment::(sum_ct0is, sum_ct1is); - let _prev_ct_commitment = + // A mask has to open `prev_ct_commitment`, the commitment the E3 program recorded for the entry + // this input names as its parent. Without it a mask could add its zero ballot to a ciphertext of + // its own choosing, and publish that as the slot. + // + // Asserted only where the addend is used. A vote adds to nothing, so it has nothing to open, + // and it can write to its own slot whatever the parent turns out to be — which matters, because + // the E3 program cannot tell whether the bytes published beside a commitment deserialize to the + // ciphertext that commitment describes. Only the Secure Process can, and only once the input + // window closes. + let recomputed_prev_ct_commitment = compute_ciphertext_commitment::(prev_ct0is, prev_ct1is); - let valid_zero_vote = check_coefficient_zero(k1, num_options); - let valid_prev_ct = prev_ct_commitment == _prev_ct_commitment; + if keep_previous { + assert(prev_ct_commitment == recomputed_prev_ct_commitment); + } + + // ============================================================================ + // STEP 4: Ciphertext Addition + // ============================================================================ + // Prove that sum_ct = addend_ct + ct, where ct = (ct0is, ct1is) is the ballot ciphertext and + // sum_ct = (sum_ct0is, sum_ct1is) is what the E3 program will store for this slot. + // + // The verification uses commitments and the Fiat-Shamir transform with the Schwartz-Zippel + // lemma to prove the homomorphic addition without decrypting anything. + // + // Asserted on every path. That is what constrains the published ciphertext: it is the ballot + // the `user_data_encryption` proof covers, added to the ciphertext the slot already held, and + // nothing else. Without it a submitter could publish arbitrary bytes into someone's slot. let ct_add: CiphertextAddition = CiphertextAddition::new( QIS, ct0is, ct1is, ct_commitment, - prev_ct0is, - prev_ct1is, + addend_ct0is, + addend_ct1is, prev_ct_commitment, sum_ct0is, sum_ct1is, @@ -172,25 +184,45 @@ fn main( sum_r0is, sum_r1is, ); - let valid_ct_add = ct_add.execute(); - // Actual conditional logic. - if is_mask_vote == false { - assert(valid_vote); - assert(valid_signature); - assert(valid_address); + assert(ct_add.execute()); - (ct_commitment, ct_commitment, k1_commitment) - } else { - assert(valid_zero_vote); + // ============================================================================ + // STEP 5: Ballot Rules + // ============================================================================ + // CASE 1: ACTUAL VOTE (is_mask_vote == false) + // - This is an eligible voter casting or replacing a ballot + // - Verify the ballot is well formed and worth no more than the balance (valid_vote) + // - Validate the signature over the public digest (valid_signature) to authenticate the + // voter for this ballot + // - Verify the signing address matches the slot address (valid_address) + // + // CASE 2: MASK VOTE (is_mask_vote == true) + // - This is anyone adding a zero ballot to mask slot activity + // - Verify the plaintext is zero everywhere (valid_zero_vote), so a mask cannot change what + // the slot decrypts to, and cannot carry a payload a coercer could read back out of the + // tally + // - No signature is required, which is what lets a third party mask a slot at all + let valid_vote = + check_coefficient_values_with_balance(k1, Q_MOD_T_CENTERED, balance, num_options); + + // The digest is a public input, so the prover cannot select the message that the signature + // covers. The contract computes the digest from the round, the slot, and this ballot, which + // binds the signature to all three. + let hashed_message = digest_from_halves(digest_hi, digest_lo); + let valid_signature = validate_signature(hashed_message, public_key_x, public_key_y, signature); + let valid_address = + slot_address == address_to_field(derive_address(public_key_x, public_key_y)); - if is_first_vote { - (ct_commitment, ct_commitment, k1_commitment) - } else { - assert(valid_prev_ct); - assert(valid_ct_add); + let valid_zero_vote = check_coefficient_zero(k1, num_options); - (sum_ct_commitment, ct_commitment, k1_commitment) - } + if is_mask_vote { + assert(valid_zero_vote); + } else { + assert(valid_vote); + assert(valid_signature); + assert(valid_address); } + + (sum_ct_commitment, ct_commitment, k1_commitment) } diff --git a/examples/CRISP/circuits/bin/crisp_onchain/src/main.nr b/examples/CRISP/circuits/bin/crisp_onchain/src/main.nr index 3a72926680..8407921f3d 100644 --- a/examples/CRISP/circuits/bin/crisp_onchain/src/main.nr +++ b/examples/CRISP/circuits/bin/crisp_onchain/src/main.nr @@ -4,7 +4,7 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. -use crisp_lib::ciphertext_addition::CiphertextAddition; +use crisp_lib::ciphertext_addition::{CiphertextAddition, select_addend}; use crisp_lib::ecdsa::{address_to_field, derive_address, digest_from_halves, validate_signature}; use crisp_lib::utils::{check_coefficient_values_with_balance, check_coefficient_zero}; use interfold_lib::configs::default::threshold::{ @@ -28,9 +28,10 @@ use interfold_lib::math::{ /// allowlist. This circuit is for a census that comes from an on-chain token. Removing the tree /// removes the trusted census producer, and it removes the Merkle path from each proof. /// -/// The circuit handles two use cases: -/// 1. **Actual Voting**: An eligible voter casts a vote -/// 2. **Mask Vote**: Anyone submits a zero vote to mask slot activity +/// The circuit covers three operations: +/// 1. **Voting**: An eligible voter casts a ballot into an empty slot +/// 2. **Updating**: The same voter replaces the ballot in their slot +/// 3. **Masking**: Anyone adds a zero ballot to any eligible slot /// /// Votes are stored in an on-chain Merkle tree, where each vote ciphertext is stored at a specific /// index. A mapping stores the slot address (Ethereum address) to the index in the Merkle tree. @@ -44,11 +45,27 @@ use interfold_lib::math::{ /// impossible for a voter to prove which ciphertext was their actual vote, preventing them from /// providing a verifiable receipt to a coercer or briber. /// -/// The circuit verifies all inputs and returns the appropriate ciphertext commitment. -/// Specifically, it returns a Field commitment depending on the case: -/// - **Actual vote**: `ct_commitment` - commitment to the new vote ciphertext -/// - **Mask vote (first vote)**: `ct_commitment` - commitment to the zero ciphertext -/// - **Mask vote (updating slot)**: `sum_ct_commitment` - commitment to the sum of previous votes in slot +/// # One relation for all three operations +/// +/// The circuit proves the same statement whichever operation is being performed: +/// +/// ```text +/// new slot ciphertext = addend + ballot ciphertext +/// ``` +/// +/// where the ballot ciphertext is a fresh BFV encryption of `k1`, and the addend is the ciphertext +/// already in the slot for a mask, or the zero ciphertext for a vote, an update, and any input to +/// an empty slot. `is_mask_vote` chooses between them and is private, so the returned commitment, +/// the public inputs, and the published ciphertext have the same shape in every case. An observer +/// cannot tell a vote from an update from a mask; distinguishing them would mean distinguishing a +/// fresh BFV ciphertext from a sum, which is what the encryption scheme hides. +/// +/// The circuit returns: +/// - `sum_ct_commitment` - the commitment to the ciphertext that becomes the slot, and the value +/// the E3 program stores and the ballot digest is built over +/// - `ct_commitment` - the commitment to the ballot ciphertext, which ties this proof to the +/// `user_data_encryption` proof in the fold circuit +/// - `k1_commitment` - the commitment to the ballot plaintext, for the same reason /// fn main( // Ciphertext Addition Section @@ -89,6 +106,8 @@ fn main( k1, DS_USER_DATA_ENCRYPTION_COMMITMENT, ); + let sum_ct_commitment = + compute_ciphertext_commitment::(sum_ct0is, sum_ct1is); // ============================================================================ // STEP 2: Eligibility @@ -102,58 +121,51 @@ fn main( // circuit. // ============================================================================ - // STEP 3: Vote Type Detection and Return Logic + // STEP 3: The Previous Ciphertext // ============================================================================ - // The circuit branches into two cases based on the is_mask_vote flag: - // - // CASE 1: ACTUAL VOTE (is_mask_vote == false) - // - This is an eligible voter casting a vote - // - Verify vote amount <= voting_power (checked via valid_vote) - // - Validate the signature over the public digest (checked via valid_signature) - // to authenticate the voter for this ballot - // - Verify address matches slot address (checked via valid_address) - // - Return new vote ciphertext commitment (ct_commitment) - let valid_vote = - check_coefficient_values_with_balance(k1, Q_MOD_T_CENTERED, voting_power, num_options); - - // The digest is a public input, so the prover cannot select the message that the signature - // covers. The contract computes the digest from the round, the slot, and this ballot, which - // binds the signature to all three. - let hashed_message = digest_from_halves(digest_hi, digest_lo); - let valid_signature = validate_signature(hashed_message, public_key_x, public_key_y, signature); - let valid_address = - slot_address == address_to_field(derive_address(public_key_x, public_key_y)); + // Only a mask keeps what the slot holds. A real vote replaces it, and an empty slot holds + // nothing, so both add the ballot to the zero ciphertext. The selector is derived rather than + // taken as a witness: a voter cannot add their new ballot on top of their old one and count + // twice, and a masker cannot discard what the slot holds and erase a vote. + let keep_previous = is_mask_vote & !is_first_vote; + let addend_ct0is = select_addend(prev_ct0is, keep_previous); + let addend_ct1is = select_addend(prev_ct1is, keep_previous); - // CASE 2: MASK VOTE (is_mask_vote == true) - // - This is anyone submitting a zero vote to mask slot activity - // - Verify vote is zero: k1 must be zero (checked via valid_zero_vote) - // - If first vote in slot: return zero ciphertext commitment (ct_commitment) - // - If updating slot (is_first_vote == false): - // * Verify prev_ct_commitment matches the computed commitment from (prev_ct0is, prev_ct1is) - // to ensure the prover hasn't tampered with the previous ciphertext - // * Verify ciphertext addition: prove that sum_ct = prev_ct + ct where: - // - prev_ct = (prev_ct0is, prev_ct1is) is the previous ciphertext in the slot - // - ct = (ct0is, ct1is) is the zero vote ciphertext being added - // - sum_ct = (sum_ct0is, sum_ct1is) is the resulting ciphertext - // * The verification uses commitments and Fiat-Shamir transform with Schwartz-Zippel - // lemma to prove the homomorphic addition without decrypting - // * Mask votes add zero to the previous ciphertext, creating a different ciphertext - // with the same plaintext, preventing receipt sharing attacks - // * Return sum ciphertext commitment (sum_ct_commitment) - let sum_ct_commitment = - compute_ciphertext_commitment::(sum_ct0is, sum_ct1is); - let _prev_ct_commitment = + // A mask has to open `prev_ct_commitment`, the commitment the E3 program recorded for the entry + // this input names as its parent. Without it a mask could add its zero ballot to a ciphertext of + // its own choosing, and publish that as the slot. + // + // Asserted only where the addend is used. A vote adds to nothing, so it has nothing to open, + // and it can write to its own slot whatever the parent turns out to be — which matters, because + // the E3 program cannot tell whether the bytes published beside a commitment deserialize to the + // ciphertext that commitment describes. Only the Secure Process can, and only once the input + // window closes. + let recomputed_prev_ct_commitment = compute_ciphertext_commitment::(prev_ct0is, prev_ct1is); - let valid_zero_vote = check_coefficient_zero(k1, num_options); - let valid_prev_ct = prev_ct_commitment == _prev_ct_commitment; + if keep_previous { + assert(prev_ct_commitment == recomputed_prev_ct_commitment); + } + + // ============================================================================ + // STEP 4: Ciphertext Addition + // ============================================================================ + // Prove that sum_ct = addend_ct + ct, where ct = (ct0is, ct1is) is the ballot ciphertext and + // sum_ct = (sum_ct0is, sum_ct1is) is what the E3 program will store for this slot. + // + // The verification uses commitments and the Fiat-Shamir transform with the Schwartz-Zippel + // lemma to prove the homomorphic addition without decrypting anything. + // + // Asserted on every path. That is what constrains the published ciphertext: it is the ballot + // the `user_data_encryption` proof covers, added to the ciphertext the slot already held, and + // nothing else. Without it a submitter could publish arbitrary bytes into someone's slot. let ct_add: CiphertextAddition = CiphertextAddition::new( QIS, ct0is, ct1is, ct_commitment, - prev_ct0is, - prev_ct1is, + addend_ct0is, + addend_ct1is, prev_ct_commitment, sum_ct0is, sum_ct1is, @@ -161,25 +173,45 @@ fn main( sum_r0is, sum_r1is, ); - let valid_ct_add = ct_add.execute(); - // Actual conditional logic. - if is_mask_vote == false { - assert(valid_vote); - assert(valid_signature); - assert(valid_address); + assert(ct_add.execute()); - (ct_commitment, ct_commitment, k1_commitment) - } else { - assert(valid_zero_vote); + // ============================================================================ + // STEP 5: Ballot Rules + // ============================================================================ + // CASE 1: ACTUAL VOTE (is_mask_vote == false) + // - This is an eligible voter casting or replacing a ballot + // - Verify the ballot is well formed and worth no more than the voting power (valid_vote) + // - Validate the signature over the public digest (valid_signature) to authenticate the + // voter for this ballot + // - Verify the signing address matches the slot address (valid_address) + // + // CASE 2: MASK VOTE (is_mask_vote == true) + // - This is anyone adding a zero ballot to mask slot activity + // - Verify the plaintext is zero everywhere (valid_zero_vote), so a mask cannot change what + // the slot decrypts to, and cannot carry a payload a coercer could read back out of the + // tally + // - No signature is required, which is what lets a third party mask a slot at all + let valid_vote = + check_coefficient_values_with_balance(k1, Q_MOD_T_CENTERED, voting_power, num_options); + + // The digest is a public input, so the prover cannot select the message that the signature + // covers. The contract computes the digest from the round, the slot, and this ballot, which + // binds the signature to all three. + let hashed_message = digest_from_halves(digest_hi, digest_lo); + let valid_signature = validate_signature(hashed_message, public_key_x, public_key_y, signature); + let valid_address = + slot_address == address_to_field(derive_address(public_key_x, public_key_y)); - if is_first_vote { - (ct_commitment, ct_commitment, k1_commitment) - } else { - assert(valid_prev_ct); - assert(valid_ct_add); + let valid_zero_vote = check_coefficient_zero(k1, num_options); - (sum_ct_commitment, ct_commitment, k1_commitment) - } + if is_mask_vote { + assert(valid_zero_vote); + } else { + assert(valid_vote); + assert(valid_signature); + assert(valid_address); } + + (sum_ct_commitment, ct_commitment, k1_commitment) } diff --git a/examples/CRISP/circuits/bin/fold/src/main.nr b/examples/CRISP/circuits/bin/fold/src/main.nr index 64781419c1..dd8c7559eb 100644 --- a/examples/CRISP/circuits/bin/fold/src/main.nr +++ b/examples/CRISP/circuits/bin/fold/src/main.nr @@ -11,9 +11,9 @@ use interfold_lib::math::commitments::compute_vk_hash; /// `examples/CRISP` after changing ct0 / ct1 / user_data_encryption / crisp (or the lib preset). /// Insecure: `lib::configs::default` uses `insecure::*`; secure: uses `secure::*`. pub global CRISP_FOLD_EXPECTED_KEY_HASH_INSECURE: Field = - 0x104652e4140264c4bf518b0469d4062b9e2d4a9b4567979be7568a010a0db217; + 0x0cfedc11fbb1437ca55ba6ae31fe3adb29eefba7a62ea8257d427ce1e1e32e6f; pub global CRISP_FOLD_EXPECTED_KEY_HASH_SECURE: Field = - 0x02068bab950c2f2f8ceb72404c406facc12e658233af8c7d732775bf36962783; + 0x133970fec6679c0be03d6bf71981d9bdb9135f919b1c8c3fcc24aee68acb12ad; fn main( // User Data Encryption Section. diff --git a/examples/CRISP/circuits/bin/fold_onchain/src/main.nr b/examples/CRISP/circuits/bin/fold_onchain/src/main.nr index 2d56161350..f908d258e8 100644 --- a/examples/CRISP/circuits/bin/fold_onchain/src/main.nr +++ b/examples/CRISP/circuits/bin/fold_onchain/src/main.nr @@ -11,9 +11,9 @@ use interfold_lib::math::commitments::compute_vk_hash; /// `examples/CRISP` after changing ct0 / ct1 / user_data_encryption / crisp_onchain (or the lib /// preset). Insecure: `lib::configs::default` uses `insecure::*`; secure: uses `secure::*`. pub global CRISP_ONCHAIN_FOLD_EXPECTED_KEY_HASH_INSECURE: Field = - 0x1833f8fa609f79d0fafb594b3648322f084733077f6bc1b3a8e5f21aa35dd156; + 0x06c830801c0712d55b8095ea55a13d5122694f33969a2dfd8694575d37a22b48; pub global CRISP_ONCHAIN_FOLD_EXPECTED_KEY_HASH_SECURE: Field = - 0x0a58bb5d3d8e3f18d08ca977488c07ef6fa22cf42304e30c7333de1484842012; + 0x16818150403cb64eb84e5dccc9d68d33c5a8c8e70fb40de649e6b758db10e7fa; fn main( // User Data Encryption Section. diff --git a/examples/CRISP/circuits/lib/src/ciphertext_addition.nr b/examples/CRISP/circuits/lib/src/ciphertext_addition.nr index d25564cefd..67aa6b6910 100644 --- a/examples/CRISP/circuits/lib/src/ciphertext_addition.nr +++ b/examples/CRISP/circuits/lib/src/ciphertext_addition.nr @@ -42,6 +42,40 @@ use interfold_lib::math::commitments::compute_challenge; use interfold_lib::math::polynomial::Polynomial; +/// The ciphertext an input adds to, which is either the one already in the slot or nothing. +/// +/// Every CRISP input proves the same relation, `slot = addend + ballot`. A mask keeps what the slot +/// holds, so its addend is that ciphertext. A real vote replaces the slot, and a first input has +/// nothing to replace, so both add to the zero ciphertext instead. +/// +/// Selecting here rather than branching keeps the addition constraints live on every path. The +/// alternative — proving the addition only for masks — leaves the published ciphertext of a real +/// vote unconstrained by this gadget, and makes the two paths different shapes of proof. +/// +/// # Arguments +/// * `polynomials` - The ciphertext limbs already in the slot +/// * `keep` - Whether this input adds to them +/// +/// # Returns +/// The limbs when `keep` is true, all zeros otherwise. +pub fn select_addend( + polynomials: [Polynomial; L], + keep: bool, +) -> [Polynomial; L] { + let factor: Field = if keep { + 1 + } else { + 0 + }; + + let mut selected = polynomials; + for i in 0..L { + selected[i] = polynomials[i].mul_scalar(factor); + } + + selected +} + /// Parameters for ciphertext addition verification. /// /// # Arguments diff --git a/examples/CRISP/circuits/lib/src/utils.nr b/examples/CRISP/circuits/lib/src/utils.nr index c8c8579691..cbd1636607 100644 --- a/examples/CRISP/circuits/lib/src/utils.nr +++ b/examples/CRISP/circuits/lib/src/utils.nr @@ -4,12 +4,80 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. +use interfold_lib::configs::default::MAX_MSG_NON_ZERO_COEFFS; use interfold_lib::math::polynomial::Polynomial; use crate::constants::{MAX_OPTIONS, MAX_VOTE_BITS}; +/// Where the ballot sits inside `k1`, and how wide one option is. +/// +/// `k1` reaches this circuit as the whole BFV plaintext polynomial, of degree `D`, reversed: +/// `k1[i]` is coefficient `D - 1 - i` of the message. The SDK writes the ballot into the first +/// `MAX_MSG_NON_ZERO_COEFFS` message coefficients and pads the rest with zeros, so after the +/// reversal the ballot occupies the **last** `MAX_MSG_NON_ZERO_COEFFS` coefficients of `k1` and the +/// options appear back to front. +/// +/// The layout inside `k1` is: +/// +/// ```text +/// [ ------- zero prefix ------- | pad | option n-1 | ... | option 1 | option 0 ] +/// 0 base D +/// ``` +/// +/// `pad` is the tail of the message region that no option uses, because the SDK divides +/// `MAX_MSG_NON_ZERO_COEFFS` between the options and zero-fills the remainder. The reversal moves +/// that tail to the front of the ballot region, which is why it counts towards the zero prefix. +/// +/// Each option holds `segment_size` binary coefficients, least significant first. +/// +/// # Arguments +/// * `num_options` - Number of voting options +/// +/// # Returns +/// The first coefficient of option `num_options - 1`, and the width of one option. +fn ballot_layout(num_options: u32) -> (u32, u32) { + assert(num_options > 1); + assert(num_options as u64 <= MAX_OPTIONS as u64); + // The ballot cannot be laid out in a polynomial too short to hold the message region. `D` is + // the compiled BFV degree, so this is decided when the circuit is built. + assert(D >= MAX_MSG_NON_ZERO_COEFFS); + + let segment_size: u32 = MAX_MSG_NON_ZERO_COEFFS / num_options; + let pad: u32 = MAX_MSG_NON_ZERO_COEFFS - (segment_size * num_options); + + (D - MAX_MSG_NON_ZERO_COEFFS + pad, segment_size) +} + +/// Checks that every coefficient before the ballot region is zero. +/// +/// The ballot only occupies the coefficients the options are laid out in. Everything before them +/// must be zero, or a submitter could attach a payload that the vote rules never look at. Such a +/// payload survives homomorphic addition and is decrypted with the tally, so it is both a way to +/// corrupt a slot and a channel a coercer could read a receipt out of. +/// +/// # Arguments +/// * `k1` - The plaintext polynomial encoding the vote +/// * `base` - The first coefficient of the ballot region, from [`ballot_layout`] +/// +/// # Returns +/// Returns `true` if every coefficient below `base` is zero, `false` otherwise. +fn check_prefix_zero(k1: Polynomial, base: u32) -> bool { + let mut valid = true; + + for i in 0..D { + if i < base { + valid = valid & (k1.coefficients[i] == 0); + } + } + + valid +} + /// Validates vote coefficients for n-choice voting. /// +/// Constrains the whole plaintext, not only the option segments: coefficients outside the ballot +/// region must be zero, and every coefficient inside it must be a binary digit. +/// /// Rules: /// - 2 options: Mutually exclusive (only one can be non-zero) /// - 3+ options: Can split votes, but total must not exceed balance @@ -25,13 +93,9 @@ pub fn check_coefficient_values_with_balance( balance: Field, num_options: u32, ) -> bool { - assert(num_options > 1); - assert(num_options as u64 <= MAX_OPTIONS as u64); - - let mut valid = true; + let (base, segment_size) = ballot_layout::(num_options); - let segment_size: u32 = D / num_options; - let remainder: u32 = D - (segment_size * num_options); + let mut valid = check_prefix_zero(k1, base); let mut sums: [u64; MAX_OPTIONS] = [0; MAX_OPTIONS]; let mut non_zero_count: u64 = 0; @@ -40,10 +104,9 @@ pub fn check_coefficient_values_with_balance( // Process each option for opt_idx in 0..MAX_OPTIONS { if (opt_idx as u64) < (num_options as u64) { - // Due to reversal, TypeScript option opt_idx is at circuit segment (num_options - 1 - opt_idx) - // Remainder zeros at end of TypeScript layout become start after k1.reverse() + // Because of the reversal, option `opt_idx` sits at segment `num_options - 1 - opt_idx`. let circuit_segment: u32 = num_options - 1 - opt_idx; - let start_idx: u32 = remainder + (circuit_segment * segment_size); + let start_idx: u32 = base + (circuit_segment * segment_size); let mut sum: u64 = 0; @@ -86,226 +149,202 @@ pub fn check_coefficient_values_with_balance( valid } -/// Checks if all vote coefficients are zero. +/// Checks that the plaintext carries no ballot at all. /// -/// This function verifies that all vote regions contain only zeros. -/// Used in masking operations to ensure that invalid or unauthorized votes are properly -/// masked (set to zero) before being added to the tally. +/// Every coefficient of `k1` must be zero, not only the option segments. A mask needs no signature, +/// so anyone may write one to any eligible slot; a mask that could carry a payload outside the +/// segments would let anyone corrupt a slot they cannot vote in, and would give a coercer a channel +/// to mark a ballot with. Checking the whole polynomial closes both. +/// +/// `num_options` is still taken, and still range-checked, so a caller cannot reach this function +/// with a round configuration the vote rules would refuse. /// /// # Arguments /// * `k1` - The plaintext polynomial encoding the vote +/// * `num_options` - Number of voting options /// /// # Returns -/// Returns `true` if all vote coefficients are zero, `false` otherwise. +/// Returns `true` if every coefficient is zero, `false` otherwise. /// pub fn check_coefficient_zero(k1: Polynomial, num_options: u32) -> bool { - assert(num_options > 1); - assert(num_options as u64 <= MAX_OPTIONS as u64); + let (_base, _segment_size) = ballot_layout::(num_options); let mut valid = true; - let segment_size: u32 = D / num_options; - let remainder: u32 = D - (segment_size * num_options); + for i in 0..D { + valid = valid & (k1.coefficients[i] == 0); + } - for opt_idx in 0..MAX_OPTIONS { - if (opt_idx as u64) < (num_options as u64) { - let circuit_segment: u32 = num_options - 1 - opt_idx; - let start_idx: u32 = remainder + (circuit_segment * segment_size); + valid +} - for bit_pos in 0..MAX_VOTE_BITS { - if (bit_pos as u64) < (segment_size as u64) { - let idx: u32 = start_idx + bit_pos; - valid = valid & (k1.coefficients[idx] == 0); +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +// +// Every test builds `k1` the way the witness generator does — SDK message layout, reversed over the +// full BFV degree — rather than at the width of the message region. The two only agree when the +// degree equals `MAX_MSG_NON_ZERO_COEFFS`, which no preset uses, so testing at the message width +// hid the layout the circuit actually receives. + +/// The BFV degree of the `insecure-512` preset. Large enough that the padding between the top of +/// the message region and the degree is most of the polynomial, which is the region the old index +/// arithmetic read from. +global TEST_DEGREE: u32 = 512; + +/// Builds `k1` for a ballot: `encodeVote` from `@crisp-e3/sdk`, followed by the reversal the +/// witness generator applies. +/// +/// # Arguments +/// * `values` - One value per option, in option order; entries past `num_options` are ignored +/// * `num_options` - Number of voting options +/// * `one` - The coefficient a set bit encodes to, which is `q_mod_t` in centered form +fn encode_ballot( + values: [u64; MAX_OPTIONS], + num_options: u32, + one: Field, +) -> Polynomial { + let segment_size: u32 = MAX_MSG_NON_ZERO_COEFFS / num_options; + let mut coefficients: [Field; D] = [0; D]; + + for opt in 0..MAX_OPTIONS { + if opt < num_options { + let start: u32 = opt * segment_size; + + for i in 0..MAX_VOTE_BITS { + if i < segment_size { + // The SDK writes each segment most significant bit first. + let bit_pos: u32 = segment_size - 1 - i; + let bit: u64 = (values[opt] >> (bit_pos as u64)) & 1; + + coefficients[D - 1 - (start + i)] = if bit == 1 { one } else { 0 }; } } } } - valid + Polynomial { coefficients } } #[test] fn test_2_options_vote_first() { - let mut coeffs = [0; 100]; - // Option 0, value 5, circuit segment 1 [50..99], LSB first - coeffs[50] = 1; // 2^0 = 1 - coeffs[52] = 1; // 2^2 = 4 - - let pol = Polynomial { coefficients: coeffs }; + let pol = encode_ballot::([5, 0, 0, 0, 0, 0, 0, 0, 0, 0], 2, 1); assert(check_coefficient_values_with_balance(pol, 1, 100, 2)); } #[test] fn test_2_options_vote_second() { - let mut coeffs = [0; 100]; - // Option 1, value 3, circuit segment 0 [0..49], LSB first - coeffs[0] = 1; // 2^0 = 1 - coeffs[1] = 1; // 2^1 = 2 - - let pol = Polynomial { coefficients: coeffs }; + let pol = encode_ballot::([0, 3, 0, 0, 0, 0, 0, 0, 0, 0], 2, 1); assert(check_coefficient_values_with_balance(pol, 1, 100, 2)); } #[test] fn test_2_options_both_voted_fails() { - let mut coeffs = [0; 100]; - coeffs[50] = 1; // Option 0 - coeffs[0] = 1; // Option 1 - - let pol = Polynomial { coefficients: coeffs }; + let pol = encode_ballot::([1, 1, 0, 0, 0, 0, 0, 0, 0, 0], 2, 1); assert(!check_coefficient_values_with_balance(pol, 1, 100, 2)); } #[test] fn test_2_options_exceeds_balance_fails() { - let mut coeffs = [0; 100]; - // Option 0, value 10, balance 5 - coeffs[51] = 1; // 2^1 = 2 - coeffs[53] = 1; // 2^3 = 8 - - let pol = Polynomial { coefficients: coeffs }; + let pol = encode_ballot::([10, 0, 0, 0, 0, 0, 0, 0, 0, 0], 2, 1); assert(!check_coefficient_values_with_balance(pol, 1, 5, 2)); } +#[test] +fn test_2_options_at_balance_passes() { + let pol = encode_ballot::([5, 0, 0, 0, 0, 0, 0, 0, 0, 0], 2, 1); + assert(check_coefficient_values_with_balance(pol, 1, 5, 2)); +} + +/// The top bit of an option segment. The old index arithmetic read the zero padding instead, so a +/// ballot worth `2**49` passed against any balance. +#[test] +fn test_2_options_top_bit_is_bounded() { + let pol = encode_ballot::([562949953421312, 0, 0, 0, 0, 0, 0, 0, 0, 0], 2, 1); + assert(!check_coefficient_values_with_balance(pol, 1, 1, 2)); + assert(check_coefficient_values_with_balance(pol, 1, 562949953421312, 2)); +} + // --- 3+ Options (Split Voting) --- #[test] fn test_3_options_split_vote() { - // D = 99, segment_size = 33 - // Vote 40 on option 0, 60 on option 2, total = 100, balance = 100 - let mut coeffs = [0; 99]; - - // Option 0 at circuit segment 2 [66..98], value 40 = 101000, LSB first - coeffs[69] = 1; // 2^3 = 8 - coeffs[71] = 1; // 2^5 = 32 - // Total: 40 - - // Option 2 at circuit segment 0 [0..32], value 60 = 111100, LSB first - coeffs[2] = 1; // 2^2 = 4 - coeffs[3] = 1; // 2^3 = 8 - coeffs[4] = 1; // 2^4 = 16 - coeffs[5] = 1; // 2^5 = 32 - // Total: 60 - - let pol = Polynomial { coefficients: coeffs }; + let pol = encode_ballot::([40, 0, 60, 0, 0, 0, 0, 0, 0, 0], 3, 1); assert(check_coefficient_values_with_balance(pol, 1, 100, 3)); } #[test] fn test_3_options_vote_all_three() { - // Vote 30 on each, total = 90, balance = 100 - let mut coeffs = [0; 99]; - - // Option 0 at circuit segment 2 [66..98], value 30 = 11110, LSB first - coeffs[67] = 1; // 2^1 = 2 - coeffs[68] = 1; // 2^2 = 4 - coeffs[69] = 1; // 2^3 = 8 - coeffs[70] = 1; // 2^4 = 16 - // Total: 30 - - // Option 1 at circuit segment 1 [33..65], value 30 - coeffs[34] = 1; // 2^1 = 2 - coeffs[35] = 1; // 2^2 = 4 - coeffs[36] = 1; // 2^3 = 8 - coeffs[37] = 1; // 2^4 = 16 - // Total: 30 - - // Option 2 at circuit segment 0 [0..32], value 30 - coeffs[1] = 1; // 2^1 = 2 - coeffs[2] = 1; // 2^2 = 4 - coeffs[3] = 1; // 2^3 = 8 - coeffs[4] = 1; // 2^4 = 16 - // Total: 30 - - let pol = Polynomial { coefficients: coeffs }; + let pol = encode_ballot::([30, 30, 30, 0, 0, 0, 0, 0, 0, 0], 3, 1); assert(check_coefficient_values_with_balance(pol, 1, 100, 3)); } #[test] fn test_3_options_total_exceeds_balance_fails() { - // Vote 60 + 60 = 120, balance = 100 - let mut coeffs = [0; 99]; - - // Option 0, value 60 - coeffs[68] = 1; // 2^2 = 4 - coeffs[69] = 1; // 2^3 = 8 - coeffs[70] = 1; // 2^4 = 16 - coeffs[71] = 1; // 2^5 = 32 - - // Option 1, value 60 - coeffs[35] = 1; // 2^2 = 4 - coeffs[36] = 1; // 2^3 = 8 - coeffs[37] = 1; // 2^4 = 16 - coeffs[38] = 1; // 2^5 = 32 - - let pol = Polynomial { coefficients: coeffs }; + let pol = encode_ballot::([60, 60, 0, 0, 0, 0, 0, 0, 0, 0], 3, 1); assert(!check_coefficient_values_with_balance(pol, 1, 100, 3)); } #[test] fn test_5_options_single_vote() { - // D = 100, segment_size = 20 - // Vote only on option 2 with value 50 - let mut coeffs = [0; 100]; - - // Option 2 at circuit segment 2 [40..59], value 50 = 110010, LSB first - coeffs[41] = 1; // 2^1 = 2 - coeffs[44] = 1; // 2^4 = 16 - coeffs[45] = 1; // 2^5 = 32 - // Total: 50 - - let pol = Polynomial { coefficients: coeffs }; + let pol = encode_ballot::([0, 0, 50, 0, 0, 0, 0, 0, 0, 0], 5, 1); assert(check_coefficient_values_with_balance(pol, 1, 100, 5)); } #[test] fn test_zero_vote_passes() { - let pol = Polynomial { coefficients: [0; 100] }; + let pol = encode_ballot::([0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 3, 1); assert(check_coefficient_values_with_balance(pol, 1, 100, 3)); } #[test] -fn test_non_divisible_d_vote_passes() { - // D=100, num_options=3 => D % num_options != 0 (remainder=1) - // segment_size=33, remainder=1; option 0 at circuit_segment 2 [67..99] - let mut coeffs = [0; 100]; - coeffs[67] = 1; // Option 0, bit 0 (2^0) - coeffs[69] = 1; // Option 0, bit 2 (2^2) => vote value 5 - - let pol = Polynomial { coefficients: coeffs }; - assert(check_coefficient_values_with_balance(pol, 1, 100, 3)); +fn test_invalid_coefficient_fails() { + let mut pol: Polynomial = encode_ballot([1, 0, 0, 0, 0, 0, 0, 0, 0, 0], 2, 1); + // Option 0 occupies the top segment, so its least significant coefficient is the last one. + pol.coefficients[TEST_DEGREE - 1] = 2; + + assert(!check_coefficient_values_with_balance(pol, 1, 100, 2)); } +/// Coefficients the ballot layout does not use must be zero. Before the layout was corrected they +/// were never read, which let any submitter attach a payload to a slot. #[test] -fn test_invalid_coefficient_fails() { - let mut coeffs = [0; 100]; - coeffs[50] = 2; // Invalid +fn test_payload_outside_ballot_region_fails() { + let mut pol: Polynomial = encode_ballot([1, 0, 0, 0, 0, 0, 0, 0, 0, 0], 2, 1); + pol.coefficients[0] = 1; - let pol = Polynomial { coefficients: coeffs }; assert(!check_coefficient_values_with_balance(pol, 1, 100, 2)); } +/// With three options the SDK leaves one message coefficient unused, which the reversal puts at the +/// bottom of the ballot region. It belongs to no option, so it must be zero. +#[test] +fn test_unused_message_coefficient_fails() { + let mut pol: Polynomial = encode_ballot([1, 0, 0, 0, 0, 0, 0, 0, 0, 0], 3, 1); + // MAX_MSG_NON_ZERO_COEFFS is 100 and three segments of 33 leave one coefficient over. + pol.coefficients[TEST_DEGREE - MAX_MSG_NON_ZERO_COEFFS] = 1; + + assert(!check_coefficient_values_with_balance(pol, 1, 100, 3)); +} + #[test] fn test_check_coefficient_zero_passes() { - let pol = Polynomial { coefficients: [0; 100] }; + let pol: Polynomial = Polynomial { coefficients: [0; TEST_DEGREE] }; assert(check_coefficient_zero(pol, 3)); } #[test] fn test_check_coefficient_zero_fails() { - let mut coeffs = [0; 99]; - coeffs[33] = 1; - - let pol = Polynomial { coefficients: coeffs }; + let pol = encode_ballot::([1, 0, 0, 0, 0, 0, 0, 0, 0, 0], 3, 1); assert(!check_coefficient_zero(pol, 3)); } +/// A mask must be zero everywhere, not only where the vote rules look. This coefficient is outside +/// every option segment, and it used to pass. #[test] -fn test_check_coefficient_zero_non_divisible_d_fails() { - // D=100, num_options=3 => remainder=1; option 2 at circuit_segment 0 [1..33] - let mut coeffs = [0; 100]; - coeffs[1] = 1; // Option 2 LSB +fn test_check_coefficient_zero_outside_ballot_region_fails() { + let mut pol: Polynomial = Polynomial { coefficients: [0; TEST_DEGREE] }; + pol.coefficients[0] = 1; - let pol = Polynomial { coefficients: coeffs }; assert(!check_coefficient_zero(pol, 3)); } diff --git a/examples/CRISP/client/package.json b/examples/CRISP/client/package.json index 5443cc520d..50e18f5035 100644 --- a/examples/CRISP/client/package.json +++ b/examples/CRISP/client/package.json @@ -18,7 +18,7 @@ "deploy": "gh-pages -d dist" }, "dependencies": { - "@crisp-e3/sdk": "0.17.0", + "@crisp-e3/sdk": "0.18.0-insecure.0", "@emotion/babel-plugin": "^11.11.0", "@emotion/react": "^11.11.4", "@phosphor-icons/react": "^2.1.4", diff --git a/examples/CRISP/client/pnpm-lock.yaml b/examples/CRISP/client/pnpm-lock.yaml index d1c12e3ba5..e888ab2b59 100644 --- a/examples/CRISP/client/pnpm-lock.yaml +++ b/examples/CRISP/client/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: .: dependencies: '@crisp-e3/sdk': - specifier: 0.17.0 - version: 0.17.0(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) + specifier: 0.18.0-insecure.0 + version: 0.18.0-insecure.0(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76) '@emotion/babel-plugin': specifier: ^11.11.0 version: 11.13.5 @@ -733,11 +733,11 @@ packages: '@coinbase/wallet-sdk@4.3.6': resolution: {integrity: sha512-4q8BNG1ViL4mSAAvPAtpwlOs1gpC+67eQtgIwNvT3xyeyFFd+guwkc8bcX5rTmQhXpqnhzC4f0obACbP9CqMSA==} - '@crisp-e3/sdk@0.17.0': - resolution: {integrity: sha512-hNjEsx3CRvaN5Gu9EJa0/vaGLxSDy2a4bfJJrQ5a+FR0TwzQu7W+GA7JMjLRDDdcTAUDJ0G/bak5kzxa+0ZYQg==} + '@crisp-e3/sdk@0.18.0-insecure.0': + resolution: {integrity: sha512-1qwFF8sR1VDDoSH178gr+DzluMThGhdCNpx9SsMJLC+gpPlU8A7qKThNv1P7hxaMB12q15so6KJumhL7euf7Gg==} - '@crisp-e3/zk-inputs@0.17.0': - resolution: {integrity: sha512-wd61MnDStHokxZHCNMIdqdC7vuiNkWwUzs2T4+Ag87vnTWVltHL3dUHqdx0eFxFQU2YXlKo16+CfbsfxZYv9HQ==} + '@crisp-e3/zk-inputs@0.18.0-insecure.0': + resolution: {integrity: sha512-vgIb8cixmxeLTYEfvmKlzGzMDQg1/Y1xc9wIcSrlDBZRxEdHz3bpnBV59GPVCG4Fx5obXHKDvILhyIdT2n8Nqw==} '@ecies/ciphers@0.2.6': resolution: {integrity: sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g==} @@ -5635,10 +5635,10 @@ snapshots: - utf-8-validate - zod - '@crisp-e3/sdk@0.17.0(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': + '@crisp-e3/sdk@0.18.0-insecure.0(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.76)': dependencies: '@aztec/bb.js': 5.1.0 - '@crisp-e3/zk-inputs': 0.17.0 + '@crisp-e3/zk-inputs': 0.18.0-insecure.0 '@noir-lang/noir_js': 1.0.0-beta.26 '@zk-kit/lean-imt': 2.2.5(bufferutil@4.1.0)(utf-8-validate@5.0.10) poseidon-lite: 0.3.0 @@ -5649,7 +5649,7 @@ snapshots: - utf-8-validate - zod - '@crisp-e3/zk-inputs@0.17.0': {} + '@crisp-e3/zk-inputs@0.18.0-insecure.0': {} '@ecies/ciphers@0.2.6(@noble/ciphers@1.3.0)': dependencies: diff --git a/examples/CRISP/client/src/hooks/voting/useVoteCasting.ts b/examples/CRISP/client/src/hooks/voting/useVoteCasting.ts index fb9d79843e..e79afa1a45 100644 --- a/examples/CRISP/client/src/hooks/voting/useVoteCasting.ts +++ b/examples/CRISP/client/src/hooks/voting/useVoteCasting.ts @@ -8,6 +8,7 @@ import { useState, useCallback } from 'react' import { useNavigate } from 'react-router-dom' import { useSignTypedData, usePublicClient, useChainId } from 'wagmi' import { encodeSolidityProof, finishBallotProof, finishMaskProof, prepareBallot } from '@crisp-e3/sdk' +import { ensureCircuits } from '@/utils/circuits' import { useVoteManagementContext } from '@/context/voteManagement' import { useNotificationAlertContext } from '@/context/NotificationAlert/NotificationAlert.context.tsx' @@ -25,7 +26,11 @@ const INTERFOLD_API = import.meta.env.VITE_INTERFOLD_API /// into telling a voter two different things about the same round. const ONCHAIN_UNSUPPORTED = 'This round uses an on-chain census, which this client cannot vote in yet.' -const getPreviousCiphertext = async (e3Id: string, address: string): Promise => { +/// The end of the slot's chain of usable entries, with the tree index the new input will name as +/// its parent. Not simply the newest entry published: one whose bytes do not reproduce its +/// commitment is never selected by the Secure Process and is never a valid parent, so the server +/// resolves the chain and answers with the entry that actually holds the slot. +const getSlotHead = async (e3Id: string, address: string): Promise<{ ciphertext: Uint8Array; index: number } | undefined> => { const response = await fetch(`${INTERFOLD_API}/state/previous-ciphertext`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -46,7 +51,11 @@ const getPreviousCiphertext = async (e3Id: string, address: string): Promise | null = null + +/** Install the circuits needed for proving, at most once per session. */ +export const ensureCircuits = async (): Promise => { + if (registeredPreset()) return + + pending ??= (async () => { + try { + const { loadCircuits } = await import('@crisp-e3/sdk/insecure-512') + setCircuits(await loadCircuits()) + } catch (error) { + // Let the next attempt retry rather than caching a failed fetch for the session. + pending = null + throw error + } + })() + + await pending +} diff --git a/examples/CRISP/crates/evm_helpers/src/lib.rs b/examples/CRISP/crates/evm_helpers/src/lib.rs index d92cb4028e..fa527a4b5f 100644 --- a/examples/CRISP/crates/evm_helpers/src/lib.rs +++ b/examples/CRISP/crates/evm_helpers/src/lib.rs @@ -28,11 +28,12 @@ sol! { function setMerkleRoot(uint256 e3_id, uint256 _root) external; function getSlotIndex(uint256 e3_id, address slot_address) external view returns (int256); function publishInput(uint256 e3_id, bytes data) external; + function getRoundData(uint256 e3_id) external view returns (uint256 merkleRoot, bytes32 paramsHash, uint256 numOptions, uint8 creditMode, uint256 inputRoot, uint40 numberOfVotes); } } sol! { - event InputPublished(uint256 indexed e3Id, bytes encryptedVote, uint256 index); + event InputPublished(uint256 indexed e3Id, address indexed slotAddress, bytes32 encryptedVoteCommitment, bytes encryptedVote, uint256 index, uint40 parentIndexPlusOne); } /// Type alias for read-only provider (no wallet) @@ -129,6 +130,19 @@ impl CRISPContract { }) } + /// The number of inputs `CRISPProgram` accepted for a round. + /// + /// The authority on how many there are. An indexer's own count can be short: the contract + /// accepts an input while `block.timestamp == inputWindow[1]`, and the deadline callback can + /// run before that log is stored. Computing then would tally a subset and derive a root the + /// contract rejects, which fails the round with nothing to explain why. + pub async fn get_published_input_count(&self, e3_id: U256) -> Result { + let contract = CRISPProgram::new(self.contract_address, self.provider.clone()); + let round = contract.getRoundData(e3_id).call().await?; + + Ok(round.numberOfVotes.to::()) + } + /// Get the slot index from a given slot address. /// Returns `None` when the slot is empty (contract returns -1). pub async fn get_slot_index_from_address( diff --git a/examples/CRISP/crates/zk-inputs-wasm/src/lib.rs b/examples/CRISP/crates/zk-inputs-wasm/src/lib.rs index 6a48a3a91a..29ab48edb0 100644 --- a/examples/CRISP/crates/zk-inputs-wasm/src/lib.rs +++ b/examples/CRISP/crates/zk-inputs-wasm/src/lib.rs @@ -46,20 +46,34 @@ impl ZKInputsGenerator { Ok(ZKInputsGenerator { generator }) } - /// Generate CRISP ZK inputs from JavaScript. + /// Generate the CRISP ZK inputs for one ballot from JavaScript. + /// + /// One entry point for a first vote, a re-vote, and a mask. Pass the ciphertext currently in + /// the slot, or `undefined` when the slot is empty, and set `keepPrevious` only for a mask over + /// an occupied slot. Everything else is identical between the three, which is what keeps them + /// indistinguishable once published. + /// + /// # Arguments + /// - `previous_ciphertext`: The ciphertext in the slot, or `undefined` when it is empty + /// - `public_key`: Public key bytes for encryption + /// - `vote`: Vote value as a vector of coefficients + /// - `keep_previous`: Whether the ballot adds to the slot rather than replacing it #[wasm_bindgen(js_name = "generateInputs")] pub fn generate_inputs( &self, - prev_ciphertext: &[u8], + previous_ciphertext: Option>, public_key: &[u8], vote: Vec, + keep_previous: bool, ) -> Result { let vote_vec: Vec = vote.into_iter().map(|v| v as u64).collect(); - match self - .generator - .generate_inputs(prev_ciphertext, public_key, vote_vec) - { + match self.generator.generate_inputs( + previous_ciphertext.as_deref(), + public_key, + vote_vec, + keep_previous, + ) { Ok((ciphertext_bytes, inputs_json)) => { // Parse the JSON string and return as an object with both encryptedVote and inputs. let result = js_sys::Object::new(); @@ -81,39 +95,16 @@ impl ZKInputsGenerator { } } - /// Generate CRISP ZK inputs for a vote update (either from voter or as a masker) from JavaScript. - #[wasm_bindgen(js_name = "generateInputsForUpdate")] - pub fn generate_inputs_for_update( - &self, - prev_ciphertext: &[u8], - public_key: &[u8], - vote: Vec, - ) -> Result { - let vote_vec: Vec = vote.into_iter().map(|v| v as u64).collect(); - - match self - .generator - .generate_inputs_for_update(prev_ciphertext, public_key, vote_vec) - { - Ok((ciphertext_bytes, inputs_json)) => { - // Parse the JSON string and return as an object with both encryptedVote and inputs. - let result = js_sys::Object::new(); - - // Set encryptedVote as Uint8Array - let ciphertext_array = js_sys::Uint8Array::from(&ciphertext_bytes[..]); - js_sys::Reflect::set(&result, &"encryptedVote".into(), &ciphertext_array.into())?; - - // Parse and set inputs JSON - match js_sys::JSON::parse(&inputs_json) { - Ok(js_value) => { - js_sys::Reflect::set(&result, &"inputs".into(), &js_value)?; - Ok(result.into()) - } - Err(_) => Err(JsValue::from_str("Failed to parse inputs JSON")), - } - } - Err(e) => Err(JsValue::from_str(&e.to_string())), - } + /// Computes the SAFE commitment of serialized ciphertext bytes. + /// + /// The witness already carries this value for the ciphertext a ballot publishes. Kept for + /// callers that hold bytes they did not generate, such as a slot ciphertext read back from the + /// server. + #[wasm_bindgen(js_name = "computeCtCommitment")] + pub fn compute_ct_commitment(&self, ciphertext: Vec) -> Result, JsValue> { + self.generator + .compute_ciphertext_commitment(&ciphertext) + .map_err(|e| JsValue::from_str(&format!("{e}"))) } /// Generate a public/secret key pair from JavaScript. @@ -264,7 +255,7 @@ mod tests { let vote = create_vote_vector(); let old_ciphertext = generator.encrypt_vote(&public_key, vote.clone()).unwrap(); - let result = generator.generate_inputs(&old_ciphertext, &public_key, vote); + let result = generator.generate_inputs(Some(old_ciphertext), &public_key, vote, false); assert!(result.is_ok()); @@ -304,7 +295,7 @@ mod tests { let public_key: Vec = public_key_array.to_vec(); let vote = create_vote_vector(); let old_ciphertext = generator.encrypt_vote(&public_key, vote.clone()).unwrap(); - let result = generator.generate_inputs(&old_ciphertext, &public_key, vote); + let result = generator.generate_inputs(Some(old_ciphertext), &public_key, vote, false); assert!(result.is_ok()); diff --git a/examples/CRISP/crates/zk-inputs/src/ciphertext_addition.rs b/examples/CRISP/crates/zk-inputs/src/ciphertext_addition.rs index ffb91cb58a..009c0df28b 100644 --- a/examples/CRISP/crates/zk-inputs/src/ciphertext_addition.rs +++ b/examples/CRISP/crates/zk-inputs/src/ciphertext_addition.rs @@ -12,6 +12,7 @@ use eyre::{Context, Result}; use fhe::bfv::BfvParameters; use fhe::bfv::Ciphertext; use num_bigint::BigInt; +use num_traits::Zero; /// Set of inputs for validation of a ciphertext addition. /// @@ -38,65 +39,116 @@ pub struct CiphertextAdditionWitness { /// fine and is rejected on chain, because the digest the contract rebuilds does not match the /// one that was signed. pub ct_commitment: BigInt, + /// Commitment to the ciphertext that becomes the slot, which is what gets published. + /// + /// The circuit returns this value, `CRISPProgram` stores it, and `CRISPProgram.ballotDigest` + /// builds the digest the voter signs over it. Exported for the same reason as `ct_commitment`: + /// the digest is itself a circuit input, so the value has to be known before proving. It + /// equals `ct_commitment` whenever the ballot replaces the slot rather than adding to it. + pub sum_ct_commitment: BigInt, } impl CiphertextAdditionWitness { /// Computes the ciphertext addition inputs for zero-knowledge proof validation. /// + /// The circuit always proves `sum_ct = addend + ct`, and picks the addend itself from the + /// private mask flag: the ciphertext already in the slot for a mask, the zero ciphertext for a + /// vote, an update, or any input to an empty slot. This witness has to be built for the same + /// addend, which is what `keep_previous` selects. + /// + /// The previous ciphertext is exported and committed to whichever addend is used, because the + /// circuit checks it against the commitment `CRISPProgram` stored for the slot. Only the + /// quotient polynomials depend on the addend. + /// + /// An empty slot has no previous ciphertext and no stored commitment. It gets zero limbs and a + /// zero commitment, which is what the contract passes the circuit for such a slot. + /// /// # Arguments /// * `params` - BFV parameters - /// * `prev_ct` - The existing ciphertext to add to - /// * `ct` - The ciphertext being added - /// * `sum_ct` - The result of the ciphertext addition + /// * `previous_ct` - The ciphertext currently in the slot, or `None` when the slot is empty + /// * `ct` - The ballot ciphertext + /// * `sum_ct` - The ciphertext that becomes the slot + /// * `keep_previous` - Whether `sum_ct` adds to the previous ciphertext rather than replacing it /// /// # Returns /// CiphertextAdditionInputs containing all necessary proof data pub fn compute( params: &BfvParameters, - prev_ct: &Ciphertext, + previous_ct: Option<&Ciphertext>, ct: &Ciphertext, sum_ct: &Ciphertext, + keep_previous: bool, ) -> Result { let moduli = params.moduli(); - - let mut crt_polynomials = [ - CrtPolynomial::from_fhe_polynomial(&prev_ct[0]), - CrtPolynomial::from_fhe_polynomial(&prev_ct[1]), - CrtPolynomial::from_fhe_polynomial(&ct[0]), - CrtPolynomial::from_fhe_polynomial(&ct[1]), - CrtPolynomial::from_fhe_polynomial(&sum_ct[0]), - CrtPolynomial::from_fhe_polynomial(&sum_ct[1]), - ]; + let pk_bit = compute_modulus_bit(params); // fhe-math stores coefficients in ascending degree (c_0, c_1, …). But here we want // that each limb is stored in **descending** order (a_n, …, a_0) so circuit evaluation can use Horner's // method in one forward pass: `result = result * x + coefficients[i]` from i = 0, // i.e. P(x) = ((…((a_n·x + a_{n-1})·x + …)·x + a_0), with no extra reversing or reindexing. // - // We center so the quotient r = (sum − (prev + ct)) / q_i lies in {-1, 0, 1}. + // We center so the quotient r = (sum − (addend + ct)) / q_i lies in {-1, 0, 1}. // BFV/fhe-math already gives coefficients in [0, q_i), so reduce is redundant. We need centering // into (-q/2, q/2]: then the difference per coefficient is small in absolute value, and for valid // ciphertext addition that difference is a multiple of q_i, so the quotient is in {-1, 0, 1}, // which the circuit and compute_quotient expect. + let mut crt_polynomials = [ + CrtPolynomial::from_fhe_polynomial(&ct[0]), + CrtPolynomial::from_fhe_polynomial(&ct[1]), + CrtPolynomial::from_fhe_polynomial(&sum_ct[0]), + CrtPolynomial::from_fhe_polynomial(&sum_ct[1]), + ]; + for c in &mut crt_polynomials { c.reverse(); c.center(moduli)?; } - let [prev_ct0, prev_ct1, ct0, ct1, sum_ct0, sum_ct1] = crt_polynomials; + let [ct0, ct1, sum_ct0, sum_ct1] = crt_polynomials; + + let (prev_ct0, prev_ct1, prev_ct_commitment) = match previous_ct { + Some(previous) => { + let mut limbs = [ + CrtPolynomial::from_fhe_polynomial(&previous[0]), + CrtPolynomial::from_fhe_polynomial(&previous[1]), + ]; + + for c in &mut limbs { + c.reverse(); + c.center(moduli)?; + } + + let [p0, p1] = limbs; + let commitment = compute_ciphertext_commitment(&p0, &p1, pk_bit); - // Compute quotient polynomials: r = (sum_centered - (ct_centered + prev_ct_centered)) / qi. - // For ciphertext addition: sum_centered = ct_centered + prev_ct_centered + r * qi. - // So: r = (sum_centered - (ct_centered + prev_ct_centered)) / qi. - let r0 = Self::compute_quotient(&sum_ct0, &ct0, &prev_ct0, moduli) + (p0, p1, commitment) + } + // Shaped from the ballot limbs, so the degree and the number of moduli follow the + // parameters rather than being restated here. + None => ( + Self::select_addend(&ct0, false), + Self::select_addend(&ct1, false), + BigInt::zero(), + ), + }; + + // What the circuit adds the ballot to, which is the slot's ciphertext only for a mask over + // an occupied slot. An empty slot has nothing to keep, whatever the caller asked for. + let keep = keep_previous && previous_ct.is_some(); + let addend_ct0 = Self::select_addend(&prev_ct0, keep); + let addend_ct1 = Self::select_addend(&prev_ct1, keep); + + // Compute quotient polynomials: r = (sum_centered - (ct_centered + addend_centered)) / qi. + // For ciphertext addition: sum_centered = ct_centered + addend_centered + r * qi. + // So: r = (sum_centered - (ct_centered + addend_centered)) / qi. + let r0 = Self::compute_quotient(&sum_ct0, &ct0, &addend_ct0, moduli) .with_context(|| "Failed to compute r0 quotient")?; - let r1 = Self::compute_quotient(&sum_ct1, &ct1, &prev_ct1, moduli) + let r1 = Self::compute_quotient(&sum_ct1, &ct1, &addend_ct1, moduli) .with_context(|| "Failed to compute r1 quotient")?; // Coefficients are centered per modulus; no zkp reduce. The circuit reduces mod r when needed. - let pk_bit = compute_modulus_bit(params); - let prev_ct_commitment = compute_ciphertext_commitment(&prev_ct0, &prev_ct1, pk_bit); let ct_commitment = compute_ciphertext_commitment(&ct0, &ct1, pk_bit); + let sum_ct_commitment = compute_ciphertext_commitment(&sum_ct0, &sum_ct1, pk_bit); Ok(CiphertextAdditionWitness { prev_ct0is: prev_ct0, @@ -107,9 +159,30 @@ impl CiphertextAdditionWitness { r1is: r1, prev_ct_commitment, ct_commitment, + sum_ct_commitment, }) } + /// The limbs the ballot is added to, mirroring `crisp_lib::ciphertext_addition::select_addend`. + /// + /// Zeroed rather than dropped so the quotient computation keeps the shape of the ciphertext it + /// replaces, whatever the degree and the number of moduli. + /// + /// # Arguments + /// + /// * `previous` - The limbs of the ciphertext currently in the slot + /// * `keep_previous` - Whether the ballot adds to them + fn select_addend(previous: &CrtPolynomial, keep_previous: bool) -> CrtPolynomial { + if keep_previous { + return previous.clone(); + } + + let mut zeroed = previous.clone(); + zeroed.scalar_mul(&BigInt::zero()); + + zeroed + } + /// Computes the quotient CRT polynomial `(sum - (a + b)) / q_i` per modulus. /// /// For each limb index `i`, divides `sum_i - (a_i + b_i)` by the modulus `q_i`. @@ -186,6 +259,7 @@ impl CiphertextAdditionWitness { let r1is = crt_polynomial_to_toml_json(&self.r1is); let prev_ct_commitment = self.prev_ct_commitment.to_string(); let ct_commitment = self.ct_commitment.to_string(); + let sum_ct_commitment = self.sum_ct_commitment.to_string(); let json = serde_json::json!({ "prev_ct0is": prev_ct0is, @@ -195,9 +269,11 @@ impl CiphertextAdditionWitness { "sum_r0is": r0is, "sum_r1is": r1is, "prev_ct_commitment": prev_ct_commitment, - // Not a crisp circuit input. The caller needs it to build the ballot digest before - // signing, so it rides along with the witness rather than being recomputed. + // Neither of these is a crisp circuit input. The caller needs `sum_ct_commitment` to + // build the ballot digest before signing, so both ride along with the witness rather + // than being recomputed. "ct_commitment": ct_commitment, + "sum_ct_commitment": sum_ct_commitment, }); Ok(json) diff --git a/examples/CRISP/crates/zk-inputs/src/lib.rs b/examples/CRISP/crates/zk-inputs/src/lib.rs index cb23c356f8..5e834cd6ed 100644 --- a/examples/CRISP/crates/zk-inputs/src/lib.rs +++ b/examples/CRISP/crates/zk-inputs/src/lib.rs @@ -26,8 +26,6 @@ use fhe::bfv::{Encoding, Plaintext}; use fhe_traits::FheDecoder; use fhe_traits::FheDecrypter; use fhe_traits::{DeserializeParametrized, FheEncoder, Serialize}; -use num_bigint::BigInt; -use num_traits::Zero; use rand::rng; use std::sync::Arc; mod ciphertext_addition; @@ -67,91 +65,66 @@ impl ZKInputsGenerator { Self::from_set(default_param_set()) } - /// Generates CRISP ZK inputs for a vote encryption and addition operation. - /// Note that this accepts the previous ciphertext in GRECO ABI encoded format. + /// Computes the SAFE commitment of serialized ciphertext bytes. /// - /// # Arguments - /// * `prev_ciphertext` - Previous ciphertext bytes to add to (in GRECO ABI Encoded format) - /// * `public_key` - Public key bytes for encryption - /// * `vote` - Vote value as a vector of coefficients - /// - /// # Returns - /// Tuple containing the sum ciphertext bytes and JSON string with CRISP ZK inputs - pub fn generate_inputs_for_update( - &self, - prev_ciphertext: &[u8], - public_key: &[u8], - vote: Vec, - ) -> Result<(Vec, String)> { - // Deserialize the provided public key. - let pk = PublicKey::from_bytes(public_key, &self.bfv_params) - .with_context(|| "Failed to deserialize public key")?; - - // Encode the plaintext into a polynomial. - let pt = Plaintext::try_encode(&vote, Encoding::poly(), &self.bfv_params) - .with_context(|| "Failed to encode plaintext")?; - - let user_data_encryption_computation_output = UserDataEncryptionCircuit::compute( - DEFAULT_BFV_PRESET, - &UserDataEncryptionCircuitData { - public_key: pk, - plaintext: pt, - }, - )?; - - let ct = Ciphertext::from_bytes( - &user_data_encryption_computation_output.inputs.ciphertext, - &self.bfv_params, + /// The commitment a CRISP round stores for an input is computed inside the circuit, from the + /// ciphertext the circuit built. For a first vote that is the ballot; for an update it is the + /// sum of the new ciphertext and the previous one. Either way it is the commitment of the + /// bytes that get published, so a caller can derive it here — which it must, because + /// `CRISPProgram.publishInput` builds the ballot digest over that commitment and the digest is + /// itself a circuit input. Without this the update path is unusable: the digest would depend + /// on a value that only exists after proving. + pub fn compute_ciphertext_commitment(&self, ciphertext: &[u8]) -> Result> { + let commitment = e3_bfv_client::client::compute_ct_commitment( + ciphertext.to_vec(), + self.bfv_params.degree(), + self.bfv_params.plaintext(), + self.bfv_params.moduli().to_vec(), ) - .with_context(|| "Failed to deserialize ciphertext")?; - - // Ciphertext Addition Section. - // Deserialize the previous ciphertext. - let prev_ct = Ciphertext::from_bytes(prev_ciphertext, &self.bfv_params) - .with_context(|| "Failed to deserialize previous ciphertext")?; - - // Compute the ciphertext addition. - let sum_ct = &ct + &prev_ct; - - // Compute the inputs of the ciphertext addition. - let ciphertext_addition_inputs = - CiphertextAdditionWitness::compute(&self.bfv_params, &prev_ct, &ct, &sum_ct) - .with_context(|| "Failed to compute ciphertext addition inputs")?; - - let ciphertext_addition_witness_json = ciphertext_addition_inputs.to_json()?; - let user_data_encryption_witness_json = - user_data_encryption_computation_output.inputs.to_json()?; - let inputs_json = utils::merge_json_objects( - ciphertext_addition_witness_json, - user_data_encryption_witness_json, - )?; - - // For updates, return the sum ciphertext (ct + prev_ct) - let ciphertext_bytes = sum_ct.to_bytes(); - - Ok((ciphertext_bytes, inputs_json)) + .map_err(|e| eyre::eyre!("Failed to compute ciphertext commitment: {e}"))?; + Ok(commitment.to_vec()) } - /// Generates CRISP ZK inputs for a vote encryption and addition operation. + /// Generates the CRISP ZK inputs for one ballot. + /// + /// One function for all three operations. A first vote, a re-vote, and a mask differ only in + /// what the caller passes here; the encryption, the witness, the published ciphertext, and the + /// proof all have the same shape, so nothing about a submission says which one it was. + /// + /// What the circuit proves is always `published = addend + ballot`, where the ballot is a fresh + /// BFV encryption of `vote` and the addend is the ciphertext already in the slot when + /// `keep_previous` is set, and the zero ciphertext otherwise: + /// + /// | Operation | `previous_ciphertext` | `keep_previous` | Published | + /// | --------------------- | --------------------- | --------------- | ---------------- | + /// | First vote | `None` | `false` | ballot | + /// | Re-vote | slot ciphertext | `false` | ballot | + /// | Mask, empty slot | `None` | `false` | zero ballot | + /// | Mask, occupied slot | slot ciphertext | `true` | slot + zero | + /// + /// A re-vote replaces the slot, so a voter cannot count their old ballot twice; the circuit + /// derives the same choice from its private mask flag and would reject any other. The previous + /// ciphertext is still required for a re-vote, because the circuit checks it against the + /// commitment `CRISPProgram` stored for the slot. /// /// # Arguments - /// * `prev_ciphertext` - Previous ciphertext bytes to add to + /// * `previous_ciphertext` - The ciphertext currently in the slot, or `None` when it is empty /// * `public_key` - Public key bytes for encryption /// * `vote` - Vote value as a vector of coefficients + /// * `keep_previous` - Whether the ballot adds to the slot rather than replacing it /// /// # Returns - /// Tuple containing the vote ciphertext bytes and JSON string with CRISP ZK inputs + /// Tuple containing the ciphertext bytes to publish and a JSON string with CRISP ZK inputs pub fn generate_inputs( &self, - prev_ciphertext: &[u8], + previous_ciphertext: Option<&[u8]>, public_key: &[u8], vote: Vec, + keep_previous: bool, ) -> Result<(Vec, String)> { - // Deserialize the provided public key. let pk = PublicKey::from_bytes(public_key, &self.bfv_params) .with_context(|| "Failed to deserialize public key")?; - // Encode the plaintext into a polynomial. let pt = Plaintext::try_encode(&vote, Encoding::poly(), &self.bfv_params) .with_context(|| "Failed to encode plaintext")?; @@ -169,22 +142,29 @@ impl ZKInputsGenerator { ) .with_context(|| "Failed to deserialize ciphertext")?; - // Ciphertext Addition Section. - // Deserialize the previous ciphertext. - let prev_ct = Ciphertext::from_bytes(prev_ciphertext, &self.bfv_params) - .with_context(|| "Failed to deserialize previous ciphertext")?; + let previous_ct = previous_ciphertext + .map(|bytes| { + Ciphertext::from_bytes(bytes, &self.bfv_params) + .with_context(|| "Failed to deserialize previous ciphertext") + }) + .transpose()?; - // Compute the ciphertext addition. - let sum_ct = &ct + &prev_ct; + // An empty slot holds nothing to keep, whatever the caller asked for. + let keep = keep_previous && previous_ct.is_some(); - // Compute the inputs of the ciphertext addition. - let mut ciphertext_addition_inputs = - CiphertextAdditionWitness::compute(&self.bfv_params, &prev_ct, &ct, &sum_ct) - .with_context(|| "Failed to compute ciphertext addition inputs")?; + let published_ct = match (keep, previous_ct.as_ref()) { + (true, Some(previous)) => &ct + previous, + _ => ct.clone(), + }; - // IMPORTANT: First-in-slot votes have no previous ciphertext; set prev_ct_commitment to 0 - // so the on-chain verifier accepts the proof. - ciphertext_addition_inputs.prev_ct_commitment = BigInt::zero(); + let ciphertext_addition_inputs = CiphertextAdditionWitness::compute( + &self.bfv_params, + previous_ct.as_ref(), + &ct, + &published_ct, + keep, + ) + .with_context(|| "Failed to compute ciphertext addition inputs")?; let ciphertext_addition_witness_json = ciphertext_addition_inputs.to_json()?; let user_data_encryption_witness_json = @@ -194,9 +174,7 @@ impl ZKInputsGenerator { user_data_encryption_witness_json, )?; - let ciphertext_bytes = ct.to_bytes(); - - Ok((ciphertext_bytes, inputs_json)) + Ok((published_ct.to_bytes(), inputs_json)) } /// Encrypts a vote using the provided public key. @@ -275,12 +253,39 @@ mod tests { use super::*; use e3_fhe_params::constants::insecure_512; use e3_fhe_params::{BfvParamSet, BfvPreset}; + use num_bigint::BigUint; /// Helper function to create a vote vector with alternating 0s and 1s (deterministic) fn create_vote_vector() -> Vec { (0..insecure_512::DEGREE).map(|i| (i % 2) as u64).collect() } + /// A ballot of all zeros, which is what a mask encrypts. + fn zero_vote() -> Vec { + vec![0u64; insecure_512::DEGREE] + } + + /// Reads one commitment out of the witness JSON, as the decimal string the circuit takes. + fn commitment_field(json: &str, name: &str) -> String { + let parsed: serde_json::Value = serde_json::from_str(json).expect("Invalid JSON output"); + + parsed + .get(name) + .unwrap_or_else(|| panic!("witness has no {name}")) + .as_str() + .expect("commitment is a decimal string") + .to_string() + } + + /// The same commitment computed from serialized bytes, for comparison with the witness. + fn commitment_of(generator: &ZKInputsGenerator, ciphertext: &[u8]) -> String { + let bytes = generator + .compute_ciphertext_commitment(ciphertext) + .expect("failed to compute ciphertext commitment"); + + BigUint::from_bytes_be(&bytes).to_string() + } + #[test] fn test_inputs_generation_with_defaults() { let generator = ZKInputsGenerator::with_defaults(); @@ -289,7 +294,8 @@ mod tests { let prev_ciphertext = generator .encrypt_vote(&public_key, vote.clone()) .expect("failed to generate previous ciphertext"); - let result = generator.generate_inputs(&prev_ciphertext, &public_key, vote.clone()); + let result = + generator.generate_inputs(Some(&prev_ciphertext), &public_key, vote.clone(), false); assert!(result.is_ok()); let (ciphertext_bytes, json_output) = result.unwrap(); @@ -310,7 +316,8 @@ mod tests { let prev_ciphertext = generator .encrypt_vote(&public_key, vote.clone()) .expect("failed to generate previous ciphertext"); - let result = generator.generate_inputs(&prev_ciphertext, &public_key, vote.clone()); + let result = + generator.generate_inputs(Some(&prev_ciphertext), &public_key, vote.clone(), false); assert!(result.is_ok()); let (ciphertext_bytes, json_output) = result.unwrap(); @@ -330,7 +337,8 @@ mod tests { let prev_ciphertext = generator .encrypt_vote(&public_key, vote.clone()) .expect("failed to generate previous ciphertext"); - let result = generator.generate_inputs(&prev_ciphertext, &public_key, vote.clone()); + let result = + generator.generate_inputs(Some(&prev_ciphertext), &public_key, vote.clone(), false); assert!(result.is_ok()); let (ciphertext_bytes, json_output) = result.unwrap(); @@ -368,7 +376,7 @@ mod tests { .expect("failed to encrypt vote"); assert!(!ciphertext.is_empty()); - let result = generator.generate_inputs(&ciphertext, &public_key, vote.clone()); + let result = generator.generate_inputs(Some(&ciphertext), &public_key, vote.clone(), false); assert!(result.is_ok()); let (ciphertext_bytes, json_output) = result.unwrap(); assert!(!ciphertext_bytes.is_empty()); @@ -384,11 +392,11 @@ mod tests { let vote = create_vote_vector(); // Test invalid byte inputs. - let result = generator.generate_inputs(&[1, 2, 3], &[4, 5, 6], vote.clone()); + let result = generator.generate_inputs(Some(&[1, 2, 3]), &[4, 5, 6], vote.clone(), false); assert!(result.is_err()); // Test empty slices. - let result = generator.generate_inputs(&[], &[], vote.clone()); + let result = generator.generate_inputs(Some(&[]), &[], vote.clone(), false); assert!(result.is_err()); // Test invalid public key for encryption. @@ -407,12 +415,14 @@ mod tests { .expect("failed to encrypt vote"); // Test vote = 0. - let result_0 = generator.generate_inputs(&prev_ciphertext, &public_key, vote.clone()); + let result_0 = + generator.generate_inputs(Some(&prev_ciphertext), &public_key, vote.clone(), false); assert!(result_0.is_ok()); let (_, _) = result_0.unwrap(); // Test vote = 1. - let result_1 = generator.generate_inputs(&prev_ciphertext, &public_key, vote.clone()); + let result_1 = + generator.generate_inputs(Some(&prev_ciphertext), &public_key, vote.clone(), false); assert!(result_1.is_ok()); let (_, _) = result_1.unwrap(); } @@ -425,7 +435,8 @@ mod tests { let prev_ciphertext = generator .encrypt_vote(&public_key, vote.clone()) .expect("failed to encrypt vote"); - let result = generator.generate_inputs(&prev_ciphertext, &public_key, vote.clone()); + let result = + generator.generate_inputs(Some(&prev_ciphertext), &public_key, vote.clone(), false); assert!(result.is_ok()); let (ciphertext_bytes, json_output) = result.unwrap(); @@ -571,4 +582,192 @@ mod tests { let result = generator.decrypt_vote(&[], &[]); assert!(result.is_err(), "Should fail with empty inputs"); } + + // ----------------------------------------------------------------------- + // The three operations + // ----------------------------------------------------------------------- + // + // A first vote, a re-vote, and a mask all go through `generate_inputs`. These check that each + // publishes the ciphertext the circuit commits to, because the E3 program stores that + // commitment and the Secure Process drops any input whose bytes disagree with it. + + #[test] + fn test_first_vote_publishes_the_ballot() { + let generator = ZKInputsGenerator::with_defaults(); + let (_secret_key, public_key) = generator.generate_keys().expect("failed to generate keys"); + + let (published, json) = generator + .generate_inputs(None, &public_key, create_vote_vector(), false) + .expect("failed to generate first-vote inputs"); + + // Nothing was added, so the published ciphertext is the ballot itself. + assert_eq!( + commitment_field(&json, "sum_ct_commitment"), + commitment_field(&json, "ct_commitment") + ); + // The contract passes zero for an empty slot, and a public input that disagrees would make + // the proof unverifiable. + assert_eq!(commitment_field(&json, "prev_ct_commitment"), "0"); + assert_eq!( + commitment_field(&json, "sum_ct_commitment"), + commitment_of(&generator, &published) + ); + } + + #[test] + fn test_revote_replaces_the_ballot_in_the_slot() { + let generator = ZKInputsGenerator::with_defaults(); + let (secret_key, public_key) = generator.generate_keys().expect("failed to generate keys"); + + let first = create_vote_vector(); + let (slot, _) = generator + .generate_inputs(None, &public_key, first.clone(), false) + .expect("failed to generate first-vote inputs"); + + let second: Vec = first.iter().map(|c| 1 - c).collect(); + let (published, json) = generator + .generate_inputs(Some(&slot), &public_key, second.clone(), false) + .expect("failed to generate re-vote inputs"); + + // A re-vote replaces rather than adds, or the two ballots would both count. + assert_eq!( + commitment_field(&json, "sum_ct_commitment"), + commitment_field(&json, "ct_commitment") + ); + assert_eq!( + commitment_field(&json, "sum_ct_commitment"), + commitment_of(&generator, &published) + ); + // The circuit checks this against the commitment the contract stored for the slot, so a + // re-vote still has to know what the slot holds. + assert_eq!( + commitment_field(&json, "prev_ct_commitment"), + commitment_of(&generator, &slot) + ); + assert_eq!( + generator + .decrypt_vote(&secret_key, &published) + .expect("failed to decrypt re-vote"), + second + ); + } + + #[test] + fn test_mask_over_occupied_slot_preserves_the_ballot() { + let generator = ZKInputsGenerator::with_defaults(); + let (secret_key, public_key) = generator.generate_keys().expect("failed to generate keys"); + + let ballot = create_vote_vector(); + let (slot, _) = generator + .generate_inputs(None, &public_key, ballot.clone(), false) + .expect("failed to generate first-vote inputs"); + + let (published, json) = generator + .generate_inputs(Some(&slot), &public_key, zero_vote(), true) + .expect("failed to generate mask inputs"); + + // A mask adds to the slot, so what it publishes is not the ballot it encrypted. + assert_ne!( + commitment_field(&json, "sum_ct_commitment"), + commitment_field(&json, "ct_commitment") + ); + assert_eq!( + commitment_field(&json, "sum_ct_commitment"), + commitment_of(&generator, &published) + ); + assert_eq!( + commitment_field(&json, "prev_ct_commitment"), + commitment_of(&generator, &slot) + ); + // Adding a zero ballot leaves the vote in the slot untouched, which is the whole point. + assert_eq!( + generator + .decrypt_vote(&secret_key, &published) + .expect("failed to decrypt masked slot"), + ballot + ); + } + + #[test] + fn test_mask_over_empty_slot_publishes_the_zero_ballot() { + let generator = ZKInputsGenerator::with_defaults(); + let (secret_key, public_key) = generator.generate_keys().expect("failed to generate keys"); + + // `keep_previous` is set, but an empty slot holds nothing to keep. + let (published, json) = generator + .generate_inputs(None, &public_key, zero_vote(), true) + .expect("failed to generate mask inputs"); + + assert_eq!( + commitment_field(&json, "sum_ct_commitment"), + commitment_field(&json, "ct_commitment") + ); + assert_eq!(commitment_field(&json, "prev_ct_commitment"), "0"); + assert_eq!( + commitment_field(&json, "sum_ct_commitment"), + commitment_of(&generator, &published) + ); + assert_eq!( + generator + .decrypt_vote(&secret_key, &published) + .expect("failed to decrypt mask"), + zero_vote() + ); + } + + #[test] + fn test_repeated_masks_preserve_the_ballot() { + let generator = ZKInputsGenerator::with_defaults(); + let (secret_key, public_key) = generator.generate_keys().expect("failed to generate keys"); + + let ballot = create_vote_vector(); + let (mut slot, _) = generator + .generate_inputs(None, &public_key, ballot.clone(), false) + .expect("failed to generate first-vote inputs"); + + for _ in 0..3 { + let (published, _) = generator + .generate_inputs(Some(&slot), &public_key, zero_vote(), true) + .expect("failed to generate mask inputs"); + slot = published; + } + + assert_eq!( + generator + .decrypt_vote(&secret_key, &slot) + .expect("failed to decrypt masked slot"), + ballot + ); + } + + #[test] + fn test_revote_after_masks_replaces_the_slot() { + let generator = ZKInputsGenerator::with_defaults(); + let (secret_key, public_key) = generator.generate_keys().expect("failed to generate keys"); + + let first = create_vote_vector(); + let (slot, _) = generator + .generate_inputs(None, &public_key, first.clone(), false) + .expect("failed to generate first-vote inputs"); + let (masked, _) = generator + .generate_inputs(Some(&slot), &public_key, zero_vote(), true) + .expect("failed to generate mask inputs"); + + let second: Vec = first.iter().map(|c| 1 - c).collect(); + let (published, json) = generator + .generate_inputs(Some(&masked), &public_key, second.clone(), false) + .expect("failed to generate re-vote inputs"); + + // The masks are discarded with the ballot they covered, and they carried nothing. + assert_eq!( + generator + .decrypt_vote(&secret_key, &published) + .expect("failed to decrypt re-vote"), + second + ); + assert_eq!( + commitment_field(&json, "prev_ct_commitment"), + commitment_of(&generator, &masked) + ); + } } diff --git a/examples/CRISP/package.json b/examples/CRISP/package.json index bb45334935..447fe818a4 100644 --- a/examples/CRISP/package.json +++ b/examples/CRISP/package.json @@ -16,6 +16,9 @@ "test:e2e": "bash ./scripts/test_e2e.sh", "compile:contracts": "pnpm -C packages/crisp-contracts compile", "test:contracts": "pnpm -C packages/crisp-contracts test", + "test:contracts:unit": "pnpm -C packages/crisp-contracts test:unit", + "test:contracts:input-tree": "pnpm -C packages/crisp-contracts test:input-tree", + "test:contracts:ballots": "pnpm -C packages/crisp-contracts test:ballots", "ciphernode:add": "pnpm -C packages/crisp-contracts ciphernode:add", "ciphernode:mint:tokens": "pnpm -C packages/crisp-contracts ciphernode:mint:tokens", "ciphernode:add:self": "pnpm -C packages/crisp-contracts ciphernode:add:self", diff --git a/examples/CRISP/packages/crisp-contracts/contracts/CRISPProgram.sol b/examples/CRISP/packages/crisp-contracts/contracts/CRISPProgram.sol index 738d15fe37..2b0651f7ef 100644 --- a/examples/CRISP/packages/crisp-contracts/contracts/CRISPProgram.sol +++ b/examples/CRISP/packages/crisp-contracts/contracts/CRISPProgram.sol @@ -12,6 +12,7 @@ import { IInterfold } from "@interfold/contracts/contracts/interfaces/IInterfold import { E3 } from "@interfold/contracts/contracts/interfaces/IE3.sol"; import { Risc0ComputeProof } from "@interfold/contracts/contracts/lib/Risc0ComputeProof.sol"; import { LazyIMTData, InternalLazyIMT } from "@zk-kit/lazy-imt.sol/InternalLazyIMT.sol"; +import { SNARK_SCALAR_FIELD } from "@zk-kit/lazy-imt.sol/Constants.sol"; import { EIP712 } from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; import { IHonkVerifier } from "./interfaces/IHonkVerifier.sol"; import { IVotesToken } from "./interfaces/IVotesToken.sol"; @@ -63,6 +64,30 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 { uint256 merkleRoot; bytes32 paramsHash; mapping(address slot => uint40 index) voteSlots; + /// @notice The proven ciphertext commitment of every input, keyed by slot and tree index. + /// @dev Keyed by both so a parent lookup for the wrong slot returns zero and is refused: it + /// costs no more storage than a single-key map and removes a separate same-slot check. + /// + /// A history rather than one commitment per slot. An input names the entry it extends, and + /// this contract cannot tell whether the bytes published with an entry deserialize to the + /// ciphertext its commitment describes — only the Secure Process can, once the input window + /// closes. Keeping only the newest commitment would therefore let anyone leave a slot whose + /// head nobody but they can open, and a slot that cannot be masked is a slot whose every later + /// input is provably its owner voting again. With the history, an entry like that is simply + /// never extended: the next input names the same parent, and masking continues. + mapping(address slot => mapping(uint40 index => bytes32 commitment)) inputCommitment; + /// @notice Leaves already appended to this round's input tree. + /// @dev A replay guard, not a uniqueness requirement on ballots. The proof constrains the + /// commitment, not who submits it, so anyone who observes a published input can resubmit the + /// identical calldata: the proof still verifies and {_processVote} appends again. The tally + /// does not change — the replay names the same parent as the original, which is no longer the + /// head, so the Secure Process drops it — but the tree is fixed-depth, so enough replays reach + /// capacity and every later input reverts, denying the round. + /// + /// Keyed by the leaf rather than the proof because the leaf is exactly what an append adds. + /// Two genuinely distinct inputs differ in bytes, commitment, slot or parent, so they differ + /// here; only a byte-identical resubmission collides. + mapping(uint256 leaf => bool) appendedLeaf; LazyIMTData votes; uint256 numOptions; CreditMode creditMode; @@ -157,6 +182,13 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 { /// to zero — able to publish, but unable to carry any weight, which is disenfranchisement that /// nothing on-chain would report. error MinVotingPowerBelowScale(); + /// @notice An input names a parent this slot never wrote to. + /// @dev Includes an index belonging to another slot, which the per-slot commitment map reads as + /// absent. Name no parent instead when there is nothing to extend. + error UnknownParentInput(uint40 parentIndex); + + /// @notice Thrown when an input identical to one already published is submitted again. + error InputAlreadyPublished(uint256 leaf); error SlotIsEmpty(); error MerkleRootNotSet(); error InvalidNumOptions(); @@ -167,7 +199,20 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 { // Events event InterfoldBound(address indexed interfold); - event InputPublished(uint256 indexed e3Id, bytes encryptedVote, uint256 index); + + /// @notice A ciphertext input was accepted for a round. + /// @dev Carries the slot and the commitment as well as the bytes. Both are already public — the + /// slot is a plaintext `publishInput` argument and `getSlotIndex` exposes it — so emitting them + /// leaks nothing and saves every consumer from parsing transaction calldata. The Secure Process + /// needs the commitment to check that the published bytes are the ciphertext that was proven. + event InputPublished( + uint256 indexed e3Id, + address indexed slotAddress, + bytes32 encryptedVoteCommitment, + bytes encryptedVote, + uint256 index, + uint40 parentIndexPlusOne + ); /// @notice Initialize the contract without an Interfold controller. /// @dev The owner binds the controller after Interfold registers this program. @@ -231,12 +276,19 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 { } /// @notice Set the Image ID for the guest program + /// @dev This value is application state, not protocol state. Interfold snapshots the protocol + /// ciphertext verifier for each E3 at request time, and that verifier's own `imageId` is + /// immutable, so changing this value cannot replace a computation the protocol already accepted. + /// It can still break an E3 that is in flight: `verify` would then check the receipt against a + /// guest that did not produce it, the round would fail as a compute timeout, and + /// `FailurePayerLib` bills that to the requester. Change it only between rounds. /// @param _imageId The new image ID. function setImageId(bytes32 _imageId) external onlyOwner { imageId = _imageId; } /// @notice Set the RISC Zero verifier. + /// @dev Carries the same in-flight risk as `setImageId`. Change it only between rounds. /// @param _risc0Verifier The new RISC Zero verifier address function setRisc0Verifier(IRiscZeroVerifier _risc0Verifier) external onlyOwner { if (address(_risc0Verifier) == address(0)) revert Risc0VerifierAddressZero(); @@ -459,21 +511,26 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 { if (data.length == 0) revert EmptyInputData(); - (bytes memory noirProof, address slotAddress, bytes32 encryptedVoteCommitment, bytes memory encryptedVote) = abi.decode( - data, - (bytes, address, bytes32, bytes) - ); + ( + bytes memory noirProof, + address slotAddress, + bytes32 encryptedVoteCommitment, + bytes memory encryptedVote, + uint40 parentIndexPlusOne + ) = abi.decode(data, (bytes, address, bytes32, bytes, uint40)); // The two census families differ here and nowhere else. A Merkle round proves membership // inside the circuit against a posted root. An ONCHAIN round reads the power from the token // and gives it to the circuit, so the eligibility check has to happen here instead. (bytes32 eligibility, IHonkVerifier verifier) = _eligibility(e3Id, slotAddress); - (uint40 voteIndex, bytes32 previousEncryptedVoteCommitment) = _processVote(e3Id, slotAddress, encryptedVoteCommitment); + bytes32 parentCommitment = _parentCommitment(e3Id, slotAddress, parentIndexPlusOne); + + uint40 voteIndex = _processVote(e3Id, slotAddress, encryptedVoteCommitment, encryptedVote, parentIndexPlusOne); // Set the public inputs for the proof. Order must match Noir circuit. bytes32[] memory noirPublicInputs = new bytes32[](9); - noirPublicInputs[0] = previousEncryptedVoteCommitment; + noirPublicInputs[0] = parentCommitment; // A Keccak digest does not fit in one field element, so it enters the circuit as its two // 16-byte halves. The circuit rebuilds the 32 bytes with `digest_from_halves`. { @@ -483,7 +540,7 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 { } noirPublicInputs[3] = bytes32(uint256(uint160(slotAddress))); noirPublicInputs[4] = eligibility; - noirPublicInputs[5] = bytes32(uint256(previousEncryptedVoteCommitment == bytes32(0) ? 1 : 0)); + noirPublicInputs[5] = bytes32(uint256(parentIndexPlusOne == 0 ? 1 : 0)); noirPublicInputs[6] = bytes32(e3Data[e3Id].numOptions); noirPublicInputs[7] = encryptedVoteCommitment; noirPublicInputs[8] = e3.committeePublicKey; @@ -493,7 +550,31 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 { revert InvalidNoirProof(); } - emit InputPublished(e3Id, encryptedVote, voteIndex); + emit InputPublished(e3Id, slotAddress, encryptedVoteCommitment, encryptedVote, voteIndex, parentIndexPlusOne); + } + + /// @notice The commitment of the entry an input names as its parent. + /// @dev Zero when the input names none, which is what the circuit reads as `is_first_vote`. + /// + /// Naming no parent is allowed even when the slot already holds entries, and it has to be: a + /// slot whose every entry is unusable has nothing to extend, and refusing that here would leave + /// it permanently unwritable. Nothing is gained by refusing it either — the Secure Process + /// accepts an entry only when its parent is the one currently selected for the slot, so an input + /// that skips a usable parent is dropped from the tally wherever this contract lets it through. + /// @param e3Id The round. + /// @param slotAddress The slot the input is written to. + /// @param parentIndexPlusOne The tree index of the parent entry plus one, or zero for none. + /// @return The parent's commitment, or zero. + function _parentCommitment(uint256 e3Id, address slotAddress, uint40 parentIndexPlusOne) internal view returns (bytes32) { + if (parentIndexPlusOne == 0) return bytes32(0); + + bytes32 commitment = e3Data[e3Id].inputCommitment[slotAddress][parentIndexPlusOne - 1]; + // Zero for an index this slot never wrote to, including one belonging to another slot. The + // circuit would read it as `is_first_vote` while this contract reads it as an update, so the + // two would disagree about the same input. + if (commitment == bytes32(0)) revert UnknownParentInput(parentIndexPlusOne - 1); + + return commitment; } /// @notice Resolve the eligibility public input and the verifier for a round. @@ -529,7 +610,6 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 { // census families encode ballots in the same units and a tally decodes the same way. uint256 power = rawPower / round.votingPowerDivisor; - // Eligibility comes from the power at the snapshot. The weight the circuit enforces comes from // the credit mode, so a CONSTANT round gives every eligible slot the same credits. return (bytes32(round.creditMode == CreditMode.CONSTANT ? round.credits : power), onchainHonkVerifier); @@ -611,15 +691,32 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 { return votes; } - /// @notice Get the slot index for a given E3 ID and slot address + /// @notice The index of the last input published to a slot. + /// @dev The last one *published*, which is not always the one that holds the slot. This contract + /// cannot tell whether an input's bytes deserialize to the ciphertext its commitment describes, + /// so the entry at this index may be one the Secure Process will never select. A client naming a + /// parent must resolve the chain — from the published bytes, or from the CRISP server's + /// `state/previous-ciphertext` — rather than reading it from here. /// @param e3Id The E3 program ID /// @param slotAddress The slot address - /// @return The slot index, or -1 if the slot is empty + /// @return The index of the last published input, or -1 if the slot is empty function getSlotIndex(uint256 e3Id, address slotAddress) external view returns (int40) { uint40 storedIndexPlusOne = e3Data[e3Id].voteSlots[slotAddress]; return int40(storedIndexPlusOne) - 1; } + /// @notice The commitment this contract recorded for one entry of a slot. + /// @dev Zero for an index this slot never wrote to. A client names a parent by index and must + /// prove against exactly the commitment stored for it, so this is how it checks what that will + /// be before proving. + /// @param e3Id The round. + /// @param slotAddress The slot address. + /// @param index The tree index of the entry. + /// @return The stored commitment, or zero when there is no such entry for this slot. + function inputCommitmentOf(uint256 e3Id, address slotAddress, uint40 index) external view returns (bytes32) { + return e3Data[e3Id].inputCommitment[slotAddress][index]; + } + /// @inheritdoc IE3Program function verify( uint256 e3Id, @@ -649,29 +746,56 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 { return true; } - /// @notice Process a vote: insert or update in the merkle tree depending - /// on whether it's the first vote or an override. + /// @notice Record one input: append its leaf and remember its commitment for later parents. function _processVote( uint256 e3Id, address slotAddress, - bytes32 encryptedVoteCommitment - ) internal returns (uint40 voteIndex, bytes32 previousEncryptedVoteCommitment) { - uint40 storedIndexPlusOne = e3Data[e3Id].voteSlots[slotAddress]; + bytes32 encryptedVoteCommitment, + bytes memory encryptedVote, + uint40 parentIndexPlusOne + ) internal returns (uint40 voteIndex) { + RoundData storage round = e3Data[e3Id]; - // we treat the index 0 as not voted yet - // any valid index will be index + 1 - if (storedIndexPlusOne == 0) { - // FIRST VOTE - previousEncryptedVoteCommitment = bytes32(0); - voteIndex = e3Data[e3Id].votes.numberOfLeaves; - e3Data[e3Id].voteSlots[slotAddress] = voteIndex + 1; - e3Data[e3Id].votes._insert(uint256(encryptedVoteCommitment)); - } else { - // RE-VOTE - voteIndex = storedIndexPlusOne - 1; - previousEncryptedVoteCommitment = bytes32(e3Data[e3Id].votes.elements[voteIndex]); - e3Data[e3Id].votes._update(uint256(encryptedVoteCommitment), voteIndex); - } + // Append-only. Updating a slot's leaf in place would let anyone who can write to a slot — and + // the mask path needs no signature — replace the bytes of a vote that was already counted, + // erasing it. Appending leaves the earlier entry in the tree, so the Secure Process can fall + // back to it when a later entry is unusable, and nothing is lost. + uint256 leaf = inputLeaf(encryptedVote, encryptedVoteCommitment, slotAddress, parentIndexPlusOne); + + // Refuse a byte-identical resubmission. Without this the tree is a free growth surface for + // anyone replaying a published input, and the round dies at tree capacity rather than at the + // input deadline. + if (round.appendedLeaf[leaf]) revert InputAlreadyPublished(leaf); + round.appendedLeaf[leaf] = true; + + voteIndex = round.votes.numberOfLeaves; + round.votes._insert(leaf); + + round.voteSlots[slotAddress] = voteIndex + 1; + round.inputCommitment[slotAddress][voteIndex] = encryptedVoteCommitment; + } + + /// @notice Builds the input tree leaf for one published input. + /// @dev Binds four things the Secure Process must be able to trust: + /// + /// - the **bytes**, because the Noir proof constrains the commitment and never sees the + /// serialized ciphertext, so the two can disagree and only the guest can tell; + /// - the **commitment**, so a submitter cannot pair any commitment with any ciphertext; + /// - the **slot**, because the tree is append-only and the guest tallies one entry per slot. + /// Without the slot in the leaf a prover could re-group entries and change which one wins; + /// - the **parent**, because that is what the guest walks the slot's chain by. An unbound parent + /// would let a prover re-point entries and select a different one. + /// + /// SHA-256 rather than Keccak: the zkVM accelerates SHA-256 inline, while its Keccak accelerator + /// emits a proof assumption the host must prove separately and compose. The extra on-chain cost + /// is about 67k gas on a transaction that already carries the ciphertext. + function inputLeaf( + bytes memory encryptedVote, + bytes32 commitment, + address slotAddress, + uint40 parentIndexPlusOne + ) public pure returns (uint256) { + return uint256(sha256(abi.encodePacked(sha256(encryptedVote), commitment, slotAddress, parentIndexPlusOne))) % SNARK_SCALAR_FIELD; } /// @notice Decode bytes to uint64 array diff --git a/examples/CRISP/packages/crisp-contracts/contracts/interfaces/IHonkVerifier.sol b/examples/CRISP/packages/crisp-contracts/contracts/interfaces/IHonkVerifier.sol index 6e091b1658..5947ac8d0d 100644 --- a/examples/CRISP/packages/crisp-contracts/contracts/interfaces/IHonkVerifier.sol +++ b/examples/CRISP/packages/crisp-contracts/contracts/interfaces/IHonkVerifier.sol @@ -7,8 +7,10 @@ pragma solidity >=0.8.27; /// @notice The subset of a generated Honk verifier that `CRISPProgram` calls. /// @dev Declared as an interface so the census paths can hold verifiers generated from different -/// circuits. `CRISPVerifier.sol` and `CRISPOnchainVerifier.sol` both declare a contract named -/// `HonkVerifier`, so importing both concrete types would collide. +/// circuits. Every generated verifier declares a contract named `HonkVerifier`, and there is one +/// per census mode per BFV preset under `contracts/verifiers//`, so importing the concrete +/// types would collide. Deployment resolves the right one by fully qualified name; see +/// `scripts/verifiers.ts`. interface IHonkVerifier { /// @notice Verify a folded ballot proof against its public inputs. /// @dev Reverts rather than returning false when the public inputs do not match the proof. diff --git a/examples/CRISP/packages/crisp-contracts/contracts/CRISPOnchainVerifier.sol b/examples/CRISP/packages/crisp-contracts/contracts/verifiers/CRISPOnchainVerifier.sol similarity index 99% rename from examples/CRISP/packages/crisp-contracts/contracts/CRISPOnchainVerifier.sol rename to examples/CRISP/packages/crisp-contracts/contracts/verifiers/CRISPOnchainVerifier.sol index b8534a884b..d1e699ac66 100644 --- a/examples/CRISP/packages/crisp-contracts/contracts/CRISPOnchainVerifier.sol +++ b/examples/CRISP/packages/crisp-contracts/contracts/verifiers/CRISPOnchainVerifier.sol @@ -8,7 +8,7 @@ pragma solidity >=0.8.21; uint256 constant N = 2097152; uint256 constant LOG_N = 21; uint256 constant NUMBER_OF_PUBLIC_INPUTS = 17; -uint256 constant VK_HASH = 0x0b1a8d6509209282299c53d628ce472a47ef437bdb6799ccbc0423372bb530d4; +uint256 constant VK_HASH = 0x0a12a4d2efe48fe7bee586e156eb7cb5d2d7dc71bccf0fa4b4a9b91c097b4c8c; library HonkVerificationKey { function loadVerificationKey() internal pure returns (Honk.VerificationKey memory) { Honk.VerificationKey memory vk = Honk.VerificationKey({ @@ -20,8 +20,8 @@ library HonkVerificationKey { y: uint256(0x01137e39f6b1ec6101fad7a4472102bfe11e7534e55e58109734c4b927efe6a6) }), qr: Honk.G1Point({ - x: uint256(0x289b159ca7e4437437f18d73d2b649c2043fa68dfcf97dbfeedf6f1b424d068b), - y: uint256(0x28ed000f4b9a44c36695072beaab8802db7f39473ce933426e62ee4f817e0899) + x: uint256(0x2ac08692659e39f2a9f8cff82c54221ef67e138279c03ffbe9cba0976cb5c5bb), + y: uint256(0x18d2a25f4040b02577dcec145c99e389c578119bc9652bd2e07cf1e6ac2edbd9) }), qo: Honk.G1Point({ x: uint256(0x18424976826978ddb710bfdc21c64a296884c27b0d8dadf6ac7681445dde8d8c), diff --git a/examples/CRISP/packages/crisp-contracts/contracts/CRISPVerifier.sol b/examples/CRISP/packages/crisp-contracts/contracts/verifiers/CRISPVerifier.sol similarity index 99% rename from examples/CRISP/packages/crisp-contracts/contracts/CRISPVerifier.sol rename to examples/CRISP/packages/crisp-contracts/contracts/verifiers/CRISPVerifier.sol index d02d02e1da..5b85606560 100644 --- a/examples/CRISP/packages/crisp-contracts/contracts/CRISPVerifier.sol +++ b/examples/CRISP/packages/crisp-contracts/contracts/verifiers/CRISPVerifier.sol @@ -8,7 +8,7 @@ pragma solidity >=0.8.21; uint256 constant N = 2097152; uint256 constant LOG_N = 21; uint256 constant NUMBER_OF_PUBLIC_INPUTS = 17; -uint256 constant VK_HASH = 0x1d0e6ec8f2fe720a1f528f02a66b66c1479c1648afaaedec06d89b889a0ff0c2; +uint256 constant VK_HASH = 0x00d736cd7cf93a94fe0fa02be1bd85f7ea05d3bfa1697f95456c05a7cce354ce; library HonkVerificationKey { function loadVerificationKey() internal pure returns (Honk.VerificationKey memory) { Honk.VerificationKey memory vk = Honk.VerificationKey({ @@ -20,8 +20,8 @@ library HonkVerificationKey { y: uint256(0x01137e39f6b1ec6101fad7a4472102bfe11e7534e55e58109734c4b927efe6a6) }), qr: Honk.G1Point({ - x: uint256(0x0c9d534606fb88b2814caaec28c8f0ccd9f903b2236fe399ce723cb7a89cff54), - y: uint256(0x12d1f3a930e1c9be768e98d5ed2c5c74b3bd1bce2f50ce042cde7270f8032df1) + x: uint256(0x048bf2f40b9f0e61917b3bd9d156debaa531af8449eebbb46c6ba23cb0de4ae2), + y: uint256(0x25842efe61b33504731c38e50c899a32ec606172705f7a3c74ca1427d83d0fe4) }), qo: Honk.G1Point({ x: uint256(0x18424976826978ddb710bfdc21c64a296884c27b0d8dadf6ac7681445dde8d8c), diff --git a/examples/CRISP/packages/crisp-contracts/deploy/crisp.ts b/examples/CRISP/packages/crisp-contracts/deploy/crisp.ts index a58783ecaf..5a4faf1c6f 100644 --- a/examples/CRISP/packages/crisp-contracts/deploy/crisp.ts +++ b/examples/CRISP/packages/crisp-contracts/deploy/crisp.ts @@ -11,6 +11,7 @@ import { readFileSync } from 'fs' import hre from 'hardhat' import { CRISPProgram__factory as CRISPProgramFactory } from '../types' +import { verifierNames } from '../scripts/verifiers' const imageIdContent = readFileSync('../../.interfold/generated/contracts/ImageID.sol', 'utf-8') const match = imageIdContent.match(/bytes32 public constant PROGRAM_ID = bytes32\((0x[a-fA-F0-9]+)\)/) @@ -69,19 +70,22 @@ export const deployCRISPContracts = async (): Promise => ) } - const zkTranscriptLib = await ethers.deployContract('contracts/CRISPVerifier.sol:ZKTranscriptLib') + // Every generated verifier declares a contract called `HonkVerifier`, and there is one stack per + // census mode per preset, so factory lookups have to be fully qualified by file. `verifierNames` + // owns that convention and the preset choice — see scripts/verifiers.ts. + const merkleVerifier = verifierNames('merkle') + + const zkTranscriptLib = await ethers.deployContract(merkleVerifier.zkTranscriptLib) await zkTranscriptLib.waitForDeployment() const zkTranscriptLibAddress = await zkTranscriptLib.getAddress() - const relationsLib = await ethers.deployContract('contracts/CRISPVerifier.sol:RelationsLib') + const relationsLib = await ethers.deployContract(merkleVerifier.relationsLib) await relationsLib.waitForDeployment() const relationsLibAddress = await relationsLib.getAddress() - // Both generated verifiers declare a contract called `HonkVerifier`, so every factory lookup - // here has to be fully qualified by file. A bare 'HonkVerifier' is ambiguous. - const honkVerifierFactory = await ethers.getContractFactory('contracts/CRISPVerifier.sol:HonkVerifier', { + const honkVerifierFactory = await ethers.getContractFactory(merkleVerifier.honkVerifier, { libraries: { - 'project/contracts/CRISPVerifier.sol:ZKTranscriptLib': zkTranscriptLibAddress, - 'project/contracts/CRISPVerifier.sol:RelationsLib': relationsLibAddress, + [merkleVerifier.libraryKeys.zkTranscriptLib]: zkTranscriptLibAddress, + [merkleVerifier.libraryKeys.relationsLib]: relationsLibAddress, }, }) const honkVerifier = await honkVerifierFactory.deploy() @@ -99,15 +103,17 @@ export const deployCRISPContracts = async (): Promise => // The `CensusMode.ONCHAIN` verifier. Generated from the `crisp_onchain` circuit, which has no // Merkle inputs and takes voting power as a public input, so it needs its own libraries. - const onchainZkTranscriptLib = await ethers.deployContract('contracts/CRISPOnchainVerifier.sol:ZKTranscriptLib') + const onchainVerifier = verifierNames('onchain') + + const onchainZkTranscriptLib = await ethers.deployContract(onchainVerifier.zkTranscriptLib) await onchainZkTranscriptLib.waitForDeployment() - const onchainRelationsLib = await ethers.deployContract('contracts/CRISPOnchainVerifier.sol:RelationsLib') + const onchainRelationsLib = await ethers.deployContract(onchainVerifier.relationsLib) await onchainRelationsLib.waitForDeployment() - const onchainHonkVerifierFactory = await ethers.getContractFactory('contracts/CRISPOnchainVerifier.sol:HonkVerifier', { + const onchainHonkVerifierFactory = await ethers.getContractFactory(onchainVerifier.honkVerifier, { libraries: { - 'project/contracts/CRISPOnchainVerifier.sol:ZKTranscriptLib': await onchainZkTranscriptLib.getAddress(), - 'project/contracts/CRISPOnchainVerifier.sol:RelationsLib': await onchainRelationsLib.getAddress(), + [onchainVerifier.libraryKeys.zkTranscriptLib]: await onchainZkTranscriptLib.getAddress(), + [onchainVerifier.libraryKeys.relationsLib]: await onchainRelationsLib.getAddress(), }, }) const onchainHonkVerifier = await onchainHonkVerifierFactory.deploy() diff --git a/examples/CRISP/packages/crisp-contracts/package.json b/examples/CRISP/packages/crisp-contracts/package.json index 36dd57c4c7..7b14164616 100644 --- a/examples/CRISP/packages/crisp-contracts/package.json +++ b/examples/CRISP/packages/crisp-contracts/package.json @@ -1,6 +1,6 @@ { "name": "@crisp-e3/contracts", - "version": "0.17.0", + "version": "0.18.0-insecure.0", "type": "module", "files": [ "contracts", @@ -37,8 +37,14 @@ "deploy:contracts:full": "export DEPLOY_INTERFOLD=true && pnpm deploy:contracts", "deploy:contracts:full:mock": "export DEPLOY_INTERFOLD=true USE_MOCKS=true PRINT_ENV_VARS=true && pnpm deploy:contracts", "test": "hardhat test mocha", + "check:test-legs": "node scripts/check-test-legs.mjs", + "test:unit": "pnpm check:test-legs && hardhat test mocha tests/census-mode.test.ts tests/crisp.journal.test.ts tests/input-leaf.test.ts tests/interfold-binding.test.ts tests/tally.decoding.test.ts", + "test:input-tree": "hardhat test mocha tests/input-tree-e2e.test.ts", + "test:ballots": "hardhat test mocha tests/crisp.contracts.test.ts tests/onchain-census.test.ts", "verify": "hardhat run deploy/verify.ts", - "updateSubmissionWindow": "hardhat ciphernode:window" + "updateSubmissionWindow": "hardhat ciphernode:window", + "check:presets": "node ../../scripts/check-presets.mjs", + "prepublishOnly": "pnpm check:presets" }, "dependencies": { "@interfold/contracts": "workspace:*", diff --git a/examples/CRISP/packages/crisp-contracts/scripts/check-test-legs.mjs b/examples/CRISP/packages/crisp-contracts/scripts/check-test-legs.mjs new file mode 100644 index 0000000000..aa9566f7ae --- /dev/null +++ b/examples/CRISP/packages/crisp-contracts/scripts/check-test-legs.mjs @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// +// This file is provided WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. + +/** + * Assert every contract test file belongs to exactly one CI leg. + * + * The suite is split across `test:unit`, `test:input-tree` and `test:ballots` because proving a + * ballot costs minutes and the legs run in parallel. Naming files explicitly is what makes the + * split balanced, and it is also how the split breaks: a new test file that no leg names is never + * run by CI, and nothing reports it. `pnpm test` still runs everything, so the gap only exists in + * CI, where it looks exactly like a passing build. + */ + +import { readFileSync, readdirSync } from 'fs' +import { dirname, join } from 'path' +import { fileURLToPath } from 'url' + +const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..') +const LEGS = ['test:unit', 'test:input-tree', 'test:ballots'] + +const scripts = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf-8')).scripts ?? {} + +const legOf = new Map() +for (const leg of LEGS) { + const command = scripts[leg] + if (!command) { + console.error(`✗ package.json has no "${leg}" script, but CI runs it.`) + process.exit(1) + } + for (const file of command.match(/tests\/\S+\.test\.ts/g) ?? []) { + legOf.set(file, [...(legOf.get(file) ?? []), leg]) + } +} + +const onDisk = readdirSync(join(packageRoot, 'tests')) + .filter((name) => name.endsWith('.test.ts')) + .map((name) => `tests/${name}`) + .sort() + +const problems = [] +for (const file of onDisk) { + const legs = legOf.get(file) + if (!legs) problems.push(`${file} is in no leg — CI would never run it. Add it to one of ${LEGS.join(', ')}.`) + else if (legs.length > 1) problems.push(`${file} is in ${legs.length} legs (${legs.join(', ')}) — it would run twice.`) +} +for (const file of legOf.keys()) { + if (!onDisk.includes(file)) problems.push(`${file} is named by a leg but does not exist.`) +} + +if (problems.length > 0) { + console.error('✗ contract test legs do not cover the suite:') + for (const problem of problems) console.error(` ${problem}`) + process.exit(1) +} + +console.log(`✓ ${onDisk.length} contract test file(s), each in exactly one leg.`) diff --git a/examples/CRISP/packages/crisp-contracts/scripts/verifiers.ts b/examples/CRISP/packages/crisp-contracts/scripts/verifiers.ts new file mode 100644 index 0000000000..a29dc83bfa --- /dev/null +++ b/examples/CRISP/packages/crisp-contracts/scripts/verifiers.ts @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// +// This file is provided WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. + +// Fully qualified names for the generated Honk verifiers. +// +// There is one verifier per census mode, and they are NOT preset-specific. `compile_circuits.sh` +// generates them from the fold circuit's verification key, and the fold circuit takes the inner +// key as an input and checks its hash against either preset's constant — so its own structure +// carries no BFV degree and a single verifier accepts proofs from either preset. Building both +// presets produces byte-identical verifier sources, which is the design working rather than a +// coincidence to rely on. +// +// Both files still declare a contract named `HonkVerifier`, so every lookup has to be qualified by +// file. That convention lives here so the deploy script and the tests cannot drift apart. + +/** Which census a round establishes eligibility with. */ +export type VerifierVariant = 'merkle' | 'onchain' + +const VERIFIER_FILES: Record = { + merkle: 'CRISPVerifier.sol', + onchain: 'CRISPOnchainVerifier.sol', +} + +/** + * Fully qualified names for one verifier stack. + * + * `libraryKeys` carry hardhat's `project/` prefix, which library linking requires and plain factory + * lookups reject, so the two forms are returned separately rather than derived at each call site. + */ +export const verifierNames = (variant: VerifierVariant) => { + const source = `contracts/verifiers/${VERIFIER_FILES[variant]}` + + return { + source, + honkVerifier: `${source}:HonkVerifier`, + zkTranscriptLib: `${source}:ZKTranscriptLib`, + relationsLib: `${source}:RelationsLib`, + libraryKeys: { + zkTranscriptLib: `project/${source}:ZKTranscriptLib`, + relationsLib: `project/${source}:RelationsLib`, + }, + } +} diff --git a/examples/CRISP/packages/crisp-contracts/tests/census-mode.test.ts b/examples/CRISP/packages/crisp-contracts/tests/census-mode.test.ts index 4bcbec49ed..4f7ffdea29 100644 --- a/examples/CRISP/packages/crisp-contracts/tests/census-mode.test.ts +++ b/examples/CRISP/packages/crisp-contracts/tests/census-mode.test.ts @@ -35,13 +35,7 @@ describe('CRISPProgram census mode', function () { opts: { token?: string; credits?: number; divisor?: number; minVotingPower?: bigint } = {}, ) => { const types = ['address', 'uint256', 'uint256', 'uint256', 'uint256'] - const values: unknown[] = [ - opts.token ?? ethers.ZeroAddress, - opts.minVotingPower ?? 0n, - numOptions, - creditMode, - opts.credits ?? 1, - ] + const values: unknown[] = [opts.token ?? ethers.ZeroAddress, opts.minVotingPower ?? 0n, numOptions, creditMode, opts.credits ?? 1] if (censusMode !== undefined) { types.push('uint256', 'uint256') // 0 means "derive the divisor from the token's decimals". @@ -136,9 +130,10 @@ describe('CRISPProgram census mode', function () { const token = await votes.getAddress() // Derived divisor is 10 ** (18 - 1); a floor under that admits sub-unit voters. - await expect( - validate(40, encode(CUSTOM, ONCHAIN, 2, { token, minVotingPower: 10n ** 17n - 1n })), - ).to.be.revertedWithCustomError(crispProgram, 'MinVotingPowerBelowScale') + await expect(validate(40, encode(CUSTOM, ONCHAIN, 2, { token, minVotingPower: 10n ** 17n - 1n }))).to.be.revertedWithCustomError( + crispProgram, + 'MinVotingPowerBelowScale', + ) // Exactly one ballot unit is enough. await validate(41, encode(CUSTOM, ONCHAIN, 2, { token, minVotingPower: 10n ** 17n })) diff --git a/examples/CRISP/packages/crisp-contracts/tests/crisp.contracts.test.ts b/examples/CRISP/packages/crisp-contracts/tests/crisp.contracts.test.ts index ba6641a7be..e67b476d57 100644 --- a/examples/CRISP/packages/crisp-contracts/tests/crisp.contracts.test.ts +++ b/examples/CRISP/packages/crisp-contracts/tests/crisp.contracts.test.ts @@ -18,6 +18,14 @@ import { destroyBBApi, } from '@crisp-e3/sdk' import type { ProofData } from '@crisp-e3/sdk' +import { setCircuits } from '@crisp-e3/sdk' +import { loadCircuits } from '@crisp-e3/sdk/insecure-512' + +// The BFV-shaped circuits ship as a separate entry point per preset, so proving needs one +// installed. These tests run against the insecure-512 parameters the contracts are deployed with. +before(async () => { + setCircuits(await loadCircuits()) +}) import { expect } from 'chai' import { deployCRISPProgram, deployHonkVerifier, deployMockInterfold, ethers } from './utils' import type { CRISPProgram, HonkVerifier, MockInterfold } from '../types' @@ -27,7 +35,20 @@ let publicKey = keys.publicKey describe('CRISP Contracts', function () { // Allow time for contract deployments + proof generation in before() - this.timeout(600000) + // 600s was a per-test budget, not a per-file one, and the tests are unevenly weighted: the + // heaviest here generates three ballots where the lightest generates one. A CI runner proves + // roughly 4x slower than a dev machine, which put the three-ballot test over the line while + // every lighter test stayed comfortably inside it. + // + // A timeout here is also not contained. `destroyBBApi()` runs in `after()`, so one Barretenberg + // instance is shared by the whole file, and mocha abandons a timed-out test without stopping the + // proof it left in flight. The next test then fails inside witness generation with "Cannot + // satisfy constraint" rather than a timeout of its own — the fold circuit asserts the inner + // proofs verify, so a proof that came back from a contended instance fails there rather than + // where it was produced. Treat a constraint error immediately after a timeout as fallout from + // that timeout, not as a circuit bug. The per-leg `timeout-minutes` in CI is the real backstop + // for a genuine hang, so this only needs to clear honest work. + this.timeout(1_200_000) let honkVerifier: HonkVerifier let mockInterfold: MockInterfold diff --git a/examples/CRISP/packages/crisp-contracts/tests/fixtures/input-tree-append.json b/examples/CRISP/packages/crisp-contracts/tests/fixtures/input-tree-append.json new file mode 100644 index 0000000000..dce7eb7dbc --- /dev/null +++ b/examples/CRISP/packages/crisp-contracts/tests/fixtures/input-tree-append.json @@ -0,0 +1,25 @@ +{ + "note": "Generated by tests/input-tree-e2e.test.ts. Asserted by program/tests/onchain_root_agreement.rs.", + "inputRoot": "0x036374502f7484886e156270a41b94633363c0faa3c6e4d8ecb23882beb4469c", + "honestIndex": 2, + "inputs": [ + { + "encryptedVote": "0x0a8a2408021080041a8024add4b51990b07ac16361142944c8a9dfcb5643fa0bc02ce942fca08830ed09caa3e8e04e6cc8e86c8deba8098c00210c5f3e2578f19dd9e9910ed28a48e0ad60e7240a728ea1f050f1d366aa3d3475286c3e009c9db8ab62146163c5842c029967e3ae965f59690801ffb8d21144656a85341c05f713885f97ef4e721d832576b8d8ad5758aa00f9e28024a358354df6f422fb1c80dd9357e63b253979b9f0540f8cc413e394d1853d8086fa6b94be1748d4ee1da20a94e790072c08e6ab8e69d2b1bc86aa510dc2b7757a7471d1f1576fbf406cc4cd89cc245ba9f41ffb26a73c0eca8288f6fce6cb4c4bb8a59779ade044937deca3461f034df5d8b85795dcafe96235c82753eb73e4caf0076b778b3d187dd1ec35433f3c6357b576cea2664e6db2df66facb5cff104e99ca713a08b083cac781881ebc62c7c92f706581e2709586f3c59e84320d6a0992b13d6fb4235be530fa1d98e666ea569bae312c925ded65b033e57c56ebce3448141e7f59405a2ed72d0cc37fc6d625b04b55f3d004d10a94422615cbbe1e054eb90491d6b589ec2b6835ab24b91e85889aeff7de5dd56c64ad2c0a7d9fcf7751ae768eac08b66b69db668b6366ac7ba50bfe2e8e260f8b67071330fa7f7a6847964c42c4dd3da8984eb0e8d56deec5951dd06551ada1691a78e87ef375d9942db9cb71122407f100f1e07e8efc3b96d1c8085356d1d50ad11ddc4362000c7234b29e581bb1a0711e3adb188aeebe0641012603f1143986b090a1bb0fa4981d9bbb8553b71ec5815f16b072b7926aeef4fad3a8b4c279fe513d7d57014cbaecb1a0cff83223929790768d82442fbca8988fd6de6800fb2c0e146cab2c1b2682c27533cb749b1601e7d6ea7ef7e4f57ea106405699412c15725675f2ae9ed52a0bc5c342a558e2e9aa4e0369e0e48a83ea9102672f8b918786223ed544130cedf7a21a7bd496ccb88ec6f97475cd595a8643792d7175a72815b2d61dbfb0c953a0e092e659a83604374daba7f6db2a10a01b8ce10ab2d19a03c90d2a0eaac5b0ed0feb88a711732cd742edb645e8d205ae4e3ef52788c513d043e57c955c3351114c46f1c86174af54a492292ff061b53d1cfb724928009c98c71204bf5d78e2e51f6eb9817b2a7c306a136e3de75d579033b63f3f0a093f2901a853a5bf9c9f3e4ac180dc4ede74c77c7f285e178c1d421bbbf05437c01d4454f6f68eecbd889230b38d5c740485243f0dc771b8ed007c04bcdb78048360dff89b056f42987900f349d723ef3509e116007f045d575119a075cac283443db0312276e5be7c5adac8d074c5eb04fb98d9f832b54a5ee3a6121f7d9223f003f08fe817d3e6304a6cbdadc8ca73bd651c40dc46d4ff33a8fa52000bd57ce94f21661e649d5400bc444259175eba3a61b4cd44598593af875537b57334742fe17fc12765083dab06998bc220a3c13e9775b70121634a37f8e05306bca5c24f1f4df798340f98d06c89f4072265ff9c1bb925e17445bd84c1136f6f84b9885166b5c7a3f6f93e272a04405363db80bc6160ef419b9e8a1bd4404c068b58517ad6e15ae315fc5b234f56543bda2ac889b5277f3e66b5e087eff4d7a159737f2397be9aba4f05ed0a46f167121884fadd27d3a1675b628920acae7ad41d49ca70c37110407031d1a762f62e1450b167094090a435bcf213aa5dd5145581e42fad0e60c73ad0e81eaecfec5d0e61c4e6079f72d15a0a3daaedc308adfb004a0b03d4d95dce6eb72e9aad098017b6a57466ea9f48f78bd5f79e5c6ac19b97c466bf5684d28be92cfb827bc914d0bae5026baf976cdbd6925c5461432e8b09193d805e7c06c42379f02dc6edb82861a86f613c8f9b3f625b14f52ef890480466623471a83fa17c3baf401f389b9d35135526cca54ed15c2f64d3dc38321e3fb94d438d6b7137dc21412c5ea13cdb77bd07be9ac2ffe70f00917589a88d100f58445bb5f2dddb9110d5e5acb3eee30a57beb0b73ec12995e7d502f5891698de615b2fa3ce0a856dbf2751684c3d5d2980a601b62864161ebb4419903e030b4224a99d2eefd6f463b9a3d4c84fa8ae0b52e001940ecd78485e26083f6dd8a8505e4c301e7b1339e1dc56bb8288dd18fe5b6a352dd95996b32a2d99b2fcb33b420f3cfbc6e8af39cc91467044ac6f30597eeceb598c576a2e266260f099489d317999196a4d8031900d9efd662dcabf0e9d865100c589f12e6c178fc1b9342f3bfbfc560f477ac13ff4b925a5a23f0619a83e8ea781f4232607aa6e432026c0d2e8f2340a23dbd3ef0222e8914d7a163ec3c68af53cc49ab6a18369a70a0daeaf08c6710acb730bb76756ae5393c04d327a57c38e018696a1fc79d9ab672b695c58c55f04f2a2378d5ead2ab0554b35c7fd9329ff58b58c1fff015e04cd31550be6f94a3f366d5bab46844f1aea49faaeafc71eea81b365c47370a8fc7b3c8d151f8f96ae0212bf0f504c447c2ea0ea8167726dad6daddc08d653979292d3b88cfdef0ac568cad4c9f0d49543b6b5ccec6b63f556bd6428520186dfe57f69122b8212d4c2a6b1d81086172aab051568a9c81d1a8860226d0fa7e1416b6650df2e829a4e8e498abd9317dc888e26402d35c95750d381910802423a436712071e87eaf48c1f0b6799a74a36a5f783d6deb9443288951ca6eb418b66d660bff8216abb9d3a51eb36ad6468fbba5b08758c65eddacd41d114e069b937f0546ea0bcaef38ded6a5c2de3c7789f86a0238e614d1b5ca058a1cbfaeb96e1f995264c8b93e6058f17ced1d7ab427cf874ba2bf6f2ea2c9f1ad3468f580e2f836dadd94fd9f3444e31de335dba6b464ffc158a0a73394f25017ab3103963ae177f90a7d4da097c0b10f6dc35cede984bd46d1d84b581b50398a7466ba278bc1ac6560d6372759c98d984eb7b55f68ae9b34fd0264d9b3c93b0f1af469327d4bacd74bab3c2ef635540f20683483716cdb78421813053f4ddcd7601d975848338ede6f9dbf36e07f4bf1c04f9c801db71818cc68d11cdb9f712791e0c2afec1ba7e91a7f2e679514f57a7ab2715514154df41cb307343f1cad6b4ed4f99245dc4f4efe05ef99685c407a12771b2be041ec2c6986bf776039fdb009da2117be4f8506b6d45246a55ea98be6f750016265afa8184d2a4d16061636cb0067f7be376468b7d5a8d7d9f3c5e6fa8e21ade2695e8aad1fa932ee49928430b8ff30d504be3de28fe0493fc2ad0b099cbe3a39475c70c3ef0a3c8c662c99b382987ce1ea8964258bcfcfcddec7b8f91dde1c1252f91447677838f4077e64fc7acd861dd3001f49116a4be8cefc47a27ae5f9293501d478e2a7ad89e6b7a1f01360430b75705f58ffd45376d27c3dc81734ab6d79eb6cf10b94c5694f6de7ac205989452e44db9f2e63f68e736dd44e64896a4b76603077fdd30140dad9cbae53f187dee6a3c91fb6495bc681338f0ebcf96686c94be5ba6e024f8350b35c98ff466b9c7a1419155221f76fb58bdaba476250183f97dbbe13ba28f2bc20fbc8e98593315499cf7590c929562aa627838daeda93943a8a2d99c80a00d792af2dcaf711ff7477f9ba9f11687f945dcc3bce8f57439dda4623f3ec5a09cf0c510339354d72e3ee5d2fca764e0caf1d6cfbdc458dd4996a0c4005e1465deb389d30d0e55baafac37e0f94c1eda3eacd70ad8a3f656b173f1d19ed8527ad9788ca090deaf2c83720272d9c49e371843bb29ae64a94d7c2264bd4545d7734c872d6f58bf2af1e73ae497d3d8272cca68b48426e8cf1356576e529f233f8267841b6e24ee52068ef312ed06292cdfa025e84159b09a4db8ac61a12c4dfe947bcbdd245b5396ec8c6970ecbf9058f1daccb9113cd061d7f9e15f15b01207d04a4d814fe66bce52b1c243c63eadf6db06ac56e03d2d91feffd0902a08fc0b3ef857a496392d70c849373f47bdc218582367b4366fde1f525b15d1e3d9eea35fc89259a3774a9ff6a11dc390f8dbf3e97e8f1e393ec20d4a52dbb5b042d5a7a34f0c3a0f9afed4aedd11ade308e7b7b3a016a93e3f0ce4522a4f64e45cbdf5932db757629faebc461d7d1876c9cd03a430557ddf16a6a16574494c275920f5dcc94e9d7a9b82b7b66a8fd4bcfa924740caf096976e6c7a79eb3ae77ed6b6c6d473cfe8e3c4cf12402b757c388c99ebfc4db816322cf047b8c4b019418b6b55c3078508443640eccea18d7b50e6fc615e2b35a388749ffe9648351fd14df28078a959e52b51d3f7a5a977ee7e2567373aaf0a353987eca56de933259ca804a394644d1b703775c6b4ea7591ea56a0ad8dd639b46df5894e0659905ee79489d3653f7538c0923704550551d467396aa140d3041bfd9bc6dc86fe17c585d217127a55b63b780b47d4eec751d3db7e98df5f066347accf959ce2d970ce8e64e4f09ff30ddae8366a8910c81f76369fd6c1e5209715bd37bc5ecc44a88294c9c0abf39f9f0476399d72fca59842a8aa0c6565ca9ad746d0b0800b11c3c4c7f990b998b6992432a8662c5caa15a27ffcb8e0ae3dc4155c0a0b73306fed0a7a0aa9ec68a49b5fc5df4e67971e714a1ebc4a5eded05fa299d9d3ddd8535c3035664361fccd2092427878939de6ccdf54826264927dc1199b46bedf62f9aa9993eb7872e345b041187ce6cccf63cc67a37565cba81951dc47f67490de88d4015e256cf670bc25b24792bd7b795664376eba6e79e1f028241fa345eb872fdf3b5526d5873c1b8ea150f4ea693a8059046f9a5f8d6980eddad36415377bb072da2c7fc73859de22a921d42d6d64aae5698e10a7fee1d61c5cda22a1d329a24a2c5c90528304efec9ae34bd219407d6a32fd58da06bbe1314690ca17d369adfb846e01b48c044bae7050a3f317abcb66e9b3a7b821ef58e25c255e81eb0790e7c8cc15e726c68650ace6ba7ec42773a9ec037cf16f5e1d4f8afc1e961e7df42f005cac54dcbfa0fe11407d5567a0a48458e20e205ee74e3956c2f8c7895fadb0185dbeb6e1a8e5727ff72f0b6bd22390b2cbaffc46a9a0e6c1ed11b690529dd39891e6e4b7888ad1e7bdbcf99d8df8b3284ee86c0d6d5bf10ec24116099f092076c4fa36a33017e771331d6f2a15ad084d6a3b200e42d1e5088ef3764c4a7bdc74d3cf00ed532e7345ef7d953a8c2f48c36f130d0e6194e80f400b12407cdc677a800a7711ad694d0ed2fed58e7b800abcf5ca685ac65be9bc00964f630bfae20596d17aaab8dcd873aba70af52e2ec85ce46f13dde3af4a5aab56e71d586c57ad8bf197d1000ac159cc7a5b8e1504bc867f8cd2f6ccbb84b33e2f8b718f4f713456f0b06cc2dc6f810ac7f33eb9d2e9b60972c9ff5f621f0d3d78cfcbd196dac94b73f499e42a05b812a61891c93dc745316c753ca1fb3891e03f6568065692100c094d0f335fcde5df068365df2c672c08318c3b392042f2196404bc410a2b907a452610fa7f1bb3ba1fbfc961608bdab98a8b063385aa787188707ccc230242b965fedfc43ded80786af34cb966543e8ec521e0f2bd151ee5e96377f302d5c6001dfc26090c1df0cc6ea96836ec39ed115c0d43f01dcf2354567511fcf58c5c33dafcef55a16fe66080a72dda25ba6d9a53d08d3f3f12d8f1fdd44a73c6ae6025f0bdf8977e8d7921103c93670d1c90cb89d7a3237a7b507e6f295fa1c0c8d639b9070aea64305889974ab66b85527c59cd71190c9c40f2f040be3ff366212889c89957a5a254fd408d33aa48e78e789e4e4bacbecd575ee20b90f8e7bf331bd64c8f81ae4c492eeab65960c745eb7ebcc7bd4747d5108f6c3ab18498c50c89cf65d69e16d16a79f66558ad3c190f8ae318d353cf91af5962378fa4d2e2b1f667f7e837cb69c054db1367cdfca5524d2ce717e9ee53be4b92fd498d92678db29e62859bfaaddbfa4a8dd169ac94803dfd26f7448d4b13f86b184dcb63711e1647208a3c093943e44efd16909f568e5914a74502a6a05853dcf61675b7dea8479da00c3bae88f7038b282cbde60d3a9d29f9d12a5cd225bf42d4247ca93da001634256fa7c76ad478d71866b1aeea24a8b10c6f24737f8c54c65c0cbd9c4a25f7a294f45598d32b83ee4010dd433a1c108496cf785ebab20b00ddc2d7594cf9deae913ff0eeddb91e90a636622e67cfd1fecce3d11940cbf7e5aadcaab8d8c2f81884c157f4c68e9011dd33a8814f335ed93128520c29bdba66e27dc99840a9b00c293231368112e5eea48767a43712f5999ff5922a0132aede9db38664b48c27714bc32a6ba72f7846cee9078f3a5fd0bce9cac5e45dcce76b50c23f75578d43c04cb4b82e41eee94d7d126c984252d6d7a0f9e8a38a0c44a4198a682018fd3723d796bb08cc7a6965530ddb53f06e91f83bc12577aa96f29c468fcc266d33a5c996c2501b4a21680015dd1ec6f9abfeb100a761856312e6baa7c7e6d54b234476c8eb39763e2c27ec8c24ace685b8e488392c8eb20010a8a2408021080041a80242f23a0a811166fc584279fb281ba91bd8f5e88b05f4a3729e8b17bea88376eb7f3c4801a0b7573bd37dd84285d9a371282a68892d01d93ce36ec7ed22a8f5a613513bb5a418dedc718222be9f1ba49efcf51973aaec54232b897d789ee03b3b46038362fac07ace44d88cc94fccf785a49e17a0c1ecb708eb2a40787cafe0505f0729ecad57cf010234aa2f32b57c690126bcb0e35dbd3264264b286fcc5c6d71c9decb78f08af2ab749b6f15b8b180b199002caa4c69e912ff86115bc7277513c401a58185e5156cf460381da4baed7503136b2a2124c731e1e2c1a2115a17e32482e628e5265f58d500ce6fca11af816bb4057f4db869b9605215894f9cb9fd850d198d713e58b37e7bd1bcf3c193647c0ce1d879c9294f5e8f138bc01b120acc1f69e05c1933292225fa44dadbd5b721cd0a26294e418cbc2c4856ca66ac4d250b3b68ce2fe353427f613555644af03b8707d05dd46732e9196969f392b000ffa97623e338c7a22f061c31f34816f174db6d06f9f850b1d8b412c4e7fd91140c8ea537307136a598e6f605cc3e154ecd32d12e7fb0c327f7b3f96ddbab9bd9c0daea94a05c9c038fbcd04652e189444ff2ff4abfbd6ee37da47a233b3efcf270e6a5c2c1167a62bc77b495d9c6ef1cab130c7f26a4dd1efd512d7e42d608a532286791a7fde9d3dde719b954af82fc25f696b2860c18373f7e255094b7a891b32b5db01dd939a5394907882035c4cf55435dfb50989989cd4e67f624b6accd7cd9227f73eaf79a29fa49d269bea2ce47e705eb2b55b392832427fc75924a824e6769fd597b27460a354a3bdd54fc8b50fc0e4a96460de155f8262d7735247b64f550a3476ed27d9a893505d26ffd0b52635c6ba62eb86dec145205cbf81443d214f42eb48bd1be78071f0397c74ecbada883295236f597eb1af0bbdcf169ed0eccbdc0e0ab5f11e1356c0f54b754c50d5029fab5cb377b5d1d60690e0b2d0583418d5b68c0a9e2620a9cb0c5d0c87d7de68f9f35f683e6e6edbbd76eeaea5d6c63346db6b4eaa0c90d5c3df5193cf769fd778f20f826acc3ac67356d35a137ec0e384237e7e1f44c519ce6cb36d6aca42664381c695062eb6bb558c8bc63e53adf41f97fc71125d6d18cf52abc08b70122608de89aa24ae99ed94fc9e4620ee3635eb872bccc11fbf6a0370c47fd808a5d545b39fc5c2d6c889fead20a1406969e51f70420a62303f7dc11922bdb1219b2158743ee85cbbcfed35b8f434d1a2443e34956bfb7710e239829a0de49e39b5501142cdfaa5af80554a99e04702a9d996beaebd90f5410faef332558b2ddfe6babce9cc87aa8a4844fe8f481b9554c2b1ff1a2636b9e0c6d2b85f6c55aa3eba8dab1096a9854aa63c86f06af75becc233c2419e5d242d7244c34d580f2d003097778624611c64f76df5e770be6fbe4c807aa4190eb26f631a1b3fb0b24e87c341171aa4917fe8e3272ae4ff4b0e4e1a0ea7bd60ad55d3d45f1772b88a861cdad22b025d06ecc78601b38d4b811d2b94c8ec651c33c217eb671cd2111ca0db9bf8cac4512cdfe33e91287e7449fad52e502d4ccbd7cf08bc72fc6fbf29c4a0c7c6c029dba23bbfa202c77cd8188048dd30e88a37fca65d35e443c4228e82218ab851fdd79067a0e12a45059817d0ae74289554f2c764853148beebd8cd207514b09609c1342487e951f54bf1982e79f2ef2f385710f74a46ff36cb3493ebcb9206ccc4a7ae661968afe51e710140c50bba251cc0263b38a39dd69350ef8dd1090b13a8fcea22202965565bfa5b51d67c569692220ead7ce0afe594558e13b89499861d0dcf9c5f6c65c2916892e2fd667baf58142893979f7721c38d7ce9ff7b8b8aa445a2d36848305f00a472dd5bf72b6c35b9eb4521891bc66fab9daf1ad0e58665a77a002951622cf2106c52b70c3ba8d4c0f3d28e687197a5cc0a434efe8b7effe68c31240246b8e0f344524d454e012414bfa6097ac4226a6b67d89392656e7a38fa2a8e920190a86b6d1ef1f7167b7707cbf72a4e8659334bac5a40a35726fdf5c8f12f9b43967ab1724864af1b82e4ee48422b2ad07e5e1c5f326f84f0aa444ca34f23c093da461b48da59382b5a43e77476636dd4137126b3ffcd6c5c1af4ace705be38aa32e3283ebab929a0b4d9c52966716f82b598e441718f07c72834c17ab7e67c6da680de56bc8d3afe6e1f0635a4897caca22543099d2fd371a05a2688d0c08725eb42fcf9b0e22f6ff2fa573a63b4238461b67f0780add28cd9d49a931bb9d21a0a930172e0178bb04e81bf31f9a4bf7090ccf33251108f9467a1e2ac3f160eb3cdee97e27cca2ce41b9d0acaff010a567f92e9e9b91360a9767ae6431267679a1c17cff52690f6ee43380c2cb99f07ebf135adca33ea25a50eddc2429746772308722995e6414279eb7adad3fa94eaa77b8594d6c62d0fce126569e5f900e4869d3dfda37ee662d34f39236e0162d5173aa5a09d9477c21a8a653f742cbf0e54285ecb337b9cc3fc434d2bfcb5abfc6bd806eedad194fae69f4f2462047eef8416bace7d7b89a4abd4effc7a02fb1cb39b2895794afd658ed1af50a8712a019c382b909ad8f2404c9cc435ed2d2ba777c1ee1158a97a8d6ab633176c7f78acb762996ce1e87d0b0f3dfd58b289ab4ab6cebd1986fff2fea01c9f79316db2037fead9532e7095e94bd2700f3cdc07c0b313fed5a035679a443ff4a431e2eb622d01b966cc7dac92ac494fc9d610c15fcf67f69504d9f4a1f51ce65a4bf4eb5098f272424c99fbe0e2d611a923f85e650b43868c1a56ab77a45fee30f9a8d5842ff5a0adb0d29882fd36c90cd55b6d81f1056658ca889f2232274647fb1bc63359488596b11050c8fa229ddd43a0fcd531e250a7331f1f058770907c3a06b0a98381ad9670be63297f4e2c3b3cefdde8406537dd88a274e9b1d377169dd4c8e6036ea620e3f14c04c0cbde47c0ba122a9ce616fa14b893ebdf7de3f116783e2acfc9c4588b389aceb9c8569df953e3842a2d399cca7190bbad8fe339455adc760f179d6237112bc94e272d0bd11c96ba1e85f2d5852fae45b5eb3b7296de91dd7e13f56133b5f5467ecc4b5f0832933b9c5f851c3c63ad6c0162e13c121eafcb23c734aef8b958a0af0871b1bcf5aeb4792829ca47291870608f46faf2a11352f32346dac435f9876f471e3c5a2881ad3f17c2567619c49aff279938254f9b51bb4ff64a4909677ef194dc7d32b4671adea4353d02b33c48fe8f919ad43cc2b47271892831196ab8937a8c53e7f4d3d6a537831dada5b6102d46944fbcbe33d9e161a2d43c04f218db620dfbf93d08f395fc0843b8d6531839d73f3c7d75b260cf1a02e813d701fd45eaf43a42320b86e13b4f7d5cd651ac131688b2af5d9c6add1583d48ebed22484b484b5917611efe6c0d6668d128c88d4fe578d6b1dcb72aeccf9d977b9f3064a4fc00db1822465a53a85f8b2628c25d74a6e6fd4975384de31e235d6ea568b735b1fe21361c905f01960faa545c685da6e9ad94886cd69d804d7a4338b79ef46d0fe539daf0ef0b040761ca25cbe966296de655426994631f16a03eacac05fc940d2466bb8f15e741c0b9e8946dff2bbac08368ee162fc839cd2ba6a7a0c9ee00a47444dce731e5eda4b8c088e873e3fda767f1ea92ae70d14db9a33454b2b4bef565ed66e1113c1e944d2bb64c114cd24462123d35fbe5bbccacba1fb7a5d714086ab52f27fad361f768d9b03330d762bf62e8f2d79e20f9e2c37cce8e05a3a00e136ed196bc5b9a8a6511f36ea5e7f80470c75a2cdfc1a7911d442622d44e97466dfda8a8505ff4b6732c3a6fe195be96544a7b5e72b6a9e129fdb9cedde0f92fe9ce05f28a178f0233df2dc6ebdb8fe36f45e125730ea70bf76f1a3a1c0f9488069d32c1af15ed6eeb46bf316cdaa41c6396ce6c4c97d27db0a40d406aa7918198088f649501624f03a1a805970aa2b2303f9aaddd4c552c188d30519b9c63c566bbee8ed79bd1d125959d0791e74a5e066ab9151745389fee95bf6cb7801b82b6f73c72b271a26f9b27f12df4cff0536c7840b15f3579bcfa5aca94c70fd062aefb6d06b5d0bb469f309ad4d64e98c4677870286eb76243b604e7155af900ad49107d3c8c4b4382ed237239c35d208ae06807bab8befc18c57192c18e7e7e541d879b05ebf7629d9a4c1ee11ce36d8e1bbea8d88bbab64e1991baef2edb8b8d9d9814dc78793fce8d9d0f5eefd04c5cf8b9d03d321fa2614ff73f4439a134fa347226b19de69c8256e3c96206cb99d7f765136d575cd7d24ab2055dbb7a69f46b2ceb65c2eb08ff5a1554e89b4a5ca59e3e94f20e62fc85f5d08552850ea9031c7c421443fda7d1a90d6bc22d1ca3946c4aae94d0ec9071f2a8e54d3fb2ee2b8df55aa61604663142a8866f31c49fc9e6666750d2ee8ac320029aaabbe2c3b95463421ed61714f0ffb8062548480546fea93b41e92b433e807a18fcd3e27e6dd4fe6b27a5098f1e3138650a10b834c163c2643c444b6f727a8333e87b2640bee7e75fc030bf57a9db53621724e84345f7b708c15c9e052215eabdf9675a28629e28ec7be2a4dd22acb5e4d95b16c9ffffc49326b5a29c6b3c9672b686fcb0cbd99d9479181a08a44da021e7a9a213736d957056e2a01af31333aedc45d797d2d6e7244e5677be17cd792fd9e86e1b61b50512fa6d0d2e5d795c76cb74e8f86832ceef20769bac4f781b8995907f3d20091d3dc8845de056e317e487f07b0bf4295ea7f95d18d32ef5812b0c9a78d155eba67a3b0446ca49a9efb0ecb13a71e01476c5fc2ac724ce5369c821b7e0b6f418d8577398d8e3f44d7765fe9e75d7482e1feb651c7d00eda467437d2d9e3790db8c614607bb39cfa22ff38eff7d71dd68b7371b48f5c5adc4aa28949f3469e120660991ef41ad999958475c0bdb9c6acd60000e2d3e2d5c60b6af8439cfd02d1cdf8d1527250e02f9cfc499a54df128ee5627c185ea2115fc583a068000592855e4875d4664ed7ef19b61db6a1d571972b30701a36d6c9f9183e448d7c1119b40f5700b94b7f026c0ca6ba1dcc4a459b048a7042b4674588d1d2ebf492daaa13b09b53894261d36d74388960340061aa81e924743c846f1dbb0ed7cc62c6d7dfeaca80956dd752a8f58e581ca2284c95d1c362d1954dff1d810db0754547acdd6a050ad9d9f3ee1d033d90dfd5bb8a0f8023a3e191a91cac5de4f5485847d84cf1b28f21958644065e83fb368042c180869eebc867029b15c973ea7f63c6f59046975840616de9875c06e2d5e32ed14fb2d10c0a7713302be7932fa3b4d23e0a6dcf9eec10f32b32c20eea5634cd4eff8d13d0dd3c5603178c730ae5d77ccfb1a65735967afcc36023103becfb57421adf8ab1a9a3de840ca6eb1923e57e1bd462cefb8c954058732a1609b64e4b4f1f0bbd67296825440b889baf5460d8c0facb79dcd919f74ebfd033b5ec17694ec9cd4a6af56138023a861f37aabf7b108ef1e82bba3d5f6ec09551de58ac80f81d25506968e90bd10553799e17e45feb459612f053cfcfd3a10d6b3a670aa11627f4bbd1ccefedcef8ecf59321c47a4c8dd81e42641ec48ab0456e0aee721c590c50d991a5d1a5a9302f62dd949efcae759f38273ba13a1213657db777d19043c8ade3f286ec1e91511859f640871f3bbe4a65bc9cbd989b3c87a348deec985426f3e54940b9b43b2316c21d22290c54667e9d87bf29519552ae20a9b99f6dd07247de31e89dd2b3c20d076d7a8b79b934de60758eb1d5d1d93e653438c0d05bc790f75f66d7420171fa897c87999788df3df8fc1fbf55fc32dc98ecb4479575bf616626fbe394668b5a5b846529435a7b95411966cba55a65a86b5c9d690e88fbad7d6719b3f26564e334b545129e7b08c0bc2b35b6e2fd17d817e2057b57085e588b291d79cef237dc94203d546db76bf1f05ef60fc38097927999e236cbd8b078a613da28dfa01352f66abb6036cc1792aee15e36e00631ceba8812caf22fe7cbae73ef97f925f7c0ea79616f5de510f186777f05be016f08171241ce3aa4a9b97fb143603ecd1f10c3e8cdea81b636cddafe72057277e897004fe870f28466cfd44b6bc3140d3e5c4194997db84917fb07c691713cfe50a54eda8d8190de30c778198953b37d01195e757389ba8a74ebff2dff32aee40091e5309a9e827abd9776b8a571aaada12bc33913d698bc765192e01934ed0ff28f94a5f34710272177e5325c106b23040e7d50e1bced5466ca8bf0f185b79fd45be14ea623e6a18d0b5489011a7688ed4c4eb921c48a8790fac6bfc96c9c7a32ae97e90127fbab8f3baff7faa626bce463cb967cfbd0dc12718027fa990ffd6f3c2985304da96d081800cd268d74dbaa6daf245dde3743a7f3347f1f36e74f89ab4a400bc762e666968c20a273d80059441aa3cccdb67e626dc8b18a278e39e2bcda6c2001", + "commitment": "0x1943df9816bf3590226989eb2a997504c6ba6328e7cffe838aebb7540d217a01", + "slot": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "parentIndexPlusOne": 0 + }, + { + "encryptedVote": "0xdeadbeef", + "commitment": "0x283312a32cc00d80920a684bb27931dcfa6d24cc48b0677b9dfe546dd9f55acd", + "slot": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "parentIndexPlusOne": 1 + }, + { + "encryptedVote": "0x0a8a2408021080041a8024549a8ab7527c5aed12d6c40c1a5539175091ed717127f85705267cfc1ec7a3a3b4126235ef9e57f5566a924785d802e1aa71953ae24d661ea55abbb9b5141c7d065a41d67bf50382c663dfbac81c1a69b84f2694c48cc7ae0b805c75af42971d1e18c835d9c1859b353fc6f45be8c2283b397de437822679c2173f869913513cef0025874c91295701d1dbcf1a6b018f59a333ed3ba0907fe0546a963a751c9b18ca459920aafe6840f44cb39ee871bed45f07903025cd0c4dff68c937b98d2fbff06e41cef40ecaf44422e350a26b713677c7f3a7584cacbc188ae759e54d5f15dc45de3844ec2411f1d73c580762efce2b535cb8a5ae27b5bf39730a7d5d3269300d58060012baedeaaa1d0f929edefe01dcf7c1405972e5521a84b21cbe4432b9b07125251f5d0c3bacdf1fa0c7450db174f67ca5a523c04b5053eab107bbb9ddf0ded0be93ba14f30e2df5c8fac3fc7f8425309229788ecbcb173356fb128048663471b2aa253ba35d8db3ad37351c031458f82cac02e632cdddc86cc07f45413fba47d2b16d1230937a97fc1e0eb538590e5817bbb86616aaf248eb58ce855fdf3eebf2d442c1b2862706863ae1c135b960a3e59e14261560ce0e934156407a2322e1d956622f667bd5ace762e0fd82ae709d2555920954adc21a3468c07265e9c1c0696933683ab7f88a52a823d344bf99c0d614c8afc35b4c4ad028c947398a97742d39d5d81c080a439a248e957bf9c92a19e222ba7dedf4dc2480e385288554e5ebb6499a81aa8cd3a92d73fd25b6cb49a440bbeb6e550282482f36e1ddeae61b013e05f0932ad97e2ccc7bf21500927b2fbab7f30ac13e73c88c211baa06c6a09136f07608318300bc8c597ffd81525c38cbeffbf06846e6ce8b2fdcdb868f3c92c06a62d46f76cc0ee69dcfc45dcabb3dbaa5b2e0901ef4f098f9b71d750bcf6bb168b1432f56582c9b27a59d79934ef7ca3dae589924466dbc4869d677bec4c1d24a3a5dddf4d05b246d132071727be46454e60c96afe00afdff9f7dc4c6a90289f8e008a1126f8dcb51413177d4cfcbddb196d2dfa6135d9bd05f901eaaf4d382e0cfebebc235ad5a7a9c02c4f75679cdd8db2521e06774f98165a8e22003f4ca585153c3fab66861adc977e17b256569a30773accd70282121e5975cba0a801385ed1c4d074d5713451b197d35e9ed73775dbfdfa2f725326b7a076a78d39cbbc83cca6faafe70119e53e9ef0ae1d0d72f0ecf281937236f1266ef2d99e293fbdbe9c0f46bdeb784843397978ab2fd73364b328bdf89a19572ad78cc8cddc473e50856f2edeedc76b0ee21759e1052381894063d8446e6cf5c962cef2e7af179fdd7846d50aacfada3912385b875b3c37260d6c6c8a962949685889193300f35a1b895d3f41dd86bab2a615650372956e9074817e7e0e5938000d0f3e53e37668630940964ea3e9b2e2617dc02348a9798f10a6c0315c64cd90f97c8d038eb735c8e2e9350726bc238f693060ef7af05b16d717b5f09f4928205d91b57071cafbf5508ab0b21e39db8ba69480c3907c44a3a00f9639945b4afed96abc5ac988ab5d09f5a7ddce70eb2fc0d5e6312d83e4ffc72f2c9e1c8c53eabe8db2fd3f459b157ae7570b0b7f6377d1a77a4fd6ee4c02405c2d81053aad72e10f7077d9b94177a4138ef933266fbe9e323322e08b890a60eb3e58f17f2cc3129d50e811fce022099c5d742a9865cdad0aeb1d85dc1e2bfaf999972ceb1ff777d08a6fd34d2f323719d0ef44c1da40345581a781609781a17260393139f064c8230780949d425a48d402298ec75ce97afbcbca547ce4e7cad7c7e1c3a39c2ceec7a05ea2aeb96aea12d68e677c3e572c8d49c5084c0b6837c9ccb469fe684e50106c0c395f9e3e47d4a1f09a771e4b6b25f2fb6d68183686919832882928080a2b1a4d7ba010e9f930e63f26d1cc65d1e17efc882a2107c15ddaeae172fac4e7f3d48032c2dcb7583ca044013cb572152e91cbc0d3cc3a913d341bfc3a5668fe431aeb9f0bcb9cc1d93fe1d24df6ed60cdb98af158199553f20657bb2c7cf3c4cedafe0dc828dba64acbfef766446b3274bc6f4a662e64c903249f897190d68a274a08dec63a341ac5d1c9ec16ef3684123f7fd7e677db55058b8260db55dee3318d0cd32dfe747849329644cf1cc4391229474cc5aaa4facbcf69755ee4fb436f4ecc66714c83ba341868a039e0d7c2edcf754500eb8fcc50d2a5c7d037488db6000e8bd75a6f32dd32305ae068b91ebd312625c1ec1a394dd4e76c4c31b6e408912320b91f474768bae8bd38cab3db5d2011920649bf780eb01959e2569cf89438110e9ca05bf9e0fcf74001a81c387344e61c25dd823dc22b4e54fe75041b07cc4115b413c81eedd2049e5f8d02d0e8cffe9f4103b6d66a1c9d80bd84c6180060b3d75694195868a844b988c2db0162069eab103f3212f34b0b552fec39634f84b4f0fcc06704e645afc9793c977cb164ffe9315ec0d8404f9a3d12c3a3a38f127367784182b0eabeda1a76ca9cb361dd5f9f66562dcc3b1e31169a3ec2f29063392f74c8ef20a0259eb4454c869387e2c6f24d43223d9667847c3bea17eb49bfc22fa2a9f20318068857d6017512763b6af8026b60f8e2dc2504da55c16e949699794abae9eec79ad531cbe63c22177bffdd6411b8042f1192cb0b4c2bd73a4b30fef92ec5ead65765bf663c503f19d6f2e047e91b453966f688ac7230e739ef1da449aab1843ebd90310a04fd59ef453c059f5b3d76023ba233a8a44de363aea219a1aebbe6f9fe1a982cdf10eab110c888493fd9c9823e2852adfc1ce31d8e8b17ebc63e85fe68df3b4174a4282b90571283fd57841e27861c01a72933f2431b48ee6a7e68519ad31091107a1b3532e71a96173cadf5aa97b204f0512db8711e3ae4798b4eb7ee65559439976da87f5cae5c827daec347190b8912a56063289d41263e29aefca9b7644a59ab190d52e205c97e22c43146c2e210bec60a3f77195ba1f1279f60113c1a09012f421a389e26f365950b8890cd7bf5816449f57374963c4549d4df90c0d673445dfe918bbcd8c9162070205c21f34030b3e1ad79edffc9337bda1d13ed61a0a124a3ddb2cdeb724aa5d81e646e51ccdfb8b936ab6f572bd1adb78ad89d823c7227241667be9279242877dd15ba5b05d940dda5f5d143afb405f9007e7052c3f425e5e2a4905121f7176d9a7639b8ee41d40659a90d139996db1031528bfcef80f8fd6f346e526ae5769ced0b2479c0652ff3b7ac22ca16c1202941549deccac42fbf36be58bf665560d413ee30b23e04b5e93905bbc23fbda4cb5f7f0f5eefb9246f815b5037f56bd26368e50f48346ebf259b873621982f4a2c81bf4bb3e003b25fd238d6701b58d8c7f4b0a865fb39491503036e6fe31bbc20b7d670268d1881e7efdfd0647fc93c0e79956569692929e46dce1397cc96296a0ca40b3738ed94793f825e6f8e77d1570d37eef2609945d41dcf079005102eb931bad3d1f4bc5f13ad4a1ce979007dd2afb38e3c788f484ca334c67a6efa5957d1fac5a3165e1564a96191ac508627809be59cbaa1a5c72865368090cf19d375f99c72b4cdb838c15e557c2fb613c3e078a2d0469847b81b600c35d369f18175f156d2e5ce3d0ce93526c3f0cf2f7cf4bedfb51ceb14ec6f6c045b9fc140b885f5f11595ea9620e8720adf48ecbd48d4f665abf5ac7a8c8d28fbde919b38ba0a90ad643b7ec16a6b5c20ebd1ab64af3643eb5ebd0a89f3deb75bc4a5aa849e3f2f86fea84d26efd79e0ac80f75a3b193f9313ead0b4ea6dcd9e912abe3e5b10d7abb7f96ccaefa141d10b11f2baab985d98e7fa82fdb61b1d54f69a063a889efad85c597d3d09f6d1526dd3a4a9a2e13eed472136e53017da6605a3bea374cdb5e6dd231955f5375b91977ca66557dd8168c3ade14e16d040b7b9e23a5c4052064fbf59a1360ef28c6af354ae02ed83cf31bc497df009baa3dd7c65cd462f639b8ee7cb6089d604a47d7152f47713d2bcf44743283ad71aba850ff8331126562a11b2d5bf4f09284aa7a598583166f8eace6b59e6007cc65849779ec8730927735e6d7900ecef121e34ea80bb110efd439456d1dccfba832eb66e2b8c9328b343cb0df789e83331166d845776da9cec3dbd51f75d744a7e524947df366e3eddb52da5e97f74993bc5a88d94a3810be2cced09b45063dc18f65d56d4873f1cb1a05d932aee6292dd963d017d8890e61c27be87af863e06f4fab2c1038095613480ce8f8dc3849d3208fc9f24170295b7cb34d8fd1e7ba10dec96fa2e06d6c4632034e76f4401fa8b266e0e2178dc1e1e936b546e046dc84fcfc04c4de6ace008367cbd08334e36ba32fc71f27edae7e924160b1f3f3872879bf4155827bfec91fe7a694aed5b64d0933b4b1f1d43b55deb77ce83613cb9d136f853f04ec28b4d10bdaed3a61b865341a29f20f0c158a1341d26cc8c0c0953a9bd0ab49235c88fab419e0168b0f2254dd083f019aa5bb8e83b4f1b86852dbc811038acce206381cd6e766e2f7f85a50539ccc1784e2e30faef0c2eb41fe5357376d69e77c9aacdfd76b4da4d53119b087e889b0c8f93a77b9fdb2e04b3a12dd02a71a2e0dc339262141fdd71c3e18e53262c47594e3c32ddcccd4d5a73ef606ec61015134dd786c5ba2019739e0a933fbaebc904da33a8599d8d6cc8510d3776d4c14f4a882107b6fe586df256197bdfe1c10ea20da0ed451b170e8a648967b7a865e02400fe407dc8788c1f805a543d2d23e7ac7cbd8148d8e83e144f92f0cb1f69e918ebc8130472577dd9d30277b61f23c4cffd7d0ef2c03293c544cab4fcd13852b74e143c62eb21b57d93fb7f1b07268d2105771528055a4554c26a4cfe9f5983be9395e1c62063b6c07921dfff8f6cd5a27bd84e1febe4b848aa51e7d2d074e14c56c366b736584ffe69c3e7ed3d212d4fbfdde25490db2d41363ecad4a428d9d0cd92053cff99ccb4581ba256d64d50da86e2a3b893d2101211915ed7b236fdcb1b6de4818b1eaf79fa71ae85979ac903ceae8e3c46c36ac607315273a89e9f77874c372105291d76595b14af0eeb2daa37483bc9ebe5387a28fe4fd9371ce848c9f3a4347fcf7aa5e808f1abe4ee8d1139cd88c9a755cd45538c0509d35734e0f288a262c2e749067f5e7ec9c492db2c2b2b1a6d4146dfb4ed26ef328385a3e5b25b0414350b74eb05e03f4633c471fa1dd3d387d4cc11981b23ba41c2b64cdf96f949e0f20bea9e4170497bf08aae46bda4d8c0a62734542bf1c317468e8b367c54b7040c0700c1813338057aa7a0fb924ff07ed89a7bfe4b2c72478aea9cd4461dd2996baa511a8d7f0192f9188e2dd59e146f8639dc1bc9181d8c982a4761dcd8a555c51e8596b70fcfb54f779244368f2e366ce9a4298e2d0c75b2eac8ded17421c3c584c6e3e6bfddff8741610c8d1a1b3084dd49b03ecba80ffc39e8d4a223b8be9214579aefa2813e36f5140b66766280c52cf8558804dbb8f63cde30f11024b373272c41bd9f337fbf7b4d31098e45c686b99ab837b63a2e4a200302ce8175008e4efff8aaae97a95bff049282dfbc8319dd78e2a9dc3d211ce58cb7c56993a886b6683ef0f4a2c9f06e7804fc2248d500e987fad469561006d9d2ba44118fef964213ccbce18e7d9257307b0e69f52f5a2efbb12a0797c96241250a1507c3e1159a50694d699302a29eee808c63959fe68c69a12b872f7a52bb21efec95b7a2e7d5b2e24f7db8fd85bd65827e3af7a7e5366759821e0bf0fc178137b1398d2f2f1627c448292f0f1016791b406fbf4f853bef70c78d6a85fa13c7dad2904a45bb2f189a784b3b6133486afdb77da2c7c6864dba460b08e062342aced74e92f97515ef56e5d147fec504df3d5d08ec8e2daa98064c50c42dfddddec9902d37f404391ca4dc194c3d0e53e498a28c82a64d8f8de124a20e0a0fadef452e19062247a7b9499db070e264b5ce16bacfed4e48fa6c0c8448ec4a0ee45d1a42f9e30151eb2a48dd26e7e3ea86be9e3be5ec2163dc037efc3c98d6f00f74a68b3f5a9f88b4ac32f62003f27a163b4b47fe1279170b939309b8cd6e002fa5faa55a2768cf5d13b4813adaaa0e001f4a8addcaf2c1581aea406569d5ecc18d123be0cdf05e2420bda61d20a20c5c43e1e376f24fcb4e92f792be910bf86c34d65f769f308c442bc7fa8b4a7c7e9bd07c7b9bf2b00de9d664f81df0f80266f9e2ceb2419026092f7a21c84c34e4472e4b41ffe25a5e931981ec68d3ad84cb642ab10d1942bcc7c16b7686b522dfe18107e7167a7b39c5e3f6993373d1e4f25a2cc5164d2ffd230ca38edaca66480081ddd0000fa3e26cb2551ab78ef5d8488b2156c6350c784467c5639e96223cc9e9065dde4e268f3c98bebda1caf79931fcb1aa5a138d9399eeffdf7be251a6becc1cd3af74ea6bedb1eba46120010a8a2408021080041a8024bfb9908b743398e01dceadb26eecbd202bfa4947e8fd2eeab566ebb0456c3ae3964ac9cc615757655d520df4422fa017b0ef1b181e9650f4b73c654fa029dbfbbe0cea1c8bf705496a8b06b32654887f4040f4d8c072be5d5c6a3a6c58ef244e2e69b82721d971a9d64fb5c12e328e68b7a5d75eb1d8ce3bfb8c33c494d509149c48c57f8c63ea909f1da8415a2c92bd4058205626f31daa9cc125eec9b04c8760c4923c34811ee290d8325e9b3d4c58ec640ef59e8c4ec5cb8de83e5c961d8e63021f14c26d7eb740eadfd59f0928261e2ed9faaf41c9c293b72ead20b2d54112b7053d7d33c31e7d9655c4a536b8b3e56c39d28e66c33fa2e41a24c268ee5951ff8a4172cf267ab05dce6709ca87c85eb4f4dcf78783d54b5c68447536f0652b8e79f0886d75ebd5e591bb5473fb08738a27edfc56c25054f5ef0e06063c601ce00a8f47009864c2b5b40f97f2adb8749f26009f96f60ce033a5873b54f79cb17641187eec4018c016d81041ed06787a298960c307ed6c43e6dada32a17ef444174d6952b54b5ee71b3e6b8a24ea7df3431a5636f77a8828aa8adf6d7f23ca13a9b8af9109f0302e9651721ab54d4dc434d92ba103ba6f973e49cd1b77918b5057b9660ea7ccee5cf216e03e826129ad1d401433cf553e40af526ab96705ebc90ed8ab4486ec490683a0536df1c647b37c0ddd02416b379a95b0c0e63a4da62119bd76386cce37571abfb84bf8db03345be38893403f6f8925b0417da320c0499b0c65106ece00c6ae27b4d1a4cd4198dafefb179d446167b12f775eaeaf8a0c7783f14a2b6be4bc8a135d731cdc6803e1b0129bd82e4ba8bc918ea2444abc7f396c60c79c2eafdf08f0e8d1bdd99e7d6853d9787dbac5fd35fd36a21d9ec09bdb17f14737598e6af817507d0e4fbdd195a09a5c5cb187164c86e3ce8c5b6d5d608f3755d14f79d54119056be9d18d17f994d439668dc107fdc4936afcee4ff37b59343d1c680d5ff99bddb455cdd6e441787b53e569f2df63980f34b389503eb1b1d5cf5aa9929d4bd342abc95e283abff18e85503bc1c20953f0b579125734f549deba15c7909f0a60a365d3b3ac86995c0eb426be82dec1aa15df90d164b4a56395849e3e3195f696db845af07dcda85e130b6e37844cc752b67b922c3d71d9aa5d146693a8629a003b4a9273468f5a2c182842d4ff317b6ad930c90b019ceeca08723e994aa79b25b8e1c7b7a7fdf391b1ba80d38c289c699340943b8d6ea127111426f5d6cbf1482e1dfb29ae57bbdb1010334315ea8f580b362b33b963da31a3863f8a3ea5595931dae246ba37a1ccca13bb45f1b38080608806ef24dd05a36ae48ef02ec332f6b781f908244183cb846114b3edc506629c72bb06c6fc7a431a59c1f2774e5a775ed7a77e031e9fcf96f5c9bff9a2bf39b335571af31879505f27f732db6348f480e8c3a123f86893585fa7afd74dd3c13ea1a4dd89c248427e248d064d067cbe63fb89b20f6ae6ed93bf2603580e8dfbee4d2d62d72287007d48b174c5f1178bc13b68392e337195bf08342cdd38abed93a28fb82e91954d547e567cfb80f960e395b4442118401f7f33805de66cb3da3b23a77baa3f12e48b054626904fedb340e3328c52e756b08f9bfaff3564a0bb06a4e8612e13f79c7f1571d4875c788dea60d3b9ebac80792846356515fc3036f6341431744a16b291de00ebc5fd5e5f2d19b1e3c62d92cc9f3bfa46a26fea5f7b26b29ec3fe5e1df6d5ffe440c26da4289a82b3bc99902f0292d7401e40a6f1bde169e0c3e9b0b1df2badd7a4f9c0cda11f5fee8672e86af0a457eeec6ef9a3d91648628b666af1f57f586187df87b1f5bf1e621dfeee95ab801bf5c224f46d02c097018e235722e74d87bed7967bab3d00d6ffa01ce3234e2996b048b4f02c02cb81c22709e4e08504914d08f5cc6b434abdc840a5c8c9b1325749f9a5e4de250b8cc7d3536b166ca2ab6fcf6033bf4a1cc220b57e022e96d1a702dc5852a6587dfcd1f54668384c5e139b147d88d33492fe96e9104e1c62d485d90c8b549beb14dbd0b3581cec1fb22b6bf472e13dc79402b09609b646b00de1c5a4cb772aab12e008c7616568386b01fb6de94463147c458788cc7a5c185940d5c46e834a7edab9636fc834d398742861c1965c3eadb0a7d083207469d2a8d7c3edefe0ac71043190495982a02ac0031f5bcbf6ce5488f86f8368ae9755b85563acc55e72a5c92015f8ade6edf634717fe51391b2cd866c24c42e3d5aa89c27c0add0b09c7e87c4e3d80b2e80d9f0eba7d362ef3e1064bcd4900947a1aba9ecf1ea6f2e6df058d236de4893ea4097e43e423be7b063a93f3e68cbcfe25c965368652df6571b6461f43f9358feb47f4355936f1572b94c4838aaa8abdd3940e848da96e903f8572f45a7471ceb7ebbe47af98a93a3ce17b174dd7fd863968a43f7b2bbe055fd0a8f8c9e166673ef4081c082496cc5feee712468995eae1bbacf49a271c795ab06d4d4f6937d86efd792a9e7fca69dc4c7e637f31538b17dd776af42403293c3ff22067b518d10841403566123322fc13fd1b4896f1ec7d7800bc7131218a97f41298419d146aa0bee9c2aabd538984bee9ec6ff2e43b528fef9e6054a80027ade17b5bd4aabcc174d32bc4f329187f3c8b30d7a4f7d4708863814bc36d657c3031ea919ba874563ac1350bfc4196631b584c25b8a403e3d80ab3908a88a328fddd02769d2d730045a70743fd9d334043b518b73ae822bd27400034a07cda50771760b00ed97f6a55dc1050b5de38b48cb30c0bfc7e98b242df2d261026b5c19ff1a36fe3b99736918f7b155078d49d2064f8f90f254d5a84e75cbefcf6b96b38a570db64dbba9bab76c663d973436c1a56337476e904e1d38a1024cc9e377a38f0b177fb37a4239290c05ab2e616d9104dc95c8e636380313f281bd0db877e3ba4fb48050e3225380dc9911306113025abf3925c0b3f344bccd00f1b0fd7846435200c0001e81e435f3c565ee9c8fcba5dd6ffd98afc5ffb63065d19d889ab15bf0e81bdab46c92538188a9823bf41b5d12ab11cb070c2159a835a09d3ae78011c0d45a61ff6fb4d0c78e33f414a635d7401729734b4452c73fbf6874b87271d91cc323c794e4c8a96cdfe967b58a065f7b36c905aa1341d779363b1afc76e63990acada1e6bc94237f82bb598ca96b0254f659a835b8530b2fb2a7601232b3685caae30259b2aa748504b4859eb43f87f0c20693ed8c8ec3afffee71f40321affefdf0046f52076b7f1fdd0f32ec8aaf71de6d8bb056bd1458a9d71eb15fe3a7438b13373a4a2a4625277df50f365a1a4534ccd1bd095e9cd288658baa6c5b8c52897cf56b4b7df7cd802988d821e020292b22abc03d7238bf323d3ebc606ed7303774f2b8650ef21e024b95f68e81b3dad4be0222a3bd2cdb4e90fc008e9c903c44fda2ffb78a02c559413b521c58b576b81e943cc958a154c1011a1e5a17bc37fc33e94998927daebadd4fcb5cc5b79aff936427a0e7a2f160f272fdbf46e9c1d5a8d5bdc7417a9c7ce2d6a6d29fc5e7856b7a5b2ca37ac8de098769fb6bca721dddb93524cd3190ca61b7f37c8adef864c1108b902955da7005dced78b7bb460a6a0c53a2312647a0a4c7268838d8fe3d83c4497232c86380abe37b425b03aa51852e863497e2e008e7cd3b1cb1d1f350fe8b0d51ead3b7ef7e4ee3876ef100f78aee49f86fbe28b92e7adb3f4c5de314f289ff5175736318010fd40c60b63a3efbd7c858960ad42cf207d33793cf13512270eba9f3e0c6206dd626d2284777f7b6450b1662f2c3942b58a46684e96021db8530510d7b329e85fd726f46379f81bdee325c556c6989d61491ad3d15cb770fa10b84d4415d9af49abe6855fc755b2844ed1d400dfb8a8aa85a0f3186bc72b5e8ca4bb5739c068408c2ca9eabc17f805e7f1e3e52c3ca0217361e75d786659565e36d3784269eaa435405ece93de9ab6db3ffa973cb21688c2168f5bf9864d0488033c3b9acddfc1a7e561f06d93c5df506d0a549384aa8a859fb3f23161c31d1c65d5633bacd500c5787f971ea780607374a2cc659519d14e4a203845621066a8ca2d13de859b6891ca188f75197187c8af605b7f8664fb7a9fac69772d27cdfb2e84e9f082981c4b3c3ebd8d6e873d268367e1659c94d167b1aaa2863a94134210856813ff9b0fb4551e9d9b9e2c2edc122cc78fc5b95e0ffee67f425ccde57e0c40036eb8fb68ff570c4426c6afbcd144c85dc873f3ff019bf815965c198e47b016b18b306fa114f77963219876a0571c65d37829ed1b7c95a32aa7ccda1a57b1391dfaaeda9f41997cf927239f48140d43ad759bf0b110c899b9aa19b8c81a63fa61932259d5c5462f0fb90350b5fba4047a980a2979634bbbfbe369ccbb2a177d7c97f18cbf8e261d279cf4e49ecd2d6581e7a8beae8992b93355832bcc053d306025af5a493f53277daf57f890a0dcd3b15addbc3d991a3b1c154c6144fe7dbdf82c0950d5a039f8bb8b8d8a2f19403a312306daa6f6ffb0a707968e6aacb75e91bac4e5d19e2458f53e19e4fdf22121bf0f6640f4138d4c2eba98003d230d29478a0b679efaf1c0fde74654253d62a62c134c37ce5da9a54c1ee1039ee2ad2e9d79d69d19b13a348d88f6bdd17410edbbd57f97874eadfe58d1778b959fa6c42cd853fde38d1c9b0e63d98ed3092b565d03447f94f8a896a083e0d6fc2bd9d7991b2cafaa3f3f50320395999d37b9e1b68e6d5a07ab7d7620b58564ca1f144e70c01f57c0c848e9e40f6fc6a3d320a915fd5fb8f5d515bdb192ad863f0c7a39da7f429c8807a295400bd1c67328c18aed3d7565c7af768f3b200e5a05c7b070d9f2406ae51a465020778289ae81f60230f56d38c0891ed79604e69bc7f3cef9d2cf0185d01aee179be20668eb624d5fb6a10c92dece00315f3e317dbb955e4b48d96d4545e74589af1f3cee486aa28fb85a998d899f88034b25aaa657c920195aab95bbf87f3c9ac920df169c9389d6a98c51c871faa6f4ace0e537ced895ea10047d743b4a3df3f6eb9a0b508007637adffe664caaa773e8e7ad164e052650cbc7f68387084e5ca26208871743e3abacf75e125c4bdfde24a37881923e633113d744b90db4384e0ba5d9ff0bd933616852b9dc4ef8b902ac265bf3ccc8a157bcc1894f6b55611ee7389231f87b0a752aba1b65503818629e0cff48e1407729bc8a338341d8b8402ce6b3aed5ddc9c3f7df4bd0f18619d477bde79ac71c4e55def0bcf780e9a4696c00123e4eb2b7927d9ec61bde58337c88331fa40146b0e80eb33ede75522030ef18008e5d28e69cbd0eeded4a3bba22b2358ec8559216b481beccf75d4f0ac5018828695e881cfe93fd03de0112f08332e81c363f0c7176b86490260b01a84156001db484785803cef9da697f3fb7a44d671c68d4acb364786103f1fe543a690c9f765ddd17e251ab822add43073f64ca119edd31d09c290e485eebe9e4490378b7997e9fffba6cef3f39dd84133a4d71d415e4e5ef8384a146984cab8f0baefbc42030fdfa8f9d99aaced4caf50fe64b8798d743f2199430999ad893244a102396338922477bc7fafb200805d9dd88bdd53dfdbac1e46c6c4c98d40a1bbf950e903ab87236a79013c539dc56b66c0968c89c9c506cef851a00e0b71ba4ade5d1fa3926bbe5e08c119c5409353e99f45efeb9d40bf195d8c4fc1cf353672e4426408a4f56a3311dcc9d173514c608c8bf7f21b952c7743db6b8e332b985f81d500e6807b4b9ad0df2e766e7c70687b131b2559ac4db88d05fb60ea293c074392e8854495b327a34be022cd89960c6452e449b2068f276745c3194bd5b3b6d2e8382c7593cf17babc680456f1f297612bd22a8e13b1d12fca91b739cf96d1a8910bdadc40a37f8f4b1c85e721406331ff2d65a05763346091340ede3eacdadc640a742069d441700a0297cf28841a9c145f6d691352442886856973cb1bfbe8db079ef75cfdae762a676e6d28d50d991fda2ad8db194bc4aa6af8d0b3d46e19fdb98a58aa07117e28d295fc3ff526cd7e9d21226918258dd8629d0eaf33ec9417cd0df255ef982d33e09f9254b15ff44b753fd794f9f4835a2982bfbbe99a862a7862eda7184d7d212397a4598337822dbaec3acd4598230160e0813c7ce0fe0c1ea6ff7eb94bcdf25aa259af06d7424e63a641b2d9baa83591e3e2eb2dcb18f4f56075b34e923797887bc493d6486055df0dbc0811cedfd2e9d32c62defa3ff4b2c578489e2485eb41a3465c363bf56b2274d9e8579b49bffc004af50a75171e0f022a138476627d450623b1ba74a0a392240189d9b9b2c5a969386269277e1e2a5fb657a5446bcb18ede9ca99bf515db8d4a98dda6a73eb2277ae01a1e5c67b054f1b6c0d4641beb696c97be9a7c594796733062f2c83c0573e8bf8512be921f46ba9a82001", + "commitment": "0x2e31221fdadf16d05bfde6a977228f508359251c1e0844e09e9c035da9536216", + "slot": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "parentIndexPlusOne": 1 + } + ] +} diff --git a/examples/CRISP/packages/crisp-contracts/tests/fixtures/input-tree-revote.json b/examples/CRISP/packages/crisp-contracts/tests/fixtures/input-tree-revote.json new file mode 100644 index 0000000000..64b693ed1b --- /dev/null +++ b/examples/CRISP/packages/crisp-contracts/tests/fixtures/input-tree-revote.json @@ -0,0 +1,19 @@ +{ + "note": "Generated by tests/input-tree-e2e.test.ts. Asserted by program/tests/onchain_root_agreement.rs.", + "inputRoot": "0x28315053ea867b2f8f82d6e5af47b20039b4ff95eb88d9193a4ef5c2929fd077", + "reVoteIndex": 1, + "inputs": [ + { + "encryptedVote": "0x0a8a2408021080041a8024f7ecbe4c047e3c375f1492c56213e8df855d302e3c539ea8a6b1ca077c974eb3d65425a45c035e4f6c6307a5d89a462943140879b988fde8cfcf361777b48ccea7195b7d5f08474bd808010c81662c773872a05dcbb9b285d5e9dca5b81741e4dd213e97ca0eebc65a0636b69d9fbf11fd0e7a5f0bdeb0f024917eb3990296d8d227556ec6c21bd5091f62ed2f441c23175603a3fd8d5a850ba9431db6c914446f444fe5db05ca53658c8b24d921f1d0dc827bbe2a2c62799288eb0bbb96423b13687cb1febf3811e5fb3d59bd674f1d9bc01511981e89723c999c719981cac035ed45351fdff97f081c511dc17b6cdb2c64bfefe0d459e4321e4050055c59c4a89605ae1364397032e780a6c40bba4e767685e3c9c9edb54ad3f51997742a9499180aa4a61377ac84a395bd7dc92df4391047908c8950588e9515245b25a0603fb629c26b3e8f76b45b6235c3a2e34e0cda94746df74fe8798b6afdeb05c3d58fd826bbb3ee0b5805e69ea535e08acdd441f1e439552782497d02440592bfb8fd7597a1d574fe13e704bf1cc81c6c2ef7bfc725af7a488de826880d28e85c93847e9f415b5cc60475c789a0a4b5f34326a85723aa85766b748f44abc53cb3349c37fe27a1c5472ba340ffcef6f1483e0ddf4cd682fee5d4da03b48d83ab6cc262472f614f8bcdce6ee2eccef05fe32dd3d933bc4017c41572295c30d959e1bfe6b8a823a3abcd8f45b3c9f0f2b39d4055a3886639ca2e846a2c5a1302892020a3e18d8686043ee76f9b6767873a4064480dcfe25944a3d3632d5377ae784ef9e23eb74c67009bf833cc972ff73f0418f75e9fe180e3124c7e2bf1a5f019dfd2910e2cca1602ce8eb4ff932a098be2041a6d7fe1960edc2df6162dd061091f20f65d26ac0ef4f0c1aaa8c932a16557b817d0976cabb4d2a3761f03f41c4103b86d73dc1175c9c47b592a6888443aa6e410d426bb0e388cea374f1a383dc3dd995a3a9390b19c4e0143e9b7348c6cda3beb50395dc15719c0f21a8ee3531f774e9207ab8af0d849290b3d08c107f572f341a0cae96f0d70bbc5b6d5f1ef0b40331b53ad0f6d72b8ac3a97ed24b8bf5d810b1f67bb33694d286e768dcc264881da775842b2977b59d6da57734c1f8b31c3a94e5b2a771f0660d8e5096756a2b07020a0a1faf61a6e4489f3fd3160ecf06721cecfa6499330f3121ce2c3c550b9c6754b192550bbbbcf958d44f89b39837fe6d2618459c95ed4503ad818a11cfc5e2d7b549aed10ef467d4baea942364e96568da642caea0627c219e01e823ffc76efec71c1e17f1d631a8fdfbf77fbf4c2a9fd20907a072261e530b2836f32e03a6bf0a6a3ce49e3cf88a7c13d6a370e0492bb30973c8a13b56ec6e102cae2eb393c5ef18e8d7ea81341ffac3f35b19fdc0eb5eaf595c5e6a19e6b25d1fbac17d282ff813c293da827cc7fb5ffcf0a919b0eae9d3557ff478872e5f561ca4f026820a8433ed11cbf6abc5533740259c5c4e93df78cf3d5879e8c0c0991cc9502b2f67f019727a1fc3477ad33d8afbefdd98206599edddc2ac0e14b0a10678b5ebe7d006d7b226773345c8e1b59107c7241da80679d5f5a47ec26adf250851597516b1e2c151036498a4600cf7dc2a1e410d9d571ffb0fc2f8576ed14b490f3ece104a35200066db2d07c323ea073fc4b80e1ce9fdbe727c3397756384f683a3be161ea9740c7dfe7a55b314e60b26d40d22da49d9cd23e9f44b6b1581e6dc5cd1fedbdc8b28f5fac4e0ba099367a9e7e51aac7b0c05214681af6c85a2d3a828da21a49a24b1ee3a3ec7a80472433062db1bc4227493350feb292d9ebb82e8b538a8f67f9a82573848fb2d4f94acad9f3f104d1174a017c7368159349ff82345a09d5c35a81a57d38c78341f7b82daeb6aaeecc7c44c90249c693de7f95c98e1f409c013f038afcbc375e3adeb6c167eeecde6c6434d6eb8db829f5b0700ae612f0dfd10dcfacb2d9508bcdf27a680a465d9babefed7f1cd5c4c34d7b9547aebd7bf6391507f380390ab8ebb882b4832a2084d510d9907abec3825221d525c5377662fd8e9e817e9add86416a5d612b205138a057abc64d45e046241bfd013e7b314d33bfe8dfa51487f3861b9aabd998805bb7ef0c8b1dd4ee77932e8cf24ec51e0a0e77cd62a7c04c98ce288bf713abc0aa53b44efdf5510d3aee2d902a745ec8d0723558129d7e3f671b3002cf58b869450fae2a046061e99da6fa0d894659d7da6dc669e88fbc7207f8a0b3559c51e6979eb03c551ac50b9f6dc957b5ced507d2f51a969064bcc65aeb8dcbb477cb6592e71697af5461962183129555c308a610682595074656b5512254f9a027f61599c2fc15152933405c10a05e77b8594131f40386035c47f57a70be937237599ef34e2fadc123256e47d0aee4d5b29cd244b08921a1cf55375032e51d64b70c7ab279642b3af614e6db2dc092f33d980dd404e3803d6710108580d368375802bbc5a0c7cf330906359a5926c099d7b65bc179bcda959f5d88d390a70cf4bd5728b3a7b4d527a891338b799442210dc59a2babffe74b216fdb80c940d57b5109223b9648e554754f5a54ebaf6554c43d2b97018de3f79c2b2d28248c8a70890a5557320d582c090171b847d3618f0ed172c86413499323789b2615de807f47b4e2e30ee5b2993709d0d92c64c328693178903a14aaa3c1a04d2996e7d90831211a0bbc2e5538639bab11af48c9e7320886c691278915473d5547435aeeb58603e4bdcd0786d87d87a488f608570ca767d4b1534c8c0092458df4ca11c32cc1d50229ccafa8765e1ec36f56c67bf843879405226179c48167ff4d873532cc775d94a097096618719a75f137bf3a9bb6c066306f702459138def6c2e286212bcac79410c6917510a81169d89b77602c277c0b747ff61d052063fb1c687fdf0ee62a3e91b41c22c1bf0765a508bb1c41d5edaefef247af873b1f99ae6c0d03cc5614f2803e22fa784a039c521fc1ad354a446468e22dd62422f960023c24cd07b8cd3d6d283c1173f6a72168bff61b81c50ceb960efb56279c51d7d929bcaacde7a84e4465bc4e8a70949b5eb0f50bb37efa6274bdbc8e3708ff3f62ec2b726905dfc4f2bd0ba72018fe5e829988fced7a2a90878bdc0f2bf1468a390cf2c6e2dfe41c9abd37ba9249e7b36a56df4f7232390c8db9bad4fe36734e7a9e1470ffe72bd12641f5a4a8fdac8200fefcfa03aee976edd22a674740e86ec2b96a56b7bff956e55e7a52ebff1e4d1ce426036f39b8245b067a60334f5ad40883878101e9bdfce21abfadfb689b35db2a6b8341f862e6d6ac4a162656d5e17184bd6a14dce166a32a9b92967ff9be78204dc2f59dcb6b4dc07b01274db323ece3121f9007e549cbff65e163a2cc8e67d4c6b648925056353eacde5f0f7b4fb2415b601b13af20d4b36eae907c27ab9733c49467fc1e1a04d3da4fa4d5d9ad7b0e46036d266107c65236e43b9b6f3e7179cb0a62d9d78a95be9d10c0a63e5886bb5983c74573bbcecce738414f87fbc36057353b0d5d4bc88b3ea4ef1ad827729ac7072daae45816399e78d1491fd1890f4eef64b33ba420c8cdf920f24883f696592f3a5deb41a93c930a8dc5a153f0af27ddbbe05f31d9c137a3faed8a18e2454880af7e6c73dbbe6670bbdd869905cd3602634841cec7237fd3e8a88e1305588825304a7a94e06c278a75b66d8cafab0b81d9e607bda8c6bf8e5d9d44742754dffc74aada29f73307bdb8196094e6a31ecdbd0179c4f09a5d10440f17c30c71ad2143daa77ee6808564d887d0b337ac39695dceb328c2f8253a660e7b713f82f5dcc31687556ccecdc077dbeb39ac1819acb7b965bc745b2d35ca17297b7aa7627a92c3f5313745ea74bfb2d1df322e7c89c88bcd7a5470ae61bd5260b7fb2a1230bede0775de7778b05d94bbe3c2043108f6561a2c51beb408ba16710153702fe04bf97ed32a1048a25982ca874f29e28909972f51cb942cc8c6422e4561d3c9ff339875018db5440d95a6696d4e0c0ca24615025b620d9074d45e855897d130ab46185f99862a2c07ed7650dfab0514ffcf7d5872470d9576c3705de12a4c0d16bb2701febd89384e26f8b9604573cca155e7f2054a2ea6840cc01426de361a26d93a677b54f74b1b9926ad7550011c09842eb56b9130606ae38e29f6e90965cf58c7f74e310887b9052aaeddf85e7c0783928847367e7de7862100114efd69d067e9b4e91abbe2da63203be3690380bb6301c796904f43cbfe4b0d5a043a8900695ca196365d273c8583ba22f24b7b2cdece697e15b011fd531f8c8d42f4b739664d1b986f3eea73f58acba083e82bff1d3feaec464e7c5783a2ad45e9072eafd9aad4df7f4a97f3cdf52faa26da2b303a6a22a5ca4b62d9b67639243a4d14201e19ebd03dfa42f2c0abd8acdd545f537ba2fb7cc61d392e53f40618c1714d5896768aee71c3a2ad0261e197da4f8b440d4422241746d80e1ff01c53962ccf58a7b0e8ef4d614ca6903460d88de9c41a0579a0f6309da84da85eec8f2b4307cad57d129e1fea24e7acfe916968d4e2045fd904d38897af75021ef7d1138f010b975ae8e083f78812127b09d8e1a6a6495264e98690a18bbac2c2b0a7d53616bf59819d5c61c18e650508a4721dbb9c1b6c3e9916e4302247dc20ce40f7b18c955f330036daba596365f13a0246a3388d33550870180227f92c2ae35624400ed0c0deb9bdf3526ad662a5f0673ef64c70732582bb9524e2430b7f3059090ed9687ec3c4985710083b3894f8ef6bd1f6d08454197ae213ce80bfddf5574747b9d8a9dd23101875a9e5f0ee993d18d610d300ae3e17c97145f0c17c6b33a5c04808eca38961217a1aaa755956ce21b6d29487ca5dcd017e6962aa262a3ca25199672aaf85c609d4156933eec495c6b2db8ab4940334786064473f97e57da4cda1e001547c485856d92ec4562078464fa01ecffe7f808a8cfab1fc4e509bd2801b15d8e926daf1de89c1c40c4a5e2e5902fe438c8b75717f96ddae37497ea38c01212e2fd1930818f02dfd359d621c8e2050b48a27ef978b0f5fb0b80c5e0c7aef9fd1e931c8d1803f780ba596c3ab8574684f2db6760e3f48cf4c8bce9eae96a8d8617f4a2f73af46a22d418b55b2d842abefbe74e00e88fff5db865dc8c10cdfbcf12764c25c230f178446cffa6f81f16b994b3ce423e6ef3dc0b779405497e84c7f24a1adecff68cd567f611af0ee2299593b9c18b546316ff594e41da50d9e354ce2a96d285c98ffca80a4f5b73dad1ce1a6709984c3c9ab9b864bf208726429ee7d87aac7964da54503bc18b80fd6e6b4bd2581763e47731095fe4487b40b37a116a536c5d83b1a4eb3a6bb5604ba1d6e3cff86f9346879fa700e9d573ca6c6bc692d3b37d63905cecf4fd95953644ba48526f0c287f067c1022b83fc22a61fcae4b04847c4bdced88839975f156f366e06c13d33843b76e3f3cfaf16ac6a564fd49ddad2a317f12ffb7f222b45cf661d10df8f88d6e1227dc3b612d5eb84ee9ddf0f45c21aa736e81c796fc9a5e0dc26902300cdf64cb01ea2d46bb8d1977709e2b16d58c6dee087e3edab06101bf14d4104f469169ea77f6f3f1a2701548dd0f0b824c055f8ac470bb1db42de98b79420b83dc302da89b21dbad709e031fea9e71d87b45d178aae3c93fa2d5541c9aa795843aa993feb30d28616d7bb7b3d223b1ee1dc51be95792abed2bf987149c06ded51d966d4bcf807b4055361215ab7243f4b2495bca34fdc2663bfbdf3374774242c2e8d646ca32bb0b801a0442c0c0f8bfa37aaf4dff53e36fa12e9ed35c913fef90f1bb291eb2f42234aa476511b6a201f104edd75132b81edd2bd774a2ce256bcc08c5fbaad7698e0cccf035019917d1e6b44e17b182dfd9f628209fb808f8b701cf87bf726a92e0b98e54a9acc3882d90f214c747230e0028642dcb24f571d07605799adfdccea3d08c556e028b29f7dcb7a36f39508ec426a8965b200637164970c4aa1f2ec7a76232048d098bdc552c6781f4b9c37df3f067b1db16ba31517d243d30ea1c287770c313a7f8d8a577a13426ee2368f5e38c7869ca7aa0cace6f8d6537decfa6723a1e97e68ca73d773632423de85728008bde747bdc23d42d85fcb9d5ea9c879b78d16131d035a58ea2a62a5e6fdaf4a6796a183932e9326605803a9adf7459dec0d33524b9301a9be4781e29a218a528ff813942c3fbb44af1450d4eb21a4c150882ed4f4ecf6104828c8747b66523f3190eeec5a7619611016a5c5ad76cdaffc185221f5c82e5481c443308f21e58ca8f6bd1e95be74a1019eee10c09e397432df490f09c4d0772865380c681f5a8295f718e9bef02e10ca73c10507c76ba7d0d37d295146dd74c9f23cc5fa62980420d647bc25b718bc2ea5d59e8fd16825904e6662302f49fa6f18c032045262f47de8ff2e163de47e920010a8a2408021080041a80245a25233d89a1ee1d9e714d14839d48c16083457883e5d0449e4ca3d7fdc71aedae2624e91c118af9959b3d05f8a9a18047f9e6660fd953d6f3bdca8b5b710645838274796af7b78813fdb99213ad80afc8ced8267c113766c8331306faee23b58b5958cd066e8e84bd35c7de78da051f725095421e6de80d6341b88d7abb3ae333be72dc5441c1786841acdbb3220b1c6ce9fbfec6b5db821a1c3cbc3b2c8f34552666ee1b010e00e9e4c938d32e82c3b035dffb48686d09b72f37706b8bdaaf4c726381043bf1d9054e98a02aae928ec69c195830f108171a677a5f9435ab62b15f7260f99541a586d931967c393f8d7efdfca86c9f9887226ae91902807b9aeab917c1b7e744dc9b70ff13f7f169d7fae27631909a8f88194e792b087753619fd990208b82923e9e150c5b912fbca229d786f51b20144e1e6a7c629e0b8172fa6a2ee83ee39d9274f771d68a3614af4603f8a61bfd3ec623cf125ab6304bc4893505cd2ac6fa2c9228e813ecd4fc4874a7a500464f5961c097e2d39abc097864d33f6840f9141887d82fc25fa72e92732c586e1d28b7a5434599db4b5b658555305bc18b73900b47cdaef683486ce237807816d90bee5c8c55d5fccba2bc1fe136ec7592163f8d15eb615d034138746a3180768e6747e071228afe3f88f456e4a8219799a4a8910df656ac3615444464acf0753fe356f8a51f6ef93c5ecd3248add0bfc9cb51328ff03e378e98f9ebe2f1fa9df1e96ec9f5b24c88394c3fcabc05fabbf3a7127e4e35dedc2495fe026040acb7f7ebed22d63f879da771998421b52975e58d024b0f48fbe3000c30476bc455fd96904133541d19902ca2836b87fce13216bdab191c6e2b2f405b0c229f95a47f926a167b1fddaca5a73fa89d7e1986804b34cdcbc904eb2414aceef197454fefd8882f8f809472dcd1c23109678c40e895ad6b4cc7d2226a22b9f75cf72eb90db0a4b390d63be9126e982a7389f38281e4f5db5358ce278f3861262ef26362ac60a9ba6f880ffa94e577e572de1009cdd0cd0c6a307a93e40b11b7d29be671dbc30befe5f86105f22d69cb97f7d677e7f1af4787cc9ac82b3eee13ccefd3260477fc02e03a5f4ef3c4f000496cd9e09f1317d4751634ac95767bd95beee2bf8a8d719ee3c909cffc79dc00651577443c120757118e17c0665583a6f212ca8ef7aab63d91504ffd69720207e9e9dfe48727389d19876f2fa11f0debc0832c055f4a4955e4b8f3b835836a247f4eac80d08c4ceac0644bf59827848d4602cd803955c5bf782fe18686fdf719ffe84168d899518586298bac64d0cd3f5d2313cfcb76ce0d1ad822ea3db5a29e947c8e75839bae0c1232e5c7d6bd60abbe6fcc91ed42a88bf543085688c5d99d407053814d108101b663e3ba2097e983df746b8e1d63e1fec61fa2d7a41e519c74ac1434da16bb6a52b219df17db8183c5c97298e6d3d9bda9fa0ca0b9ee3c32d831d2c63841f199c1b7c7fd9594a0ce93a2fff6bdba99d114f088bd500e59d85cb58afbe08bce035835c7f6e3d8a28bc61261e0f1168c0eb16147a1475def4a3e66020d8aa85841b540575c5a8267222fd83dbf6012f847b101821ea436d31eba93a2cefde01de9414dc5486fb669e75582e93f2ae3259c84ba35713d6095245876e4997e71538e8451edd687936f7f04ebee61c50804f0410288c62ba5739845e90dd1a5b711080674a7886919222bfd0221c0e19a30afb637c21bb69db61c3b6f7eb20545cfb8a0c89a8017a2739a3064324a8651199b1a18ba80265fe9f1d20da0339d7e7bb9419de17ba240887fbff5eb6298ed03f5dbceb5bec75b8f5427433e3d01b49a042e422c40c123dcfd1aa9caf682bccae9bbff82720416520286930eedc40fab92905db5c534a6772b9c3315ef8b4a1a265c1d5ba99cc895b2db012e75f61857a8acfedc1978cb1728f7e0cebc0f2b7104f6323258872a65a36010d01203737bf874781ff2fc779bf6a4f5fabdedd114dc1c24f9a227e1ac42a234c2408202d8a962c64d3363a3b482040c24a929b186c5d515d0bee0122ca46e24d099a2d1186a346fcdb1d8c68e9a47aba7d788fcbc3f6f71d960e72739f6ba6689b96ebf77ccfaf65ade1ab0e672e7189258ce9d2b336095a1b51e9efb29d52b80f2a548c3ffcc85662af38ad3517a7d4fcb001eb5e5b73fc12c89dfebf8fe834cc195cc32dd4f3d94679c17086accbeece218c2abdd0582e6ea34ef58538e4f5b8a21c354f5c7da7d415b7673d66bde6882136348c1a3671ad7c83ae0cbed7dee22b3d2e8d62d394b4790055d909ff243213f7c6f012105523411edee17c0a50d4e1ab91cf8e7d20d10ee76431a0d5a5003ae3ed1e4cac8e67a3757cb931080d4653448530b52b060097dcc5c2acaf7ba6356e72a6739b6bab24383aba5bc93b7321326fb6b9f4275db9ba715ce6a8f3f82fb0454744c23d7a7a4f25bcd71c32bf1ea599391138a124fed35a17e851c2734805261adebffa693986d14547a3085b9b9ee4c9e1bad145a8a004396350f403759b0df4a306927608057128e326fcaa57aedc592ae0e6b06d0d1156d9c0a79efbb513acad019f55779ef698440da232e2e0845c360cb5545a8987b062fbd035300c6a717b8c4eaf6522f8637f79e4b3bb5111ad9cbd276eef7473e61337d0c460e732d8ebdc891b1df6b8fbe5d5f4f0a3a84991d83bac6e6a246f023d252f37afb19cf3bcfc487e8981a5719466b28f945913fdbfe782aa569e47b7a5224f72f90032dd1d7260d7d7c2f82f1feccaac2a8d5b0f061d53bfe388590ad9b5c55a35f7ac4fb9ffd81367351233cd3e36430687895eaac83d18fedf4ca3c26ac492253a7db203805efd7be83f23af1c0ad4b3ea77575ae40c3fa4ed30102cb92a02c2bb8879001ae100f723d3dc5a57d8088e458ea9094e91255540bc5bb78bc1a9005df2fc8d3d3994b1ad6556b8fc1d5b883141e784f8829dcf2f5ada8f09bc44b06abb25b4aeebfb59fe3d0f8733dcc997cca86d941dd36126de3763f1d0d147bb308dac0c2ad8d6014f58b0ecf562364a84dce796ea5fce35e28969c06f75caf1b75c4e9d2ea34bcd0c1c2e4bcef71373ddf345ea316a4e2f83722bf6a05cef13098a159c9c7bdcf74f9b945001b7f5c4d432e710273f0d97a1dc03c548c4cef6d4c6c9429c6603b7bd946e27efe1e1cd2e4d5b4713294f4d52a0ee04ae13814fed7a59629d3ec34ac578d9ddaaac9e31c7ae3d25fad04c813b03e35f6019823660a430baa83da43171d7db60c930719b586cf7648f8dc377fa8c2fe39c6bfcaec1d083a9663da4e757c71cbed59c4a72d5b37003dbfa2546696d4ba57fe36efd2ff9d7084333cad620dfa9b579d9c44ad7cf61aa307a0d218781efd5c3ebed5c61b889d2bca81e7477fc982cf854b5a298db6ebe654cdd601a6d2bac9774f7de3f6bac445fcc65c7b96d7f88ae2d9c3478a54ade562ac949545e0f8ff94cc395f11d8244b55bb86d53671b983ff8afeaf1f634d566f2cd0a991263b08a9a92eff78d681847b827c6dbac70af290cd104e070d282cf2f8b7bac6dce8e4345292bd6fb2bb10764a84b108a9b5370e91fb2f76f20fbf262f6876380dc30d2970f2c7c9cb619883bbb71cd9072d987cc899f8df5f93a7015057edeeea5e2e164eca526fcf3b4400124d9f7285bc0d0ba25e6b6ca0c07ed5a7917da8551b4063f0487801eb75e31b406268fe900489376a55a7398e9fa67dcd49879162176ac72c97d0476e71adb817ba924ce0654eb8c653f9d3adffeb9fcc0928b3d59dc657fc31e4cb8af37ccfde2550a6fce925815595287982d2583a8f9f222f02c982957fea511fe247c62c0d8643cf38503a95168982fca2d48a9d47ec6654de88db1dc297f3a6421e48acde28002f3b4fbee197405cdb43a9a600a7c33fde6cd431923e81d994020029833fe2d1fa9edb65188bba57bfbafe0b26fca2788c85c053171de59f7fe65a6e92afbf1ff4ad297bb3f0abbe3806ebdae6fd13ceadc388c636ba510317c78f213447a158c4b95c89783759f842b15a76278c03a41cf92f0242a16f7e8742b0083449b2e52bd10d16f5b27adaa92667b8b4f623b380a2084c3aaf79150bc564e631d84ceaaa5ebfe7b12657aa37c2bd4f0abe6cb74ae4409c603279fe117a9a00d90e42c2e89c6e4c7abfe297271bf139cfd14a8a70a22c3c6c96f02063eb20abc29d8ea808098804128c3617c528058bb84b914d126777ddfe34c2eb680b99352951eb3c338db565a0ffbbda371269a284bc8f1b2135e0284e88b1dbf7435976ec4b37f1fa9da0b3c95ffdb252d6c222e08afecff2190c5c32eecaa955b3d7a8042afffe6113cb99e56a3e8a6daee9fa86205d395b046569b16e984eff5d5cb7ca7374275fabde9d5efaab3ae975b051467ec95d4c323c99636b51a3fb1465d32228eb9bfc3ca738a67636b8579d11699a6ba9d02b515546a406a04347ec0f619c6dc580b8190dd567dd4806ccdb81a835d14723828cff250ed078a2b4533ded14f2705ee72aeb9e7e97aabb60f0e6ccf56f4923e2abc937e048daee53e41ced11a249d275ca90c733f6df76960347b68491edfc1e8c51cae7fe02d88605745803b734c83f784e01b1140d6a1a010b4b738234fe561d496cecb3187a86cc79b3aeb57ff5256fed88d0953db415d2bba710f50e5e2115a0d0590a7c0b4bcc4159eb60ab4e933d78a37470289daf261c21333b739c8f59b316483ac33669702993707e1402599f5987ac688007bb0c1c88f308ce8d7d5bcec99a370de667d4d441b51fed960f67e5b529fd2b481363465b8b9c702da5f362d0b42fc25368e38a9bbc99bed7a77ed8e633bc25a36fb81508d54e587656ed1572aef1972c467cd8157c157cebc68495ff3add1ad8d5ff47e1d276970554abd463ea06769ff580ebc73ce2226fe014208f38d1152bfc4fd9bc0367f90b7fc3ab20e0399b76ebd34df9957e51d9e657b4a0b07979b03ab6a497ddca9f4f66e5905ac76125816a82bb682c334600738acce083c0afe6d9e0619709c94a1686b45be2dd514352aa0882dd3330667af187572334afcfbcf8f03a46afe86c4e433a191944231a2596768dbe01b8485f6e4ec1149abb2cc071d5a5956cbd7b93ac9276d53511fd33e31928d6f55fd0228c7ca63689e177a91a54f7e1bed823a5758907707c975fe6311e6e3e8658f0af84db69453b529781af904981fe38b2ce5d92805a6c47f8ecb27960e878a4295f22bf9a31e99cb7cfd0f8fe11cadc5091358c464ebf0800cfb10ec868e7ec8aa754ed0b70b4e4f8a64813033fdd1f409d6f11adaa6776784f81b80ccfa86445d4194e8f7b43bc2e2ebd7d9527f34b23c1f7cbcbc68c9c95964ac39b32718c8d3a2a040f5aab5b070771b57627089fd4909584a629170100627d20062bf05b6db2a6b6658667065301ea8d1a47ac9482a94dd58aa80162047e27aa3b033f7c620f56684b9afc26545c6e39bf0fcf06eee6e00b7d875ddb67584dda0972b9c15189b325465568a000d91e37d4d2e7d70bce2e7d0672e9bc6d495c9fb63d910b064eb73478ed38fd11ca766cc2fb69f547cad2623bfd1af8414273032b2abddd97e1e9d2073286cfe2d0bbb95ed743a9cc86a474f0df1a79247afce79f83f234ce98507e92b5fd64cc84b9e5202f94988477131010edcdbaff2c36225539d318c7eb5058f1835abc80635d0d44c9717d4c6953553994b2b736cd07b033d3ce6533e9226136972112396dd6793ad9e92cefa71254294afe6be0a4ff8568ba80288b261e8e05d65fdab958d86176663910b676eb7e34fd46fb951528444c4543044096f4539d4cc1f3914eacc67626c57ec3d668de332c2ae89a23b7ea5b1340db2b15ef028740b19eddf31ec01db8a9a997b347710bcb92c6cdf30a369a84b1ea13d6ebe19aad6fe435b0ad8c3e348161fab4fb9b1b71ae7d2c0b376a402745c12acc44bb65e76b49fe166e361a2fb9ee00ccb956984335a65f6a8c21767cdbfa51909a7ab58dd153e39b18132fc906e714958c6a1325b2b9e0db83da10f2eefc865cfac20c889b25ffb83b0fce6d51612aeca66354a544c2b40fb4c9b6ec4cb0d3e0934144745bb46b77c5ca1ca873e8672a0bffb185bb495b2c1fd58fb2a36c53d0394ddc2a67949789f68592da9595b63d60ae34227930ec82092910863e0a1906a811a2a28d620fcafb22835df2c9112e53b804bfd47858f3be503aa8452c01b4a98609e3bde0a209552b53b5f28dec97f2cb062b31f8b365d78fddb39b9dc0f406641b5d1d0d731a50824162770beaf873d5be4bf11282ca02b31151eb6ef895f32a0c5530d5c650ca87a407aa8ebfefcf3fa1cda60a40ad3ff36cddeeb54ca9e403c13adf62eadbf3332ed62e63b34923eeae1e89477add09a5384c0a8af90e6f4674cab1614b65e965f35f0874ba4cb23cfb9a1e68df7ba08a9b797885ac37d05d0bf12001", + "commitment": "0x01f234deff6725bde5e3cc9821c6c7544cecedfa6bc55501deed2439cf73c9d7", + "slot": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "parentIndexPlusOne": 0 + }, + { + "encryptedVote": "0x0a8a2408021080041a8024f1ca817a3013bf381235b147c5e52cb5f0dbfa2ba506bd48441172ed9a592863922564010a26c0ae24415b94ee3c53ef5938e65221acc2e3c171e928663e43ea99401ebbbea49561cba4b526f84042c587338825f60d728ec26273d770f335e16ef36af09865ef1dd67363f7ae3b9100de3ff3ee7d86ac256ef024017f96f7ecc9a58ce3323182c86e5815aaae915fc96094eec46394075ecdbfaee3bca983dcf82bbf479e79fe300a4828e5a0020cbfa12ed4096697f37b99fb9837ac3be1281ea04ced3dbff03546bf365a23d886fca9344739a2d7da3631d5bb6ebb1fdb65509a90ec0232604323a5916c5f48fcfda38bd4379323a1050b2258d97e43703a7cb48a02f74a850ced25a6012da9ca102c24a3c5059ca89781dc8bbb93939dc21492966b7af0f039980f5ecc57e9151d8ca0d8327e8ef67e16dfb0c255edac0c0b1bce1e1ae9e5196d3e1186396d07c398e2b111dfeaf6c28351956a5eb107451901e3a7821c3871fdc1c64ea8d0d37dc0fcab421721375b9d3ed470c0ae058509ae5dea0cd016fa223197ac2f21e333b4c1e7f0fa30432957a7b49ea1b7575a63f0b1726ffaaacb87d0da265feb0e1f9e5020aa32b2ee2381f94e5a74ee5dfabbb05210747f137c62b01755c72ab3e99ae500cdceb7c4d16c0d983253a95c4570b8b6439f72f30a0a22194ab9f1f60dd928628441f97e7f2a48c06be58b265effbb87268c99acf93fee6d924052eae805efd1799cc8be958f7fafe3dc2d1bb00507f11e443cdcd6d4881cfa89ab3e0d3f9a33c2f9d51a4d48578bc2530b0fe51ba2fc4fb118a2c17e7e146fe16b5763b8c7b854d6d2436b8d74dcb2e056d8d1b7f803720a3682ff94ad008a140fcf1daf0757ae2c4e04a685bac9ef9c6ad978c05378907c08be0f2c17a09984be1c3527c548220793dcfd35ce744a70f46f522c9e3a5f35801f49853679242127241a770e95eb54fd8a3f1a0fe49597cf9ef50a1b2645cda49873de071118bc366335fa5f56e3f366a414a6e0783491f19f25d6e053653f531526baef2c3a61a216248a623f326274130f01cf78b77690214e890d3f95e82ad8cb97f9dec4ad074491562cf4d240ce916d96091ccf691ac84cadba0b94f00884c80fc89d35cf9018753c7abff6fa75c8f26d5fa3cffb546bc583034ad046a0d6a9c1818a1ba04d98abd395fef4302778209c1572efe65ceca3ce9850f71264ce3bcad0712594667311ac75a1a05ae1522c7130f8a9f1d7613905f28dd575ffbe39213cef4565c060c4ca085361d67c163ea92c7e8b390941263b582eb46080f75fd19ca9bb5da90e7742af317589668b05bfc8569cc95be85487e953bc2f219ca18b687f5938d912a74d5fe5fb3b1927b62d73d63282f4d1146db1f460526affc10852155aa7117aa7ed17ca5e05833445f207f6c364cad8f842fd3a0f9884f667dc25ff381791a274c83eb87b67f6142d6f40a47e2c8317296ef5ee4171e5dbd3bf53e08880525b1a081bf0c9e89cca8be424d8c8d6b070f434abf9f55207e67be94cf2aec4789c46f2af23ec9f641b383886cc848e751338b63543cb2eeec764d92f6b9afc38698803932faa20556f2f3e7ef05039dbff9821120fbb0cdbb9c7fd80471221d25a26a0435014ca66fdf289828b7b91a5bfeadb37c4d559630a3a9d5e1e328ae3e9abcb34082d3040831e797eaf1df30bffd7fcb4d33b6efc1c7aad390eac260b844b98160ec23673d9153d624f3eb8f4e8f4c61006142ec40d39557b8bda0d4096f8b8da0ba10f55e161834f1720d1bebbc7dd5ff04f6768f249c1f79c4c3fb040ab03e11373b269b54d27b0e31b096fc91a5488c638aff516ae7c113e4d94b0ba73c395290acf529166d6f6625a0a03ccd6b0616ac1bf791b54782350fd0f8cb4d8f58d6bcd183a3b9bf7b4da4aa09d5d30c0ce919ea17b044d8bf45dc2b378f13688a5c08e65df1966420fb39bfd38ac3dc316452079158e3559fef297b68fbc0889355d0fb5270433cb299fad2ea34cb9c21beaedc4856a4667b0bc39a3b9a45e070d265905627ab3d83176a2fb0cd751dc4ee986e2ff8ab723dc8d708ab9f982cc5f163b01a9c3998a68066b25c30c1666727770b361a2ce37ce3f64bbd340c1e6d35130fc86c3ef925d521f903164add53e7914ccf97f553ff7bc164378dc79e6b6d539cc826778f89afdf4c7ea74fbef5e468a95a25642257e2dff128733f3cea62dacc825c9220d60acbf2f4f389bf77ceade6c9006b509f889cbfb21ed27f902aeb51ac715b0741338842efd5be069c73184ab6b69e97840ac5be53f0744979d728ea4a255579a367c2ed318b1be8b931175ac83f1db823886b027ac6b9ac7f485144f2e1461679ab2e33e9e002379011d26274f9ee59f26a7471dfcfe195a488b58cc1bbf904e088e5ec3e2f76b8c37fa8b93474e8905c9a4f780ceecd5dbe4030539cf48929428c9c80222abfa9eb99646feacfae6186d2f6d3ecbfce906c19c109b68e5afe4e1a9b6260061b298c677d574f566b9997e10c4564c8e77b385c90d6327a61bf329bddd4f676dfa6865ad0f137a476127edc03c12714f0842cbfc30b26cfc7979923feb2900f80011b865aa25306c2cb411acfd94348b4cc51f79bce0c9138198617ab49fa54769759615689b3fe096897d72c301ed2bc6e52dc8a362ccdd875992fd314a0a301792e98c4c8d47e0ee921dd1c48aefff46d0cd18b3d9fc68a7c278040e651f777c300a9359c2f91ce91d43c95bda272ae13ff5767772a35449c2bafcd26ad77c6d9d4ad01655587d9f055fb7965b8b220e08daa8f19a620fdffb46008d77bc60efd3782537456628fa3b506a2091c586b046dfa26572cb0230fd70018e49a31ca421b44d89ad89c1033cd9cf9b28a897a473bb1b86eb1fddaab7f4adc238efd7cccc36a0703aec410eb865a89b18a9a081d1a44687b2c49879c1e3fb7b66dd53a8ef460899b5d44921beb12d85ad523f92825ce6cb49eb26015f4528e56b2cac873079dc23bedf2f946c9815e78e3dda618dfd95650b4453b92e0c56cc8ec6f38aad51c13368b8d84987910749b66c9283f19034602b6ea9d78317a33098041a4ff1d58bccabd1cb50d61e18e264d80e210100c642284777e7128215d1d07512c0a15e895c0ea526efd6879da6ce45704d176838a9fd48046ce7e8b38f662b75efdc9c845b623e2bc27a765d7848833b010bcda92e841c41184f846b3ce854aa6eb081230f39ac26fcae5bb1acd47821e9db08607f5ae1d43a0fc231d739d8ad58870709ea544e5e9f036431899bda20f3133a4356599fd884056c3603eda3b4a456a24a47c7a6431d404019010cbd5e9bf65f339db5dd59050aee4998c2ebc5f19da11c00f38f0fd993f1080bdda0da3a7f0b8017042a30e8667dfd964c0e833270d4ddfd4870ce34949f6a347ecf7dcff35d75d9f81bb2362802fcab6a1cce91fbcc7fd3288d9a692e5e7880b785a3591d1d2a87501d308e0eaa9ff61685f90c762fb303114c9403a389ff6fb60d52ed6e629a01828fb4be411de3e2fca1d785870b42892b84dd10be7ee37b6eae02ecb2653fb3e5244c31a0168de2c7b3b7b49a9ef7b39e5a70ebb61605c383e845f415de31aa4f8cac23590801a6e9d8ba0c76dd0b8ed2277612a20ba7f95537167a07dff060fdd651d9cc04abec793bd0d96bcd4b8856465298aafc2dc179518d6b07515840d96151882cd6a0712430eec5586595d9161eae70369dde7fe89b5c015614dac70a3f2ef60e6b75f9e1bafb79d122faf479e162034bb3ba2b9f1a0fb345024f9b33f8ffdb9ed50e0c703c379596d259b7c583ab234e9242d4eca0d2efaf3e4b43a85102c73db3dd8765d5f8c53576f60507081883c753358cd2daf1777f174cd650a69dae6a3e44932eafdb49482c6351e0d9a9daf2ebe122521fbb8d30d586446d80b07730025f934c61fae4c3e14212fa58cb4df724924dbc9fb8ae8661625f544ce87e6aeedeb7333628088fe3290c86d5936897ad14b4644591d7a090f98c591feced4dc9ec8a6dff9231bb5caba01ab772a6564028dd0d9bcda9852624865afb86661339c5094677eac99b9254db9e9c159c2ca73d9eb093993d0ed8ce19709e02181408a17d0baf6c463a97c74be87fe0377587833e2dea23e6b81718294a82bd755c7a3a77f342fab442a0f46eca0a2f9f2ebcab7683b47d818efe0373eaf4136bd8b0c3a5b79b77462a63a85441f050d8d2f4784f7da4b825504f28d1283fa1ae764560ecbb8f9a865317ac7885766c19b3881295fffcdf282ec219873b08be145fdc1c03fb46b101f1d4a6cecc56df85980a8ac5bfaf0f4bbc3ae8aaf96a932830df3f201ad727f7cbb41e1bfc4747957d43a0fe9f3524396dac2db50b49ac7f4b9c03336e853b9268e2d793b05572aa8a0addcc9f9c63327bc7a9f3ed39c634b303c61defde7639e4d5d65ef267f471fb732da82005fb2d57b296c3b48d5877ae99ff3a6a746f12fe2ea6fc6068930546ffca737635b0cf3f3e29e25d2e2867a0906cb491d7afe7eb32bed2bc308a5627132cab3fb0f430f7777d44d14c2c2f6e7532043265de107f5cf9a4892f5da0a6b138165d402849933fe7bc27ae46084293c884903d9eac38d1afc7098e165d3fb034b5ac55370d214c91a1b42ede7f8d2b0962c8944ac68a6b9a8237777a36917db9b696a964244371dcf402b278d9ccf137c0345239f04aba6032ee4752f3b74ac6965c17e2936e3d6c88c1cf4ed7d5e3a3d088fb7eb828a5ac053e5a17bdbfc38f9fbbf0cd338dd3edb23900179de4e56a3fb1727ff28dce4b80b128f6ffc37ec7487676cb39e24bfda3a81912bc97a2f88b799944d1f3a727e1e7c682d843eb13f21256bc1293f277b404f31075d38c485bfa6e63d1450236414836322d4cd04466261225418387f6ebdfdbfe0f3999e5f400a5eb7b25f6e45745bd84f7887cfc10af7e29386c20e30d3cba996bf96ebf84669b90ae2cfd5f49e0fa3be3f91674d8f35797cb8d1e5b03526a02ad9b29f30fca48ddf951b052b8e01dc0a4e5eb2bbfc48e55c788705c96bf85e162272a6e089ebc8c60106e9745caec20e77042e84029377c18de9118642b7c7d0dfccd03752e0a990cd30554719f47b644125830677bc583584c4419867c6ca8011f1899c2c5c7ff741aa9799e0c32c54ee7560f48faa50ddd2a11948f74d60250fa205c61885ae6845454d611fb1b857f3ad523ec8bbd985a5222a4e041fdd0bfc8e2f3c80a4de2b9a41962ae9fc41c24e17004f7ea4413ad3a085037670930da8701607aeed0ef8adeeb211213b0bd865d22a2dc9f0669025b97fc013493fbaae2e2f6bd65fe1ee55e7dbab823f5d8163ca00afee2d4ef04be8960575ad1f5e626185162fe129cb6b09d8a6156d197fdb70c17c09bae112c8f8d67201c1148257b9ad5356dc45b229bf654e84a56f5cd074e8423349b4193580bcbb2dd3f6b22d1101f882d0fdbae3c9f81140a2b57993ef5f45e30ed434a27ef339956d3d383b4974834fc5b2ce8527324af7049cfd41851170bf975950cd62fdbe08e68e14cd9932fd035c04582eff506f0bc1e9a687e2ff33c540cc2b4b4c6be5803161a047282c3a87948325dad9f4f1ccc520e736cfaa8cd201110a119e3a6c858f33f4574cb6314c9cd3b0afda761f12028c8067f92d64682a51f943cc314c65f4fe2860d5c7726dc73f00b4a310f9d359557c0f2367d11ccf8fd04dbf2abab38d3c04d03b09d7b5e5ce8c335abab5185a7b835e60dc568a20b75e47eddfefd039eaa4179f99ea37e9ac92c8527a514010fcecbac643b6e2deb37cd71bd4f312274d0385424cab727b963e40a03c032e352ebd66c8bf97645eb193cdbaa92b94e1eb4b645250172bbf990501d595f8de26dd8f1677f025d183c416dbe5ad8a107af418d7081d9d35d745a6c205e1fcf912b96b24ddbd78567e966f16390623d882d9671cd9ee687fb09805a9896f449ccb3ebdea78729b6993f1c605d88e03d87f139c489dbdd10b53bfa993acf1dfbd17a44bacd210426270f1ce17a83d78855a9e8a09a416a0e8b4dcd76184996e9f00aa3328b237034ca16c556ca1b302c05b3af2341b4eca39a242375aa9ddf912793e4341bf701d42c63cb917a1028154d6900719745a8286ef3a32e23cbc5d099b6a03ab177fd70c0ee45badea1d01af77dcd84e7bf949e287b2358b77b49c13cb868df38b40c154508454a15a774a1a1a54a1853c451783272feb588a975fb587a66bea904d8a5e2817ddb863f1a7532b8bf507432110d20c0eb3845d736e63be50e06f2996c69ce56eb6bbea307c4137787d7f8864e6d08eaf4f31d8c73231985b1d8c8e40ca7d0dc325ef33b42017d6b322bbc534a1e39c2ad9ab0fed1a273dd0eb92cc535052a28c90f12a807a9e9d99cd097d1940ed1dee5cebf09c42948e2c220915a2b6b759289768e2ecc05450aff89f7ce56d48996ff1e20010a8a2408021080041a8024d4dc83e9069d6e04b68be1afe10dd78d32e7ee85982e390d51ac0117fde120eda64e7c495d24906fdd6f71eae036be4d7041c7b30f7e18c6b5829c31fe81e285ccdb9b4abe455769fc45a3ff7de6c9cca03ee7525b1d5e76532eabc31b93772482a97f05e5c6b1f1f2eb6211328a39daa37e38d2443777b4ecd44f5a507f412731fec9bf59ddf3c8a90fc530ba4e7c61ac2fbea6aa5c0d93fa8409a823f1cd5e1a4da35366dab217c67af9a398d499b4478283da0b42468644e5cf98b937c3cd33d697c61d0cce71ab9aa41b87ac004c01a317e0e1f027ed6d62d954960f63d783e6031059c44084597d21af5bd9eabcf62ee11c4b74cb08c67bb49d823e051b7ecf13bef86967848931d36042f6bc0767c8369d652fae562f69835de57286632345359a048883f01de5b85fdc2cf3058f9caa8f6675a71744b7ecf28cfe6893bd0c9b0dd287d788c98406c2a60b7268bbb59e682462a7ac7346d0992409906c4f9afcc9daa235597459e06cd285670e3ad68244b69538f8a6ca5130bdec0984678f09750ee2f35de69742aecb5e8f97f36eea28083c13f656c6d9aff0a9a2cf79535d5d8495e495327c337205d37e2c2fe07a3488f5af995cd73f587be7bdaa19c586082012195d98fefb36090d61febe73a5c6213b238c4f81ddb00a3f5e07c99c338fbe9fa3ec50757920fb0cca84e7cf61fefbac36d3cf3eaad18f617eec3f8910de51c65b3671058f8cf00c375570625fbce4e4666f25e2bb789c71d7041f3482ab34f864c55caaba09704c68318ab653d776a0cfdef153d685ed2555461d4be29ced35eb29955862bfd58841c7d7e9db6ff2cbfd6a7e30fb117e17fae3bf08179531270e72ba65fb4cf93470506352541d8699d7f21de58c9751369f6fc73c3ac05e5cd82e0ba59d98a843d753d256e2d70f991a3926bbf0576d431c5b2c716346e40feddb4af20775f552a33ada83edcb3ec3d65c530da87af7569f5385d0929050190c6271e3f3c997b0f133b6008bbb68a40b468c1969fa57f17fad45ef0af76d84807bbe9242967fea3b6b1107d5d55280df67c81d658cbd565260c2098dd2d8b6fcfebe5d8ea5e783f50839e9d96b67412fb363bd101bd96db1acaa932f111f808fa6f63dc6fda4ab014f636f03ab9d4c0f0c6feb317a1e1ed99e6ffa2f1f323989e055e4c70fb72ca849c4f7f8da8bfee735ba2f64bf5253834b88d6efdf24c9311f9a6d72b627ef2369ea66b5325da0fa5d8962f77b48b00d4d88137c07090ecbad9b1cad370745311ce5db871f761da5a365789021614f7197cc9ba68da83449f2c865a4760d73e632477b6e0728f7ebdd255675f809be0327452599f9038ca72c8cc8b6ab2a85dc887dd8018d78aa87b5c5d6f3fa10dc91336d942c62c671ac01ded6fd33d388beed784f7ee927e8223af6dcbe58a66acefebc16f032a9472440ba5f81351188fd15e939cb3e45aa836c6bb5268bca705abefda657eaf19283a7a4b18bbf3a191438dddfa484566fb4d8a6c3bb956b6263b37ed8a5d7ce2db7596191bcd368d58c422f6d3b1e87bf9d4c203880e47e70e9263a79be9068fb8af64da78cba65252c787baec93a768b301f5645756b65e74e6f4936608ca51c92f4d093f3d78d19d5c5e7a0072b1e3f3d9cb51cf2dbf567116c9ed2251c916ef83036f5a13df0cfe3530fa27645b102bfb3d0fd387ff0ed667222299a812747f538613a922b6b6761b6bc5d2d27fd8275f43de08d72144e821bc782403ff5c31ee51ae2a75824176f06e7fa56bd54485f7c90d4d33b1ce2adb276dd6fea13f52caf95c61990b861049233c883b85296cb869a8e42f52c1b646d90bfc1d07bfcbd9077a5fa8114df353252dc631ab1b33448dee5612ca8e68cde8f5437362f48a160e15ca80a2ab1beedd169a4b7cda54d96779ec3bd4cf4204cde1dd4cc78419b6080232e03db7e4c6867cbacc75a79db5a2791e8c754544d4b2934ef22eacda30e966b5b49755f5571a3f84bc39bdf963372432818fd4d3ead627391fe8db3771d0041414da0fea571ff636d4c5b466901d4a0c859650fbbb48d60c7b0b13c1cc21315eb91db516d498eace237926bbad2348561a9842cb41e432e5b401e156bbf938bfcc59b7e41fe6457e17474d9121ce239b168f3ccb57669f31da296c20fae666f33ce48994261fc1c40a5af4a53a57cad7ba7bfb8e9357f3f300139a5854947073dd1af91acfdacf0b072e4f403b5e4d27591b593154b7e19171c48d2601735646d8557fe3840e0416089db1972a46bc7b670332bb5675d7c7a6084618e4bddc811b34ac234a3ad100d342cebae28646f8ae66f8e873c0c78d03c0e9323651ca7e20c4782f1f121a7760244dce21d34609cb73a3988ab196e20fb50c3049d294069d6b908bbe34c687a578389a2c2244841729cf51676c70c19db6053b98fcd0979cc63817f07453cc63269db2a857bc21aabd11c5b5d8e8b0723a517825bf3a3cfb0b4af701f28a832bd0a2fec34cb7201940e2441e10aae68e35688973e112c87e7e3b5b3207806e663ddc0480b0aef4aa316c9372b3898495a82fcb7987b77187e2a4d177a593fbc4ad4f43eaefb840dce0b047599a7d5278b455478f3b372a90fc0d374ed6ec15ebd546f014aee553b3c1ce61aec8cf8d3437f06cb49b341c1d5d4ab2cea26dae3c81d831a7eedfe7a4426e0eaf6347c8ec856ff8afbb21d5e3f80182f62c9c90580a6c5b51f1493ce1d86c9ff17da9b699b8d20e3c0017d25b585cc1fbb6d1c8f6d378d46808dcd1894840817b7aae63cc29fd88125c38bf8ae24f5566ef1e7436e44095ac259f9b0e180d8b28d72a002669502a6251df0dfeab0e65d4a9b60d0686e96d835b05520c2d7f6b8f60c3269e7f74c19ed1c8bae956f566883427bff1450595b47bd6faef597009f495a5b050f3ed829f404c38c453e045532d4526fe121f3e1e8b8c5cd65e021de38736c33eab88ddcd89adb970a991a22e45d3eddaef634b1ce61dad8a45ba3b4caa9850245cb22e288bd9aab5cc1543b8e0ae33b3a521e9f913084da1f89e62383a7c4c5cd155c4d37cb8a2d8e7ab8b1085929b6fdfada7fcbe7d8dac32db7ca4e82d3f51d17a4003ede0b623f5f52d31bea6015c8c8f7000cd73684a79ea09d752846afc7e9c3350c3055e35a3123b235d47d81af67bec16a9955a8f87618a91d1abd300bc64a6d1f9da137bccbc2c88c18020bceb8459fc307ada8ab5af99f5d07b914b7f4227edecded557558b064908520bfb6a26d06036a5a2e68e6eca2c3dd13c1c2affee45bd43debe0586ea9ca38406c6b9794ee15485b3e60b7f2cb39bf9cd59e3b924638c4f5be6df08cd201d57fef9e88d7b423f2681e2644c6cc66fd9dd22222c3b5848bb958ea29fe493780de8f217e13c4e2518cd362544ec2246ec93a9e6d0edcb0528eab7f2a4c312c3d75fbba614c7d43c3d9f3e9364ca2a9fd6ebc6d5db6cac6ad57ad1ac9ce662c553f6d1e0953452ce27972d7d3205abe5e03c41497aa94cbf6845189ab8d38444bc3d807256d42eb09bf19858612d9d54849f5bf8b705ce97e245be5d92e5e0ceace1c7fbbc8847c4ce20ba527865c3d46c8bbec564a69e60bba977258a745a01db25b09fe576e05fa64312b83936559abba8f7dae8a1a5e86cc24043d2412d85f3f34147cf885315d18e782e06fbddca6178841989b4132b272cfe7b8d8ab67daf663f33822b0ef1e55f19d547cb489a0e0cc8812b347da660ae4b9e72d4219fcc1bbfb23acf62976936b90194b84bc52ee43ec85e0afa0d3d2b78b07fbd72c195b57102f21118278700c04d9958b1518bca5c8e7e38b15e25794792b252088918bf1f74b57aa186d44db189f151ea297f595fa0883c067726a9ddae64fd63457b49f74be87f39ecda232013f13a2894f47c9676a43cea2cba888e44f796e5ad7c1b571af1a0f1dfa630adf71ec3caca041d1b7aaf7ab62f3c786a30e8a5c8929e7243975966fe46abadd72325fc35134ea81edbc3c0ece1c679740ba59b3967ae2cb7722fa9ba146bd69bc77e8b65a3038dc240e7f6119f124b44a1ec86ca96eb732fcabfecfc3f040ff6934963718265a4c3b37d003de2a8fe2364ea3f8d8b73bf8a02ced52149062e67cf866d87dc3e8eb4430a8a3b3501b32265317875515558d92e3dfb3d8ed2b3c193f7bf7ff7d405cf0fdd3e2c256dfdf879c6f3133b146d36a474e863a4320d7118066732b0bd0b743f7dc6a017af8a14c528b8f0964d72cf6728e11b896f95b96c05c27c85329745564f2392502cb5b659be3552aafcf1ddeb4a386e541a3be2668f059b7af25b84ddbd70bc51256fd3fd30ebb6b742208e6c82a43a430b7ba817e63a4acf1a4ec03f6c0e4cd1e5d02a26d9793a2ab2b157a6d37e976d5c096d5b356612fea5fc60613a38ecff3877d11e87eec414ea8cd4bf3304f8fa97ef9e1ae5671cdf4b495babc5642bdc1ff42b074d1319babc0d6f5c77ac6c38c72888bbf1572d816119f41475f4cc7c1e7aac4da242bd49d67767109f9c79e3f9bd16f2ec4e1290fd13818035804333416f7f978dae43f6f83bb1efad45365dbc6218ffd0aa73e857c8171f8c1cebad22efeb68f0414a87a2cf7c84f7c9a53d755fac78083f37306f7483bf3f49bb271babaeafd0f9225ccba9843f5f5715fc1c4f2d0c4a92eb2f73a0eaa7688c8247b3685ab0ecc704201709b1aad105f73031febb3d77ddc07412c3cd44bcffa8f9f18478a777cd635185975e49a294ec67dbc268d25cbce8d4781a5c7514052460c1a5292c08f05815f510f7371344a3441f1391cf723662a8b7c89ad742e20b2f4ee6d8566173a3ec1508194f9b24f721d0685222b924730047d96d7ca69cd00f0c03733d5ff695327ac97b4107d1c304aaf65ec8d2d3fd3763cadabfd4fda94bf19a921d0fcdb8564d4f54cae213326a57bf532ab1b64ddbe98f923407c8fd0f5464b622c9c04d4c1a60f3f4759bedb05813748e72051d766c88de80fb943ad00a702361232c6c969fddf63cf34111ac34cb0167e4cce1a83fe56e431b0a177e5aae7a53b57bcb8d8d0991dc1c99bc3a06fd9bc30fde0126464cdf3ae7e4f12bcc0f8156b5e0d66a4615a6a4ac8d27fe8b6c763aa8390cd5a2a5f44164894ead868481c393a35987642fa951876997498b6078bc5e4cdd737b15e0a1a25232533dfc0c51d9c4b747ad321b1d6da0771a1604762681fe120a379ccdff72b77610f80f3a695b4169094df7637d360addf65ebe480a10336cb164f0a05555a7887480b6c7a959dd9e7d78b95189537c8ad6070017b769e49507a10e59410c2fcae3e7c559bd46fc7eee37a95a2f4a6c3c01fa7337048d02a7b255ccf8c3536be53c89f53d7c18832c8c878f31b5b517bea6e17a768bff6ee479bed51dd9ec12c61f4086aba127dee5f67ddb52a01f9760472f159f4e2381f77ba025eb7be31a5142bb5b58dd175828a0e175421c31651aa9f0024961b06f0174d1606f280087265cc4d3b465ecfec9f1b972d1f9f5b6c3aea91fe84f97ce9a894aa0f6c36c26b429edcc47942b885937d7bb5d2ad873da7160564015d302db05c3d064fde4732947b4700c9548e8bd0a84bb3bc46e0b87b4231dcfb685274ff91c7f85741e5c2cd60e84d8722f2e24e9e608b68a710d7fae7d00ec142ba2b4948ac24aaf67ca64676f42da866aff870c827d5e779310ee645949e569a274f53751f4e20aa757c7a8b67e2865be66ec7b97ad137df8f288d1a90b80917c831eeb975983679dc2680301215da15bb3a207d96ba14baf20e336851342fd9aaa1654adeefe898a09d915bffd32a3b460cede60419aa4dde948cc7951be8dc752ce9a4e043a342192e0a8403b235071e5cd7fa68e7c00e0c7cc97c8758211e90d6ddb1921897cece550286966c1a491e0a17880e26b5ba3d918acbe1933af0f13242bcac25729b4e67d08efb19b0959454f0b6e60fcb2fca2a57aeb48e27c29be5526df29d7f878363e59d76e6879cf87ff3c46c1db5ac0d2a0c188a9a41465a2119e4a2fc542d492afe7347c23c154ed26a689224d5091763dce53f67d5bb37a84d3ea0c8ee2e5933f999e5892532c46d870225a7950e920f56f44ce937fd462f590953ec097b927e37a0b8d7d443c39a9eca5780968e5dbe4c56835369d2672e2cc2dfd33e645501668d1fbd4b991e401c24b2e22b67a492ff38858a168146471e8d50afff77870e93cf037099308d58a9de9fadab6422b3b25b7a7631eb7528714e113def7bf42ac9a3d1a08d0163c42a2cdec19e1b74f2b97d0cebd23e9b3c096f0ee2d54d744474b1e124fa7f9ff5f55f68f3bf5cfb94e5a8857ca8c7a2076eba8057a73777972b278b54ccdcca64bc157f6058fdcbfbcd98341492abb657932b0b6d23497a84a17090cc3eec2800c2246085b3ae74de1a08c14061d24800b7f3265ba37c00f5fcb4ed16687512a5b0f50d833cef4759ac845275756d21fd8d2001", + "commitment": "0x04a6783beadad0a1f4db6ce525278990eeeb752e77724d80cf0fbb861cc36833", + "slot": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "parentIndexPlusOne": 1 + } + ] +} diff --git a/examples/CRISP/packages/crisp-contracts/tests/fixtures/input-tree.json b/examples/CRISP/packages/crisp-contracts/tests/fixtures/input-tree.json new file mode 100644 index 0000000000..38e9aed4e1 --- /dev/null +++ b/examples/CRISP/packages/crisp-contracts/tests/fixtures/input-tree.json @@ -0,0 +1,14 @@ +{ + "note": "Generated by tests/input-tree-e2e.test.ts. Asserted by program/tests/onchain_root_agreement.rs.", + "inputRoot": "0x1ade9c021ae6826e42a57bee4cd4ec59dfe37898930d0860d2cdaacf05f5064e", + "contractLeaf": "13593275176504203661465029575122777041120943298103109219065935251286905649840", + "inputs": [ + { + "encryptedVote": "0x0a8a2408021080041a80249b1f2ccac22f3b773835b809deb9f5b9038dc298d37d6a0db63a924623b30c4ceccb59f79bba107e1387265d2a565910de308ee71f9d7561473ead878d44702d0c22a01e259dde4d8e8ef02c5a84ab6b12e6a9cf99532372b6792e7074167ee3d47611fb9f643a2983b3e5ceff322d3a4cffd886818d2ca2dd40927938dc70da3240b15b22e19cd326c78de248331252d6513fdc9fbd8ec45e6fce9047dca63dca45458d84f07e243ba35d56fe5255e51e0de0096e1bb90776c00dcc5f350e98825194f643bebbdf9e3eace6bc7c5be82a7d9d398e47d38727f6840b681780d88c41ad77f646ca4ef19b8aed4232dca7570a1a0c9ac5e7eb65ae8471d9d131d64e7322b65100a332925ef3a9181c9e6bc40593cf7697bd51b07f56017257131219ad3d6e8acf874835501d36a3395a2a2a65d8ec830d52a9b35ec0fb93dcb00a94a8af55b5020fbdc4375160da973e4a7520775cea605cc1123b117d352976adfc6d069d933bec30e6d0db6f30401db5b1e309fc97a28397af4001e3cd91afb44efa65e5296460d19fad54bc92d9ac61c38fe48a90fea5d92da448bfe2839d7339a218a3ed411cd2a999a62e539f37eb1fc9a9ab35703643915f506b70c9e816c80237bdb590c1aa2fa027addc58b1f3bb2ad75f9486d909f0906c24f8e6e2f8d21918c97e8123bc475e5d6efc3ceb25c97acfe5368715d6e5b0be59ae40b21d18c3f06d6a343cd870f8611aec6a933656e68f6940e24a483b192d445198b72b67515cddf9ec7a97230993b092804689f756957b58bf268c4267ff571576364fc3fd44c57658eafb3c45bbc7883953358a00085dbe8d5767565e789be70c6390a7c98595b5353b28d76bfed0eedaae89911baf5ba43e4eeae4450493dd8f569e99a99bc2169da6a09c05e2437d25015bdc040ce6988a4bcd12a89359265a9e464626437622860437e63256f1929c5c50b60fd19e94d2e8745ed0697544fafead4a9c68326c2558222ec2b5b9db705ea04595c9e8dff9725000613e3434a4fac2ddb9bc55bf73a8efb20e09def951f496185a81ee41dec3b31fc362f41cad542f5759a658bc57109bf774b858fd5f3bdfaf64b201688f46f87f5462e6f41fdab6419d51b70fc75ba8b2d6560b52a0fbde92d5185e3ce7312da41c6aa2d090aee8aaa7e26c67b83192330b34bfaa79e046bc8ddb726f932df5b1d1a99d8a6ba06ccb95c3824a4ee979759490bc303b1a87c52e43f48ff53aa8dddfd1dc4f0d5c15903c2c33586be9fd78961db94ddaac4edcdeabcac71fc20c64de4ea7aab51eda7c85d38264d9ec273df0917534e749a28fb08d1ad9114ce0628081e13867e9aa57345b77dff55a0b51c5276a94b978fa33adcbee8eef7c192d23fd9d47302b7149d6820b7f5bab1ffaecea9be6ba4f244a8826629e2ff4bc1ac2f18c7a5ce6a44ccbbd87b41f36474b5536bc27895d209239c020405b64833963a568566010fb53a5615b026a9288b819f43fc3b390780b2954134ad14fedf3ae6c9b32d9cf469e1380f380223970264e24e24e6fc9617233eccd52a88bdcec57ed8ac1587c25b8e3f54539b4128dd871728f9e78daceb27a004696662adc4a96406c42bf41f8e364f5b3287752eb6a65be5f7b2eba3ebbf77e98c91b3b641e9958544538bb5ed88ff520d2ef6e5c883707f57c16cc233231e794c6408ddaa6c4085485ad5c3d58cea0cfdcbf69babc69aa4a90cdcaeb7f32b06c68290eaeb996650a307ff61955afda67a6d1ccd7ac126d83dce9b6c5b59108cd36f76fe990b9c2bb636308af3096c6f74a713f1adb4d0e26a16b3504567c41cba0c2dfcba2312490f666de77e379d5c0b581248664150ab9cd486934f3cb46ffd80c4aa3aed55ed90d1daccaf4a5ca1cef158fc8712af94ce4130b40be81ad46655b42565cfb325d76a513f2e8cb361227b824c69a1d0f09198e4c2555bbf3a2252eb03f1c7fe2138ca76ff8de267a67540c9bbbb99d7819cf0135d3f0e7f8d54fde562a110d721d391b96edcaa3a7d0484e3351a02d4d1a32ad87522b225d1f68de80d2607ead8017a3731f1a3421d769019bdacda2944d48b33c5c5c6d12e36fa1362fe77f42fb85384f240c0aa5c83288d5830f76161a3927754180908249b6b4f2e2cfbf96cbf4273c3c50bfb6874e56fffa167de0ff041bfe3b4fb6c4bc3ee594d22b877f5b276847e84cf6966785e1ea3c1f6dcd168aa813a4a01d90dfd94f3040b937d293b6f89ab4a286ee55a5f06b72deb2dd3cfe87eaee8cd383ead25432c1d59e1a14baf207fdb0c011e601658d4d2422a6c0a60bf65d2637f8a02a126db3659ed9cb41875ba32160a92d9946350f24cb0f120389919829e64fff8773835981b117a445782b435f91250d0e0551967fd739e5d3e304bb6c349e683ba8a0fdfaff0e79b3085ba70be08010dc9c18f75ebcfddee168a789aebb660358446d7ecb81bdf85865f70f1468d4ecd89c4be2bd9cf6db669c41ea1ed532ca3ec29d6689455eff7945f77ced597b9e5df1a2712f7efa179baaff8086b65a77aec92d5ba5f0e03c2e03a576786c9591a6fccc911f34ca0607a6591c40757a402ec685f90eddb9ddbada02a1514d96e5be395a6224f196f739a6d4a2dbb29c91e717fdb8a9111205bf7cbdb338b6420ddd4e36fbd112b4d0cdedaf6515e08e6ae853014d41c88610b183c4ac43bb4b03a6f8bff651f5ce80ccb25e7a0154749f10611f280a3d8ebfccc1dfbb15a93b8a9b56441adb58bd62ae48efbd3ebef09fefc64c6b91fb5694e2429382919745e3d2037524ff9cc726e21aa00b34dcfab09f3f36ee001233032a429ae2896344ffddec9036451c3c23ddd07e370d185a838b8265e3b7ba07a5a5909fda8ecb389d01de499b63fb2e57dd44367119d3f6f677b69f56d227a427b6ea20d0b952c3eb0a42bb248e3eaa7ce2576ee1d0068179d660e3e620a6b65c74302b992e2c86eaca367e4c95964592778d478cb1840a68cd3f86c520978e1491ea2e584acfc9de3335b66e031e4e5e897b253c3a983485fed8a6767758a329eeecf06d68f1ea448c3cb8c60cc9f5117f3102610480cd9a9128b9d7cf1123e908117ad22a3a1c5604549f8fea755c425761e53a3a7c38c44322dc88b22fbffed255acbbdc3b18487b63925fe6796099982812f68789c18ef686b40d52d8115faeb23dbadedac73692ce9c001bf6a25e668eb8395f830e69a7c07512319f5fc6201e57f13222a62a14a6b2290567fdbdc56ce7844fbc2719bc08728a60624eb8bfb25b3b5e1556841cb314a672fcadd7e484b0f45fb0dcacbb725d4eee77d665ab6d76cde1152c056f7286a2d936a0dd7df07aa7cbcf7d963a44b45db56070262cbb8517e5072a4a225271bec9cf0632112ac7a9fd5b77fdac307cfd62a07f686cf16ea040b5974ea0750b960862b576c0f7c913f7045605921abbba3e5e77e53fcd0d3c0951d8711749e15fefb5d62d6bf860c806f4529d38fb00b4aa5176afb8e1fb769cda046a90b840cd30569da859f4718062098aaa28bb2b29dfb9bedac694a34c33979b5aa2667f355deb18c1737dffacbe0b5aa6061e22ca9b2f5234ab26bcfce2e737408c8d1a2685483a8abb6f22d8e36de4ed0592251fbcd604c7837956e970270e52a2c3eaec89351c436239d8043b323e31d14049de5834735e802324fa9f916a083924b1ce20fd75ec20acbc9dfbc7b429de8e3655d6ea04c0209d80feed20ff5dbf739b843485f2cc381d6a28b5c28b3cb46f665bfd3ee3469f7e7e363200b9553893719db417f497e22832932bd288eb269b1cc0a8fa4f2d5beba1b18d33a448b4b7086b0974a767c574267742cf57a538c0826f3d9b1cddffa4d573b4fc20bbae2986b2a1ddf108be75bdb5f29e323267754e06d25f54541a01ce17177d51f484a1c75af92bf4e09d6862e18b9864403f03e1da9a6cc03fceb575b1cdbab7cccdf8bf485fb84e1d6e08c5331d195dc6bc33d341e1b41e82b37ebeefcd866df150ad9f0a41438a8b93ccb91c4fc060fbd23475327657e01bf7504874cb816cfc8d32330a5708eb98e9af79c2a5ab059bb51cb1f338cc424c50341d9c9d57b589253982da210a353e69e556afe33f959fbd86af5358f231aa7a00525ed38ae62c0a4a237082c7fb78fb514cffde59865d446827dcf60633f649a98366c5e24442857dd89c4a2bffff27d189b0fa993abcea1724602190e3f7540d5a8abe961ccbbe8f7214ad0acee6779792a52fb0fedd6311f12a67e26132d2f920a82e931a3469ffe61432a749555b4661200d43771615709ed4d7f79d881097bd9ec53c2f59c34de49321b32fa45f289e9f0c736a0e1b4a5551b18bf18d2421f2c8d06daf892e93d3d611e2135487020e4bccb034bafdf4e6d9c12f8075915bb78536b97fb61948b4e36b32400a2b09574e1a8b24fe07647e92cd08b33fe4376aaba9a610d26082fb0a0169f6c185ed507d27bc38bd78c04f2e70a0c4e229952e0f0a4b456fa066bc389b9c9402d337ef804b63b294c142a8739a1ef5d0d0b3a66d013eda477215887df3e94dd98f2989b44d9779eae122a062baa4d65cb37daba8822c82dc769eb7d5132b04d103510fb76116dd1fb9f338a2596835acf81c6b4b0b68538b1acf33c5a7eb9436eec6ede70eb5ce6b2a4bf10a42e59bde02d7292c42bdec52ae0b7d8eb485d135b11edb5a7bc09f89513dc201276a82f10db47a04f007e4dcf763d0eb2964f32460ec313d02b6d9b9a1be9289b38939c994af310bbae0b1817fd50a24ec92c0f981aaf1f33c2f3bf160d7d2126e4f4c85675d1a279e9722860dc6f3a73f38c958cfa213299cb4fda5a6c95731b3d90ebbee1b7ce43eb4685813592e50d58c62095106cff16593a31bc61d0f661d1339b1c0b305edc1768668e4ddfb3d565285199d3f2ec765120105852e5a8bf1cac1acef37ebea6dbcd8b61de8afb80eb4f82c69d9897d29f22d5d36423068447f15cbca20eaa8735859316de6121386a6ff3bd9f503d8161ad1d931e45ffa31c40047c783026ebfce2d9d525242201a0444303242b6ede68c9a6be1307ec05acc332c9b9c9e6ddb2af9d5222eb3ffb4a6a79d3ea1362de6e30e367e84d723778747fd55323567727b42d185072ad5cd48b8eacb20798c01d57bbd629a4f85367c4613b8b09470f965733f64e4fb5e0cb693b700dc75b9a37944bb9cc7e90c338a89ab23acdf1ae06f31abcb961e46d13106b47cf783f72ee70cfbd501f27db7e48dad069303ad4856c13007aaf72df33e1c409597d93cb7a72687218193dfd16d090d1e6ee090563d2c30bf58b38dc2db74738a11dde9f218202faf9546d1030fb04079efa37209dc25929dfa1416e94d5fe955b29fb03ffb7d960f111786155c13400567dfe21ee6201ffed106f97bf86fc53585e35cb2893926b94caebade6a827220bddfb018d33516360959443fc7d65de74606256751683ab56d146eac07689c004b136dd470f98d712767ccec982345f4494f9bd34c04fbb4cedbe0e5596234c06f86a606195ef529d34e37549ad3ccc9cfaa1ae0460914b2b5ff335b0ef3c234c29eb6ee74f8a09cca632f9fa8b4b084c7b93dd70dc43e9fe8edb0a3344932e7e004b0eb1c3a8e2b5ed7e0544fb2f2400416712a2ba271a92bcd467e3c9430272755f228ccc2df99fedeeaeec72ae1cf6f070e8acd1789f9162ac79742b29b6b04bf5623520af947626fb4cac8e5adfefd832eb143ef92ba6f8f1861f75d8be893aed69d9a02535675054db118f05860fe4fc5e00b960941fb4cf9f7b78df8e3a97238257e7878ad2832f804e2a613d120641dd7d8a82ec53a979fbee8e07f577a0f71775de8b57a3aa4bc55cca17f7bc2d78cc0ceaf838ca87b827b3b9e6ea7a0d3e5ec80a08809a1083bfe6b666ef96f40dd23044819928d47159d9a89492a80b7d647b9bba4c2766509ef85f958b3577e5e52ef31c276dc150fd42dbf052f566b0f9cfbd5c0dee445c611abe72a8711d1db66e8b7cf56dcc1e449c695c483da2832415a821ba260fc0234a0df6b57bf5f73a1892d5eceaf461aaef106362459b18588fb8779b39e9b785508ebb6550b486da43558f7caa8e93eb4c64a8997b9151bcbfb1d13e930a623ad5241be63ae3690d28f33a95bc2158f5de5c477979e88d050ca6ff7f23e9b1a3228e52c800c072c65af3568a6e67607939470856ccba184a4deb35ad2ecb34db258e4cd01da4993fc177ed542615eb1113fe95ecc3df10505d1c9f04dcb794640d9b083de06bf750445fa0857b636b815780a08861424d88a3ec4e420ef12dba3bde0bbb40389abc8a4031110b67ef643cc76e488fc5f501b64ac588f8b373fb0fab03dee503f4c08bb2afbd22e5f4133dd60b86f2bba1125d022d5648f9771fa651c6f5f65ec4abc613277ed3c1f43489738b36b198e46b0b7b1a1d1a90623eb6294132e8264d5636e39293de1eddf0fb9aee4ced0d5bf20010a8a2408021080041a8024e5a92e1cb1f1969ec5c85792140ab8b6e9ceca97869755ff415665c86c24ea342692878e395361a80b62e6aefa01e1c30e526855bac7ecdceba9236568b723754b49769c380a0e5a2b6d09bf59b83e6af7353db3643ed7cc3625e0d47428f7fa136178b68b6a59c682117c3a9f9595cd9e996de7bdae4bc73ad1444020f3f109b4a63789b7e81f5234862949f0337085c8468d19d9b7a1986b94448785cd4d5501c0b85ed73cf9954478f3b92cfd3dce0f8175024f5e62151d8880453cf112b0b555b87a13f955fea4ebcd521d9533a0978b403be0c114b1f53afac54fff78c7e2d852b226a4618de8a6d15545fabcaa018cbb8c4a5804548dc1868bc31946b7bbaaed1622f7500bb9ca5aee93e865b7541f06e44e991e9502711e165dbab18606ceb2ca92a5c2bac5fc651e8f30cbc09caee38e5a50ced57d0d0e3fe9f7fb40ae49eaf53052577b2f3ca2a665e14ca761a9e6a983836ef92e60e98607eb303cbfb8518505e430bf10138814d24ab1f8beb97627969043a72ae5a1991b4d8791417952c914925c37a726f196dc91825acd65f271e554cfbc81252c26cad7ebc28cd37aed5481c8ce2e788850f6cfa33bc685164f428c23f29010db213786d0bb2d6b1fee0beebe4b6f59e1ccc8557edf49d4f1c4449e02d05d424de9a66049f46c66865c0e207b01c6ab88bb2c12b2fa6eb71c3f1312de012c7c8674fa69dd84c63b55a3a26a8645c04eebde3490c68bdd4580ec8004c21d239e6e33a402bf9e83fac0f770f9e582803a74938e244f5963f467567cf634526f5b83914013afb66db360785c78406b9633ee70353ad804efa2ae8612c441c39ff2f17f15234c5dc82289ccf26fc360096120d45091221cf484bdf8a333465caa48d7629fa45dbaa16fc49e01943667f929b02019273182b61a8f30798a57077d92ea6d0e6f8e0cfe31eea4f489c53ce515219123cd1206f74c935f0c6b4efda54f323aa0f6c5eb8ed469df9f728270d994a3a8aeb6c13bd98c04f04e75c1123a67219ceb26fc52778abf81315f8915a478438dac99a6f901005cf72f3671842ea0b33328df6d894ef065d433293442076b6f2d1310bcee109ba19f76e5bf4cf88af7d9f22e55fcc3a53f357a77d127c5d12f978f008b3e9f66fd307c64b9c84d1e7e9f3b792b1633e88fa96c9f9a364e7e666857bc3ae1738a5453f1e764c04db531be62be6ae0a15b6df2907187f34b8a8c2c630485a52dd2596f9bc7988c6715e8b5f73575ed2529cbdd75c214627a1400691e1fa99676e8b4750575170f7987ef593461ee078e464aa79e82ab87e61c20de5976fbfb118f9deeb1518c04ef492f028ff7d985cfb0ebcd52a89c1543fa151f7451d08bf4fed58f0ecd0f0b3ebd27f7970d234d99b4277e1fbb4c13337fa9c03d585b18bf5979bc97a5f967dd734392c45b671b714f3a94f5a1c9a76298bac4e92ba9207d371949c341efd4e3ce1b5ef695bd5dd6ae583aca74e20aa148e4ecb10104f1174527495a0434ca967e026cd6ef7d625012a4267b85ec2ec0f83abbe7f1e4e4ea7125b47c27ad1bc9b998ae9b24fba5e9682dc5a26b6bea7380b577f5e8e7fce5f8ad7dfbcdeb0c371f9597aa76c8e51472d9fde49b9a7d773f659f33ecf2b1fb9e72f58850d6b268a6dae6ee688da4cbd8e6538d1746a26f9c0200bc9887073a74d9127fa6d7d63c14c631ee06ea847383c3f54990cf5dc3b7243ad8a413d0256722c657a31c4ddb4858a8d5262aab86c17c546d8dc94fbbaa6397718221ecd99791967b477675e20243992af20011b4501036d5fd46b93546a4135dc28f192ed57b91c4e4b62071f2a25395b98e33170a2f9bea2d1575b5639b85145474bc52b33b57656d4d5df6294d5b1f314a58cb124fb00ff91edba5737e01da3a2a613cdb12c6921f66527be51d9dd32f57729e064bb05ec86ed1db1ad06c79015613953139abb2745538501087d31b6b303130147addaa3f14d3dfc462395387418cdf43a33f65f0a4e4ec812aa6a755180317f2a8b75d45df5790f1f1798ef445ffbc9ec04a3ff80afa853614e2ac3e614d4069a20e1e58e8748f2c404083bd2b7d01df51553eed33dbc64619eb651349686797915c8c323c138a94f7c584397497b5c62581351fe58e09512ac8927dc9d37e411c699a3248db68c8fa0ced74e943a986a90fcc9825acf618525395e67b187e2abed177bf7133671413054aa97e06addf455bf4cf71ecdfcf688b12e930f58d4e3ccc2d8ede52bbe39ec820e22a0ea57cca670f07cccfaa08574c4ef34ae8e91b6de4006df16e569873d11bdc7006513af7c027e283b9f675541e469653b66ec464f43ba4d2c210d66cbfde7ba029793ce5c526c553ca274fc25043dd770133dd41bacd1747eaa8519c701c91c6cbf920acb3d02b86df0db004cd357f94561be77c7a9c96cdcb8dedeeeb36230b2fdfca41bd60fac5561d847c230f096cbf41be07c3a28a178195bf64a354f1caf32cd4fa790eb0309b757aa7839d608538d047121baa3a6abb531c52abad25968e8e9c749a9a8b85b0254ddeb09ce59d23102743cb5fc204b16cb8d7d0188df51ba6c1f199df00304ec3332a2221a3d770da1d206fe00e922ff5a7cfd866a93b4a8f4d74fcfe7910882e1a0a08a04afac3377c7e4a912ce02731c2db2f435a95c93f02b155d29b87890aa102977b2895eeb0603c2cd26c2c628b1ae1922919fadf96646b61534c645019688d16806ff216322294a72cbaf406332b4afa898ec20ecd16cbd7549a301897b90933d8438598de70d3af643088326fc08cbb68b7bf97ab05deb028e3fb59b5a28357adf7e996da60ad80f88a8238b5081cf4e0ebfb0ea11cc9d3baf718adb74d9f9bb4d2caede01ac6f51f281bb9a7419f031cd600b85533fba7ffab3271df7d17de470cc423e6766c37a553850bb5e71e8a740b31f5d0bf89975c717a57e0959e52bfe85dd7f9c9f5ab28e772f7c0a76c6279390e52537d8ac88659ed96c2570b113d5f771bf6d6f876aa81d0664d3f6da3fc7a7b66ec4f52a1c505d9ccd453c1f82d15a93b9a73ee8f588d76173daa06db2e8f90055cc7910170836c13277baaccdff7d99132b2932918b40df2ca44e5ce13a995c2d09d2d7f05664f8efaf8013bdded858e6a46cc49229dff0c1c8350e03036c5ad1766ad6c3d15aa47423e4395425366acd0fdb66c6aeb58c7f37ab0e2a2665ea7f16b3ea32cf4d1062661102c59f0b22419c3a7abbdd6d0c346f8b1cc2979d3943c0d4cefbbc3865a175221f0cfb4425afd23fe81f7cd58c6d7c19d857db9121649d55a24dd3edf48b2c5b9f51f8acfe72847ae7f93d2051ea62e9f33daf5e8aab62d5eb61b680dba6e46315155e53aa658c2d054348db8c550ba3e6299b48e2ab720a979d94f4909b217f8f789181550a16ddc7402487304224617c021f562c4d629d0138b9b3af71535e920db064ba0cd80a17e0accfb9577e8be0acbf01dec315a376cf6f1df95e3ac39a999d6087872c911e01d7e4c369dd593de550a46560a3c65aa1cfacd8070ef5226a18466ca736c636fcf9e786d7e2089c764515eec1e9290cae9b347b8cef79df9c557a3285bf2d2f105a488c70b9a5a5f1e3eb7618339ef75c4fc8bde11382f746a301b64c030845373b1f83d2aa73af4a4a74ea6a1b0a4ffae8274d4359a7bbadad9c994441f9733f975323d6ea6b392fd53fe4ce97a568211768251675b428cae6b2f1db9a76d4fac7f7d61c3290d255cb62cff665e313482f3f106a88953c197e8e080bf69c56581e5daccb3ffb58ab553e82d87d61c3b2a53aa5be53a9166a062bda63a1da595c110ab84e665e3edf5630695e020e21e5c9ced648fd081a6e642d30df3d98ed989f2d6af4ee6bab65d768bd983985bd9a7b6f7b01d06b7fdc2cb598e98ce7da77b10436d8fc31c43d950645c03339fa347247fca6d972becf6d5f09e416116775909f2ed8fbaf7ad8a66bcdfb5f17139f9df854fd5248885f675065656d108baf7b959fb2ac33d504d80c23704ee290ec2507568dff71fc27456913ed7fd1dea0d6e2fc60e4d3b72b0b8747ea10d690b4c46bf53c4547a016b1808378c41fcdf48e65f6243b6ee7f18f31fd081763333cf76f3b5349bc4fec55c887400df780da89da2c62a80523c3beef9dc31c08d450568e0d7dea884c8cd85f7e50e88542374746ece08ee33e83e6cf2826ded585d91b4ad8a184e71e8cf6437d0f5c9fad24260b1763c336cf28d1bdff69c83c1ada98d8b318ef8688bdee29b05c7b4bb86237fa698bf07fe5431bc5b1aa5dcf05e0fab27e6cf62a6fdcbd764c71cb626581b35dd7f72c5441d115a155d7668c3596c2c5590b71e3be8098d7ccaa4c69ff4b96d7ca425881d7ee3edac08d27b0e5d766d567edaec42dc472e111309ee254586821efe3425290d83ea9631781676f12733f804356ff7a4324757cf21c80331b85e6e24369c413a95451878ca61d82a974522b6d6b9c919bbbfa5e0bb5c6fd545508f9c544daf61ba51464672a358e0ba16881da7ce4f1054f74dca15c7627f61757d5472af5a04a491c80ff8182cd85ec69bc09d96680fddf6c50e8b5f5e1f2d748a045391d635d33c7953f289c8b1f8915a8174fb445420add503983e5e5dbac6be66fc4d7b8048a0b075b63aef5c20b99528bc38112fd2db08b5ba26ca844f72218d6d6d90073e2af95bf5d8cab4f3c49038aaf503689c02acda8fb5c86986f13103a76a68ca5ff39a4f894bacc642437bc408cda502e9d7fd6542e55d6844b854c583650f5633394c7262b1f7e0ec3840d082d2e6de30e98f3a5eab2978d70cf82bea467ca86114d89643e501732cf0fd1b3ce146e2dc182e55f803b0c97dfd195ffb27d11fe14c89f9653c7ab92d98885c1bc27919c389e16312c3601cf95a390418aeb3804bbdd18e2c1ca502801b7195cbb2ede320e92223c869882789a2f5dde9ff53565b8778344f6f59d831843ac9d22245c26411da11853c2b8cf4c381540298d6ef1948c33e8c75152499f1972929d92f5dfc231d9ed0c1c865a4c1e4436296452f8572eb836569852b8add6d42b8444e2b3ca4bccbaeb2d525b002d40b1f82de7a0957ea1dc610eef8f3b47c1ae896e7acfa4d40022b736909bddcf49214351d064c54664c6dddb276ad54e72cc4733005655013e47f2a622c81e017b3d46eff2aff87c49113688615752b2280efe68b358e6778304446fe5480804892eb687286795e8abd56c7b959bd750a1b7ccf3638b75b239bf5c3c05e75b99473df92b9721cfd57aa05d505a1b9134291d3fa2aad5703ff0391b19ee26136830dfc930b0261de902e87dfa4eb1ba4bc3fe01387aea1906ed4091586c92ec32148e8ca13aa2c92d776b7882f72930f2d59338707f22e3cb7664934f8e59f523548eb6bcd9093be48b54b30068511be0fed3520dc79898f70a132b055d1d2cad0b7e00a4259b1bd0d1f4b7f7a389498e2935a62b0253ead5529eb8544d9f32799e7cc8c698d01cec933fd4e90b4879ceda06452654462d47c43ed88f6a7caf1d51d97d225586bf555b4a76b08e2eb9c57bf795d5a4867845cafe99d527413dc4e43c706abb416a22f3b72e0c1fc6f2b2b6826eda824e1d516cb48095c96fbb758a675fc2468199b4213e84a7e04f88b650b7d589bd01f0b753e78a2bea6d9fafb5686bb1d6eea7b99899a1ab7d5a6207af794813d7da5b58315c1c104580c24bb9cb0abc3d9769857633616b192ccb0b489ad8a657f43da0889face7eacf53458c347b1d028d637605ec982343d86ee045b8f1b866b369892c1c06272c72bfba52db79362736d416be4f5116c127f18672132f554b40a02545b66885ba1dec483da1b2df0f11fb8dff81e3da29f938fbb219eddacb25cdd831b8934b2a832e05bbf26070c3945f12f66bd7327797e474186b2abddd964bb8870f65f234cca5b1bfe0f9162c0a72632ff1e55a7aedce5a21cbbceb7c92933d8b85a5e9ffe7ddc09289b74a7c3d79e929ce1308901a7e036a28891d4f20ce1aab1ea68ef575573ef4ac1fa716f5e1fc87b010da49f297408b9c0bd8aa4358371fd60a28c6347b0a416d58d05599cb90eb108bc80c91589b4bba379ea43c13df9a0100783bcbead346bab874e5b034df01f17fb37e9671818a6afb0e6cc9d8e679c4e2fb8c2021d12aa5624403f86f4205fb7dbd3cc7e6b2e82844995e3410a8c287dd6c99f9743d3328284808056e5b0ce028db9ca1ac85e56f2637dd9143cdd4447c77628e244c758c195f51ac02fec2573ab4ac5f323c1879682f6642a613564021aeb9fc2060932013ed9427f2e827c40332ab51ac9e42d85811d4fc09e0f06eb44bb937793a1a44cc40edbf7927d00a4709831090eafba8af6f12ef9f22ffe30439a63b941e8f1b90afba201aaeba94070f9782756e687313c7aa4ba048519a3264a64cbe5b072b569afadbe13b408d69bc3c25ed6de85a1856ef69cecb4ce2001", + "commitment": "0x0c0bf1e664bca23544597f46787cce734c912469953e4e5e02d7a48bd1ee9847", + "slot": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "parentIndexPlusOne": 0, + "preparedCommitment": "0x0c0bf1e664bca23544597f46787cce734c912469953e4e5e02d7a48bd1ee9847" + } + ] +} diff --git a/examples/CRISP/packages/crisp-contracts/tests/input-leaf.test.ts b/examples/CRISP/packages/crisp-contracts/tests/input-leaf.test.ts new file mode 100644 index 0000000000..1ee09167c3 --- /dev/null +++ b/examples/CRISP/packages/crisp-contracts/tests/input-leaf.test.ts @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// +// This file is provided WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. + +import { expect } from 'chai' +import { createHash } from 'crypto' +import { deployCRISPProgram, ethers } from './utils' +import type { CRISPProgram } from '../types' + +const SNARK_SCALAR_FIELD = 21888242871839275222246405745257275088548364400416034343698204186575808495617n + +/// The same vector is asserted by `the_leaf_matches_the_contract_vector` in +/// `program/tests/input_leaf.rs`. If either side changes, both tests fail. +const VECTOR = { + ciphertext: '0x' + Buffer.from(Array.from({ length: 64 }, (_, i) => i)).toString('hex'), + commitment: '0x' + 'ab'.repeat(32), + slot: '0x' + 'cd'.repeat(20), + parentIndexPlusOne: 0, + leaf: 10659496726189475271708972402425950109424933772832069858355301832948309535156n, +} + +/// The input tree leaf binds the published ciphertext bytes to the commitment the Noir proof +/// constrained. +/// +/// The proof never sees the serialized bytes, so without this binding a submitter can publish a +/// valid commitment beside unrelated bytes. The Secure Process then cannot reproduce the on-chain +/// root and the round is lost. Binding both lets it detect the mismatch and exclude that one input. +/// +/// The Secure Process rebuilds this leaf in Rust. A divergence of a single byte between the two +/// implementations makes every root mismatch, and nothing else in the system would catch it — +/// which is what these tests exist for. +describe('CRISPProgram input leaf', function () { + this.timeout(120000) + + let crispProgram: CRISPProgram + + before(async () => { + crispProgram = await deployCRISPProgram() + }) + + function expectedLeaf(ciphertext: string, commitment: string, slot: string, parentIndexPlusOne: number): bigint { + const parent = Buffer.alloc(5) + parent.writeUIntBE(parentIndexPlusOne, 0, 5) + + const inner = createHash('sha256') + .update(Buffer.from(ciphertext.slice(2), 'hex')) + .digest() + const outer = createHash('sha256') + .update(inner) + .update(Buffer.from(commitment.slice(2), 'hex')) + .update(Buffer.from(slot.slice(2), 'hex')) + .update(parent) + .digest() + return BigInt('0x' + outer.toString('hex')) % SNARK_SCALAR_FIELD + } + + it('matches the shared cross-language vector', async () => { + const leaf = await crispProgram.inputLeaf(VECTOR.ciphertext, VECTOR.commitment, VECTOR.slot, VECTOR.parentIndexPlusOne) + expect(leaf).to.equal(VECTOR.leaf) + }) + + it('is sha256(sha256(bytes) || commitment || slot || parent) reduced into the scalar field', async () => { + const leaf = await crispProgram.inputLeaf(VECTOR.ciphertext, VECTOR.commitment, VECTOR.slot, VECTOR.parentIndexPlusOne) + expect(leaf).to.equal(expectedLeaf(VECTOR.ciphertext, VECTOR.commitment, VECTOR.slot, VECTOR.parentIndexPlusOne)) + }) + + it('always produces a leaf the Poseidon tree accepts', async () => { + // A leaf at or above the field order is rejected by LazyIMT, so the reduction is not optional. + for (let i = 0; i < 8; i += 1) { + const ciphertext = ethers.hexlify(ethers.randomBytes(96)) + const commitment = ethers.hexlify(ethers.randomBytes(32)) + const leaf = await crispProgram.inputLeaf(ciphertext, commitment, ethers.hexlify(ethers.randomBytes(20)), i) + expect(leaf).to.be.lessThan(SNARK_SCALAR_FIELD) + } + }) + + it('changes when the ciphertext bytes change', async () => { + // This is the property the whole fix rests on: swapping the bytes beside a valid commitment + // must be visible. + const a = await crispProgram.inputLeaf(VECTOR.ciphertext, VECTOR.commitment, VECTOR.slot, 0) + const b = await crispProgram.inputLeaf(VECTOR.ciphertext.replace(/0f/, '1f'), VECTOR.commitment, VECTOR.slot, 0) + expect(a).to.not.equal(b) + }) + + it('changes when the commitment changes', async () => { + const a = await crispProgram.inputLeaf(VECTOR.ciphertext, VECTOR.commitment, VECTOR.slot, 0) + const b = await crispProgram.inputLeaf(VECTOR.ciphertext, '0x' + 'ef'.repeat(32), VECTOR.slot, 0) + expect(a).to.not.equal(b) + }) + + it('does not let the two fields be traded off against each other', async () => { + // Hashing the bytes before concatenating means the boundary between the two fields is fixed, + // so no pair of (bytes, commitment) can be rearranged into another pair with the same leaf. + const a = await crispProgram.inputLeaf('0x' + 'aa'.repeat(32) + 'bb'.repeat(32), '0x' + '11'.repeat(32), VECTOR.slot, 0) + const b = await crispProgram.inputLeaf('0x' + 'aa'.repeat(32), '0x' + 'bb'.repeat(32), VECTOR.slot, 0) + expect(a).to.not.equal(b) + }) + + it('accepts an empty ciphertext without reverting', async () => { + const leaf = await crispProgram.inputLeaf('0x', VECTOR.commitment, VECTOR.slot, 0) + expect(leaf).to.equal(expectedLeaf('0x', VECTOR.commitment, VECTOR.slot, 0)) + }) + + it('changes when the slot changes', async () => { + // The tree is append-only and the Secure Process groups entries by slot, so a prover must not + // be able to move an entry to a different slot. + const a = await crispProgram.inputLeaf(VECTOR.ciphertext, VECTOR.commitment, VECTOR.slot, 0) + const b = await crispProgram.inputLeaf(VECTOR.ciphertext, VECTOR.commitment, '0x' + '01'.repeat(20), 0) + expect(a).to.not.equal(b) + }) + + it('changes when the parent changes', async () => { + // The Secure Process walks each slot's chain by the parent, so an unbound parent would let a + // prover re-point entries and change which one holds the slot. + const a = await crispProgram.inputLeaf(VECTOR.ciphertext, VECTOR.commitment, VECTOR.slot, 0) + const b = await crispProgram.inputLeaf(VECTOR.ciphertext, VECTOR.commitment, VECTOR.slot, 1) + expect(a).to.not.equal(b) + expect(b).to.equal(expectedLeaf(VECTOR.ciphertext, VECTOR.commitment, VECTOR.slot, 1)) + }) +}) diff --git a/examples/CRISP/packages/crisp-contracts/tests/input-tree-e2e.test.ts b/examples/CRISP/packages/crisp-contracts/tests/input-tree-e2e.test.ts new file mode 100644 index 0000000000..a6f829c02c --- /dev/null +++ b/examples/CRISP/packages/crisp-contracts/tests/input-tree-e2e.test.ts @@ -0,0 +1,431 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// +// This file is provided WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. + +import { + hashLeaf, + generateBFVKeys, + SIGNATURE_MESSAGE, + prepareBallot, + finishBallotProof, + finishMaskProof, + getAddressFromSignature, + encodeSolidityProof, + generateMerkleTree, + SIGNATURE_MESSAGE_HASH, + destroyBBApi, +} from '@crisp-e3/sdk' +import type { ProofData } from '@crisp-e3/sdk' +import { setCircuits } from '@crisp-e3/sdk' +import { loadCircuits } from '@crisp-e3/sdk/insecure-512' + +// The BFV-shaped circuits ship as a separate entry point per preset, so proving needs one +// installed. These tests run against the insecure-512 parameters the contracts are deployed with. +before(async () => { + setCircuits(await loadCircuits()) +}) +import { expect } from 'chai' +import { mkdirSync, writeFileSync } from 'fs' +import { dirname } from 'path' +import { fileURLToPath } from 'url' +import { deployCRISPProgram, deployHonkVerifier, deployMockInterfold, ethers } from './utils' +import type { CRISPProgram, HonkVerifier, MockInterfold } from '../types' + +const keys = generateBFVKeys() +const publicKey = keys.publicKey + +/// Where the Rust side reads the same tree from. +/// Regenerate with `UPDATE_INPUT_TREE_FIXTURE=1 pnpm test`, then re-run +/// `cargo test -p e3-user-program`. +const FIXTURE = fileURLToPath(new URL('fixtures/input-tree.json', import.meta.url)) +const APPEND_FIXTURE = fileURLToPath(new URL('fixtures/input-tree-append.json', import.meta.url)) +const REVOTE_FIXTURE = fileURLToPath(new URL('fixtures/input-tree-revote.json', import.meta.url)) + +/// End-to-end over the seam that no single-language test can cover. +/// +/// The contract builds each input tree leaf from the published ciphertext bytes and the proven +/// commitment. The Secure Process rebuilds the same tree in Rust and the E3 program compares the +/// two roots, so a one-byte divergence between the implementations makes every round fail with no +/// other symptom. +/// +/// This test drives the real path — real BFV ciphertexts, real Noir proofs, real `publishInput` — +/// and records the resulting tree so `program/tests/onchain_root_agreement.rs` can assert that Rust +/// reproduces the exact on-chain root. +describe('CRISPProgram input tree (e2e)', function () { + // 600s was a per-test budget, not a per-file one, and the tests are unevenly weighted: the + // heaviest here generates three ballots where the lightest generates one. A CI runner proves + // roughly 4x slower than a dev machine, which put the three-ballot test over the line while + // every lighter test stayed comfortably inside it. + // + // A timeout here is also not contained. `destroyBBApi()` runs in `after()`, so one Barretenberg + // instance is shared by the whole file, and mocha abandons a timed-out test without stopping the + // proof it left in flight. The next test then fails inside witness generation with "Cannot + // satisfy constraint" rather than a timeout of its own — the fold circuit asserts the inner + // proofs verify, so a proof that came back from a contended instance fails there rather than + // where it was produced. Treat a constraint error immediately after a timeout as fallout from + // that timeout, not as a circuit bug. The per-leg `timeout-minutes` in CI is the real backstop + // for a genuine hang, so this only needs to clear honest work. + this.timeout(1_200_000) + + let honkVerifier: HonkVerifier + let mockInterfold: MockInterfold + let crispProgram: CRISPProgram + let address: string + let leaves: bigint[] + let e3Id: bigint + + const balance = 100n + + /// A ballot bound to this round, with its own ciphertext and commitment. + async function buildBallot(vote: number[]): Promise { + const [signer] = await ethers.getSigners() + const prepared = await prepareBallot({ + censusMode: 'merkle', + vote, + publicKey, + merkleLeaves: leaves, + balance, + slotAddress: address, + isMaskVote: false, + numOptions: 2, + }) + + const digest = (await crispProgram.ballotDigest(e3Id, address, prepared.ctCommitment)) as `0x${string}` + const domain = { + name: 'CRISP', + version: '1', + chainId: (await ethers.provider.getNetwork()).chainId, + verifyingContract: await crispProgram.getAddress(), + } + const types = { + Ballot: [ + { name: 'e3Id', type: 'uint256' }, + { name: 'slot', type: 'address' }, + { name: 'ciphertextCommitment', type: 'bytes32' }, + ], + } + const message = { e3Id, slot: address, ciphertextCommitment: prepared.ctCommitment } + const ballotSignature = (await signer.signTypedData(domain, types, message)) as `0x${string}` + + const proof = await finishBallotProof(prepared, digest, ballotSignature) + return { ...proof, ctCommitment: prepared.ctCommitment } + } + + /// A mask over an existing ciphertext: a zero vote that anyone may append to an occupied slot. + /// No signature is checked on this path, which is what makes the poisoning case reachable. + async function buildMaskOver( + previousCiphertext: Uint8Array, + previousIndex: number, + ): Promise { + const prepared = await prepareBallot({ + censusMode: 'merkle', + vote: [0, 0], + publicKey, + merkleLeaves: leaves, + balance, + slotAddress: address, + isMaskVote: true, + numOptions: 2, + previousCiphertext, + previousIndex, + }) + // One commitment for every branch: `ctCommitment` is the commitment of the ciphertext this + // input publishes, which for a mask over an occupied slot is the sum. The contract stores that + // and builds the digest over it. + const digest = (await crispProgram.ballotDigest(e3Id, address, prepared.ctCommitment)) as `0x${string}` + const proof = await finishMaskProof(prepared, digest) + return { ...proof, ctCommitment: prepared.ctCommitment } + } + + /// A real re-vote: `isMaskVote: false` over a slot that already holds a ballot. + /// + /// The digest is built over `prepared.ctCommitment` exactly as a first vote and a mask are. A + /// re-vote replaces rather than adds, so that commitment is the new ballot's own — but the caller + /// does not have to know which, and nothing about the request says so. + async function buildReVote( + vote: number[], + previousCiphertext: Uint8Array, + previousIndex: number, + ): Promise { + const [signer] = await ethers.getSigners() + const prepared = await prepareBallot({ + censusMode: 'merkle', + vote, + publicKey, + merkleLeaves: leaves, + balance, + slotAddress: address, + isMaskVote: false, + numOptions: 2, + previousCiphertext, + previousIndex, + }) + + const digest = (await crispProgram.ballotDigest(e3Id, address, prepared.ctCommitment)) as `0x${string}` + const domain = { + name: 'CRISP', + version: '1', + chainId: (await ethers.provider.getNetwork()).chainId, + verifyingContract: await crispProgram.getAddress(), + } + const types = { + Ballot: [ + { name: 'e3Id', type: 'uint256' }, + { name: 'slot', type: 'address' }, + { name: 'ciphertextCommitment', type: 'bytes32' }, + ], + } + const message = { e3Id, slot: address, ciphertextCommitment: prepared.ctCommitment } + const ballotSignature = (await signer.signTypedData(domain, types, message)) as `0x${string}` + + const proof = await finishBallotProof(prepared, digest, ballotSignature) + return { ...proof, ctCommitment: prepared.ctCommitment } + } + + before(async function () { + mockInterfold = await deployMockInterfold() + honkVerifier = await deployHonkVerifier() + crispProgram = await deployCRISPProgram({ mockInterfold, honkVerifier }) + + const [signer] = await ethers.getSigners() + const signature = (await signer.signMessage(SIGNATURE_MESSAGE)) as `0x${string}` + address = await getAddressFromSignature(signature, SIGNATURE_MESSAGE_HASH) + leaves = [...[10n, 20n, 30n], hashLeaf(address, balance)] + + e3Id = await mockInterfold.nextE3Id() + await mockInterfold.request(await crispProgram.getAddress()) + }) + + after(() => { + destroyBBApi() + }) + + it('reproduces the on-chain root in Rust, over real ciphertexts', async function () { + const ballot = await buildBallot([10, 0]) + + await mockInterfold.setCommitteePublicKey(ballot.publicInputs[8]) + await crispProgram.setMerkleRoot(e3Id, generateMerkleTree(leaves).root) + + await crispProgram.publishInput(e3Id, encodeSolidityProof(ballot)) + + const [, , , , inputRoot] = await crispProgram.getRoundData(e3Id) + expect(inputRoot, 'the round must have an input root after publishing').to.not.equal(0n) + + const record = { + note: 'Generated by tests/input-tree-e2e.test.ts. Asserted by program/tests/onchain_root_agreement.rs.', + inputRoot: `0x${BigInt(inputRoot).toString(16).padStart(64, '0')}`, + // publicInputs[7] is the commitment `publishInput` actually stores; recorded alongside the + // prepared one so a divergence between them is visible rather than silent. + contractLeaf: ( + await crispProgram.inputLeaf( + `0x${Buffer.from(ballot.encryptedVote).toString('hex')}`, + ballot.publicInputs[7], + address, + ballot.parentIndexPlusOne, + ) + ).toString(), + inputs: [ + { + encryptedVote: `0x${Buffer.from(ballot.encryptedVote).toString('hex')}`, + commitment: ballot.publicInputs[7], + slot: address, + parentIndexPlusOne: ballot.parentIndexPlusOne, + preparedCommitment: ballot.ctCommitment, + }, + ], + } + + if (process.env.UPDATE_INPUT_TREE_FIXTURE === '1') { + mkdirSync(dirname(FIXTURE), { recursive: true }) + writeFileSync(FIXTURE, `${JSON.stringify(record, null, 2)}\n`) + console.log(`[fixture] wrote ${FIXTURE}`) + } + + // The leaf the contract stored must be the one it computes from the published pair. This is + // the value the Rust side has to match; the fixture carries it across the language boundary. + const expectedLeaf = await crispProgram.inputLeaf( + record.inputs[0].encryptedVote, + record.inputs[0].commitment, + record.inputs[0].slot, + record.inputs[0].parentIndexPlusOne, + ) + expect(expectedLeaf).to.not.equal(0n) + + // Append-only: one publish, one leaf. `_processVote` never updates in place, so an entry + // already in the tree cannot be replaced by a later writer. + const [, , , , , numberOfVotes] = await crispProgram.getRoundData(e3Id) + expect(numberOfVotes).to.equal(1n) + }) + + /// The premise of the whole fix: the contract cannot tell that the bytes are wrong. + /// + /// The proof constrains the commitment, so a submitter can publish any bytes beside it and + /// `publishInput` still succeeds. Nothing on chain can reject this, which is why the check has + /// to happen in the Secure Process. + it('accepts an input whose bytes are not the ciphertext that was proven', async function () { + // Its own round: a second input from the same slot would be a re-vote, and the ballot here is + // built as a first vote. + const forgedE3Id = await mockInterfold.nextE3Id() + await mockInterfold.request(await crispProgram.getAddress()) + const previous = e3Id + e3Id = forgedE3Id + + try { + const ballot = await buildBallot([5, 0]) + await mockInterfold.setCommitteePublicKey(ballot.publicInputs[8]) + await crispProgram.setMerkleRoot(forgedE3Id, generateMerkleTree(leaves).root) + + const [, , , , rootBefore] = await crispProgram.getRoundData(forgedE3Id) + + // A genuine proof, published beside bytes that are not its ciphertext. Nothing on chain can + // reject this, which is exactly why the Secure Process has to check it. + const forged = { ...ballot, encryptedVote: new Uint8Array([0xde, 0xad, 0xbe, 0xef]) } + await crispProgram.publishInput(forgedE3Id, encodeSolidityProof(forged)) + + const [, , , , rootAfter] = await crispProgram.getRoundData(forgedE3Id) + expect(rootAfter, 'the forged input entered the tree').to.not.equal(rootBefore) + + // The leaf reflects the forged bytes, so the Secure Process still reproduces the root while + // being able to see that this input does not match its commitment. + const forgedLeaf = await crispProgram.inputLeaf('0xdeadbeef', ballot.publicInputs[7], address, ballot.parentIndexPlusOne) + const honestLeaf = await crispProgram.inputLeaf( + `0x${Buffer.from(ballot.encryptedVote).toString('hex')}`, + ballot.publicInputs[7], + address, + ballot.parentIndexPlusOne, + ) + expect(forgedLeaf).to.not.equal(honestLeaf) + } finally { + e3Id = previous + } + }) + + /// Append-only, on chain, with real masks over the ballot already in the slot. + /// + /// This is the poisoning case end to end: a genuine mask proof, published beside bytes that are + /// not the summed ciphertext it proved. The contract cannot reject it — nothing on chain can tell + /// that the bytes are wrong. What has to hold is that the slot stays writable: because the + /// poisoned entry is never selected, it is never a valid parent either, so the next honest mask + /// names the same parent it did and takes the slot. + /// + /// That is what stops the poisoning being a coercion receipt. A slot nobody can mask is one where + /// every later input is provably its owner voting again, which is exactly the receipt masks exist + /// to destroy. + it('lets an honest mask follow a poisoned one, over the same parent', async function () { + const appendE3Id = await mockInterfold.nextE3Id() + await mockInterfold.request(await crispProgram.getAddress()) + const previous = e3Id + e3Id = appendE3Id + + try { + const ballot = await buildBallot([6, 0]) + await mockInterfold.setCommitteePublicKey(ballot.publicInputs[8]) + await crispProgram.setMerkleRoot(appendE3Id, generateMerkleTree(leaves).root) + await crispProgram.publishInput(appendE3Id, encodeSolidityProof(ballot)) + + const [, , , , rootAfterFirst, votesAfterFirst] = await crispProgram.getRoundData(appendE3Id) + expect(votesAfterFirst).to.equal(1n) + expect(await crispProgram.getSlotIndex(appendE3Id, address)).to.equal(0n) + + // A third party masks over the slot. No signature is checked on this path. + const mask = await buildMaskOver(ballot.encryptedVote, 0) + const poisoned = { ...mask, encryptedVote: new Uint8Array([0xde, 0xad, 0xbe, 0xef]) } + await crispProgram.publishInput(appendE3Id, encodeSolidityProof(poisoned)) + + const [, , , , rootAfterSecond, votesAfterSecond] = await crispProgram.getRoundData(appendE3Id) + + // Append-only: the poisoned entry is a new leaf, and the honest one is untouched at index 0. + expect(votesAfterSecond, 'the second entry is a new leaf').to.equal(2n) + expect(rootAfterSecond).to.not.equal(rootAfterFirst) + + // The recovery. The poisoned entry cannot be a parent, so an honest mask names index 0 — the + // same parent the poisoned one named — and the contract accepts it. + const recovery = await buildMaskOver(ballot.encryptedVote, 0) + await crispProgram.publishInput(appendE3Id, encodeSolidityProof(recovery)) + + const [, , , , rootAfterThird, votesAfterThird] = await crispProgram.getRoundData(appendE3Id) + expect(votesAfterThird, 'the recovery is a third leaf').to.equal(3n) + + // Naming the poisoned entry is refused on chain as well, but only because its commitment is + // recorded: the contract still cannot tell the bytes were wrong. + expect(await crispProgram.inputCommitmentOf(appendE3Id, address, 1)).to.equal(mask.publicInputs[7]) + + if (process.env.UPDATE_INPUT_TREE_FIXTURE === '1') { + const record = { + note: 'Generated by tests/input-tree-e2e.test.ts. Asserted by program/tests/onchain_root_agreement.rs.', + inputRoot: `0x${BigInt(rootAfterThird).toString(16).padStart(64, '0')}`, + honestIndex: 2, + inputs: [ + { + encryptedVote: `0x${Buffer.from(ballot.encryptedVote).toString('hex')}`, + commitment: ballot.publicInputs[7], + slot: address, + parentIndexPlusOne: ballot.parentIndexPlusOne, + }, + { + encryptedVote: '0xdeadbeef', + commitment: mask.publicInputs[7], + slot: address, + parentIndexPlusOne: mask.parentIndexPlusOne, + }, + { + encryptedVote: `0x${Buffer.from(recovery.encryptedVote).toString('hex')}`, + commitment: recovery.publicInputs[7], + slot: address, + parentIndexPlusOne: recovery.parentIndexPlusOne, + }, + ], + } + mkdirSync(dirname(APPEND_FIXTURE), { recursive: true }) + writeFileSync(APPEND_FIXTURE, `${JSON.stringify(record, null, 2)}\n`) + } + } finally { + e3Id = previous + } + }) + + /// A voter must be able to change their mind, and the new ballot must be the one tallied. + /// + /// Writes a fixture so `rust_tallies_the_re_vote` can assert which entry the Secure Process + /// selects. The published bytes and the stored commitment have to describe the same ciphertext, + /// or the re-vote is excluded and the voter is silently stuck with their first choice. + it('publishes a real re-vote to a slot that already holds a ballot', async function () { + const revoteE3Id = await mockInterfold.nextE3Id() + await mockInterfold.request(await crispProgram.getAddress()) + const previous = e3Id + e3Id = revoteE3Id + + try { + const first = await buildBallot([4, 0]) + await mockInterfold.setCommitteePublicKey(first.publicInputs[8]) + await crispProgram.setMerkleRoot(revoteE3Id, generateMerkleTree(leaves).root) + await crispProgram.publishInput(revoteE3Id, encodeSolidityProof(first)) + + const second = await buildReVote([0, 9], first.encryptedVote, 0) + await crispProgram.publishInput(revoteE3Id, encodeSolidityProof(second)) + + const [, , , , root, votes] = await crispProgram.getRoundData(revoteE3Id) + expect(votes, 'the re-vote is appended').to.equal(2n) + + if (process.env.UPDATE_INPUT_TREE_FIXTURE === '1') { + const record = { + note: 'Generated by tests/input-tree-e2e.test.ts. Asserted by program/tests/onchain_root_agreement.rs.', + inputRoot: `0x${BigInt(root).toString(16).padStart(64, '0')}`, + reVoteIndex: 1, + inputs: [first, second].map((b) => ({ + encryptedVote: `0x${Buffer.from(b.encryptedVote).toString('hex')}`, + commitment: b.publicInputs[7], + slot: address, + parentIndexPlusOne: b.parentIndexPlusOne, + })), + } + mkdirSync(dirname(REVOTE_FIXTURE), { recursive: true }) + writeFileSync(REVOTE_FIXTURE, `${JSON.stringify(record, null, 2)}\n`) + } + } finally { + e3Id = previous + } + }) +}) diff --git a/examples/CRISP/packages/crisp-contracts/tests/onchain-census.test.ts b/examples/CRISP/packages/crisp-contracts/tests/onchain-census.test.ts index 319e518d64..1129ecad3f 100644 --- a/examples/CRISP/packages/crisp-contracts/tests/onchain-census.test.ts +++ b/examples/CRISP/packages/crisp-contracts/tests/onchain-census.test.ts @@ -6,6 +6,14 @@ import { generateBFVKeys, prepareBallot, finishBallotProof, encodeSolidityProof, destroyBBApi } from '@crisp-e3/sdk' import type { ProofData } from '@crisp-e3/sdk' +import { setCircuits } from '@crisp-e3/sdk' +import { loadCircuits } from '@crisp-e3/sdk/insecure-512' + +// The BFV-shaped circuits ship as a separate entry point per preset, so proving needs one +// installed. These tests run against the insecure-512 parameters the contracts are deployed with. +before(async () => { + setCircuits(await loadCircuits()) +}) import { expect } from 'chai' import { deployCRISPProgram, deployHonkVerifier, deployMockInterfold, deployOnchainHonkVerifier, ethers } from './utils' import type { CRISPProgram, HonkVerifier, MockInterfold } from '../types' @@ -25,7 +33,20 @@ const ONCHAIN = 2 /// twice cannot detect an order mistake. The last test in this file pins that. describe('CRISP on-chain census', function () { // Proof generation dominates; the same budget as the Merkle end-to-end suite. - this.timeout(600000) + // 600s was a per-test budget, not a per-file one, and the tests are unevenly weighted: the + // heaviest here generates three ballots where the lightest generates one. A CI runner proves + // roughly 4x slower than a dev machine, which put the three-ballot test over the line while + // every lighter test stayed comfortably inside it. + // + // A timeout here is also not contained. `destroyBBApi()` runs in `after()`, so one Barretenberg + // instance is shared by the whole file, and mocha abandons a timed-out test without stopping the + // proof it left in flight. The next test then fails inside witness generation with "Cannot + // satisfy constraint" rather than a timeout of its own — the fold circuit asserts the inner + // proofs verify, so a proof that came back from a contended instance fails there rather than + // where it was produced. Treat a constraint error immediately after a timeout as fallout from + // that timeout, not as a circuit bug. The per-leg `timeout-minutes` in CI is the real backstop + // for a genuine hang, so this only needs to clear honest work. + this.timeout(1_200_000) const keys = generateBFVKeys() const publicKey = keys.publicKey @@ -59,15 +80,7 @@ describe('CRISP on-chain census', function () { }) => ethers.AbiCoder.defaultAbiCoder().encode( ['address', 'uint256', 'uint256', 'uint256', 'uint256', 'uint256', 'uint256'], - [ - opts.token, - opts.minVotingPower, - opts.numOptions, - opts.creditMode, - opts.credits, - opts.censusMode, - opts.votingPowerDivisor ?? 0n, - ], + [opts.token, opts.minVotingPower, opts.numOptions, opts.creditMode, opts.credits, opts.censusMode, opts.votingPowerDivisor ?? 0n], ) before(async function () { diff --git a/examples/CRISP/packages/crisp-contracts/tests/utils.ts b/examples/CRISP/packages/crisp-contracts/tests/utils.ts index 24b8d8d1ce..8ca054968b 100644 --- a/examples/CRISP/packages/crisp-contracts/tests/utils.ts +++ b/examples/CRISP/packages/crisp-contracts/tests/utils.ts @@ -7,6 +7,7 @@ import { network } from 'hardhat' import { zeroHash } from 'viem' import { CRISPProgram, HonkVerifier, MockInterfold, MockRISC0Verifier, PoseidonT3 } from '../types' +import { verifierNames } from '../scripts/verifiers' // Non-zero address used in the tests. export const nonZeroAddress = '0xc6e7DF5E7b4f2A278906862b61205850344D4e7d' @@ -57,15 +58,16 @@ export async function deployMockRISC0Verifier() { * @returns The address of the deployed HonkVerifier contract. */ export async function deployHonkVerifier() { - const zkTranscriptLib = await deployContract('contracts/CRISPVerifier.sol:ZKTranscriptLib') - const relationsLib = await deployContract('contracts/CRISPVerifier.sol:RelationsLib') + // Fully qualified: every generated verifier declares a `HonkVerifier`, and there is one per + // census mode per preset, so the bare name is ambiguous. See scripts/verifiers.ts. + const names = verifierNames('merkle') + const zkTranscriptLib = await deployContract(names.zkTranscriptLib) + const relationsLib = await deployContract(names.relationsLib) - // Fully qualified: `CRISPOnchainVerifier.sol` declares a `HonkVerifier` too, so the bare name - // is ambiguous. - const HonkVerifierFactory = await ethers.getContractFactory('contracts/CRISPVerifier.sol:HonkVerifier', { + const HonkVerifierFactory = await ethers.getContractFactory(names.honkVerifier, { libraries: { - 'project/contracts/CRISPVerifier.sol:ZKTranscriptLib': await zkTranscriptLib.getAddress(), - 'project/contracts/CRISPVerifier.sol:RelationsLib': await relationsLib.getAddress(), + [names.libraryKeys.zkTranscriptLib]: await zkTranscriptLib.getAddress(), + [names.libraryKeys.relationsLib]: await relationsLib.getAddress(), }, }) @@ -81,13 +83,14 @@ export async function deployHonkVerifier() { * @returns The deployed verifier. */ export async function deployOnchainHonkVerifier() { - const zkTranscriptLib = await deployContract('contracts/CRISPOnchainVerifier.sol:ZKTranscriptLib') - const relationsLib = await deployContract('contracts/CRISPOnchainVerifier.sol:RelationsLib') + const names = verifierNames('onchain') + const zkTranscriptLib = await deployContract(names.zkTranscriptLib) + const relationsLib = await deployContract(names.relationsLib) - const HonkVerifierFactory = await ethers.getContractFactory('contracts/CRISPOnchainVerifier.sol:HonkVerifier', { + const HonkVerifierFactory = await ethers.getContractFactory(names.honkVerifier, { libraries: { - 'project/contracts/CRISPOnchainVerifier.sol:ZKTranscriptLib': await zkTranscriptLib.getAddress(), - 'project/contracts/CRISPOnchainVerifier.sol:RelationsLib': await relationsLib.getAddress(), + [names.libraryKeys.zkTranscriptLib]: await zkTranscriptLib.getAddress(), + [names.libraryKeys.relationsLib]: await relationsLib.getAddress(), }, }) diff --git a/examples/CRISP/packages/crisp-sdk/README.md b/examples/CRISP/packages/crisp-sdk/README.md index fcaad3e6e0..d6e5b0332f 100644 --- a/examples/CRISP/packages/crisp-sdk/README.md +++ b/examples/CRISP/packages/crisp-sdk/README.md @@ -16,6 +16,36 @@ npm install @crisp-e3/sdk - **Merkle Tree Utilities**: Generate proofs for voter inclusion in the eligibility tree - **Vote Proof Generation**: Create zero-knowledge proofs for votes and mask votes - **Proof Verification**: Verify generated proofs using Noir circuits +- **Selectable Parameters**: `insecure-512` and `secure-8192` circuits ship as separate entry points + +## Choosing a preset + +Proving needs the BFV-shaped circuits, and those exist once per parameter set. They are not part of +the main entry point: the `secure-8192` set is far larger than `insecure-512`, and no consumer needs +both. Each ships as its own subpath, so your bundler pulls only the one you import. + +```ts +import { setCircuits } from '@crisp-e3/sdk' +import { loadCircuits } from '@crisp-e3/sdk/insecure-512' // or '@crisp-e3/sdk/secure-8192' + +setCircuits(await loadCircuits()) +``` + +Register once at start-up, before the first `prepareBallot`/`generateProof`. There is deliberately +no default: a ballot proved against the wrong parameters is rejected on chain rather than locally, +so `generateProof` throws a directed error instead of guessing. + +In a browser, load it through a dynamic `import()` so the circuits become their own chunk and the +app boots without them: + +```ts +const { loadCircuits } = await import('@crisp-e3/sdk/insecure-512') +setCircuits(await loadCircuits()) +``` + +`verifyProof`, `encodeVote`, `decodeTally` and the round/token helpers need no preset. The +aggregation circuits they use are proof-shaped rather than polynomial-shaped, so a single artifact +covers every preset and ships in the main entry point. ## Usage @@ -151,8 +181,11 @@ const address = await getAddressFromSignature(signature, messageHash) ```typescript import { getPreviousCiphertext } from '@crisp-e3/sdk' -const previousCiphertext = await getPreviousCiphertext(serverUrl, e3Id, slotAddress) -// Returns undefined when the slot is empty (404) +const head = await getPreviousCiphertext(serverUrl, e3Id, slotAddress) +// { ciphertext, index }, or undefined when the slot holds nothing usable (404). +// `index` is the entry a new input names as its parent. It is the end of the slot's chain of +// usable entries, not simply the newest one published: an entry whose bytes do not reproduce its +// commitment is never selected by the Secure Process, and never a valid parent. ``` ## API @@ -170,8 +203,9 @@ const previousCiphertext = await getPreviousCiphertext(serverUrl, e3Id, slotAddr - `getRoundDetails(serverUrl: string, e3Id: bigint): Promise` - Get round details - `getRoundTokenDetails(serverUrl: string, e3Id: bigint): Promise` - Get token details for a round -- `getPreviousCiphertext(serverUrl: string, e3Id: bigint, address: string): Promise` - - Get previous ciphertext for a slot (undefined when slot is empty) +- `getPreviousCiphertext(serverUrl: string, e3Id: bigint, address: string): Promise` - + Get the end of a slot's chain of usable entries, as `{ ciphertext, index }`. `index` is what a new + input names as its parent. Undefined when the slot holds nothing usable. ### Token Functions diff --git a/examples/CRISP/packages/crisp-sdk/package.json b/examples/CRISP/packages/crisp-sdk/package.json index c5d45ba99d..976eabe56c 100644 --- a/examples/CRISP/packages/crisp-sdk/package.json +++ b/examples/CRISP/packages/crisp-sdk/package.json @@ -1,13 +1,14 @@ { "name": "@crisp-e3/sdk", - "version": "0.17.0", + "version": "0.18.0-insecure.0", "type": "module", "author": { "name": "gnosisguild", "url": "https://github.com/gnosisguild" }, "files": [ - "dist" + "dist", + "!dist/presets/*.map" ], "exports": { ".": { @@ -15,6 +16,16 @@ "import": "./dist/index.js", "default": "./dist/index.js" }, + "./insecure-512": { + "types": "./dist/presets/insecure-512.d.ts", + "import": "./dist/presets/insecure-512.js", + "default": "./dist/presets/insecure-512.js" + }, + "./secure-8192": { + "types": "./dist/presets/secure-8192.d.ts", + "import": "./dist/presets/secure-8192.js", + "default": "./dist/presets/secure-8192.js" + }, "./workers/generateCircuitInputs.worker": "./dist/workers/generateCircuitInputs.worker.js" }, "main": "./dist/index.js", @@ -31,8 +42,14 @@ "compile:fold": "cd ../../circuits/bin/fold && nargo compile", "compile:fold_onchain": "cd ../../circuits/bin/fold_onchain && nargo compile", "build:wasm": "pnpm -C ../crisp-zk-inputs build", - "build": "pnpm build:wasm && pnpm compile:circuits && tsup", - "test": "vitest --run" + "build": "pnpm build:wasm && pnpm compile:circuits && pnpm stage:preset insecure-512 && tsup", + "test": "pnpm stage:preset insecure-512 && vitest --run", + "stage:preset": "node ../../scripts/stage-preset-artifacts.mjs", + "build:presets": "node ../../scripts/build-presets.mjs", + "check:presets": "node ../../scripts/check-presets.mjs", + "prepublishOnly": "pnpm check:presets", + "build:testing": "pnpm build:wasm && CRISP_PRESET=insecure-512 tsup", + "build:prod": "pnpm build:wasm && CRISP_PRESET=secure-8192 tsup" }, "publishConfig": { "access": "public" diff --git a/examples/CRISP/packages/crisp-sdk/src/circuitInputs.ts b/examples/CRISP/packages/crisp-sdk/src/circuitInputs.ts index 05ac26c31b..7d3ac861d8 100644 --- a/examples/CRISP/packages/crisp-sdk/src/circuitInputs.ts +++ b/examples/CRISP/packages/crisp-sdk/src/circuitInputs.ts @@ -4,7 +4,7 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. -import { getZkInputsGenerator, encodeVote, encryptVote } from './encoding' +import { getZkInputsGenerator, encodeVote } from './encoding' import { extractSignatureComponents, generateMerkleProof, getZeroVote, numberArrayToBigInt64Array } from './utils' import type { PreparedBallot, PrepareBallotInputs } from './types' @@ -37,6 +37,11 @@ export const splitDigest = (digest: `0x${string}`): { digestHi: `0x${string}`; d * ciphertext has to exist first. The returned `ctCommitment` is what `CRISPProgram.ballotDigest` * takes as its `ciphertextCommitment` argument. * + * One path for all three operations. A first vote, a re-vote, and a mask reach the same generator + * with the same arguments, differ only in `isMaskVote` — which stays private to the proof — and + * produce the same shape of submission. Branching here would make the three tellable apart by + * anything watching the client, which is what masks exist to prevent. + * * Kept in a separate module so it can run in a worker. * * @param inputs The ballot to prepare. @@ -46,32 +51,20 @@ export const prepareCircuitInputsImpl = async (inputs: PrepareBallotInputs): Pro const zkInputsGenerator = getZkInputsGenerator() const numOptions = inputs.isMaskVote ? inputs.numOptions : inputs.vote.length - const zeroVote = getZeroVote(numOptions) - const vote = inputs.isMaskVote ? zeroVote : inputs.vote + const vote = inputs.isMaskVote ? getZeroVote(numOptions) : inputs.vote const encodedVote = encodeVote(vote) - let circuitInputs: any - let encryptedVote: Uint8Array - - if (!inputs.previousCiphertext) { - const result = await zkInputsGenerator.generateInputs( - encryptVote(zeroVote, inputs.publicKey), - inputs.publicKey, - numberArrayToBigInt64Array(encodedVote), - ) + // Only a mask adds to what the slot already holds. A vote replaces it, so a voter cannot have + // their old ballot counted alongside the new one. The circuit derives the same choice from + // `is_mask_vote` and rejects any witness built the other way. + const keepPrevious = inputs.isMaskVote && !!inputs.previousCiphertext - circuitInputs = result.inputs - encryptedVote = result.encryptedVote - } else { - const result = await zkInputsGenerator.generateInputsForUpdate( - inputs.previousCiphertext, - inputs.publicKey, - numberArrayToBigInt64Array(encodedVote), - ) - - circuitInputs = result.inputs - encryptedVote = result.encryptedVote - } + const { inputs: circuitInputs, encryptedVote } = await zkInputsGenerator.generateInputs( + inputs.previousCiphertext, + inputs.publicKey, + numberArrayToBigInt64Array(encodedVote), + keepPrevious, + ) circuitInputs.slot_address = inputs.slotAddress.toLowerCase() circuitInputs.is_first_vote = !inputs.previousCiphertext @@ -93,11 +86,38 @@ export const prepareCircuitInputsImpl = async (inputs: PrepareBallotInputs): Pro circuitInputs.merkle_proof_siblings = merkleProof.proof.siblings.map((s) => s.toString()) } + // The commitment to `encryptedVote`, which is the ciphertext this ballot publishes: the ballot + // itself for a vote or a re-vote, the slot plus the zero ballot for a mask over an occupied slot. + // The circuit returns the same value as `final_ct_commitment`, `CRISPProgram` stores it, and + // `CRISPProgram.ballotDigest` is built over it — so it is what a voter has to sign, and it has to + // be known before proving because the digest is itself a circuit input. + // // Exported by the wasm alongside the witness. Recomputing it here would have to match // `compute_ciphertext_commitment` exactly, so it is carried across instead. - const ctCommitment = `0x${BigInt(circuitInputs.ct_commitment).toString(16).padStart(64, '0')}` as `0x${string}` + const ctCommitment = `0x${BigInt(circuitInputs.sum_ct_commitment).toString(16).padStart(64, '0')}` as `0x${string}` + + // Zero when there is nothing to extend, which is what the contract reads as `is_first_vote`. + // + // Checked at runtime as well as in the type, because a caller reaching this through plain + // JavaScript or a widened object gets no type error. Defaulting a missing index to zero would + // name the slot's first entry as the parent, and the proof would be built against one commitment + // while the contract supplied another — visible only as a rejected proof. + if (inputs.previousCiphertext !== undefined) { + const index = inputs.previousIndex + // Non-negative and safe, not merely an integer. `-1` would come back out as zero, which the + // contract reads as "extends nothing" — a re-vote silently published as a first vote against a + // slot that already holds one. Anything at or above `MAX_SAFE_INTEGER` cannot represent + // `index + 1` exactly, so the parent it names is not the parent it meant. + if (!Number.isSafeInteger(index) || (index as number) < 0 || (index as number) + 1 > Number.MAX_SAFE_INTEGER) { + throw new Error( + `previousCiphertext needs a non-negative safe integer previousIndex; got ${String(index)}. Pass the slot head as a pair.`, + ) + } + } + + const parentIndexPlusOne = inputs.previousCiphertext !== undefined ? (inputs.previousIndex as number) + 1 : 0 - return { circuitInputs, encryptedVote, ctCommitment, censusMode: inputs.censusMode } + return { circuitInputs, encryptedVote, ctCommitment, parentIndexPlusOne, censusMode: inputs.censusMode } } /** diff --git a/examples/CRISP/packages/crisp-sdk/src/circuits.ts b/examples/CRISP/packages/crisp-sdk/src/circuits.ts new file mode 100644 index 0000000000..4be8e315ce --- /dev/null +++ b/examples/CRISP/packages/crisp-sdk/src/circuits.ts @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// +// This file is provided WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. + +import type { CompiledCircuit } from '@noir-lang/noir_js' + +/** BFV parameter sets the circuits can be compiled against. */ +export type CircuitPreset = 'insecure-512' | 'secure-8192' + +/** + * The circuits whose ABI is shaped by the BFV degree, and which therefore exist once per preset. + * + * The aggregation circuits — `crisp_fold`, `crisp_onchain_fold` and `user_data_encryption` — are + * deliberately absent. Their parameters are proof and verification-key shaped (410/115 fields), not + * polynomial shaped, so one compiled artifact serves both presets; the fold circuits assert + * `chain_key_hash` against the insecure *or* the secure constant for exactly that reason. They ship + * in the main entry point, which is why `verifyProof` works without a preset loaded. + */ +export type CircuitBundle = { + readonly preset: CircuitPreset + readonly crisp: CompiledCircuit + readonly crispOnchain: CompiledCircuit + readonly userDataEncryptionCt0: CompiledCircuit + readonly userDataEncryptionCt1: CompiledCircuit +} + +let registered: CircuitBundle | null = null + +/** + * Install the preset-bound circuits used by `generateProof`. + * + * The bundle is not bundled into the main entry point, because the secure-8192 artifacts are more + * than an order of magnitude larger than the insecure-512 ones and no consumer needs both. Load the + * one you want from its subpath and register it once at start-up: + * + * ```ts + * import { setCircuits } from '@crisp-e3/sdk' + * import { loadCircuits } from '@crisp-e3/sdk/insecure-512' + * + * setCircuits(await loadCircuits()) + * ``` + */ +export const setCircuits = (bundle: CircuitBundle): void => { + registered = bundle +} + +/** The registered bundle, or `null` when none has been installed yet. */ +export const getRegisteredCircuits = (): CircuitBundle | null => registered + +/** The preset currently installed, or `null` when none has been installed yet. */ +export const registeredPreset = (): CircuitPreset | null => registered?.preset ?? null + +/** + * The registered bundle, throwing a directed error when nothing has been installed. + * + * Proving cannot fall back to a default preset: a ballot proved against the wrong parameters fails + * on chain rather than locally, so guessing here would move the failure somewhere much harder to + * read. + */ +export const requireCircuits = (): CircuitBundle => { + if (!registered) { + throw new Error( + 'No circuit preset registered. Import `loadCircuits` from "@crisp-e3/sdk/insecure-512" or ' + + '"@crisp-e3/sdk/secure-8192" and pass the result to `setCircuits()` before proving.', + ) + } + + return registered +} diff --git a/examples/CRISP/packages/crisp-sdk/src/index.ts b/examples/CRISP/packages/crisp-sdk/src/index.ts index 4de2b1651d..6efaa690f5 100644 --- a/examples/CRISP/packages/crisp-sdk/src/index.ts +++ b/examples/CRISP/packages/crisp-sdk/src/index.ts @@ -4,6 +4,7 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. +export { setCircuits, getRegisteredCircuits, registeredPreset, requireCircuits, type CircuitBundle, type CircuitPreset } from './circuits' export * from './token' export * from './state' export * from './api' @@ -45,6 +46,7 @@ export type { PrepareBallotRequest, PreparedBallot, ProofData, + SlotHead, TallyResult, CurrentRoundResponse, E3StateLiteResponse, diff --git a/examples/CRISP/packages/crisp-sdk/src/presets/insecure-512.ts b/examples/CRISP/packages/crisp-sdk/src/presets/insecure-512.ts new file mode 100644 index 0000000000..39fafaf729 --- /dev/null +++ b/examples/CRISP/packages/crisp-sdk/src/presets/insecure-512.ts @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// +// This file is provided WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. + +// The insecure-512 (N=512, L=2) preset-bound circuits, published as a separate entry point. +// +// Kept out of the main entry so a consumer's bundler pulls one preset rather than both. The +// artifacts are staged by scripts/stage-preset-artifacts.mjs; see src/circuits.ts for why the +// aggregation circuits are not among them. + +import type { CircuitBundle, CircuitPreset } from '../circuits' +import type { CompiledCircuit } from '@noir-lang/noir_js' + +import crisp from '../../../../circuits/dist/insecure-512/crisp.json' +import crispOnchain from '../../../../circuits/dist/insecure-512/crisp_onchain.json' +import userDataEncryptionCt0 from '../../../../circuits/dist/insecure-512/user_data_encryption_ct0.json' +import userDataEncryptionCt1 from '../../../../circuits/dist/insecure-512/user_data_encryption_ct1.json' + +export const preset: CircuitPreset = 'insecure-512' + +/** + * The insecure-512 (N=512, L=2) circuits, ready for `setCircuits()`. + * + * Asynchronous because the artifacts are inlined today but need not stay that way: the secure set + * is large enough that a consumer may want it fetched on demand, and that change belongs inside + * this function rather than in every caller. + */ +export const loadCircuits = async (): Promise => ({ + preset, + crisp: crisp as CompiledCircuit, + crispOnchain: crispOnchain as CompiledCircuit, + userDataEncryptionCt0: userDataEncryptionCt0 as CompiledCircuit, + userDataEncryptionCt1: userDataEncryptionCt1 as CompiledCircuit, +}) diff --git a/examples/CRISP/packages/crisp-sdk/src/presets/secure-8192.ts b/examples/CRISP/packages/crisp-sdk/src/presets/secure-8192.ts new file mode 100644 index 0000000000..2c2d30abe9 --- /dev/null +++ b/examples/CRISP/packages/crisp-sdk/src/presets/secure-8192.ts @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// +// This file is provided WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. + +// The secure-8192 (N=8192, L=3) preset-bound circuits, published as a separate entry point. +// +// Kept out of the main entry so a consumer's bundler pulls one preset rather than both. The +// artifacts are staged by scripts/stage-preset-artifacts.mjs; see src/circuits.ts for why the +// aggregation circuits are not among them. + +import type { CircuitBundle, CircuitPreset } from '../circuits' +import type { CompiledCircuit } from '@noir-lang/noir_js' + +import crisp from '../../../../circuits/dist/secure-8192/crisp.json' +import crispOnchain from '../../../../circuits/dist/secure-8192/crisp_onchain.json' +import userDataEncryptionCt0 from '../../../../circuits/dist/secure-8192/user_data_encryption_ct0.json' +import userDataEncryptionCt1 from '../../../../circuits/dist/secure-8192/user_data_encryption_ct1.json' + +export const preset: CircuitPreset = 'secure-8192' + +/** + * The secure-8192 (N=8192, L=3) circuits, ready for `setCircuits()`. + * + * Asynchronous because the artifacts are inlined today but need not stay that way: the secure set + * is large enough that a consumer may want it fetched on demand, and that change belongs inside + * this function rather than in every caller. + */ +export const loadCircuits = async (): Promise => ({ + preset, + crisp: crisp as CompiledCircuit, + crispOnchain: crispOnchain as CompiledCircuit, + userDataEncryptionCt0: userDataEncryptionCt0 as CompiledCircuit, + userDataEncryptionCt1: userDataEncryptionCt1 as CompiledCircuit, +}) diff --git a/examples/CRISP/packages/crisp-sdk/src/sdk.ts b/examples/CRISP/packages/crisp-sdk/src/sdk.ts index 8f4683aad2..81c87f7483 100644 --- a/examples/CRISP/packages/crisp-sdk/src/sdk.ts +++ b/examples/CRISP/packages/crisp-sdk/src/sdk.ts @@ -32,6 +32,7 @@ import type { PreparedBallot, ProofData, RoundDetails, + SlotHead, TokenDetails, TokenHolder, VoteStatusResponse, @@ -72,12 +73,12 @@ export class CrispSDK { * @returns A promise that resolves to the prepared ballot. */ async prepareBallot(request: PrepareBallotRequest): Promise { - const previousCiphertext = await getPreviousCiphertext(this.serverUrl, request.e3Id, request.slotAddress) + const head = await getPreviousCiphertext(this.serverUrl, request.e3Id, request.slotAddress) - return prepareBallot({ - ...request, - previousCiphertext, - }) + // Branched rather than spread conditionally. The two halves of a slot head only mean anything + // together and the type models them as a pair, which a conditional spread widens back into two + // independent optional fields — the exact shape the pair exists to rule out. + return head ? prepareBallot({ ...request, previousCiphertext: head.ciphertext, previousIndex: head.index }) : prepareBallot(request) } /** @@ -234,9 +235,9 @@ export class CrispSDK { * Get the previous ciphertext input for a slot address in a given round. * @param e3Id - The e3Id of the round * @param address - The address of the slot - * @returns The previous ciphertext, or undefined if the slot is empty + * @returns The slot head and its tree index, or undefined if the slot holds nothing usable */ - async getPreviousCiphertext(e3Id: bigint, address: string): Promise { + async getPreviousCiphertext(e3Id: bigint, address: string): Promise { return getPreviousCiphertext(this.serverUrl, e3Id, address) } } diff --git a/examples/CRISP/packages/crisp-sdk/src/state.ts b/examples/CRISP/packages/crisp-sdk/src/state.ts index 8fd814b190..4f7e14dda7 100644 --- a/examples/CRISP/packages/crisp-sdk/src/state.ts +++ b/examples/CRISP/packages/crisp-sdk/src/state.ts @@ -10,7 +10,7 @@ import { CRISP_SERVER_PREVIOUS_CIPHERTEXT_ENDPOINT } from './constants' import { getRoundStateLite } from './api' import { getPublicClient } from './chain' -import type { CreditMode, OnChainRoundData, RoundDetails, TokenDetails } from './types' +import type { CreditMode, OnChainRoundData, RoundDetails, SlotHead, TokenDetails } from './types' /** * Get the details of a specific round in a camelCase convenience format @@ -106,12 +106,7 @@ export const getOnChainRoundData = async (programAddress: string, e3Id: bigint, * @param chainId - The chain the program is deployed on * @returns The spendable voting power in ballot units, or 0 for a round that is not ONCHAIN */ -export const getOnchainVotingPower = async ( - programAddress: string, - e3Id: bigint, - slot: string, - chainId: number, -): Promise => { +export const getOnchainVotingPower = async (programAddress: string, e3Id: bigint, slot: string, chainId: number): Promise => { const publicClient = getPublicClient(chainId) return publicClient.readContract({ @@ -129,9 +124,10 @@ export const getOnchainVotingPower = async ( * @param serverUrl - The base URL of the CRISP server * @param e3Id - The e3Id of the round * @param address - The address of the slot - * @returns The previous ciphertext for the slot, or undefined if the slot is empty + * @returns The end of the slot's chain of usable entries and its tree index, or undefined when the + * slot holds nothing usable. The index is what a new input names as its parent. */ -export const getPreviousCiphertext = async (serverUrl: string, e3Id: bigint, address: string): Promise => { +export const getPreviousCiphertext = async (serverUrl: string, e3Id: bigint, address: string): Promise => { const response = await fetch(`${serverUrl}/${CRISP_SERVER_PREVIOUS_CIPHERTEXT_ENDPOINT}`, { method: 'POST', headers: { @@ -150,5 +146,5 @@ export const getPreviousCiphertext = async (serverUrl: string, e3Id: bigint, add const data = await response.json() - return new Uint8Array(data.ciphertext) + return { ciphertext: new Uint8Array(data.ciphertext), index: Number(data.index) } } diff --git a/examples/CRISP/packages/crisp-sdk/src/types.ts b/examples/CRISP/packages/crisp-sdk/src/types.ts index ea2e3bcbd8..52cc8d9527 100644 --- a/examples/CRISP/packages/crisp-sdk/src/types.ts +++ b/examples/CRISP/packages/crisp-sdk/src/types.ts @@ -129,6 +129,14 @@ export type ProofData = { publicInputs: string[] proof: Uint8Array encryptedVote: Uint8Array + /** + * The tree index of the entry this input extends, plus one; zero when it extends nothing. + * + * `CRISPProgram` reads the parent's commitment from this and hands it to the circuit as + * `prev_ct_commitment`, and the Secure Process walks each slot's chain by it. Offset by one so + * that zero means "no parent", which is what index 0 would otherwise be ambiguous with. + */ + parentIndexPlusOne: number } /** @@ -140,15 +148,35 @@ export type ProofData = { */ export type CensusVariant = 'merkle' | 'onchain' +/** + * The two halves of a slot head, which only mean anything together. + * + * Modelled as a pair rather than two optional fields: a ciphertext without its index would be + * proven against one entry and published against another, and the mismatch only surfaces as a + * rejected proof. + */ +type SlotHeadInputs = + | { + /** + * The ciphertext currently in the slot: the end of its chain of usable entries, not simply + * the newest one published. An entry whose bytes do not reproduce its commitment is never + * selected by the Secure Process and is never a valid parent, so building on it would have + * this input dropped from the tally. + */ + previousCiphertext: Uint8Array + /** The tree index of `previousCiphertext`, which this input names as its parent. */ + previousIndex: number + } + | { previousCiphertext?: undefined; previousIndex?: undefined } + type PrepareBallotInputsBase = { - previousCiphertext?: Uint8Array publicKey: Uint8Array slotAddress: string isMaskVote: boolean /// Read for a mask, where there is no vote to take a length from. numOptions: number vote: Vote -} +} & SlotHeadInputs /** * Everything needed to encrypt a ballot, before the voter has signed anything. @@ -165,8 +193,21 @@ export type PrepareBallotInputs = */ export type PreparedBallot = { circuitInputs: any + /** + * The ciphertext to publish, which is the ballot itself for a vote or a re-vote, and the slot's + * ciphertext plus the zero ballot for a mask over an occupied slot. + */ encryptedVote: Uint8Array + /** + * The commitment to `encryptedVote`: what the circuit returns, what the E3 program stores, and + * what `CRISPProgram.ballotDigest` takes as its `ciphertextCommitment` argument. + * + * Since the digest is itself a circuit input, a caller has to know this value before proving, + * which is why the wasm exports it rather than leaving it to be read off the finished proof. + */ ctCommitment: `0x${string}` + /** The value {@link ProofData.parentIndexPlusOne} carries through to `encodeSolidityProof`. */ + parentIndexPlusOne: number censusMode: CensusVariant } @@ -181,9 +222,22 @@ type DistributiveOmit = T extends unknown ? Omit /** * A {@link PrepareBallotInputs} plus the round it belongs to. * - * The SDK resolves `previousCiphertext` from the server, so callers do not pass it. + * The SDK resolves the slot head from the server, so callers pass neither part of it. */ -export type PrepareBallotRequest = { e3Id: bigint } & DistributiveOmit +export type PrepareBallotRequest = { e3Id: bigint } & DistributiveOmit + +/** + * The end of a slot's chain of usable entries: what a new input extends. + * + * Not simply the newest entry published to the slot. An entry whose bytes do not reproduce its + * commitment is never selected by the Secure Process and is never a valid parent, so the server + * resolves the chain and answers with the entry that actually holds the slot. + */ +export type SlotHead = { + ciphertext: Uint8Array + /** The tree index of that entry. */ + index: number +} /** * Type representing the current round returned by the CRISP server (`rounds/current`) diff --git a/examples/CRISP/packages/crisp-sdk/src/vote.ts b/examples/CRISP/packages/crisp-sdk/src/vote.ts index 84120f153c..25e94a24f9 100644 --- a/examples/CRISP/packages/crisp-sdk/src/vote.ts +++ b/examples/CRISP/packages/crisp-sdk/src/vote.ts @@ -12,13 +12,13 @@ export { encodeVote, encryptVote, decodeTally, decryptVote, generateBFVKeys } fr export { splitDigest } from './circuitInputs' import { Noir, type CompiledCircuit } from '@noir-lang/noir_js' import { Barretenberg, BackendType, UltraHonkBackend } from '@aztec/bb.js' -import crispCircuit from '../../../circuits/bin/crisp/target/crisp.json' +// Only the aggregation circuits are imported here. Their ABI is proof and verification-key shaped +// rather than polynomial shaped, so one artifact serves every preset and inlining them costs ~0.3MB. +// The BFV-shaped circuits arrive through `setCircuits()` — see ./circuits. import foldCircuit from '../../../circuits/bin/fold/target/crisp_fold.json' -import crispOnchainCircuit from '../../../circuits/bin/crisp_onchain/target/crisp_onchain.json' import foldOnchainCircuit from '../../../circuits/bin/fold_onchain/target/crisp_onchain_fold.json' -import userDataEncryptionCt0Circuit from '../../../../../circuits/bin/threshold/target/user_data_encryption_ct0.json' -import userDataEncryptionCt1Circuit from '../../../../../circuits/bin/threshold/target/user_data_encryption_ct1.json' import userDataEncryptionCircuit from '../../../../../circuits/bin/threshold/target/user_data_encryption.json' +import { requireCircuits } from './circuits' import { bytesToHex, encodeAbiParameters, parseAbiParameters, numberToHex, getAddress } from 'viem/utils' import { Hex } from 'viem' @@ -108,10 +108,11 @@ export const executeCircuit = async (circuit: CompiledCircuit, inputs: any): Pro export const generateProof = async (circuitInputs: any, censusMode: CensusVariant = 'merkle') => { const api = await getBBApi() - const ballotCircuit = censusMode === 'onchain' ? crispOnchainCircuit : crispCircuit + const circuits = requireCircuits() + const ballotCircuit = censusMode === 'onchain' ? circuits.crispOnchain : circuits.crisp const foldCircuitForMode = censusMode === 'onchain' ? foldOnchainCircuit : foldCircuit - const { witness: userDataEncryptionCt0Witness } = await executeCircuit(userDataEncryptionCt0Circuit as CompiledCircuit, { + const { witness: userDataEncryptionCt0Witness } = await executeCircuit(circuits.userDataEncryptionCt0 as CompiledCircuit, { pk0is: circuitInputs.pk0is, ct0is: circuitInputs.ct0is, u: circuitInputs.u, @@ -122,7 +123,7 @@ export const generateProof = async (circuitInputs: any, censusMode: CensusVarian r1is: circuitInputs.r1is, r2is: circuitInputs.r2is, }) - const { witness: userDataEncryptionCt1Witness } = await executeCircuit(userDataEncryptionCt1Circuit as CompiledCircuit, { + const { witness: userDataEncryptionCt1Witness } = await executeCircuit(circuits.userDataEncryptionCt1 as CompiledCircuit, { pk1is: circuitInputs.pk1is, ct1is: circuitInputs.ct1is, u: circuitInputs.u, @@ -166,8 +167,8 @@ export const generateProof = async (circuitInputs: any, censusMode: CensusVarian num_options: circuitInputs.num_options, }) - const userDataEncryptionCt0Backend = new UltraHonkBackend((userDataEncryptionCt0Circuit as CompiledCircuit).bytecode, api) - const userDataEncryptionCt1Backend = new UltraHonkBackend((userDataEncryptionCt1Circuit as CompiledCircuit).bytecode, api) + const userDataEncryptionCt0Backend = new UltraHonkBackend((circuits.userDataEncryptionCt0 as CompiledCircuit).bytecode, api) + const userDataEncryptionCt1Backend = new UltraHonkBackend((circuits.userDataEncryptionCt1 as CompiledCircuit).bytecode, api) const userDataEncryptionBackend = new UltraHonkBackend((userDataEncryptionCircuit as CompiledCircuit).bytecode, api) const crispBackend = new UltraHonkBackend((ballotCircuit as CompiledCircuit).bytecode, api) const foldBackend = new UltraHonkBackend((foldCircuitForMode as CompiledCircuit).bytecode, api) @@ -326,7 +327,11 @@ export const prepareBallot = async (inputs: PrepareBallotInputs): Promise => { const circuitInputs = await attachSignatureImpl(prepared, digest, signature) - return { ...(await generateProof(circuitInputs, prepared.censusMode)), encryptedVote: prepared.encryptedVote } + return { + ...(await generateProof(circuitInputs, prepared.censusMode)), + encryptedVote: prepared.encryptedVote, + parentIndexPlusOne: prepared.parentIndexPlusOne, + } } /** @@ -364,7 +369,7 @@ export const verifyProof = async (proof: ProofData, censusMode: CensusVariant = * @param proof The proof data. * @returns The encoded proof data as a hex string. */ -export const encodeSolidityProof = ({ publicInputs, proof, encryptedVote }: ProofData): Hex => { +export const encodeSolidityProof = ({ publicInputs, proof, encryptedVote, parentIndexPlusOne }: ProofData): Hex => { // Indices follow the fold circuit public inputs: // 0 prev_ct_commitment, 1 digest_hi, 2 digest_lo, 3 slot_address, // 4 merkle_root | voting_power, 5 is_first_vote, 6 num_options, @@ -372,10 +377,11 @@ export const encodeSolidityProof = ({ publicInputs, proof, encryptedVote }: Proo const slotAddress = getAddress(numberToHex(BigInt(publicInputs[3]), { size: 20 })) const encryptedVoteCommitment = publicInputs[7] as `0x${string}` - return encodeAbiParameters(parseAbiParameters('bytes, address, bytes32, bytes'), [ + return encodeAbiParameters(parseAbiParameters('bytes, address, bytes32, bytes, uint40'), [ bytesToHex(proof), slotAddress, encryptedVoteCommitment, bytesToHex(encryptedVote), + parentIndexPlusOne, ]) } diff --git a/examples/CRISP/packages/crisp-sdk/tests/vote.test.ts b/examples/CRISP/packages/crisp-sdk/tests/vote.test.ts index 274ac6aedd..5bce54899e 100644 --- a/examples/CRISP/packages/crisp-sdk/tests/vote.test.ts +++ b/examples/CRISP/packages/crisp-sdk/tests/vote.test.ts @@ -12,8 +12,15 @@ import { publicKeyToAddress, sign, signMessage } from 'viem/accounts' import { Hex, concat, keccak256, numberToHex, recoverPublicKey } from 'viem' import { CRISP_SERVER_URL, ECDSA_PRIVATE_KEY, SLOT_ADDRESS } from './constants' import { CrispSDK } from '../src/sdk' +import { setCircuits } from '../src/circuits' +import { loadCircuits } from '../src/presets/insecure-512' import { generateTestLeaves } from './helpers' +// Proving needs a preset installed; the BFV-shaped circuits are no longer part of the main entry. +beforeAll(async () => { + setCircuits(await loadCircuits()) +}) + describe('Vote', () => { let vote: Vote let signature: Hex @@ -28,11 +35,13 @@ describe('Vote', () => { const zeroVote = getZeroVote(2) + // The server answers with the end of the slot's chain of usable entries and its tree index; the + // SDK names that index as the parent of the input it is about to build. const mockGetPreviousCiphertextResponse = () => ({ ok: true, status: 200, - json: async () => ({ ciphertext: previousCiphertext }), + json: async () => ({ ciphertext: previousCiphertext, index: 0 }), }) as Response const mockPreviousCiphertextNotFoundResponse = () => ({ ok: false, status: 404 }) as Response @@ -244,6 +253,42 @@ describe('Vote', () => { expect(isValid).toBe(true) }) + + // The third operation, and the one that had no coverage. A re-vote reaches the circuit with a + // slot that already holds a ballot, so it exercises the `prev_ct_commitment` check that a + // first vote skips, and it must replace rather than add — or a voter would have both ballots + // counted. + it('Should replace a ballot already in the slot', { timeout: 300000 }, async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockGetPreviousCiphertextResponse()) + + const updated: Vote = [0, 4, 0] + const prepared = await sdk.prepareBallot({ + censusMode: 'merkle', + vote: updated, + publicKey, + merkleLeaves: leaves, + balance, + slotAddress: address, + isMaskVote: false, + numOptions: updated.length, + e3Id, + }) + + const digest = keccak256(concat([prepared.ctCommitment, numberToHex(e3Id, { size: 32 })])) + const ballotSignature = await sign({ hash: digest, privateKey: ECDSA_PRIVATE_KEY, to: 'hex' }) + + const proof = await sdk.finishBallot(prepared, digest, ballotSignature) + + // What the contract stores, and what the digest was built over. + expect(BigInt(proof.publicInputs[7])).toBe(BigInt(prepared.ctCommitment)) + // The slot held a ballot, so the contract passes its commitment and the circuit checks it. + expect(BigInt(proof.publicInputs[0])).not.toBe(0n) + expect(proof.publicInputs[5]).toBe(`0x${'0'.repeat(64)}`) + + // Replaced, not added: the slot decrypts to the new ballot alone. + expect(decryptVote(proof.encryptedVote, secretKey, updated.length)).toEqual(updated.map(BigInt)) + expect(await verifyProof(proof)).toBe(true) + }) }) describe('onchain census', () => { @@ -339,13 +384,48 @@ describe('Vote', () => { expect(proof.proof).toBeDefined() expect(proof.publicInputs).toBeDefined() - const decryptedVote = decryptVote(previousCiphertext, secretKey, 2) + // A mask over an occupied slot publishes the sum, not the zero ballot it encrypted, so the + // commitment the caller signs over is the sum's. `CRISPProgram.publishInput` rebuilds the + // digest from the same value, and a caller signing over the ballot alone would produce a + // proof the contract rejects. + expect(BigInt(proof.publicInputs[7])).toBe(BigInt(prepared.ctCommitment)) + expect(BigInt(proof.publicInputs[0])).not.toBe(0n) - expect(decryptedVote).toEqual(zeroVote.map(BigInt)) + // Adding a zero ballot leaves what the slot decrypts to untouched. + expect(decryptVote(proof.encryptedVote, secretKey, 2)).toEqual(zeroVote.map(BigInt)) + expect(decryptVote(previousCiphertext, secretKey, 2)).toEqual(zeroVote.map(BigInt)) const isValid = await verifyProof(proof) expect(isValid).toBe(true) }) + + // A mask must not be able to carry a payload. The zero check used to read only coefficients + // that the SDK layout never writes to, so any plaintext passed it — and anyone can write a mask + // to any eligible slot without a signature, which made it a way to corrupt a slot the submitter + // cannot vote in. + it('Should refuse a mask that carries a ballot', { timeout: 300000 }, async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce(mockGetPreviousCiphertextResponse()) + + // Encrypted as a real ballot, then submitted on the mask branch, which is the only branch a + // third party can reach. The plaintext is whatever the attacker chose. + const prepared = await sdk.prepareBallot({ + censusMode: 'merkle', + vote: [7, 0], + balance, + slotAddress: SLOT_ADDRESS, + publicKey, + merkleLeaves: leaves, + isMaskVote: false, + numOptions: 2, + e3Id: 0n, + }) + + prepared.circuitInputs.is_mask_vote = true + + const digest = keccak256(concat([prepared.ctCommitment, numberToHex(0, { size: 32 })])) + + await expect(sdk.finishBallot(prepared, digest)).rejects.toThrow() + }) }) }) diff --git a/examples/CRISP/packages/crisp-sdk/tsup.config.js b/examples/CRISP/packages/crisp-sdk/tsup.config.js index ca07d181c6..33968884be 100644 --- a/examples/CRISP/packages/crisp-sdk/tsup.config.js +++ b/examples/CRISP/packages/crisp-sdk/tsup.config.js @@ -4,10 +4,47 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. +import { existsSync } from 'node:fs' import { defineConfig } from 'tsup' +// Each preset is its own entry point, so a consumer's bundler pulls one set of BFV-shaped circuits +// rather than both. +// +// A published tarball carries exactly one preset, selected by `CRISP_PRESET`, because the release +// channels are split by preset: `testing` ships insecure-512 and `latest` ships secure-8192. The +// secure circuits are far larger than the insecure ones, and shipping both would put that weight in +// every install of either. Importing the preset a tarball does not carry then fails to resolve, +// which is the right failure — the alternative is proving against parameters the deployed verifier +// does not match, and that is only discovered on chain. +// +// With `CRISP_PRESET` unset the build takes whatever is staged, which is what local development +// wants. +const PRESETS = ['insecure-512', 'secure-8192'] + +const staged = (preset) => existsSync(`../../circuits/dist/${preset}/crisp.json`) + +const requested = process.env.CRISP_PRESET +if (requested !== undefined && !PRESETS.includes(requested)) { + throw new Error(`CRISP_PRESET must be one of ${PRESETS.join(', ')}; got "${requested}".`) +} + +let selected +if (requested === undefined) { + selected = PRESETS.filter(staged) + for (const preset of PRESETS) { + if (!selected.includes(preset)) console.warn(`⚠️ tsup: skipping "${preset}" entry — circuits/dist/${preset} is not staged.`) + } + if (selected.length === 0) throw new Error('No circuit preset staged. Run `pnpm build:presets` before building the SDK.') +} else { + if (!staged(requested)) { + throw new Error(`CRISP_PRESET=${requested} but circuits/dist/${requested} is not staged. Run \`pnpm build:presets\` first.`) + } + selected = [requested] + console.log(`tsup: building the ${requested} entry only (CRISP_PRESET).`) +} + export default defineConfig({ - entry: ['src/index.ts', 'src/workers/generateCircuitInputs.worker.ts'], + entry: ['src/index.ts', 'src/workers/generateCircuitInputs.worker.ts', ...selected.map((preset) => `src/presets/${preset}.ts`)], include: ['src/**/*.ts'], splitting: false, sourcemap: true, diff --git a/examples/CRISP/packages/crisp-zk-inputs/README.md b/examples/CRISP/packages/crisp-zk-inputs/README.md index 573ca8cc6d..89bbf7060c 100644 --- a/examples/CRISP/packages/crisp-zk-inputs/README.md +++ b/examples/CRISP/packages/crisp-zk-inputs/README.md @@ -7,17 +7,25 @@ witness data needed for Noir-based vote-validity proofs without duplicating the ## What it generates The WASM module wraps a `ZKInputsGenerator` class that performs BFV encryption and produces the -witness data needed for CRISP's Noir circuits. Two main proof types are supported: +witness data needed for CRISP's Noir circuits. -- **Vote proof** (`generateInputs`) — encrypts a vote under the committee's threshold BFV public key - and produces a witness proving the vote is correctly encrypted and that the voter is eligible - (e.g. holds the required token balance, verified via a Merkle membership proof). +`generateInputs` is the only entry point, and it covers all three operations: a first vote, a +re-vote, and a masker contribution under the +[vote masking](https://blog.theinterfold.com/vote-masking-receipt-freeness-secret-ballots/) scheme +that provides receipt-freeness. -- **Vote update / mask proof** (`generateInputsForUpdate`) — same structure, but used for revotes or - masker contributions under the - [vote masking](https://blog.theinterfold.com/vote-masking-receipt-freeness-secret-ballots/) scheme - that provides receipt-freeness. Unlike the first-vote path, this preserves the real - `prev_ct_commitment` (rather than zeroing it) to chain updates together. +```typescript +generateInputs(previousCiphertext, publicKey, vote, keepPrevious) +``` + +- `previousCiphertext` is the ciphertext currently in the slot, or `undefined` when it is empty. +- `keepPrevious` is set only for a mask over an occupied slot. It makes the ballot add to that + ciphertext; everything else replaces it. + +One function rather than one per operation, deliberately: the encryption, the witness, and the +published ciphertext have the same shape in every case, so nothing about a submission says which +operation it was. Splitting the paths would make the three tellable apart, which is the attack +masking exists to prevent. The generator also exposes `encryptVote` / `decryptVote` for standalone BFV operations and `generateKeys` for key generation. @@ -38,17 +46,23 @@ The `init` subpackage handles both environments transparently. ```ts // Bad — the raw default loader doesn't work in Node.js contexts -import init, { generateVoteInputs } from '@crisp-e3/zk-inputs' +import init, { ZKInputsGenerator } from '@crisp-e3/zk-inputs' ``` ### ✅ Use the universal subpackage loader ```ts import init from '@crisp-e3/zk-inputs/init' -import { generateVoteInputs } from '@crisp-e3/zk-inputs' +import { ZKInputsGenerator } from '@crisp-e3/zk-inputs' await init() -const inputs = generateVoteInputs(/* ... */) +const generator = ZKInputsGenerator.withDefaults() +const { encryptedVote, inputs } = generator.generateInputs( + previousCiphertext, + publicKey, + vote, + keepPrevious, +) ``` Call `init()` once before using any other imports from `@crisp-e3/zk-inputs`. In browser diff --git a/examples/CRISP/packages/crisp-zk-inputs/package.json b/examples/CRISP/packages/crisp-zk-inputs/package.json index 93cb4e742a..2b81a12d38 100644 --- a/examples/CRISP/packages/crisp-zk-inputs/package.json +++ b/examples/CRISP/packages/crisp-zk-inputs/package.json @@ -2,7 +2,7 @@ "name": "@crisp-e3/zk-inputs", "type": "module", "description": "Core logic to pre-compute CRISP ZK inputs (WASM/JavaScript bindings).", - "version": "0.17.0", + "version": "0.18.0-insecure.0", "license": "LGPL-3.0-only", "repository": { "type": "git", diff --git a/examples/CRISP/program/Cargo.toml b/examples/CRISP/program/Cargo.toml index 521505b0f6..c22250bb27 100644 --- a/examples/CRISP/program/Cargo.toml +++ b/examples/CRISP/program/Cargo.toml @@ -7,4 +7,13 @@ edition = { workspace = true } fhe = { workspace = true } fhe-traits = { workspace = true } e3-compute-provider = { workspace = true } +sha2 = "=0.10.9" e3-fhe-params = { workspace = true } + +[dev-dependencies] +hex = "=0.4.3" +num-bigint = "=0.4.6" +serde_json = "=1.0.145" +sha3 = "=0.10.8" +e3-bfv-client = { workspace = true } +rand = { workspace = true } diff --git a/examples/CRISP/program/src/lib.rs b/examples/CRISP/program/src/lib.rs index 179ccc75e2..dd23e25549 100644 --- a/examples/CRISP/program/src/lib.rs +++ b/examples/CRISP/program/src/lib.rs @@ -9,6 +9,14 @@ use e3_fhe_params::decode_bfv_params_arc; use fhe::bfv::Ciphertext; use fhe_traits::{DeserializeParametrized, Serialize}; +/// The input policy this E3 program requires. +/// +/// Every E3 program exports one beside its processor, so the guest and the dev runner do not need +/// to know which program they are running. +pub fn policy() -> e3_compute_provider::InputPolicy { + policy::crisp() +} + /// CRISP Implementation of the CiphertextProcessor function pub fn fhe_processor(fhe_inputs: &FHEInputs) -> Vec { let params = decode_bfv_params_arc(&fhe_inputs.params).unwrap(); @@ -22,3 +30,136 @@ pub fn fhe_processor(fhe_inputs: &FHEInputs) -> Vec { sum.to_bytes() } + +/// CRISP's answers to how an input becomes a leaf and which inputs are tallied. +/// +/// Both are specific to this program and its contract. They live here, beside the `CRISPProgram` +/// they must agree with, rather than in `e3-compute-provider`, which every E3 program shares. +pub mod policy { + use e3_compute_provider::policy::{leaf_from_digest, PublishedInput}; + use e3_compute_provider::{ComputeError, InputPolicy}; + use sha2::{Digest, Sha256}; + use std::collections::BTreeMap; + + /// The metadata `CRISPProgram` publishes with each input: 20-byte slot, then a 5-byte parent. + const METADATA_LEN: usize = 25; + + /// What `CRISPProgram` publishes alongside one ciphertext. + struct Metadata { + slot: [u8; 20], + /// The tree index of the entry this input extends, or `None` when it extends nothing. + parent: Option, + } + + /// Splits the published metadata, which is `slot || parentIndexPlusOne` as + /// `abi.encodePacked(address, uint40)` lays it out. + fn metadata_of(input: &PublishedInput) -> Result { + if input.metadata.len() != METADATA_LEN { + return Err(ComputeError::LeafCommitment { + index: input.index, + reason: format!( + "expected {METADATA_LEN} bytes of slot and parent, got {}", + input.metadata.len() + ), + }); + } + + let mut slot = [0u8; 20]; + slot.copy_from_slice(&input.metadata[..20]); + + let mut parent_plus_one: u64 = 0; + for byte in &input.metadata[20..] { + parent_plus_one = (parent_plus_one << 8) | u64::from(*byte); + } + + Ok(Metadata { + slot, + parent: parent_plus_one.checked_sub(1), + }) + } + + /// `sha256(sha256(ciphertext) || commitment || slot || parent) mod SNARK_SCALAR_FIELD`. + /// + /// Must stay byte-identical to `CRISPProgram.inputLeaf`, or no root will ever match. It binds + /// four things: the bytes, because the Noir proof constrains only the commitment and never sees + /// the serialized ciphertext; the commitment, so no commitment can be paired with any + /// ciphertext; the slot, because selection is per slot and an unbound slot would let a prover + /// re-group entries; and the parent, because selection walks the slot's chain by it. + pub fn leaf(input: &PublishedInput) -> Result { + let commitment = input + .commitment + .ok_or_else(|| ComputeError::LeafCommitment { + index: input.index, + reason: "CRISP publishes a commitment with every input".to_string(), + })?; + // Hashed as published rather than as parsed, so the leaf cannot drift from the contract's + // `abi.encodePacked` layout. Parsed first only to refuse the wrong length. + metadata_of(input)?; + + let mut outer = Sha256::new(); + outer.update(Sha256::digest(input.ciphertext)); + outer.update(commitment); + outer.update(input.metadata); + Ok(leaf_from_digest(&outer.finalize())) + } + + /// The end of each slot's chain of usable entries. + /// + /// CRISP's input tree is append-only: anyone may write to any census member's slot, since the + /// mask path checks no signature. Overwriting in place would let a third party replace the + /// bytes of a counted vote and erase it, so entries accumulate and one per slot is selected + /// here. + /// + /// Each entry names the entry it extends, and an entry is taken only when two things hold: + /// + /// - its bytes reproduce its commitment, so it is a ciphertext anyone can read; and + /// - the entry it names is the one currently selected for that slot. + /// + /// The first rule is what stops a submitter poisoning a slot with bytes that decode to nothing. + /// The second is what stops one reaching back past a vote: an entry built on a superseded + /// ciphertext would put that older ciphertext back in the slot, erasing the vote in between. + /// + /// Together they also keep a slot writable. `CRISPProgram` cannot check the first rule — only + /// this runs late enough to — so anyone can leave an entry nobody else can open. Because such + /// an entry is never selected, it is never a valid parent either, and the next honest input + /// names the same parent it did and is taken in its place. Without that, a slot could be frozen + /// against masking, and a slot that cannot be masked is one where every later input is provably + /// its owner voting again — a receipt, which is what masks exist to prevent. + /// + /// A slot whose entries are all unusable contributes nothing — it never held a good vote. + pub fn chain_head_per_slot(inputs: &[PublishedInput]) -> Vec { + let mut head: BTreeMap<[u8; 20], u64> = BTreeMap::new(); + let mut selected_for_slot: BTreeMap<[u8; 20], usize> = BTreeMap::new(); + + // In index order, which is the order the tree was built in, so a chain is only ever + // extended forwards. + for input in inputs { + if !input.matches_commitment() { + continue; + } + + let Ok(metadata) = metadata_of(input) else { + continue; + }; + + if metadata.parent != head.get(&metadata.slot).copied() { + continue; + } + + head.insert(metadata.slot, input.index as u64); + selected_for_slot.insert(metadata.slot, input.index); + } + + let mut selected: Vec = selected_for_slot.into_values().collect(); + selected.sort_unstable(); + selected + } + + /// The policy `CRISPProgram` requires. + pub fn crisp() -> InputPolicy { + InputPolicy { + leaf, + select: chain_head_per_slot, + } + } +} diff --git a/examples/CRISP/program/tests/input_leaf.rs b/examples/CRISP/program/tests/input_leaf.rs new file mode 100644 index 0000000000..44f78f454a --- /dev/null +++ b/examples/CRISP/program/tests/input_leaf.rs @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// +// This file is provided WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. + +//! The leaf layout, against the same vector `tests/input-leaf.test.ts` asserts in `crisp-contracts`. +//! +//! `CRISPProgram.inputLeaf` and [`e3_user_program::policy::leaf`] must agree byte for byte, or no +//! root the Secure Process derives will ever match the one the contract accumulated — and that +//! failure has no other symptom. Neither language can catch a divergence alone, so both check the +//! same vector and one of them fails when either side moves. + +use e3_compute_provider::policy::PublishedInput; +use e3_user_program::policy::leaf; +use num_bigint::BigUint; + +/// `0x` + the bytes 0..64, which is what the TypeScript vector spells out. +fn ciphertext() -> Vec { + (0..64u8).collect() +} + +const COMMITMENT: [u8; 32] = [0xab; 32]; +const SLOT: [u8; 20] = [0xcd; 20]; + +/// `abi.encodePacked(address, uint40)`, as `CRISPProgram` lays the metadata out. +fn metadata(parent_index_plus_one: u64) -> Vec { + let mut bytes = SLOT.to_vec(); + bytes.extend_from_slice(&parent_index_plus_one.to_be_bytes()[3..]); + bytes +} + +/// The leaf, as `leaf_from_digest` renders it: a hex string, already reduced into the field. +/// +/// The TypeScript vector is the same number in decimal, because Solidity returns a `uint256`. +fn leaf_of(ciphertext: &[u8], commitment: &[u8; 32], metadata: &[u8]) -> String { + leaf(&PublishedInput { + index: 0, + ciphertext, + commitment: Some(commitment), + metadata, + recomputed: None, + }) + .expect("the vector is well formed") +} + +#[test] +fn the_leaf_matches_the_contract_vector() { + let metadata = metadata(0); + + assert_eq!( + leaf_of(&ciphertext(), &COMMITMENT, &metadata), + "17910fb4333ca8c834bafea151e216e488db0fdaba00d7acd8d15a9e4a6ba9b4", + "the Rust leaf diverged from the vector CRISPProgram.inputLeaf produces" + ); +} + +/// The parent is bound, so an entry cannot be re-pointed at another one. +#[test] +fn the_leaf_changes_with_the_parent() { + let first = leaf_of(&ciphertext(), &COMMITMENT, &metadata(0)); + let second = leaf_of(&ciphertext(), &COMMITMENT, &metadata(1)); + + assert_ne!(first, second); + assert_eq!( + second, "098ef4852f0e5a3ae22bee86209ba3f653af2d34632ab68fadf1525af6b26d40", + "the Rust leaf diverged from the vector CRISPProgram.inputLeaf produces" + ); +} + +/// Every leaf has to reduce into the scalar field, or `LazyIMT` refuses it on chain. +#[test] +fn every_leaf_reduces_into_the_scalar_field() { + let field = BigUint::parse_bytes( + b"21888242871839275222246405745257275088548364400416034343698204186575808495617", + 10, + ) + .unwrap(); + + for parent in 0..8u64 { + let value = BigUint::parse_bytes( + leaf_of(&ciphertext(), &COMMITMENT, &metadata(parent)).as_bytes(), + 16, + ) + .expect("the leaf is a hex string"); + + assert!(value < field); + } +} + +/// Metadata of the wrong length is refused rather than hashed, so an input that could never be +/// placed in a chain does not silently contribute a leaf the contract disagrees with. +#[test] +fn metadata_of_the_wrong_length_is_refused() { + let bytes = ciphertext(); + let result = leaf(&PublishedInput { + index: 0, + ciphertext: &bytes, + commitment: Some(&COMMITMENT), + metadata: &SLOT, + recomputed: None, + }); + + assert!(result.is_err(), "a 20-byte metadata predates the parent"); +} diff --git a/examples/CRISP/program/tests/onchain_root_agreement.rs b/examples/CRISP/program/tests/onchain_root_agreement.rs new file mode 100644 index 0000000000..516d2d3fc8 --- /dev/null +++ b/examples/CRISP/program/tests/onchain_root_agreement.rs @@ -0,0 +1,254 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// +// This file is provided WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. + +//! Asserts that the Rust input tree reproduces a root a real contract actually produced. +//! +//! The fixture is generated by `tests/input-tree-e2e.test.ts` in `crisp-contracts`, which publishes +//! a real ballot — real BFV ciphertext, real Noir proof — through `publishInput` and records the +//! resulting on-chain `inputRoot`. Regenerate with +//! `UPDATE_INPUT_TREE_FIXTURE=1 pnpm test` in that package. +//! +//! This is the one check that catches a divergence between `CRISPProgram.inputLeaf` and +//! `MerkleTreeBuilder`. Such a divergence makes every round fail with no other symptom, and no +//! single-language test can see it. + +use e3_compute_provider::{ComputeInput, FHEInputs, PublishedData}; +use e3_fhe_params::{build_pair_for_preset, encode_bfv_params, BfvPreset}; +use e3_user_program::policy::crisp; +use sha3::Digest as _; + +const FIXTURE: &str = include_str!("../../packages/crisp-contracts/tests/fixtures/input-tree.json"); + +/// Reads the fixture entries into the shapes `ComputeInput` takes. +/// +/// The metadata is `slot || parentIndexPlusOne`, byte for byte what `CRISPProgram` hashes into the +/// leaf as `abi.encodePacked(address, uint40)`. +fn load(entries: &[serde_json::Value]) -> (Vec<(Vec, u64)>, Vec) { + let mut ciphertexts = Vec::new(); + let mut published = Vec::new(); + for (index, entry) in entries.iter().enumerate() { + ciphertexts.push(( + unhex(entry["encryptedVote"].as_str().expect("encryptedVote")), + index as u64, + )); + + let parent = entry["parentIndexPlusOne"] + .as_u64() + .expect("parentIndexPlusOne"); + let mut metadata = unhex(entry["slot"].as_str().expect("slot")); + metadata.extend_from_slice(&parent.to_be_bytes()[3..]); + + published.push(PublishedData { + commitment: Some( + <[u8; 32]>::try_from( + unhex(entry["commitment"].as_str().expect("commitment")).as_slice(), + ) + .expect("32-byte commitment"), + ), + metadata, + }); + } + (ciphertexts, published) +} + +fn unhex(value: &str) -> Vec { + hex::decode(value.trim_start_matches("0x")).expect("fixture hex") +} + +#[test] +fn rust_reproduces_the_root_the_contract_produced() { + let fixture: serde_json::Value = serde_json::from_str(FIXTURE).expect("fixture json"); + let expected_root = unhex(fixture["inputRoot"].as_str().expect("inputRoot")); + + let entries = fixture["inputs"].as_array().expect("inputs"); + let (ciphertexts, published) = load(entries); + + // The SDK builds ballots under the insecure-512 threshold preset. + let (params, _) = build_pair_for_preset(BfvPreset::InsecureThreshold512).unwrap(); + let result = ComputeInput { + fhe_inputs: FHEInputs { + ciphertexts, + params: encode_bfv_params(¶ms), + }, + published, + } + .process( + |inputs| { + // Membership of the tree is what this test asserts, so the processor only has to be + // deterministic over the selected set. + inputs + .ciphertexts + .iter() + .flat_map(|(bytes, _)| bytes.clone()) + .collect() + }, + crisp(), + ) + .expect("the fixture round must process"); + + assert_eq!( + hex::encode(&result.merkle_root), + hex::encode(&expected_root), + "the Rust input tree diverged from the root CRISPProgram produced" + ); +} + +/// The TypeScript SDK and Rust must agree on `compute_ct_commitment`, or the Secure Process would +/// reject every honest input as contradicting its commitment. +#[test] +fn the_sdk_and_rust_agree_on_the_ciphertext_commitment() { + let fixture: serde_json::Value = serde_json::from_str(FIXTURE).unwrap(); + let entry = &fixture["inputs"][0]; + let bytes = unhex(entry["encryptedVote"].as_str().unwrap()); + let stored = unhex(entry["commitment"].as_str().unwrap()); + + let (params, _) = build_pair_for_preset(BfvPreset::InsecureThreshold512).unwrap(); + let recomputed = e3_bfv_client::client::compute_ct_commitment( + bytes, + params.degree(), + params.plaintext(), + params.moduli().to_vec(), + ) + .expect("a ciphertext the SDK produced must deserialize"); + + assert_eq!( + hex::encode(recomputed), + hex::encode(&stored), + "the SDK commitment and the Rust commitment diverged" + ); +} + +const APPEND_FIXTURE: &str = + include_str!("../../packages/crisp-contracts/tests/fixtures/input-tree-append.json"); + +/// The poisoning case, against a tree a real contract built. +/// +/// The fixture holds three entries for one slot: an honest ballot, a genuine mask proof published +/// beside bytes that are not the summed ciphertext it proved, and an honest mask naming the same +/// parent that poisoned one named. The Secure Process must reproduce the on-chain root over all +/// three, skip the poisoned entry, and select the honest mask. +/// +/// The recovery is the point. A poisoned entry is never selected, so it is never a valid parent +/// either, and the slot stays maskable. A slot that could be frozen against masking would be one +/// where every later input is provably its owner voting again. +#[test] +fn rust_selects_the_honest_mask_that_follows_a_poisoned_one() { + let fixture: serde_json::Value = serde_json::from_str(APPEND_FIXTURE).expect("fixture json"); + let expected_root = unhex(fixture["inputRoot"].as_str().expect("inputRoot")); + let honest_index = fixture["honestIndex"].as_u64().expect("honestIndex") as usize; + + let entries = fixture["inputs"].as_array().expect("inputs"); + let (ciphertexts, published) = load(entries); + assert_eq!(entries.len(), 3, "the fixture must hold all three entries"); + assert!( + published + .iter() + .all(|entry| entry.metadata[..20] == published[0].metadata[..20]), + "every entry belongs to one slot" + ); + + let honest_bytes = ciphertexts[honest_index].0.clone(); + let (params, _) = build_pair_for_preset(BfvPreset::InsecureThreshold512).unwrap(); + + // The processor records what it was handed, so the selection is observable. + let result = ComputeInput { + fhe_inputs: FHEInputs { + ciphertexts, + params: encode_bfv_params(¶ms), + }, + published, + } + .process( + |inputs| { + inputs + .ciphertexts + .iter() + .flat_map(|(bytes, _)| bytes.clone()) + .collect() + }, + crisp(), + ) + .expect("a poisoned append must not stop the round"); + + assert_eq!( + hex::encode(&result.merkle_root), + hex::encode(&expected_root), + "the Rust input tree diverged from the root CRISPProgram produced" + ); + + // The processor saw exactly the honest ballot: the poisoned append was skipped, and the entry + // it tried to replace survived. + assert_eq!( + result.ciphertext_hash, + sha3::Keccak256::digest(&honest_bytes).to_vec(), + "the Secure Process must select the honest mask that followed the poisoned entry" + ); +} + +const REVOTE_FIXTURE: &str = + include_str!("../../packages/crisp-contracts/tests/fixtures/input-tree-revote.json"); + +/// A voter who changes their mind must have the new ballot tallied, not the first one. +/// +/// The fixture is a real round: a ballot, then a real re-vote to the same slot, both accepted by +/// `CRISPProgram`. If the published bytes and the stored commitment describe different ciphertexts +/// the Secure Process cannot verify the re-vote, silently drops it, and the voter is stuck with +/// their first choice while the round still completes. +#[test] +fn rust_tallies_the_re_vote() { + let fixture: serde_json::Value = serde_json::from_str(REVOTE_FIXTURE).expect("fixture json"); + let expected_root = unhex(fixture["inputRoot"].as_str().expect("inputRoot")); + let re_vote_index = fixture["reVoteIndex"].as_u64().expect("reVoteIndex") as usize; + let entries = fixture["inputs"].as_array().expect("inputs"); + let (ciphertexts, published) = load(entries); + + let re_vote_bytes = ciphertexts[re_vote_index].0.clone(); + let (params, _) = build_pair_for_preset(BfvPreset::InsecureThreshold512).unwrap(); + + // Does the published ciphertext reproduce the commitment the contract stored for it? + let recomputed = e3_bfv_client::client::compute_ct_commitment( + re_vote_bytes.clone(), + params.degree(), + params.plaintext(), + params.moduli().to_vec(), + ) + .expect("the re-vote ciphertext must deserialize"); + assert_eq!( + hex::encode(recomputed), + hex::encode(published[re_vote_index].commitment.unwrap()), + "the re-vote's published bytes must be the ciphertext its stored commitment describes" + ); + + let result = ComputeInput { + fhe_inputs: FHEInputs { + ciphertexts, + params: encode_bfv_params(¶ms), + }, + published, + } + .process( + |inputs| { + inputs + .ciphertexts + .iter() + .flat_map(|(bytes, _)| bytes.clone()) + .collect() + }, + crisp(), + ) + .expect("a round with a re-vote must process"); + + assert_eq!( + hex::encode(&result.merkle_root), + hex::encode(&expected_root), + "the Rust input tree diverged from the root CRISPProgram produced" + ); + assert_eq!( + result.ciphertext_hash, + sha3::Keccak256::digest(&re_vote_bytes).to_vec(), + "the re-vote must be the entry that is tallied" + ); +} diff --git a/examples/CRISP/program/tests/secure_process.rs b/examples/CRISP/program/tests/secure_process.rs new file mode 100644 index 0000000000..d65a71697e --- /dev/null +++ b/examples/CRISP/program/tests/secure_process.rs @@ -0,0 +1,325 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// +// This file is provided WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. + +//! Runs the CRISP Secure Process natively, outside the RISC Zero zkVM. +//! +//! The guest is one line — `input.input.process(fhe_processor, crisp())` — so calling that here +//! exercises the same code the zkVM runs, with the real CRISP processor and the real CRISP policy. +//! Everything except proof generation is covered, which matters because a guest failure inside the +//! zkVM surfaces only as a missing proof and a requester-billed compute timeout. + +use e3_compute_provider::{ComputeError, ComputeInput, ComputeResult, FHEInputs, PublishedData}; +use e3_fhe_params::{build_pair_for_preset, encode_bfv_params, BfvPreset}; +use e3_user_program::fhe_processor; +use e3_user_program::policy::crisp; +use fhe::bfv::{BfvParameters, Ciphertext, Encoding, Plaintext, PublicKey, SecretKey}; +use fhe_traits::{ + DeserializeParametrized, FheDecoder, FheDecrypter, FheEncoder, FheEncrypter, + Serialize as FheSerialize, +}; +use rand::{rngs::StdRng, SeedableRng}; +use std::sync::Arc; + +const PRESET: BfvPreset = BfvPreset::InsecureThreshold512; + +struct Round { + params: Arc, + secret_key: SecretKey, + public_key: PublicKey, +} + +impl Round { + fn new() -> Self { + let (params, _) = build_pair_for_preset(PRESET).unwrap(); + let mut rng = StdRng::seed_from_u64(42); + let secret_key = SecretKey::random(¶ms, &mut rng); + let public_key = PublicKey::new(&secret_key, &mut rng); + Self { + params, + secret_key, + public_key, + } + } + + fn slot(tag: u8) -> [u8; 20] { + let mut address = [0u8; 20]; + address[19] = tag; + address + } + + /// Encrypts one ballot the way a voter's client would. + fn ballot(&self, votes: &[u64], nonce: u64) -> Vec { + let mut rng = StdRng::seed_from_u64(1_000 + nonce); + let plaintext = Plaintext::try_encode(votes, Encoding::poly(), &self.params).unwrap(); + let ciphertext: Ciphertext = self.public_key.try_encrypt(&plaintext, &mut rng).unwrap(); + ciphertext.to_bytes() + } + + /// The commitment `CRISPProgram` stores for a ciphertext. + fn commitment(&self, bytes: &[u8]) -> [u8; 32] { + e3_bfv_client::client::compute_ct_commitment( + bytes.to_vec(), + self.params.degree(), + self.params.plaintext(), + self.params.moduli().to_vec(), + ) + .unwrap() + } + + /// One ballot per slot, which is the ordinary case. + fn round_input(&self, ballots: Vec>) -> ComputeInput { + let slots = (0..ballots.len()).map(|i| Self::slot(i as u8)).collect(); + self.round_input_at(ballots, slots) + } + + /// `abi.encodePacked(address, uint40)`, which is what `CRISPProgram` publishes per input. + /// + /// `parent` is the tree index of the entry this one extends, or `None` for the first entry of a + /// slot's chain. + fn metadata(slot: [u8; 20], parent: Option) -> Vec { + let parent_plus_one = parent.map_or(0u64, |index| index as u64 + 1); + + let mut bytes = slot.to_vec(); + bytes.extend_from_slice(&parent_plus_one.to_be_bytes()[3..]); + bytes + } + + /// Ballots published to the given slots, in order, each opening its slot's chain. + fn round_input_at(&self, ballots: Vec>, slots: Vec<[u8; 20]>) -> ComputeInput { + let published = ballots + .iter() + .zip(slots.iter()) + .map(|(bytes, slot)| PublishedData { + commitment: Some(self.commitment(bytes)), + metadata: Self::metadata(*slot, None), + }) + .collect(); + + ComputeInput { + fhe_inputs: FHEInputs { + ciphertexts: ballots + .into_iter() + .enumerate() + .map(|(i, b)| (b, i as u64)) + .collect(), + params: encode_bfv_params(&self.params), + }, + published, + } + } + + fn run(&self, input: ComputeInput) -> Result { + input.process(fhe_processor, crisp()) + } + + /// Decrypts a tally ciphertext, as the ciphernode committee would. + /// + /// Against `self.params`, not a re-decoded copy: fhe.rs compares parameters by `Arc` identity, + /// so a structurally identical clone is rejected as incompatible. + fn decrypt_tally(&self, ciphertext_bytes: &[u8], options: usize) -> Vec { + let ciphertext = Ciphertext::from_bytes(ciphertext_bytes, &self.params).unwrap(); + let plaintext = self.secret_key.try_decrypt(&ciphertext).unwrap(); + Vec::::try_decode(&plaintext, Encoding::poly()).unwrap()[..options].to_vec() + } +} + +/// The honest path: every input is well formed and the tally is their homomorphic sum. +#[test] +fn the_secure_process_tallies_well_formed_ballots() { + let round = Round::new(); + let input = round.round_input(vec![ + round.ballot(&[3, 0], 1), + round.ballot(&[0, 5], 2), + round.ballot(&[2, 0], 3), + ]); + let all = input.fhe_inputs.clone(); + + let result = round.run(input).expect("an honest round must process"); + + assert_eq!(round.decrypt_tally(&fhe_processor(&all), 2), vec![5, 5]); + assert_eq!(result.merkle_root.len(), 32); +} + +/// An input whose published bytes are not the ciphertext that was proven is dropped from the tally, +/// and the round still completes. +/// +/// This is the case that used to abort the guest: `fhe_processor` deserializes with `unwrap`, so a +/// bad blob panicked before any error path could run. The policy keeps it away from the processor. +#[test] +fn a_contradicting_input_is_dropped_and_the_round_survives() { + let round = Round::new(); + let honest = vec![ + round.ballot(&[3, 0], 1), + round.ballot(&[0, 5], 2), + round.ballot(&[2, 0], 3), + ]; + + let mut attacked = round.round_input(honest.clone()); + // A real, proven commitment beside bytes that are not its ciphertext — what an E3 program + // cannot detect on chain. + attacked.fhe_inputs.ciphertexts[1].0 = round.ballot(&[0, 99], 9); + + let result = round + .run(attacked) + .expect("a contradicting input must not abort the Secure Process"); + + let survivors = round.round_input_at( + vec![honest[0].clone(), honest[2].clone()], + vec![Round::slot(0), Round::slot(2)], + ); + let survivor_inputs = survivors.fhe_inputs.clone(); + let reference = round.run(survivors).unwrap(); + + assert_eq!( + result.ciphertext_hash, reference.ciphertext_hash, + "the substituted ballot must not reach the tally" + ); + assert_eq!( + round.decrypt_tally(&fhe_processor(&survivor_inputs), 2), + vec![5, 0] + ); +} + +/// Undecodable bytes reach the same outcome. Without the binding this is the cheapest way to kill a +/// round: one input of garbage and the guest aborts. +#[test] +fn garbage_bytes_do_not_abort_the_secure_process() { + let round = Round::new(); + let mut input = round.round_input(vec![round.ballot(&[4, 0], 1), round.ballot(&[0, 1], 2)]); + input.fhe_inputs.ciphertexts[0].0 = vec![0xff; 32]; + + let result = round + .run(input) + .expect("garbage must not abort the Secure Process"); + + let survivor = round.round_input_at(vec![round.ballot(&[0, 1], 2)], vec![Round::slot(1)]); + let survivor_inputs = survivor.fhe_inputs.clone(); + let reference = round.run(survivor).unwrap(); + + assert_eq!(result.ciphertext_hash, reference.ciphertext_hash); + assert_eq!( + round.decrypt_tally(&fhe_processor(&survivor_inputs), 2), + vec![0, 1] + ); +} + +/// Two provers over the same published data must agree, or the selected set would be something a +/// prover chooses rather than something the inputs determine. +#[test] +fn the_result_is_deterministic_across_runs() { + let round = Round::new(); + let mut input = round.round_input(vec![round.ballot(&[1, 0], 1), round.ballot(&[0, 2], 2)]); + input.fhe_inputs.ciphertexts[0].0 = vec![0x00; 24]; + + let first = round.run(input.clone()).unwrap(); + let second = round.run(input).unwrap(); + + assert_eq!(first.merkle_root, second.merkle_root); + assert_eq!(first.ciphertext_hash, second.ciphertext_hash); + assert_eq!(first.ciphertext_commitment, second.ciphertext_commitment); +} + +/// Reordering the inputs changes the root, which is why the indexer must sort by the on-chain index +/// before handing them over. +#[test] +fn input_order_changes_the_root() { + let round = Round::new(); + let ballots = vec![round.ballot(&[1, 0], 1), round.ballot(&[0, 2], 2)]; + + let ordered = round.run(round.round_input(ballots.clone())).unwrap(); + let swapped = round + .run(round.round_input(vec![ballots[1].clone(), ballots[0].clone()])) + .unwrap(); + + assert_ne!(ordered.merkle_root, swapped.merkle_root); +} + +/// A round where every entry is unusable degenerates into one with nothing to tally. The processor +/// returns an empty ciphertext, which does not deserialize, so the output commitment fails with a +/// typed error rather than a panic. Only reachable when no honest input exists. +#[test] +fn an_all_unusable_round_fails_cleanly() { + let round = Round::new(); + let mut input = round.round_input(vec![round.ballot(&[1, 0], 1)]); + input.fhe_inputs.ciphertexts[0].0 = vec![0xab; 16]; + + let error = round.run(input).unwrap_err(); + + assert!( + matches!(error, ComputeError::OutputCommitment(_)), + "expected a typed error, got {error:?}" + ); +} + +/// The mask-poisoning case, through the real Secure Process. +/// +/// A third party appends to a slot that already holds a counted vote, with a real commitment and +/// bytes that are not its ciphertext. Append-only means the earlier entry is still in the tree, so +/// the victim's vote survives instead of being erased. +#[test] +fn a_poisoned_append_does_not_erase_the_vote_already_in_the_slot() { + let round = Round::new(); + let victim = Round::slot(7); + let honest_ballot = round.ballot(&[6, 0], 1); + + let mut input = round.round_input_at(vec![honest_ballot.clone()], vec![victim]); + // The attacker's entry reuses the victim's commitment beside unrelated bytes. + let reused_commitment = input.published[0].commitment; + input + .fhe_inputs + .ciphertexts + .push((round.ballot(&[0, 9], 5), 1)); + input.published.push(PublishedData { + commitment: reused_commitment, + metadata: Round::metadata(victim, Some(0)), + }); + + let result = round + .run(input) + .expect("a poisoned append must not stop the round"); + + let reference_input = round.round_input_at(vec![honest_ballot], vec![victim]); + let reference_fhe = reference_input.fhe_inputs.clone(); + assert_eq!( + round.decrypt_tally(&fhe_processor(&reference_fhe), 2), + vec![6, 0] + ); + assert_eq!( + result.ciphertext_hash, + round.run(reference_input).unwrap().ciphertext_hash, + "the poisoned append must not change the tally" + ); +} + +/// An honest re-vote still replaces the earlier ballot, so append-only does not freeze a voter into +/// their first choice. +#[test] +fn an_honest_re_vote_replaces_the_earlier_ballot() { + let round = Round::new(); + let voter = Round::slot(2); + let first = round.ballot(&[1, 0], 1); + let second = round.ballot(&[0, 7], 2); + + let mut input = round.round_input_at(vec![first], vec![voter]); + input.fhe_inputs.ciphertexts.push((second.clone(), 1)); + input.published.push(PublishedData { + commitment: Some(round.commitment(&second)), + metadata: Round::metadata(voter, Some(0)), + }); + + let result = round.run(input).unwrap(); + + let reference_input = round.round_input_at(vec![second], vec![voter]); + let reference_fhe = reference_input.fhe_inputs.clone(); + assert_eq!( + round.decrypt_tally(&fhe_processor(&reference_fhe), 2), + vec![0, 7] + ); + assert_eq!( + result.ciphertext_hash, + round.run(reference_input).unwrap().ciphertext_hash + ); +} diff --git a/examples/CRISP/program/tests/selection.rs b/examples/CRISP/program/tests/selection.rs new file mode 100644 index 0000000000..b6ad7e87f1 --- /dev/null +++ b/examples/CRISP/program/tests/selection.rs @@ -0,0 +1,239 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// +// This file is provided WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. + +//! CRISP's selection rule: the end of each slot's chain of usable entries. +//! +//! The rule lives here rather than in `e3-compute-provider` because it is CRISP's answer to CRISP's +//! problem — an append-only tree that anyone may write to. A program where every input counts wants +//! the crate's default instead. + +use e3_compute_provider::policy::PublishedInput; +use e3_user_program::policy::chain_head_per_slot; + +/// One published entry, as a test states it. +#[derive(Clone, Copy)] +struct Entry { + slot: u8, + /// Whether the bytes reproduce the commitment. A third party can publish an entry where they + /// do not, and nobody can tell until here. + usable: bool, + /// The tree index this entry names as the one it extends, or `None` for nothing. + parent: Option, +} + +/// An honest entry extending `parent`. +fn good(slot: u8, parent: Option) -> Entry { + Entry { + slot, + usable: true, + parent, + } +} + +/// An entry whose published bytes do not reproduce its commitment. +fn poisoned(slot: u8, parent: Option) -> Entry { + Entry { + slot, + usable: false, + parent, + } +} + +fn slot_address(tag: u8) -> [u8; 20] { + let mut address = [0u8; 20]; + address[19] = tag; + address +} + +/// `abi.encodePacked(address, uint40)`, which is what `CRISPProgram` publishes per input. +fn metadata(entry: &Entry) -> Vec { + let parent_plus_one = entry.parent.map_or(0u64, |index| index as u64 + 1); + + let mut bytes = slot_address(entry.slot).to_vec(); + bytes.extend_from_slice(&parent_plus_one.to_be_bytes()[3..]); + bytes +} + +fn select(entries: &[Entry]) -> Vec { + let stored: Vec<[u8; 32]> = (0..entries.len()).map(|i| [i as u8; 32]).collect(); + let metadatas: Vec> = entries.iter().map(metadata).collect(); + let bytes = vec![0u8]; + + let inputs: Vec = entries + .iter() + .enumerate() + .map(|(index, entry)| PublishedInput { + index, + ciphertext: &bytes, + commitment: Some(&stored[index]), + metadata: &metadatas[index], + recomputed: Some(if entry.usable { + stored[index] + } else { + [0xff; 32] + }), + }) + .collect(); + + chain_head_per_slot(&inputs) +} + +/// A chain of honest entries resolves to its end. +#[test] +fn selection_follows_the_chain_to_its_end() { + assert_eq!(select(&[good(1, None)]), vec![0], "a single entry"); + assert_eq!( + select(&[good(1, None), good(1, Some(0))]), + vec![1], + "an entry extending the first replaces it" + ); + assert_eq!( + select(&[good(1, None), good(1, Some(0)), good(1, Some(1))]), + vec![2], + "a three-entry chain resolves to its end" + ); +} + +/// A poisoned entry does not become the head, and the next honest input extends the same parent it +/// did. This is the whole point of naming the parent: a slot cannot be frozen against masking, and +/// a slot that cannot be masked is one where every later input is provably its owner voting again. +#[test] +fn a_poisoned_entry_does_not_freeze_the_slot() { + assert_eq!( + select(&[poisoned(1, None)]), + Vec::::new(), + "a single poisoned entry contributes nothing" + ); + assert_eq!( + select(&[good(1, None), poisoned(1, Some(0))]), + vec![0], + "a poisoned append leaves the head where it was" + ); + assert_eq!( + select(&[good(1, None), poisoned(1, Some(0)), good(1, Some(0))]), + vec![2], + "the next honest entry names the same parent and takes the head" + ); + assert_eq!( + select(&[ + good(1, None), + poisoned(1, Some(0)), + poisoned(1, Some(0)), + good(1, Some(0)), + ]), + vec![3], + "repeated poisoning does not exhaust the slot" + ); + assert_eq!( + select(&[poisoned(1, None), good(1, None)]), + vec![1], + "a slot poisoned before its first vote is still writable" + ); +} + +/// A mask cannot reach back past a re-vote to restore the ballot it replaced. +/// +/// The attack this rules out: a voter casts A, re-votes B, and a third party then masks the +/// *original* entry rather than the re-vote. A mask adds zero, so its plaintext is whatever its +/// parent held — taking it would put A back in the slot and erase B. No signature is needed to +/// publish a mask, so anyone could do it to anyone. +#[test] +fn a_mask_cannot_reach_back_past_a_re_vote() { + // vote A at 0, re-vote B at 1, then a mask naming 0 instead of 1. + assert_eq!( + select(&[good(1, None), good(1, Some(0)), good(1, Some(0))]), + vec![1], + "the re-vote keeps the slot; the stale mask is dropped" + ); + assert_eq!( + select(&[good(1, None), good(1, Some(0)), good(1, None)]), + vec![1], + "naming no parent on an occupied slot does not restart the chain either" + ); +} + +/// A poisoned entry does not shift the head, so the entry after it names the head as it stands. +#[test] +fn an_entry_after_a_poisoned_one_names_the_unchanged_head() { + assert_eq!( + select(&[ + good(1, None), + good(1, Some(0)), + poisoned(1, Some(0)), + good(1, Some(1)) + ]), + vec![3], + "the head is still 1, so an entry naming 1 is taken" + ); +} + +/// A parent belonging to another slot is not this slot's head, so it is dropped. `CRISPProgram` +/// refuses one as well, by keying its commitments on the slot. +#[test] +fn an_entry_naming_another_slots_parent_is_dropped() { + assert_eq!( + select(&[good(1, None), good(2, Some(0))]), + vec![0], + "slot 2 cannot extend slot 1's entry" + ); +} + +/// The same rule seen from the other side: whichever sibling lands first keeps the slot. +/// +/// Reverse the order of the previous test and the mask is the one that wins, so a re-vote built +/// before it landed is dropped and has to be published again against the new head. +/// +/// That asymmetry is deliberate, not an oversight. A stale parent is indistinguishable from a +/// sibling built a moment earlier: both name an entry that is no longer the head, and only the +/// circuit knows whether an entry replaces the slot or adds to it — which is exactly what +/// `is_mask_vote` keeps private. Favouring the earlier sibling costs a dropped re-vote, which the +/// voter can see and retry. Favouring the later one would let a mask on a superseded ciphertext +/// restore it over a vote, which is a silent tally corruption nobody can detect or undo. +#[test] +fn whichever_sibling_lands_first_keeps_the_slot() { + // vote A at 0, a mask naming 0 at 1, then a re-vote that also names 0. + assert_eq!( + select(&[good(1, None), good(1, Some(0)), good(1, Some(0))]), + vec![1], + "the mask got there first, so the re-vote behind it is dropped" + ); +} + +/// Sequences interleaved across slots must not bleed into one another. +#[test] +fn interleaved_slots_resolve_independently() { + let entries = [ + good(1, None), + poisoned(2, None), + poisoned(1, Some(0)), + good(2, None), + ]; + + assert_eq!( + select(&entries), + vec![0, 3], + "each slot resolves on its own entries, in tree order" + ); +} + +/// An entry whose metadata is not a slot and a parent cannot be placed in a chain, so it is not +/// selected. It still contributes a leaf — that is the crate's guarantee, not the policy's. +#[test] +fn an_entry_without_valid_metadata_is_not_selected() { + let stored = [7u8; 32]; + let bytes = vec![0u8]; + let malformed = [0u8; 4]; + + let inputs = vec![PublishedInput { + index: 0, + ciphertext: &bytes, + commitment: Some(&stored), + metadata: &malformed, + recomputed: Some(stored), + }]; + + assert!(chain_head_per_slot(&inputs).is_empty()); +} diff --git a/examples/CRISP/scripts/build-presets.mjs b/examples/CRISP/scripts/build-presets.mjs new file mode 100755 index 0000000000..75af075ff4 --- /dev/null +++ b/examples/CRISP/scripts/build-presets.mjs @@ -0,0 +1,56 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: LGPL-3.0-only +// +// This file is provided WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. + +// Compile and stage the CRISP circuits for every preset, so the SDK can publish both. +// +// The preset is a global compile-time choice (circuits/lib/src/configs/default/mod.nr), so the +// presets cannot be built concurrently — each pass switches the tree, compiles, and archives the +// result under circuits/dist// before the next pass overwrites it. +// +// The threshold circuits go through the root builder rather than a bare `nargo compile`, because +// switching preset also regenerates the parity matrices and ActiveCryptoConfig.sol. The root +// builder does not know about the CRISP circuits, so those are compiled here afterwards. + +import { execFileSync } from 'node:child_process' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const CRISP = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const REPO = resolve(CRISP, '..', '..') + +/** + * Insecure is built last so the working tree is left on the default preset. + * + * A plain `pnpm build` in the SDK inlines whatever the tree currently holds, so ending on anything + * else would quietly change what the next build produces. + */ +const PRESETS = ['secure-8192', 'insecure-512'] + +const run = (cmd, args, cwd) => { + console.log(` $ ${cmd} ${args.join(' ')}`) + execFileSync(cmd, args, { cwd, stdio: 'inherit' }) +} + +for (const preset of PRESETS) { + console.log(`\n🔮 Building CRISP circuits for ${preset}...`) + + // Switches the tree to this preset. This is the step that also regenerates the parity matrices + // and ActiveCryptoConfig.sol, which a bare `nargo compile` would leave describing the old one. + run('pnpm', ['tsx', 'scripts/build-circuits.ts', '--preset', preset, '--group', 'threshold'], REPO) + + // Compiles the CRISP circuits and writes the generated Solidity verifiers into + // packages/crisp-contracts/contracts/verifiers//. It recompiles the threshold circuits on + // the way through, which duplicates part of the step above; that is worth the few minutes rather + // than splitting the verifier generation away from the compile it has to agree with. + run('bash', ['scripts/compile_circuits.sh'], CRISP) + + run('node', [join(CRISP, 'scripts/stage-preset-artifacts.mjs'), preset], CRISP) +} + +console.log(`\n✓ staged ${PRESETS.length} preset(s); tree left on ${PRESETS.at(-1)}`) +console.log(' If the ballot circuits changed, regenerate the fold key hashes: scripts/compute_vk_hash.sh') +console.log(' Then rebuild the SDK (pnpm -C packages/crisp-sdk build) and check: pnpm -C packages/crisp-sdk check:presets') diff --git a/examples/CRISP/scripts/check-presets.mjs b/examples/CRISP/scripts/check-presets.mjs new file mode 100644 index 0000000000..fd64300dd9 --- /dev/null +++ b/examples/CRISP/scripts/check-presets.mjs @@ -0,0 +1,120 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: LGPL-3.0-only +// +// This file is provided WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. + +// Refuse to publish a channel whose artifacts do not match the preset that channel stands for. +// +// The release channels are split by BFV preset: `testing` carries insecure-512 and `latest` carries +// secure-8192. A tarball therefore has to carry exactly one preset — the missing one must be +// missing, so importing it fails to resolve, and the wrong one must be absent, so a prod consumer +// cannot reach insecure parameters at all. +// +// The SDK and the contracts package are checked together because they are a matched pair. The SDK +// inlines the compiled circuit and the contracts package ships the verifier generated from that +// same circuit's verification key, so a channel that mixes them fails on chain at proof +// verification rather than anywhere a test would catch it. + +import { existsSync, readFileSync, statSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const CRISP = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const SDK = join(CRISP, 'packages', 'crisp-sdk') +const CONTRACTS = join(CRISP, 'packages', 'crisp-contracts') + +/** Which preset each release channel stands for. */ +const CHANNEL_PRESET = { testing: 'insecure-512', latest: 'secure-8192' } + +/** Generated per census mode. Not preset-specific — see packages/crisp-contracts/scripts/verifiers.ts. */ +const VERIFIERS = ['CRISPVerifier.sol', 'CRISPOnchainVerifier.sol'] + +/** Polynomial degree each preset's circuits carry, used to prove the bundle is what it claims. */ +const EXPECTED_DEGREE = { 'insecure-512': 512, 'secure-8192': 8192 } + +/** Below this a "built" entry is a stub or a failed inline rather than a real circuit bundle. */ +const MIN_BYTES = 100 * 1024 + +// npm gives `prepublishOnly` no way to see `--tag`, so the channel comes through the environment +// when the publish scripts set it, and through argv when run by hand. +const channel = process.argv[2] ?? process.env.CRISP_CHANNEL +if (!Object.hasOwn(CHANNEL_PRESET, channel)) { + console.error( + channel === undefined + ? `Set CRISP_CHANNEL or pass a channel: check-presets.mjs <${Object.keys(CHANNEL_PRESET).join('|')}>` + : `Unknown channel "${channel}"; expected one of ${Object.keys(CHANNEL_PRESET).join(', ')}.`, + ) + process.exit(1) +} + +const wanted = CHANNEL_PRESET[channel] +const others = Object.values(CHANNEL_PRESET).filter((preset) => preset !== wanted) +const problems = [] + +// --- the SDK bundle --- +const built = join(SDK, 'dist', 'presets', `${wanted}.js`) +if (!existsSync(built)) { + problems.push(`${wanted}: dist/presets/${wanted}.js is missing — run \`pnpm build:presets\`, then \`CRISP_PRESET=${wanted} pnpm build\`.`) +} else if (statSync(built).size < MIN_BYTES) { + problems.push(`${wanted}: dist/presets/${wanted}.js is only ${statSync(built).size} bytes; the circuits did not inline.`) +} + +for (const other of others) { + if (existsSync(join(SDK, 'dist', 'presets', `${other}.js`))) { + problems.push(`${other}: dist/presets/${other}.js must not ship on "${channel}" — rebuild with CRISP_PRESET=${wanted}.`) + } +} + +// The exports map is what consumers actually resolve, so check it points at a real file. +const pkg = JSON.parse(readFileSync(join(SDK, 'package.json'), 'utf8')) +const entry = pkg.exports?.[`./${wanted}`] +if (!entry) { + problems.push(`${wanted}: package.json exports has no "./${wanted}" subpath.`) +} else { + for (const target of new Set(Object.values(entry))) { + if (!existsSync(join(SDK, target))) problems.push(`${wanted}: exports points at ${target}, which does not exist.`) + } +} + +// --- the bundle really carries the channel's circuits --- +// +// The filename says which preset a bundle is; this checks the contents agree. A bundle built from +// the wrong staged artifacts would ship under the right name and fail only at on-chain +// verification, which is the whole failure mode these channels exist to prevent. +if (existsSync(built)) { + const source = readFileSync(built, 'utf8') + const degree = EXPECTED_DEGREE[wanted] + const other = Object.entries(EXPECTED_DEGREE).find(([preset]) => preset !== wanted) + + // The bundler may emit the inlined JSON as a JS object literal, so the key can be quoted or bare + // and the space is optional. Match all of those rather than one spelling. + const hasDegree = (value) => new RegExp(`["']?length["']?\\s*:\\s*${value}\\b`).test(source) + + if (!hasDegree(degree)) { + problems.push(`${wanted}: dist/presets/${wanted}.js contains no length-${degree} arrays; the inlined circuits are not ${wanted}.`) + } + if (other && hasDegree(other[1])) { + problems.push(`${wanted}: dist/presets/${wanted}.js contains length-${other[1]} arrays, which belong to ${other[0]}.`) + } +} + +// --- the generated verifiers --- +// +// Preset-independent, so this is only an existence check: without them a consumer has nothing to +// deploy, but there is no wrong-preset case to catch here. +for (const verifier of VERIFIERS) { + if (!existsSync(join(CONTRACTS, 'contracts', 'verifiers', verifier))) { + problems.push(`contracts/verifiers/${verifier} is missing — regenerate with scripts/compile_circuits.sh.`) + } +} + +if (problems.length > 0) { + console.error(`✗ Not publishable on "${channel}" (expects ${wanted}):`) + for (const problem of problems) console.error(` - ${problem}`) + process.exit(1) +} + +const size = (statSync(built).size / 1048576).toFixed(1) +console.log(`✓ "${channel}" carries ${wanted} only (dist/presets/${wanted}.js ${size}MB, verifiers present).`) diff --git a/examples/CRISP/scripts/compile_circuits.sh b/examples/CRISP/scripts/compile_circuits.sh index 2abb8f143e..7abc6955a5 100755 --- a/examples/CRISP/scripts/compile_circuits.sh +++ b/examples/CRISP/scripts/compile_circuits.sh @@ -6,6 +6,13 @@ set -euo pipefail INTERFOLD_CIRCUITS="../../circuits" CRISP_CIRCUITS="circuits" +# The generated verifiers are NOT preset-specific. They are written from the fold circuit's +# verification key, and the fold circuit takes the inner key as an input and checks its hash against +# either preset's constant, so its own structure carries no BFV degree. Compiling both presets +# produces byte-identical verifiers; one directory is correct. +VERIFIER_DIR="packages/crisp-contracts/contracts/verifiers" +mkdir -p "$VERIFIER_DIR" + # Two ballot stacks share the same user_data_encryption dependencies and differ only in how a # round establishes eligibility: # @@ -160,7 +167,7 @@ for stack in "${STACKS[@]}"; do fi echo "Copying ${verifier_file} to contracts folder..." - if ! cp "$CRISP_CIRCUITS/bin/${fold_dir}/target/${verifier_file}" "packages/crisp-contracts/contracts/${verifier_file}"; then + if ! cp "$CRISP_CIRCUITS/bin/${fold_dir}/target/${verifier_file}" "${VERIFIER_DIR}/${verifier_file}"; then echo "Error: Failed to copy ${verifier_file} to contracts folder" exit 1 fi @@ -176,14 +183,14 @@ for stack in "${STACKS[@]}"; do TEMP_FILE=$(mktemp) { echo "$LICENSE_HEADER" - tail -n +3 "packages/crisp-contracts/contracts/${verifier_file}" + tail -n +3 "${VERIFIER_DIR}/${verifier_file}" } >"$TEMP_FILE" - mv "$TEMP_FILE" "packages/crisp-contracts/contracts/${verifier_file}" + mv "$TEMP_FILE" "${VERIFIER_DIR}/${verifier_file}" - patch_verifier "packages/crisp-contracts/contracts/${verifier_file}" + patch_verifier "${VERIFIER_DIR}/${verifier_file}" echo "Formatting ${verifier_file} with Prettier..." - if pnpm exec prettier --write "packages/crisp-contracts/contracts/${verifier_file}" 2>/dev/null; then + if pnpm exec prettier --write "${VERIFIER_DIR}/${verifier_file}" 2>/dev/null; then echo "Prettier formatting complete" else echo "Warning: Prettier formatting skipped (run pnpm install from repo root if needed)" diff --git a/examples/CRISP/scripts/publish.ts b/examples/CRISP/scripts/publish.ts index 4a439502c3..e55c746f76 100644 --- a/examples/CRISP/scripts/publish.ts +++ b/examples/CRISP/scripts/publish.ts @@ -18,11 +18,30 @@ interface PackageJson { version: string } +/** + * Release channels, split by BFV preset. + * + * The SDK inlines the compiled circuit and the contracts package ships the verifier generated from + * that same circuit's verification key, so a channel that mixes presets produces a round which + * rejects every ballot — and it fails at on-chain verification, not anywhere a test would catch it. + * Each channel therefore carries exactly one preset, and the preset decides the build. + * + * Testing versions carry a prerelease identifier so npm keeps them out of ordinary ranges: a + * consumer on `^0.18.0` can never drift onto a testing build through an update. + */ +const CHANNELS = { + testing: { tag: 'testing', preset: 'insecure-512', prerelease: true }, + prod: { tag: 'latest', preset: 'secure-8192', prerelease: false }, +} as const + +type Channel = keyof typeof CHANNELS + interface PublishOptions { skipGit?: boolean dryRun?: boolean - tag?: string // npm dist-tag (e.g., 'latest', 'beta', 'next') + tag?: string // npm dist-tag override; defaults to the channel's tag noVerify?: boolean + channel?: Channel } class CRISPPublisher { @@ -31,10 +50,13 @@ class CRISPPublisher { private crispDir: string private options: PublishOptions + private channel: Channel + constructor(newVersion: string, options: PublishOptions = {}) { this.newVersion = newVersion this.crispDir = resolve(__dirname, '..') this.options = options + this.channel = options.channel ?? 'testing' } /** @@ -159,14 +181,18 @@ class CRISPPublisher { const packageJsonPath = join(pkgPath, 'package.json') const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) - if (packageJson.scripts && packageJson.scripts.build) { - execSync('pnpm build', { + // The SDK bundles the preset-bound circuits, so it builds per channel. Everything else is + // preset-free and builds once. + const buildScript = pkg.path === 'packages/crisp-sdk' ? `build:${this.channel}` : 'build' + if (packageJson.scripts && packageJson.scripts[buildScript]) { + execSync(`pnpm ${buildScript}`, { cwd: pkgPath, stdio: 'inherit', + env: { ...process.env, CRISP_PRESET: CHANNELS[this.channel].preset }, }) console.log(` ✓ ${pkg.name} built successfully`) } else { - console.log(` ⚠️ ${pkg.name} has no build script, skipping`) + console.log(` ⚠️ ${pkg.name} has no ${buildScript} script, skipping`) } } catch (error) { console.error(` ❌ Failed to build ${pkg.name}`) @@ -181,14 +207,17 @@ class CRISPPublisher { private async publishPackages(): Promise { console.log('\n📤 Publishing packages to npm...') + // Dependency order. `@crisp-e3/sdk` depends on `@crisp-e3/zk-inputs`, and pnpm rewrites that + // workspace dependency to a concrete version at pack time, so the version it names has to be on + // the registry already — updateClientLockFile() installs from the registry right after this. const packagesToPublish = [ + { path: 'packages/crisp-zk-inputs', name: '@crisp-e3/zk-inputs' }, { path: 'packages/crisp-sdk', name: '@crisp-e3/sdk' }, { path: 'packages/crisp-contracts', name: '@crisp-e3/contracts' }, - { path: 'packages/crisp-zk-inputs', name: '@crisp-e3/zk-inputs' }, ] - const tag = this.options.tag || (this.newVersion.includes('-') ? 'next' : 'latest') - console.log(` Using npm tag: ${tag}`) + const tag = this.options.tag || CHANNELS[this.channel].tag + console.log(` Channel: ${this.channel} (${CHANNELS[this.channel].preset}), npm tag: ${tag}`) for (const pkg of packagesToPublish) { try { @@ -199,6 +228,9 @@ class CRISPPublisher { execSync(`pnpm publish --access public --tag ${tag} --no-git-checks`, { cwd: pkgPath, stdio: 'inherit', + // `prepublishOnly` runs check-presets.mjs, which refuses to publish a channel whose + // artifacts carry the wrong preset. npm gives it no way to see --tag, so it reads this. + env: { ...process.env, CRISP_CHANNEL: tag }, }) console.log(` ✓ ${pkg.name}@${this.newVersion} published successfully`) @@ -375,6 +407,20 @@ class CRISPPublisher { if (!semverRegex.test(version)) { throw new Error(`Invalid version format: ${version}. Expected format: x.y.z[-prerelease][+build]`) } + + // The prerelease identifier is what keeps the channels apart. npm excludes prereleases from + // ordinary ranges, so a consumer on `^0.18.0` cannot drift onto a testing build through an + // update — but only if testing versions actually carry one, and prod versions do not. + const isPrerelease = version.includes('-') + const expected = CHANNELS[this.channel].prerelease + + if (isPrerelease !== expected) { + throw new Error( + expected + ? `Channel "testing" needs a prerelease version so it stays out of ordinary semver ranges; got ${version}. Try ${version}-insecure.0` + : `Channel "prod" needs a plain release version; got the prerelease ${version}. Publish prereleases with --channel testing.`, + ) + } } /** @@ -473,6 +519,13 @@ async function main() { options.dryRun = true } else if (arg === '--tag') { options.tag = args[++i] + } else if (arg === '--channel') { + const value = args[++i] + if (value !== 'testing' && value !== 'prod') { + console.error(`❌ Error: --channel must be "testing" or "prod"; got "${value}"`) + process.exit(1) + } + options.channel = value } else if (arg === '--no-verify') { options.noVerify = true } else if (!arg.startsWith('-')) { @@ -486,6 +539,14 @@ async function main() { process.exit(1) } + // No default. The channel decides which preset is compiled into what gets published, and picking + // one silently is how a round ends up with an SDK and a verifier that disagree. + if (!options.channel) { + console.error('❌ Error: --channel is required (testing | prod)') + showHelp() + process.exit(1) + } + const publisher = new CRISPPublisher(version, options) await publisher.publishAll() } @@ -501,34 +562,44 @@ Arguments: version The new version (e.g., 1.0.0, 1.0.0-beta.1) Options: - --tag npm dist-tag (default: 'latest' for releases, 'next' for pre-releases) + --channel Release channel: 'testing' (insecure-512) or 'prod' (secure-8192). Required. + --tag npm dist-tag override (default: the channel's tag) --skip-git Skip all git operations (no commit) --dry-run Show what would be done without making changes --help, -h Show this help message -Examples: - # Publish stable release - tsx scripts/publish.ts 1.0.0 +Channels: + testing npm tag 'testing', insecure-512 circuits, prerelease versions only + prod npm tag 'latest', secure-8192 circuits, plain release versions only + + Each channel carries exactly one preset. The SDK inlines the compiled circuit and the contracts + package ships the verifier generated from that circuit's verification key, so mixing them + produces a round that rejects every ballot, and it fails on chain rather than in any test. - # Publish beta release - tsx scripts/publish.ts 1.0.0-beta.1 + Testing versions must carry a prerelease identifier. npm keeps prereleases out of ordinary + ranges, so a consumer on '^0.18.0' cannot drift onto a testing build through an update. + +Examples: + # Publish to the testing channel (testnets, demos) + tsx scripts/publish.ts --channel testing 0.18.0-insecure.0 - # Publish with custom tag - tsx scripts/publish.ts --tag canary 1.0.0-canary.1 + # Publish to production + tsx scripts/publish.ts --channel prod 0.18.0 # Test without publishing - tsx scripts/publish.ts --dry-run 1.0.0 + tsx scripts/publish.ts --channel testing --dry-run 0.18.0-insecure.0 # Publish without committing - tsx scripts/publish.ts --skip-git 1.0.0 + tsx scripts/publish.ts --channel testing --skip-git 0.18.0-insecure.0 The script will: 1. Check for uncommitted changes 2. Update versions in @crisp-e3/sdk, @crisp-e3/contracts, @crisp-e3/zk-inputs + 2b. Build the SDK against the channel's preset 3. Update @crisp-e3/sdk dependency in client/package.json 4. Update pnpm-lock.yaml 5. Build packages - 6. Publish to npm + 6. Publish to npm in dependency order (zk-inputs, then sdk, then contracts) 7. Update the standalone client/pnpm-lock.yaml 8. Commit changes (no tags) diff --git a/examples/CRISP/scripts/stage-preset-artifacts.mjs b/examples/CRISP/scripts/stage-preset-artifacts.mjs new file mode 100755 index 0000000000..423275a21c --- /dev/null +++ b/examples/CRISP/scripts/stage-preset-artifacts.mjs @@ -0,0 +1,78 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: LGPL-3.0-only +// +// This file is provided WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. + +// Stage the preset-bound circuit artifacts into circuits/dist//. +// +// `nargo compile` always writes to /target/, and the preset is chosen globally in +// circuits/lib/src/configs/default/mod.nr, so the working tree only ever holds one preset at a +// time. The SDK needs both side by side to publish both, so each compile pass is archived here. +// +// Only the four circuits whose ABI is shaped by the BFV degree are staged. The aggregation +// circuits are proof-shaped and preset-independent — see src/circuits.ts. + +import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const CRISP = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const REPO = resolve(CRISP, '..', '..') + +/** Degree the preset's polynomials carry, used to prove the artifact matches its directory. */ +const EXPECTED_DEGREE = { 'insecure-512': 512, 'secure-8192': 8192 } + +const ARTIFACTS = [ + { name: 'crisp', from: join(CRISP, 'circuits/bin/crisp/target/crisp.json') }, + { name: 'crisp_onchain', from: join(CRISP, 'circuits/bin/crisp_onchain/target/crisp_onchain.json') }, + { name: 'user_data_encryption_ct0', from: join(REPO, 'circuits/bin/threshold/target/user_data_encryption_ct0.json') }, + { name: 'user_data_encryption_ct1', from: join(REPO, 'circuits/bin/threshold/target/user_data_encryption_ct1.json') }, +] + +/** + * The largest array length in the ABI is the polynomial degree for every circuit staged here. + * + * Checking it is what stops a mislabelled archive. Staging an insecure artifact into secure-8192/ + * would otherwise publish a bundle that proves against the wrong parameters, and that failure only + * surfaces on chain at verification time. + */ +const degreeOf = (path) => { + const abi = JSON.parse(readFileSync(path, 'utf8')).abi + const lengths = JSON.stringify(abi.parameters).match(/"length":(\d+)/g) ?? [] + + return Math.max(...lengths.map((entry) => Number(entry.split(':')[1])), 0) +} + +const preset = process.argv[2] +if (!Object.hasOwn(EXPECTED_DEGREE, preset)) { + console.error(`Usage: stage-preset-artifacts.mjs <${Object.keys(EXPECTED_DEGREE).join('|')}>`) + process.exit(1) +} + +const outDir = join(CRISP, 'circuits/dist', preset) +mkdirSync(outDir, { recursive: true }) + +const staged = [] +for (const { name, from } of ARTIFACTS) { + if (!existsSync(from)) { + console.error(`✗ ${name}: not compiled (${from})`) + process.exit(1) + } + + // A circuit compiled at 512 carries 1023-length arrays too (the ct0/ct1 witnesses), so compare + // against the maximum rather than looking the degree up by name. + const degree = degreeOf(from) + const expected = EXPECTED_DEGREE[preset] + if (degree !== expected && degree !== 2 * expected - 1) { + console.error(`✗ ${name}: ABI reports degree ${degree}, which is not ${preset}. Wrong preset compiled?`) + process.exit(1) + } + + copyFileSync(from, join(outDir, `${name}.json`)) + staged.push({ name, degree }) +} + +writeFileSync(join(outDir, 'preset.json'), `${JSON.stringify({ preset, circuits: staged.map((s) => s.name) }, null, 2)}\n`) +console.log(`✓ staged ${staged.length} artifact(s) for ${preset}: ${staged.map((s) => s.name).join(', ')}`) diff --git a/examples/CRISP/server/Cargo.toml b/examples/CRISP/server/Cargo.toml index 6da14c63b1..72a6028a6e 100644 --- a/examples/CRISP/server/Cargo.toml +++ b/examples/CRISP/server/Cargo.toml @@ -45,6 +45,7 @@ lean-imt = "=0.1.2" e3-compute-provider.workspace = true e3-sdk = { workspace = true, default-features = false, features=["full"] } e3-fhe-params = { workspace = true } +e3-bfv-client = { workspace = true } evm-helpers = { path = "../crates/evm_helpers" } crisp-utils.workspace = true diff --git a/examples/CRISP/server/Readme.md b/examples/CRISP/server/Readme.md index 09995b8fa4..67138bb247 100644 --- a/examples/CRISP/server/Readme.md +++ b/examples/CRISP/server/Readme.md @@ -76,6 +76,24 @@ The server exposes several RESTful API endpoints: - `POST /state/lite`: Get a lite version of the state for a specific round - `POST /voting/broadcast`: Broadcast an encrypted vote +## Upgrading across an input-format change + +`InputPublished` and the durable round record both carry per-input fields, and both have changed. A +deployment that adds one cannot be rolled forward over a round that is already taking inputs: + +- the event's topic hash changes with its signature, so the indexer no longer matches the logs an + already-deployed `CRISPProgram` emits; +- the new per-input vectors default to empty when an existing round is loaded, and the Secure + Process needs one entry per ciphertext. + +`CrispE3Repository::get_input_snapshot` refuses such a round rather than computing over it, with an +error naming the field and the count. The refusal is the guard, not the fix. + +**Procedure.** Deploy a new `CRISPProgram`, point `E3_PROGRAM_ADDRESS` at it, and let existing +rounds finish against the old deployment before retiring its indexer. Rounds do not migrate: an +in-flight one has inputs whose leaves were built under the old layout, so its root can only be +reproduced by the code that built it. + ## Architecture The project is structured into several modules: diff --git a/examples/CRISP/server/src/server/indexer.rs b/examples/CRISP/server/src/server/indexer.rs index 08f5994c9f..add7795748 100644 --- a/examples/CRISP/server/src/server/indexer.rs +++ b/examples/CRISP/server/src/server/indexer.rs @@ -4,13 +4,14 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. +use crate::server::models::e3_id_to_u256; use crate::server::token_holders::{ get_mock_token_holders, try_fetch_requester_census, EtherscanClient, }; use crate::server::{ models::{CensusMode, CreditMode, CurrentRound, CustomParams, TokenHolder}, - program_server_request::run_compute, - repo::{CrispE3Repository, CurrentRoundRepository}, + program_server_request::{run_compute, RoundInputs}, + repo::{CrispE3Repository, CurrentRoundRepository, InputSnapshot}, token_holders::{build_tree, compute_token_holder_hashes}, CONFIG, }; @@ -18,6 +19,7 @@ use alloy::providers::{Provider, ProviderBuilder}; use alloy::sol_types::{sol_data, SolType}; use alloy_primitives::{Address, U256}; use crisp_utils::decode_tally; +use e3_fhe_params::decode_bfv_params_arc; use e3_sdk::{ evm_helpers::{ contracts::{InterfoldRead, ReadWrite}, @@ -33,6 +35,8 @@ use eyre::Context; use log::{info, warn}; use num_bigint::BigUint; use std::error::Error; +use std::time::Duration; +use tokio::time::sleep; type Result = std::result::Result>; @@ -406,6 +410,67 @@ pub async fn register_e3_requested( Ok(indexer) } +/// What the indexer holds for a round, measured against what `CRISPProgram` accepted. +enum IndexedInputs { + /// The indexer holds every input, and this is the snapshot it holds them in. + Complete(InputSnapshot), + /// It does not, and never did within the wait. + Short { indexed: usize, published: usize }, +} + +/// The round's inputs, once the indexer holds every one `CRISPProgram` accepted. +/// +/// Polls rather than reading once: the deadline callback and the last `InputPublished` handler race, +/// and the gap is the few seconds it takes one log to be delivered and stored. +/// +/// Both counts are re-read on every attempt. Re-reading only the chain would compare a moving number +/// against a fixed one, so the loop could never converge — it would wait out every attempt and then +/// report the same shortfall it started with, in exactly the race it exists to absorb. +/// +/// Returns the snapshot the two counts agree on, or the last pair when they never do, so the caller +/// reports the shortfall rather than looping forever. +async fn wait_for_indexed_inputs( + e3_id: &str, + repo: &CrispE3Repository, +) -> eyre::Result { + const ATTEMPTS: u32 = 10; + const INTERVAL: Duration = Duration::from_secs(3); + + let e3_id_u256 = e3_id_to_u256(e3_id).map_err(|e| eyre::eyre!("{e}"))?; + let contract = + CRISPContractFactory::create_read(&CONFIG.http_rpc_url, &CONFIG.e3_program_address).await?; + + for attempt in 0..=ATTEMPTS { + let published = contract.get_published_input_count(e3_id_u256).await? as usize; + let snapshot = repo.get_input_snapshot().await?; + let indexed = snapshot.ciphertexts.len(); + + if indexed >= published { + return Ok(IndexedInputs::Complete(snapshot)); + } + + if attempt == ATTEMPTS { + return Ok(IndexedInputs::Short { indexed, published }); + } + + info!( + "[e3_id={}] waiting for the indexer: {} of {} input(s) stored", + e3_id, indexed, published + ); + sleep(INTERVAL).await; + } + + unreachable!("the loop returns on its final attempt") +} + +/// When the deadline handler runs again after a round it could not compute. +/// +/// Each offset is a separate `do_later` registration made when the round starts, rather than the +/// handler re-arming itself: `do_later` drops a callback once it has run, and a handler that failed +/// has no way back into the schedule. The offsets are wider than the indexer wait inside the +/// handler, so two passes do not overlap. +const DEADLINE_RETRY_OFFSETS: [u64; 3] = [60, 180, 420]; + async fn handle_e3_input_deadline_expiration( e3_id: String, store: SharedStore, @@ -413,10 +478,47 @@ async fn handle_e3_input_deadline_expiration( let mut repo = CrispE3Repository::new(store.clone(), &e3_id); let e3: e3_sdk::indexer::models::E3 = repo.get_e3().await?; + // A cheap skip for a retry pass over a round that already moved on, so it does not sit through + // the indexer wait below. Not the safety barrier — `try_claim_computing` is, further down. + let status = repo.get_status().await?; + if status == "Computing" || status == "Finished" { + return Ok(()); + } + repo.update_status("Expired").await?; let voter_count = repo.get_vote_count().await?; - let votes = repo.get_ciphertext_inputs().await?; + + // The contract is the authority on how many inputs there are, and this callback can run before + // the last of them is indexed: `publishInput` still accepts one while + // `block.timestamp == inputWindow[1]`. Computation is one-shot, so starting short would tally a + // subset and derive a root the contract rejects — a failure with no other symptom. + // + // The snapshot comes back from the same call, read once. Assembling the request from separate + // reads lets an `InputPublished` event land between them, which pairs a ciphertext with another + // input's commitment and derives a root `CRISPProgram` rejects. + let snapshot = match wait_for_indexed_inputs(&e3_id, &repo).await? { + IndexedInputs::Complete(snapshot) => snapshot, + IndexedInputs::Short { indexed, published } => { + // Left "Expired" and unfinished on purpose, so a later pass can still compute it. The + // retries registered at `DEADLINE_RETRY_OFFSETS` are what come back to it; marking the + // round "Finished" here would tally nothing and close it for good. + return Err(eyre::eyre!( + "[e3_id={}] the indexer holds {} input(s) but CRISPProgram accepted {}; \ + refusing to compute over a subset. A retry pass runs at +{}s from the input \ + deadline; if every pass reports this, the indexer is behind and needs attention.", + e3_id, + indexed, + published, + DEADLINE_RETRY_OFFSETS + .iter() + .map(|offset| offset.to_string()) + .collect::>() + .join("s, +") + )); + } + }; + let votes = snapshot.ciphertexts.clone(); if voter_count > 0 && votes.is_empty() { warn!( @@ -436,7 +538,20 @@ async fn handle_e3_input_deadline_expiration( votes.len(), voter_count ); - repo.update_status("Computing").await?; + // The barrier. Two passes can be inside the indexer wait at once, and `run_compute` is + // one-shot, so the transition to "Computing" has to be the thing that decides which one + // proceeds — in a single store operation, not a read followed by a write. + // + // Claimed here rather than before the wait: a pass that gives up on a short index leaves + // the round "Expired" so a later pass can still take it, and claiming earlier would pin it + // to "Computing" and strand it. + if !repo.try_claim_computing().await? { + info!( + "[e3_id={}] another pass is already computing this round; nothing to do", + e3_id + ); + return Ok(()); + } let (id, status) = run_compute( &e3_id, @@ -445,7 +560,12 @@ async fn handle_e3_input_deadline_expiration( e3.encryption_scheme_id, e3.committee_public_key_hash, e3.e3_params, - votes, + RoundInputs { + ciphertexts: snapshot.ciphertexts, + commitments: snapshot.commitments, + slots: snapshot.slots, + parents: snapshot.parents, + }, format!( "{}/state/add-result", CONFIG.interfold_server_url_for_clients() @@ -559,9 +679,20 @@ pub async fn register_committee_published( let expiration = repo.get_input_deadline().await?; info!("[e3_id={}] Registering hook for {}", e3_id, expiration); - ctx.do_later(expiration, move |_, ctx| { - handle_e3_input_deadline_expiration(e3_id.clone(), ctx.store()) - }); + // Registered once per offset, up front. A pass that finds the indexer behind + // returns without computing, and `do_later` has already dropped that callback, so + // the round would otherwise stay "Expired" for good. Every pass after the first + // returns immediately once the round is computing or finished. + for at in std::iter::once(expiration).chain( + DEADLINE_RETRY_OFFSETS + .iter() + .map(|offset| expiration + offset), + ) { + let e3_id = e3_id.clone(); + ctx.do_later(at, move |_, ctx| { + handle_e3_input_deadline_expiration(e3_id.clone(), ctx.store()) + }); + } Ok(()) } @@ -596,8 +727,20 @@ pub async fn register_input_published( hex::encode(&event.encryptedVote[..8.min(event.encryptedVote.len())]) ); - repo.insert_ciphertext_input(event.encryptedVote.to_vec(), event.index.to::()) - .await?; + // Read here so the usability of these bytes is decided once, on the write path, + // instead of on every `state/previous-ciphertext` call. + let e3 = repo.get_e3().await?; + let params = decode_bfv_params_arc(&e3.e3_params)?; + + repo.insert_ciphertext_input( + event.encryptedVote.to_vec(), + event.index.to::(), + event.encryptedVoteCommitment.into(), + event.slotAddress.into(), + event.parentIndexPlusOne.to::(), + ¶ms, + ) + .await?; Ok(()) } }) diff --git a/examples/CRISP/server/src/server/models.rs b/examples/CRISP/server/src/server/models.rs index 1272eeccb3..8f88f07509 100644 --- a/examples/CRISP/server/src/server/models.rs +++ b/examples/CRISP/server/src/server/models.rs @@ -133,6 +133,8 @@ pub struct PreviousCiphertextRequest { #[derive(Serialize)] pub struct PreviousCiphertextResponse { pub ciphertext: Vec, + /// The tree index of that entry, which a client names as the parent of the input it builds. + pub index: u64, } #[derive(Debug, Deserialize, Serialize)] @@ -258,6 +260,38 @@ pub struct E3Crisp { pub token_address: String, pub balance_threshold: String, pub ciphertext_inputs: Vec<(Vec, u64)>, + /// The commitment the contract stored for each input, keyed by the same on-chain index. + /// + /// The Secure Process needs it to check that the published bytes are the ciphertext that was + /// actually proven. Defaulted so rounds recorded before the event carried it still load. + #[serde(default)] + pub input_commitments: Vec<(u64, [u8; 32])>, + /// The slot each input was published to, keyed by the same on-chain index. The tree is + /// append-only, so the Secure Process groups entries by slot. + #[serde(default)] + pub input_slots: Vec<(u64, [u8; 20])>, + /// Whether each input's published bytes reproduce the commitment stored with it, keyed by the + /// same on-chain index. + /// + /// Recomputing this costs a BFV commitment — about 5ms per entry — and the answer never changes, + /// because the tree is append-only and an entry's bytes are fixed once published. Deciding it + /// once here keeps it off the read path, where `state/previous-ciphertext` is called by every + /// voter before every ballot. + /// + /// A hint, not an authority. The Secure Process recomputes it from the ciphertexts it consumed + /// and never reads this, so a wrong value here can only send a client to the wrong parent — the + /// same outcome as a stale read, and the guest drops such an input either way. + #[serde(default)] + pub input_usable: Vec<(u64, bool)>, + /// The entry each input names as the one it extends, plus one, keyed by the same on-chain + /// index. Zero means it extends nothing. + /// + /// The Secure Process walks each slot's chain by this, taking an entry only when the one it + /// names is the slot's current head. That is what keeps a slot writable after someone publishes + /// bytes nobody can open: such an entry is never the head, so it is never a valid parent, and + /// the next honest input names the same parent it did. + #[serde(default)] + pub input_parents: Vec<(u64, u64)>, pub requester: String, pub num_options: String, pub credit_mode: CreditMode, @@ -389,7 +423,11 @@ mod persisted_round_tests { #[test] fn every_census_mode_round_trips_through_storage() { - for mode in [CensusMode::Token, CensusMode::ByRequester, CensusMode::Onchain] { + for mode in [ + CensusMode::Token, + CensusMode::ByRequester, + CensusMode::Onchain, + ] { let mut round: E3Crisp = serde_json::from_str(LEGACY_ROUND).unwrap(); round.census_mode = mode; diff --git a/examples/CRISP/server/src/server/program_server_request.rs b/examples/CRISP/server/src/server/program_server_request.rs index 07d53d3b7a..034d611a13 100644 --- a/examples/CRISP/server/src/server/program_server_request.rs +++ b/examples/CRISP/server/src/server/program_server_request.rs @@ -22,6 +22,16 @@ pub struct ComputeRequest { pub params: Vec, #[serde(serialize_with = "serialize_hex_tuple")] pub ciphertext_inputs: Vec<(Vec, u64)>, + /// One commitment per input, in the same order. Lets the Secure Process reject an input whose + /// published bytes are not the ciphertext that was proven, instead of losing the round. + #[serde(serialize_with = "serialize_hex_list")] + pub input_commitments: Vec<[u8; 32]>, + /// The slot each input was published to, in the same order. + #[serde(serialize_with = "serialize_hex_slots")] + pub input_slots: Vec<[u8; 20]>, + /// The entry each input names as the one it extends, plus one, in the same order. Zero means it + /// extends nothing. The Secure Process walks each slot's chain by this. + pub input_parents: Vec, pub callback_url: Option, } @@ -33,6 +43,28 @@ where serializer.serialize_str(&hex_string) } +fn serialize_hex_list(items: &[[u8; 32]], serializer: S) -> Result +where + S: Serializer, +{ + let hex_items: Vec = items + .iter() + .map(|bytes| format!("0x{}", hex::encode(bytes))) + .collect(); + hex_items.serialize(serializer) +} + +fn serialize_hex_slots(items: &[[u8; 20]], serializer: S) -> Result +where + S: Serializer, +{ + let hex_items: Vec = items + .iter() + .map(|bytes| format!("0x{}", hex::encode(bytes))) + .collect(); + hex_items.serialize(serializer) +} + fn serialize_hex_tuple(tuples: &[(Vec, u64)], serializer: S) -> Result where S: Serializer, @@ -60,6 +92,18 @@ fn build_compute_request( .json(request) } +/// The published inputs for a round, in on-chain index order. +/// +/// Grouped because the four vectors are only meaningful together: entry `i` of each describes the +/// same input, and a length mismatch mis-pairs ciphertexts with the commitments that prove them. +pub struct RoundInputs { + pub ciphertexts: Vec<(Vec, u64)>, + pub commitments: Vec<[u8; 32]>, + pub slots: Vec<[u8; 20]>, + /// The entry each input names as the one it extends, plus one; zero for none. + pub parents: Vec, +} + pub async fn run_compute( e3_id: &str, chain_id: u64, @@ -67,7 +111,7 @@ pub async fn run_compute( encryption_scheme_id: Vec, committee_public_key_hash: Vec, params: Vec, - ciphertext_inputs: Vec<(Vec, u64)>, + inputs: RoundInputs, webhook_url: String, ) -> Result<(String, String)> { let request = ComputeRequest { @@ -78,7 +122,10 @@ pub async fn run_compute( committee_public_key_hash, callback_url: Some(webhook_url), params, - ciphertext_inputs, + ciphertext_inputs: inputs.ciphertexts, + input_commitments: inputs.commitments, + input_slots: inputs.slots, + input_parents: inputs.parents, }; println!("Sending request"); @@ -123,6 +170,9 @@ mod tests { committee_public_key_hash: vec![0x33; 32], params: vec![1, 2, 3], ciphertext_inputs: vec![], + input_commitments: vec![], + input_slots: vec![], + input_parents: vec![], callback_url: Some("http://127.0.0.1:4000/state/add-result".to_string()), }; @@ -134,4 +184,32 @@ mod tests { assert_eq!(request.url().as_str(), "http://127.0.0.1:13151/run_compute"); assert!(!request.headers().contains_key(AUTHORIZATION)); } + + /// The Secure Process can only reject an input whose bytes contradict its commitment if the + /// commitments actually reach it, in the same order as the inputs. + #[test] + fn compute_request_carries_commitments_in_input_order() { + let request = ComputeRequest { + e3_id: Some("7".to_string()), + chain_id: 31_337, + interfold_address: "0x1111111111111111111111111111111111111111".to_string(), + encryption_scheme_id: vec![0x22; 32], + committee_public_key_hash: vec![0x33; 32], + params: vec![1, 2, 3], + ciphertext_inputs: vec![(vec![0xaa], 0), (vec![0xbb], 1)], + input_commitments: vec![[0x11; 32], [0x22; 32]], + input_slots: vec![[0x01; 20], [0x02; 20]], + input_parents: vec![0, 1], + callback_url: None, + }; + + let json = serde_json::to_value(&request).expect("request should serialize"); + let commitments = json["input_commitments"] + .as_array() + .expect("input_commitments must serialize as an array"); + + assert_eq!(commitments.len(), 2); + assert_eq!(commitments[0], format!("0x{}", "11".repeat(32))); + assert_eq!(commitments[1], format!("0x{}", "22".repeat(32))); + } } diff --git a/examples/CRISP/server/src/server/repo.rs b/examples/CRISP/server/src/server/repo.rs index f476175470..7fc2359c8a 100644 --- a/examples/CRISP/server/src/server/repo.rs +++ b/examples/CRISP/server/src/server/repo.rs @@ -12,6 +12,7 @@ use super::{ }; use e3_sdk::indexer::{models::E3 as InterfoldE3, DataStore, E3Repository, SharedStore}; use eyre::Result; +use fhe::bfv::BfvParameters; use log::info; use num_bigint::BigUint; @@ -115,6 +116,22 @@ impl CurrentRoundRepository { } } +/// A round's inputs, read in one shot so the four vectors describe the same moment. +/// +/// Every vector is in on-chain index order and has one entry per ciphertext. +pub struct InputSnapshot { + /// The published ciphertexts, each paired with its on-chain index. + pub ciphertexts: Vec<(Vec, u64)>, + /// The commitment `CRISPProgram` stored for each input. + pub commitments: Vec<[u8; 32]>, + /// The slot each input was published to. + pub slots: Vec<[u8; 20]>, + /// The entry each input names as the one it extends, plus one; zero for none. + pub parents: Vec, + /// Whether each input's bytes reproduce its commitment, decided when it was indexed. + pub usable: Vec, +} + pub struct CrispE3Repository { store: SharedStore, e3_id: String, @@ -155,9 +172,27 @@ impl CrispE3Repository { self.set_crisp(e3_crisp).await } - pub async fn insert_ciphertext_input(&mut self, vote: Vec, index: u64) -> Result<()> { + pub async fn insert_ciphertext_input( + &mut self, + vote: Vec, + index: u64, + commitment: [u8; 32], + slot: [u8; 20], + parent_index_plus_one: u64, + params: &BfvParameters, + ) -> Result<()> { let key = self.crisp_key(); + // Decided here, once, rather than on every read. An entry's bytes never change, so neither + // does the answer. `Err` means the bytes do not deserialize, which is itself unusable. + let usable = e3_bfv_client::client::compute_ct_commitment( + vote.clone(), + params.degree(), + params.plaintext(), + params.moduli().to_vec(), + ) + .is_ok_and(|recomputed| recomputed == commitment); + self.store .modify(&key, |e3_obj: Option| { e3_obj.map(|mut e| { @@ -171,6 +206,28 @@ impl CrispE3Repository { } else { e.ciphertext_inputs.push((vote.clone(), index)); } + if let Some(existing) = + e.input_commitments.iter_mut().find(|(i, _)| *i == index) + { + existing.1 = commitment; + } else { + e.input_commitments.push((index, commitment)); + } + if let Some(existing) = e.input_slots.iter_mut().find(|(i, _)| *i == index) { + existing.1 = slot; + } else { + e.input_slots.push((index, slot)); + } + if let Some(existing) = e.input_parents.iter_mut().find(|(i, _)| *i == index) { + existing.1 = parent_index_plus_one; + } else { + e.input_parents.push((index, parent_index_plus_one)); + } + if let Some(existing) = e.input_usable.iter_mut().find(|(i, _)| *i == index) { + existing.1 = usable; + } else { + e.input_usable.push((index, usable)); + } e }) }) @@ -180,17 +237,6 @@ impl CrispE3Repository { Ok(()) } - pub async fn get_ciphertext_input(&self, index: u64) -> Result>> { - let e3_crisp = self.get_crisp().await?; - for (vote, i) in e3_crisp.ciphertext_inputs { - if i == index { - return Ok(Some(vote)); - } - } - - Ok(None) - } - pub async fn initialize_round( &mut self, custom_params: CustomParams, @@ -199,6 +245,10 @@ impl CrispE3Repository { snapshot_block: u64, ) -> Result<()> { self.set_crisp(E3Crisp { + input_commitments: Vec::new(), + input_slots: Vec::new(), + input_parents: Vec::new(), + input_usable: Vec::new(), has_voted: vec![], start_time: 0u64, status: "Requested".to_string(), @@ -239,6 +289,41 @@ impl CrispE3Repository { Ok(u64::try_from(e3_crisp.has_voted.len())?) } + /// The round's current status. + /// + /// Read by the deadline handler so a retry pass can tell a round it already moved on from. The + /// handler runs more than once, and computation is one-shot. + pub async fn get_status(&self) -> Result { + let e3_crisp = self.get_crisp().await?; + Ok(e3_crisp.status) + } + + /// Moves the round to "Computing", but only if nothing has claimed it yet. + /// + /// Returns whether this caller made the transition. One store operation, because `modify` is a + /// read-modify-write under a single write lock: reading the status and writing it back as two + /// separate awaits leaves a window where two deadline passes both observe "Expired" and both + /// start the one-shot `run_compute`, publishing two results for one round. + pub async fn try_claim_computing(&mut self) -> Result { + let key = self.crisp_key(); + let mut claimed = false; + + self.store + .modify(&key, |e3_obj: Option| { + e3_obj.map(|mut e| { + if e.status != "Computing" && e.status != "Finished" { + e.status = "Computing".to_string(); + claimed = true; + } + e + }) + }) + .await + .map_err(|_| eyre::eyre!("Could not claim computation for '{key}'"))?; + + Ok(claimed) + } + pub async fn update_status(&mut self, value: &str) -> Result<()> { let key = self.crisp_key(); @@ -334,9 +419,107 @@ impl CrispE3Repository { Ok(e3_crisp.end_time) } + /// Returns the inputs in on-chain index order. + /// + /// Event handlers run concurrently, so arrival order is not chain order, and a leaf's position + /// in the input tree is its position in this vector. Sorting here is what keeps the root the + /// Secure Process derives equal to the one the contract accumulated. + #[allow(dead_code)] pub async fn get_ciphertext_inputs(&self) -> Result, u64)>> { let e3_crisp = self.get_crisp().await?; - Ok(e3_crisp.ciphertext_inputs) + let mut inputs = e3_crisp.ciphertext_inputs; + inputs.sort_by_key(|(_, index)| *index); + Ok(inputs) + } + + /// Everything the compute request needs about a round's inputs, from one read. + /// + /// One read rather than four getters. Each is a separate `await`, and an `InputPublished` event + /// can land between them, so a request assembled from several reads can pair a ciphertext with + /// another input's commitment or leave the vectors different lengths. The Secure Process would + /// then derive a root `CRISPProgram` rejects, and nothing would say why. + pub async fn get_input_snapshot(&self) -> Result { + let e3_crisp = self.get_crisp().await?; + + let mut ciphertexts = e3_crisp.ciphertext_inputs; + ciphertexts.sort_by_key(|(_, index)| *index); + + // A round recorded before the event carried these fields loads with them empty, because + // they default. Computing over it would fall back to the pre-binding leaf layout and derive + // a root `CRISPProgram` rejects, with nothing to explain why. Such a round has to be + // re-indexed, not computed. + let expected = ciphertexts.len(); + Self::require_indexed(expected, e3_crisp.input_commitments.len(), "commitments")?; + Self::require_indexed(expected, e3_crisp.input_slots.len(), "slots")?; + Self::require_indexed(expected, e3_crisp.input_parents.len(), "parents")?; + Self::require_indexed(expected, e3_crisp.input_usable.len(), "usability flags")?; + + let mut commitments = e3_crisp.input_commitments; + commitments.sort_by_key(|(index, _)| *index); + let mut slots = e3_crisp.input_slots; + slots.sort_by_key(|(index, _)| *index); + let mut parents = e3_crisp.input_parents; + parents.sort_by_key(|(index, _)| *index); + let mut usable = e3_crisp.input_usable; + usable.sort_by_key(|(index, _)| *index); + + Ok(InputSnapshot { + ciphertexts, + commitments: commitments.into_iter().map(|(_, value)| value).collect(), + slots: slots.into_iter().map(|(_, value)| value).collect(), + parents: parents.into_iter().map(|(_, value)| value).collect(), + usable: usable.into_iter().map(|(_, value)| value).collect(), + }) + } + + /// Refuses a round whose per-input records do not line up with its ciphertexts. + fn require_indexed(expected: usize, found: usize, field: &str) -> Result<()> { + if expected != found { + return Err(eyre::eyre!( + "round has {expected} inputs but {found} {field}; it is partially indexed or \ + predates the binding, and must be re-indexed" + )); + } + Ok(()) + } + + /// The end of a slot's chain of usable entries: the entry a new input must name as its parent. + /// + /// Resolved the same way the Secure Process resolves it, so a client that builds on this answer + /// produces an input the tally will take. An entry is only ever the head when its published + /// bytes reproduce its commitment and it names the head before it, so an entry nobody can open + /// never becomes one and never blocks the slot. + /// + /// Reads the usability decision rather than recomputing it. Recomputing costs a BFV commitment + /// per candidate — about 5ms each, comparable to deserializing a thousand-input round — and + /// every voter calls this before every ballot. The decision is made once, when the input is + /// indexed. + /// + /// `None` when the slot holds nothing usable, which is what a first vote sees. + pub async fn get_slot_head(&self, slot: [u8; 20]) -> Result, u64)>> { + let snapshot = self.get_input_snapshot().await?; + let mut head: Option = None; + let mut selected: Option = None; + + for (position, (_, index)) in snapshot.ciphertexts.iter().enumerate() { + if snapshot.slots[position] != slot || !snapshot.usable[position] { + continue; + } + + if snapshot.parents[position].checked_sub(1) != head { + continue; + } + + head = Some(*index); + // The position, not the bytes. Cloning a ciphertext for every candidate would copy the + // whole chain to return its last entry. + selected = Some(position); + } + + Ok(selected.map(|position| { + let (bytes, index) = &snapshot.ciphertexts[position]; + (bytes.clone(), *index) + })) } #[allow(dead_code)] diff --git a/examples/CRISP/server/src/server/routes/rounds.rs b/examples/CRISP/server/src/server/routes/rounds.rs index e3a26ab79b..70fcdc2e14 100644 --- a/examples/CRISP/server/src/server/routes/rounds.rs +++ b/examples/CRISP/server/src/server/routes/rounds.rs @@ -224,9 +224,9 @@ pub async fn initialize_crisp_round( let credit_mode = U256::from(0); // Constant let credits = U256::from(1); let census_mode = U256::from(0); // Token - // Seventh field: the ONCHAIN voting-power divisor, unused by a TOKEN round. Zero is also the - // "derive from the token's decimals" sentinel. Required regardless — `_initRound` decodes - // exactly seven fields, so a shorter encoding reverts the request with empty data. + // Seventh field: the ONCHAIN voting-power divisor, unused by a TOKEN round. Zero is also the + // "derive from the token's decimals" sentinel. Required regardless — `_initRound` decodes + // exactly seven fields, so a shorter encoding reverts the request with empty data. let voting_power_divisor = U256::from(0); let custom_params_bytes = Bytes::from( ( diff --git a/examples/CRISP/server/src/server/routes/state.rs b/examples/CRISP/server/src/server/routes/state.rs index b02eb5ce8a..b28f8fb5e6 100644 --- a/examples/CRISP/server/src/server/routes/state.rs +++ b/examples/CRISP/server/src/server/routes/state.rs @@ -19,7 +19,6 @@ use alloy::primitives::{Address, Bytes, B256}; use e3_sdk::evm_helpers::contracts::{ InterfoldContract, InterfoldContractFactory, InterfoldWrite, ReadWrite, }; -use evm_helpers::CRISPContractFactory; use log::{error, info}; pub fn setup_routes(config: &mut web::ServiceConfig) { @@ -44,13 +43,18 @@ pub fn setup_routes(config: &mut web::ServiceConfig) { ); } -/// Endpoint to get the ciphertext input at a certain slot. Used for masking operations +/// Endpoint to get the ciphertext a slot currently holds. Used for every ballot, not only masks. +/// +/// Answers with the end of the slot's chain of usable entries, and the tree index of that entry. +/// Not simply the newest entry published: an entry whose bytes do not reproduce its commitment is +/// never selected by the Secure Process and is never a valid parent, so building on it would have +/// the client's input dropped from the tally. /// /// # Arguments -/// * `data` - The round id and the slot index +/// * `data` - The round id and the slot address /// /// # Returns -/// * A JSON response with the result of the operation. If sucessfull it includes the ciphertext input at the given slot +/// * A JSON response with the ciphertext and its index, or 404 when the slot holds nothing usable. async fn handle_get_previous_ciphertext( data: web::Json, store: web::Data, @@ -63,17 +67,6 @@ async fn handle_get_previous_ciphertext( }; let e3_key = e3_id.to_string(); - let contract = - match CRISPContractFactory::create_read(&CONFIG.http_rpc_url, &CONFIG.e3_program_address) - .await - { - Ok(contract) => contract, - Err(e) => { - error!("Failed to create CRISP contract: {:?}", e); - return HttpResponse::InternalServerError().body("Failed to create CRISP contract"); - } - }; - let address = match Address::from_str(incoming.address.as_str()) { Ok(addr) => addr, Err(e) => { @@ -82,18 +75,12 @@ async fn handle_get_previous_ciphertext( } }; - let slot_index = match contract.get_slot_index_from_address(e3_id, address).await { - Ok(Some(index)) => index, - Ok(None) => return HttpResponse::NotFound().body("Ciphertext not found"), - Err(e) => { - error!("Error getting slot index from address: {:?}", e); - return HttpResponse::InternalServerError() - .body("Failed to get slot index from address"); + // No BFV work and no parameters here. Whether an entry's bytes reproduce its commitment is + // decided once, when the indexer stores it, so resolving the chain is a walk over flags. + match store.e3(e3_key).get_slot_head(address.into()).await { + Ok(Some((ciphertext, index))) => { + HttpResponse::Ok().json(PreviousCiphertextResponse { ciphertext, index }) } - }; - - match store.e3(e3_key).get_ciphertext_input(slot_index).await { - Ok(Some(ciphertext)) => HttpResponse::Ok().json(PreviousCiphertextResponse { ciphertext }), Ok(None) => HttpResponse::NotFound().body("Ciphertext not found"), Err(e) => { error!("Error getting previous ciphertext: {:?}", e); diff --git a/examples/CRISP/server/src/server/token_holders/etherscan.rs b/examples/CRISP/server/src/server/token_holders/etherscan.rs index ebc3bb7bc7..a22a4c0864 100644 --- a/examples/CRISP/server/src/server/token_holders/etherscan.rs +++ b/examples/CRISP/server/src/server/token_holders/etherscan.rs @@ -1575,13 +1575,19 @@ mod bond_owner_discovery_tests { "0xf09dc4a8a4e1c9233bcb1d32c04ad4c9d516f140c23aa44f9e0d680f70799e08" ); assert_eq!( - format!("{:?}", keccak256(b"BondedCheckpointed(address,uint48,uint256)")), + format!( + "{:?}", + keccak256(b"BondedCheckpointed(address,uint48,uint256)") + ), "0xb6241efac9a4f02e4f1ba6a30a3a5fc5ba4b23a47f181eca3055466c775eb32c" ); // The one that was wrong in this file: it matched nothing, so delegation logs always came // back empty and a pure delegatee never became a candidate. assert_eq!( - format!("{:?}", keccak256(b"DelegateVotesChanged(address,uint256,uint256)")), + format!( + "{:?}", + keccak256(b"DelegateVotesChanged(address,uint256,uint256)") + ), "0xdec2bacdd2f05b59de34da9b523dff8be42e5e38e818c82fdb0bae774387a724" ); } @@ -1591,7 +1597,10 @@ mod bond_owner_discovery_tests { let expected: Address = address!("f39Fd6e51aad88F6F4ce6aB8827279cffFb92266"); let topic = "0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266"; - assert_eq!(EtherscanClient::address_from_topic(topic).unwrap(), expected); + assert_eq!( + EtherscanClient::address_from_topic(topic).unwrap(), + expected + ); } #[test] diff --git a/package.json b/package.json index a32b5a3b41..60753bd074 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,8 @@ "check:committee": "./scripts/check-committee.sh", "check:docs": "./scripts/check-doc-sync.sh", "check:invariants": "./scripts/check-invariants.sh", + "provenance:manifest": "tsx scripts/generate-provenance-manifest.ts", + "check:verifiers": "tsx scripts/generate-verifiers.ts --circuits dkg_aggregator,decryption_aggregator --check", "test": "pnpm evm:test && pnpm rust:test && pnpm sdk:test && pnpm noir:test", "test:integration": "cd ./tests/integration && ./test.sh", "coverage": "pnpm evm:coverage", diff --git a/packages/interfold-contracts/audits/20260714-Interfold - Zenith Audit Report.pdf b/packages/interfold-contracts/audits/20260714-Interfold - Zenith Audit Report.pdf new file mode 100644 index 0000000000..173b6720ad Binary files /dev/null and b/packages/interfold-contracts/audits/20260714-Interfold - Zenith Audit Report.pdf differ diff --git a/packages/interfold-contracts/audits/README.md b/packages/interfold-contracts/audits/README.md index 675b991614..4cdb34dc53 100644 --- a/packages/interfold-contracts/audits/README.md +++ b/packages/interfold-contracts/audits/README.md @@ -1,5 +1,45 @@ # Interfold Contract Audits -| Date | Auditor | Scope | Report | -| ---------- | ------- | ---------- | -------------------------------------------------------------------- | -| 2026-07-02 | Zenith | FOLD token | [20260702_audit_token_zenith.pdf](./20260702_audit_token_zenith.pdf) | +| Date | Auditor | Scope | Report | +| ---------- | ------- | ---------------------------- | ------------------------------------------------------------------------------------------------ | +| 2026-07-02 | Zenith | FOLD token | [20260702_audit_token_zenith.pdf](./20260702_audit_token_zenith.pdf) | +| 2026-08-17 | Zenith | Protocol contracts (6 files) | [20260714-Interfold - Zenith Audit Report.pdf](<./20260714-Interfold - Zenith Audit Report.pdf>) | + +## What the 2026-08-17 protocol audit covered + +Scope commit `c2097da61b4d07c4ce83840393ff4e9f171eefb4`; mitigation review at +`c64bcfb890b596e626ea6578c5fbd53f808c3b43`. Both reviews list the same six +files: + +``` +E3RefundManager.sol +Interfold.sol +lib/ExitQueueLib.sol +lib/InterfoldPricing.sol +registry/BondingRegistry.sol +registry/CiphernodeRegistryOwnable.sol +``` + +62 issues: 1 Critical, 6 High, 18 Medium, 19 Low, 18 Informational. + +**Read the scope before citing this report as assurance for anything else.** It +covers no Rust and no circuits. In particular, these are _not_ in either file +list: + +- `crates/compute-provider` and the RISC Zero guest — the Secure Process and its + input binding +- `crates/zk-helpers` — the SAFE ciphertext commitment +- `contracts/verifiers/bfv/Risc0BfvCiphertextVerifier.sol` +- `contracts/lib/InterfoldLifecycle.sol`, `lib/Risc0ComputeProof.sol`, + `lib/FailurePayerLib.sol` +- `examples/CRISP/**` + +Several `Z-` entries in `agent/flow-trace/00_INDEX.md` were remediated by adding +files that are themselves outside both file lists, so the remediation code did +not go through the mitigation review either. + +Zenith's own §2.5 Security Note: "it is statistically likely that there are more +complex bugs still present given the time-boxed nature of this engagement... a +follow-up audit and development of a more complex stateful test suite [should] +be undertaken prior to continuing to deploy significant monetary capital to +production." diff --git a/packages/interfold-contracts/scripts/protocol/tx/interfold.ts b/packages/interfold-contracts/scripts/protocol/tx/interfold.ts index 38aaa178f9..1c75f22c26 100644 --- a/packages/interfold-contracts/scripts/protocol/tx/interfold.ts +++ b/packages/interfold-contracts/scripts/protocol/tx/interfold.ts @@ -133,9 +133,22 @@ function appendVerifierTxs( ), ); } + // The compute-receipt verifier. Each E3 snapshots this at request time, so a later change never + // affects an E3 in flight; leaving it unset means no protocol-level ciphertext verification. + // + // Three sources, in precedence order. A verifier deployed by this run wins, because it is the one + // the rest of the deployment just wired up. Otherwise the config decides, and it is read from both + // shapes `ProtocolConfig` declares: `verifiers.ciphertextVerifier` groups it with the other + // verifier addresses, and the top-level field predates that grouping. Preferring one config shape + // and ignoring the other would silently resolve to `undefined` for any config written against the + // other, and an unset verifier is indistinguishable from one deliberately omitted — it just means + // no ciphertext verification, with nothing to say it was a mistake. const ciphertext = c.ciphertextVerifier ?? - optionalAddress(config.ciphertextVerifier, "ciphertextVerifier"); + optionalAddress( + config.verifiers?.ciphertextVerifier ?? config.ciphertextVerifier, + "ciphertextVerifier", + ); if (ciphertext) { txs.push( safeTx( diff --git a/packages/interfold-contracts/scripts/protocol/types.ts b/packages/interfold-contracts/scripts/protocol/types.ts index d74f6ffafd..5acd2f1977 100644 --- a/packages/interfold-contracts/scripts/protocol/types.ts +++ b/packages/interfold-contracts/scripts/protocol/types.ts @@ -90,6 +90,15 @@ export interface ProtocolConfigFile { decryptionVerifier?: string; pkVerifier?: string; dkgFoldAttestationVerifier?: string; + /** + * The protocol ciphertext verifier for the BFV scheme, e.g. `Risc0BfvCiphertextVerifier`. + * + * This is the contract that checks the compute receipt before an E3 reaches + * `CiphertextReady`. Its `imageId` is immutable, so replacing it is a redeployment, not a + * setter call on the existing one. Leaving it unset ships a protocol with no ciphertext + * verification for the scheme. + */ + ciphertextVerifier?: string; }; ciphertextVerifier?: string; /** Deploy a stateless always-accepting ciphertext verifier for rehearsal deployments. */ diff --git a/packages/interfold-contracts/scripts/protocol/validate.ts b/packages/interfold-contracts/scripts/protocol/validate.ts index e2eb837edd..70c45858e5 100644 --- a/packages/interfold-contracts/scripts/protocol/validate.ts +++ b/packages/interfold-contracts/scripts/protocol/validate.ts @@ -257,6 +257,42 @@ export async function actionValidate(): Promise { console.log(" -- bondedVotes not deployed yet (--action activate-voting)"); } + // Verifier read-backs. Each is configured by a Safe transaction, so a dropped or reverted write + // would otherwise leave the reference at address(0) with the deploy script still exiting zero. + const bfvSchemeId = ethers.id("fhe.rs:BFV"); + // A read-back alone proves only that the address was stored. Interfold returns whatever was + // configured, so an EOA or an undeployed address passes while being unusable when a receipt is + // actually verified. + for (const [label, address] of [ + ["verifiers.ciphertextVerifier", config.verifiers?.ciphertextVerifier], + ["verifiers.decryptionVerifier", config.verifiers?.decryptionVerifier], + ["verifiers.pkVerifier", config.verifiers?.pkVerifier], + ] as const) { + if (address) await requireContract(ethers.provider, address, label); + } + + if (config.verifiers?.ciphertextVerifier) { + checks.push([ + "interfold.getCiphertextVerifier(fhe.rs:BFV)", + interfold.getCiphertextVerifier(bfvSchemeId), + config.verifiers.ciphertextVerifier, + ]); + } + if (config.verifiers?.decryptionVerifier) { + checks.push([ + "interfold.getDecryptionVerifier(fhe.rs:BFV)", + interfold.getDecryptionVerifier(bfvSchemeId), + config.verifiers.decryptionVerifier, + ]); + } + if (config.verifiers?.pkVerifier) { + checks.push([ + "interfold.getPkVerifier(fhe.rs:BFV)", + interfold.getPkVerifier(bfvSchemeId), + config.verifiers.pkVerifier, + ]); + } + for (const [label, actualPromise, expected] of checks) { const actual = await actualPromise; assertEqual(label, actual, expected); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bedbebeccb..5a4555bbf9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,16 +115,16 @@ importers: devDependencies: '@interfold/config': specifier: ^0 - version: link:../../packages/interfold-config + version: 0.9.0(eslint@9.39.1(jiti@1.21.7))(tsup@8.5.0(@microsoft/api-extractor@7.58.12(@types/node@22.7.5))(@swc/core@1.15.46)(jiti@1.21.7)(postcss@8.5.25)(tsx@4.20.6)(typescript@5.8.3)(yaml@2.8.2))(typescript@5.8.3) '@playwright/test': specifier: 1.52.0 version: 1.52.0 '@synthetixio/synpress': specifier: 4.1.1 - version: 4.1.1(@depay/solana-web3.js@1.98.3)(@depay/web3-blockchains@9.8.13)(@microsoft/api-extractor@7.58.12(@types/node@22.7.5))(@playwright/test@1.52.0)(@swc/core@1.15.46)(bufferutil@4.1.0)(ethers@6.17.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jiti@1.21.7)(playwright-core@1.52.0)(postcss@8.5.25)(tsx@4.20.6)(typescript@5.8.3)(utf-8-validate@5.0.10) + version: 4.1.1(@depay/solana-web3.js@1.98.3)(@depay/web3-blockchains@9.8.13)(@microsoft/api-extractor@7.58.12(@types/node@22.7.5))(@playwright/test@1.52.0)(@swc/core@1.15.46)(bufferutil@4.1.0)(ethers@6.17.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jiti@1.21.7)(playwright-core@1.52.0)(postcss@8.5.25)(tsx@4.20.6)(typescript@5.8.3)(utf-8-validate@5.0.10)(yaml@2.8.2) '@synthetixio/synpress-cache': specifier: 0.0.13 - version: 0.0.13(@microsoft/api-extractor@7.58.12(@types/node@22.7.5))(@swc/core@1.15.46)(jiti@1.21.7)(playwright-core@1.52.0)(postcss@8.5.25)(tsx@4.20.6)(typescript@5.8.3) + version: 0.0.13(@microsoft/api-extractor@7.58.12(@types/node@22.7.5))(@swc/core@1.15.46)(jiti@1.21.7)(playwright-core@1.52.0)(postcss@8.5.25)(tsx@4.20.6)(typescript@5.8.3)(yaml@2.8.2) '@types/node': specifier: 22.7.5 version: 22.7.5 @@ -153,7 +153,7 @@ importers: examples/CRISP/client: dependencies: '@crisp-e3/sdk': - specifier: 0.17.0 + specifier: 0.18.0-insecure.0 version: link:../packages/crisp-sdk '@emotion/babel-plugin': specifier: ^11.11.0 @@ -782,7 +782,7 @@ importers: devDependencies: '@interfold/config': specifier: ^0 - version: link:../../packages/interfold-config + version: 0.9.0(eslint@9.39.1(jiti@1.21.7))(tsup@8.5.0(@microsoft/api-extractor@7.58.12(@types/node@22.7.5))(@swc/core@1.15.46)(jiti@1.21.7)(postcss@8.5.25)(tsx@4.20.6)(typescript@5.8.3)(yaml@2.8.2))(typescript@5.8.3) '@nomicfoundation/hardhat-ethers': specifier: 4.0.3 version: 4.0.3(bufferutil@4.1.0)(hardhat@3.0.11(bufferutil@4.1.0)(utf-8-validate@5.0.10))(utf-8-validate@5.0.10) @@ -2540,6 +2540,12 @@ packages: cpu: [x64] os: [win32] + '@interfold/config@0.9.0': + resolution: {integrity: sha512-lJfbdJDR1DWj17wm2CAZJjPx5Oo+u5AQhGY0vFDrX2tIHuU2uKJ+Y1GqK3OT/J8QMfbWXeI2w2oTcGSK3O0uPA==} + peerDependencies: + eslint: ^9.0.0 + tsup: 8.5.0 + '@isaacs/balanced-match@4.0.1': resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} engines: {node: 20 || >=22} @@ -12451,6 +12457,22 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true + '@interfold/config@0.9.0(eslint@9.39.1(jiti@1.21.7))(tsup@8.5.0(@microsoft/api-extractor@7.58.12(@types/node@22.7.5))(@swc/core@1.15.46)(jiti@1.21.7)(postcss@8.5.25)(tsx@4.20.6)(typescript@5.8.3)(yaml@2.8.2))(typescript@5.8.3)': + dependencies: + '@eslint/eslintrc': 3.3.1 + '@eslint/js': 9.39.1 + '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.8.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.8.3) + '@typescript-eslint/parser': 8.65.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.8.3) + eslint: 9.39.1(jiti@1.21.7) + eslint-config-prettier: 10.1.8(eslint@9.39.1(jiti@1.21.7)) + eslint-plugin-react-hooks: 7.1.1(eslint@9.39.1(jiti@1.21.7)) + eslint-plugin-react-refresh: 0.4.26(eslint@9.39.1(jiti@1.21.7)) + globals: 15.15.0 + tsup: 8.5.0(@microsoft/api-extractor@7.58.12(@types/node@22.7.5))(@swc/core@1.15.46)(jiti@1.21.7)(postcss@8.5.25)(tsx@4.20.6)(typescript@5.8.3)(yaml@2.8.2) + transitivePeerDependencies: + - supports-color + - typescript + '@isaacs/balanced-match@4.0.1': {} '@isaacs/brace-expansion@5.0.0': @@ -14545,7 +14567,7 @@ snapshots: - utf-8-validate - zod - '@synthetixio/synpress-cache@0.0.13(@microsoft/api-extractor@7.58.12(@types/node@22.7.5))(@swc/core@1.15.46)(jiti@1.21.7)(playwright-core@1.52.0)(postcss@8.5.25)(tsx@4.20.6)(typescript@5.8.3)': + '@synthetixio/synpress-cache@0.0.13(@microsoft/api-extractor@7.58.12(@types/node@22.7.5))(@swc/core@1.15.46)(jiti@1.21.7)(playwright-core@1.52.0)(postcss@8.5.25)(tsx@4.20.6)(typescript@5.8.3)(yaml@2.8.2)': dependencies: axios: 1.6.7 chalk: 5.3.0 @@ -14575,10 +14597,10 @@ snapshots: dependencies: '@playwright/test': 1.52.0 - '@synthetixio/synpress-metamask@0.0.13(@microsoft/api-extractor@7.58.12(@types/node@22.7.5))(@playwright/test@1.52.0)(@swc/core@1.15.46)(bufferutil@4.1.0)(jiti@1.21.7)(playwright-core@1.52.0)(postcss@8.5.25)(tsx@4.20.6)(typescript@5.8.3)(utf-8-validate@5.0.10)': + '@synthetixio/synpress-metamask@0.0.13(@microsoft/api-extractor@7.58.12(@types/node@22.7.5))(@playwright/test@1.52.0)(@swc/core@1.15.46)(bufferutil@4.1.0)(jiti@1.21.7)(playwright-core@1.52.0)(postcss@8.5.25)(tsx@4.20.6)(typescript@5.8.3)(utf-8-validate@5.0.10)(yaml@2.8.2)': dependencies: '@playwright/test': 1.52.0 - '@synthetixio/synpress-cache': 0.0.13(@microsoft/api-extractor@7.58.12(@types/node@22.7.5))(@swc/core@1.15.46)(jiti@1.21.7)(playwright-core@1.52.0)(postcss@8.5.25)(tsx@4.20.6)(typescript@5.8.3) + '@synthetixio/synpress-cache': 0.0.13(@microsoft/api-extractor@7.58.12(@types/node@22.7.5))(@swc/core@1.15.46)(jiti@1.21.7)(playwright-core@1.52.0)(postcss@8.5.25)(tsx@4.20.6)(typescript@5.8.3)(yaml@2.8.2) '@synthetixio/synpress-core': 0.0.13(@playwright/test@1.52.0) '@viem/anvil': 0.0.7(bufferutil@4.1.0)(utf-8-validate@5.0.10) fs-extra: 11.2.0 @@ -14597,10 +14619,10 @@ snapshots: - utf-8-validate - yaml - '@synthetixio/synpress-phantom@0.0.13(@microsoft/api-extractor@7.58.12(@types/node@22.7.5))(@playwright/test@1.52.0)(@swc/core@1.15.46)(bufferutil@4.1.0)(jiti@1.21.7)(playwright-core@1.52.0)(postcss@8.5.25)(tsx@4.20.6)(typescript@5.8.3)(utf-8-validate@5.0.10)': + '@synthetixio/synpress-phantom@0.0.13(@microsoft/api-extractor@7.58.12(@types/node@22.7.5))(@playwright/test@1.52.0)(@swc/core@1.15.46)(bufferutil@4.1.0)(jiti@1.21.7)(playwright-core@1.52.0)(postcss@8.5.25)(tsx@4.20.6)(typescript@5.8.3)(utf-8-validate@5.0.10)(yaml@2.8.2)': dependencies: '@playwright/test': 1.52.0 - '@synthetixio/synpress-cache': 0.0.13(@microsoft/api-extractor@7.58.12(@types/node@22.7.5))(@swc/core@1.15.46)(jiti@1.21.7)(playwright-core@1.52.0)(postcss@8.5.25)(tsx@4.20.6)(typescript@5.8.3) + '@synthetixio/synpress-cache': 0.0.13(@microsoft/api-extractor@7.58.12(@types/node@22.7.5))(@swc/core@1.15.46)(jiti@1.21.7)(playwright-core@1.52.0)(postcss@8.5.25)(tsx@4.20.6)(typescript@5.8.3)(yaml@2.8.2) '@synthetixio/synpress-core': 0.0.13(@playwright/test@1.52.0) '@viem/anvil': 0.0.7(bufferutil@4.1.0)(utf-8-validate@5.0.10) fs-extra: 11.2.0 @@ -14619,14 +14641,14 @@ snapshots: - utf-8-validate - yaml - '@synthetixio/synpress@4.1.1(@depay/solana-web3.js@1.98.3)(@depay/web3-blockchains@9.8.13)(@microsoft/api-extractor@7.58.12(@types/node@22.7.5))(@playwright/test@1.52.0)(@swc/core@1.15.46)(bufferutil@4.1.0)(ethers@6.17.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jiti@1.21.7)(playwright-core@1.52.0)(postcss@8.5.25)(tsx@4.20.6)(typescript@5.8.3)(utf-8-validate@5.0.10)': + '@synthetixio/synpress@4.1.1(@depay/solana-web3.js@1.98.3)(@depay/web3-blockchains@9.8.13)(@microsoft/api-extractor@7.58.12(@types/node@22.7.5))(@playwright/test@1.52.0)(@swc/core@1.15.46)(bufferutil@4.1.0)(ethers@6.17.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jiti@1.21.7)(playwright-core@1.52.0)(postcss@8.5.25)(tsx@4.20.6)(typescript@5.8.3)(utf-8-validate@5.0.10)(yaml@2.8.2)': dependencies: '@playwright/test': 1.52.0 '@synthetixio/ethereum-wallet-mock': 0.0.13(@depay/solana-web3.js@1.98.3)(@depay/web3-blockchains@9.8.13)(@playwright/test@1.52.0)(bufferutil@4.1.0)(ethers@6.17.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(typescript@5.8.3)(utf-8-validate@5.0.10) - '@synthetixio/synpress-cache': 0.0.13(@microsoft/api-extractor@7.58.12(@types/node@22.7.5))(@swc/core@1.15.46)(jiti@1.21.7)(playwright-core@1.52.0)(postcss@8.5.25)(tsx@4.20.6)(typescript@5.8.3) + '@synthetixio/synpress-cache': 0.0.13(@microsoft/api-extractor@7.58.12(@types/node@22.7.5))(@swc/core@1.15.46)(jiti@1.21.7)(playwright-core@1.52.0)(postcss@8.5.25)(tsx@4.20.6)(typescript@5.8.3)(yaml@2.8.2) '@synthetixio/synpress-core': 0.0.13(@playwright/test@1.52.0) - '@synthetixio/synpress-metamask': 0.0.13(@microsoft/api-extractor@7.58.12(@types/node@22.7.5))(@playwright/test@1.52.0)(@swc/core@1.15.46)(bufferutil@4.1.0)(jiti@1.21.7)(playwright-core@1.52.0)(postcss@8.5.25)(tsx@4.20.6)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@synthetixio/synpress-phantom': 0.0.13(@microsoft/api-extractor@7.58.12(@types/node@22.7.5))(@playwright/test@1.52.0)(@swc/core@1.15.46)(bufferutil@4.1.0)(jiti@1.21.7)(playwright-core@1.52.0)(postcss@8.5.25)(tsx@4.20.6)(typescript@5.8.3)(utf-8-validate@5.0.10) + '@synthetixio/synpress-metamask': 0.0.13(@microsoft/api-extractor@7.58.12(@types/node@22.7.5))(@playwright/test@1.52.0)(@swc/core@1.15.46)(bufferutil@4.1.0)(jiti@1.21.7)(playwright-core@1.52.0)(postcss@8.5.25)(tsx@4.20.6)(typescript@5.8.3)(utf-8-validate@5.0.10)(yaml@2.8.2) + '@synthetixio/synpress-phantom': 0.0.13(@microsoft/api-extractor@7.58.12(@types/node@22.7.5))(@playwright/test@1.52.0)(@swc/core@1.15.46)(bufferutil@4.1.0)(jiti@1.21.7)(playwright-core@1.52.0)(postcss@8.5.25)(tsx@4.20.6)(typescript@5.8.3)(utf-8-validate@5.0.10)(yaml@2.8.2) transitivePeerDependencies: - '@depay/solana-web3.js' - '@depay/web3-blockchains' diff --git a/scripts/README.md b/scripts/README.md index 2c8c7e74e8..a9c3076c79 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -436,3 +436,25 @@ Generated verifiers are automatically: - Verifier contracts are large (~24KB) due to pairing cryptography - Library linking (e.g., `ZKTranscriptLib`) is handled automatically during deployment - Generated files are excluded from linting (`.solhintignore`) + +## Guest provenance + +Two commands cover the RISC Zero compute guest. The full reviewer-facing procedure is +`docs/pages/verifying-the-compute-provider.mdx`. + +### `generate-provenance-manifest.ts` + +Emits the release record: source commit, lockfile digests, pinned revisions, RISC Zero version, +builder image tag and digest, guest ELF SHA-256, image ID, and — with an RPC — the deployed verifier +address, its runtime code digest, the underlying RISC Zero verifier, and the on-chain `imageId()`. + +```bash +pnpm provenance:manifest +pnpm provenance:manifest --rpc --verifier

--out manifest.json +``` + +It prints `"complete": false` and lists unresolved fields when anything is missing. A release +manifest must be complete. + +Note: the SHA-256 of the ELF is **not** the image ID. SHA-256 checks binary integrity; the image ID +is computed from the loaded memory image. Both are recorded, for different purposes. diff --git a/scripts/generate-provenance-manifest.ts b/scripts/generate-provenance-manifest.ts new file mode 100644 index 0000000000..ee5f8b9239 --- /dev/null +++ b/scripts/generate-provenance-manifest.ts @@ -0,0 +1,345 @@ +#!/usr/bin/env tsx +// SPDX-License-Identifier: LGPL-3.0-only +// +// This file is provided WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. + +/** + * Build the release provenance manifest for the RISC Zero compute guest. + * + * `Risc0BfvCiphertextVerifier.imageId` is immutable and names exactly one guest image. A proof + * tells a verifier which guest ran; it says nothing about which source produced that guest. This + * manifest is the record that closes that gap, so a third party can start from a released tag and + * arrive at the deployed image ID. + * + * Local fields come from the working tree and the build output. Chain fields need an RPC endpoint + * and the deployed verifier address; without them the manifest still emits, with those fields null + * and `complete` false. + * + * pnpm provenance:manifest + * pnpm provenance:manifest --rpc https://... --verifier 0x... --out manifest.json + * + * The manifest is a record, not a check: it describes what was built and where it was deployed. + * Nothing in this repository verifies that the recorded image ID is the one the committed sources + * produce — that takes a reproducible Docker rebuild of the guest, and the reviewer-facing + * procedure is documented at docs/pages/verifying-the-compute-provider.mdx. + */ + +import { execFileSync } from 'child_process' +import { createHash } from 'crypto' +import fs from 'fs' +import path from 'path' + +const REPO_ROOT = path.resolve(__dirname, '..') +const SUPPORT = path.join(REPO_ROOT, 'crates', 'support') +const IMAGE_ID_SOL = path.join(SUPPORT, 'contracts', 'ImageID.sol') +const DOCKERFILE = path.join(SUPPORT, 'Dockerfile') + +interface Args { + rpc?: string + verifier?: string + out?: string +} + +function parseArgs(argv: string[]): Args { + const args: Args = {} + for (let i = 0; i < argv.length; i += 1) { + const flag = argv[i] + const value = argv[i + 1] + if (flag === '--rpc' || flag === '--verifier' || flag === '--out') { + if (!value || value.startsWith('--')) { + throw new Error(`${flag} needs a value`) + } + args[flag.slice(2) as keyof Args] = value + i += 1 + } else { + throw new Error(`unknown argument '${flag}'`) + } + } + if (args.rpc && !args.verifier) throw new Error('--rpc also needs --verifier') + if (args.verifier && !args.rpc) throw new Error('--verifier also needs --rpc') + return args +} + +function sh(command: string, commandArgs: string[]): string | null { + try { + return execFileSync(command, commandArgs, { + cwd: REPO_ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim() + } catch { + return null + } +} + +function readIfPresent(file: string): string | null { + return fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null +} + +function sha256File(file: string): string | null { + if (!fs.existsSync(file)) return null + return createHash('sha256').update(fs.readFileSync(file)).digest('hex') +} + +function firstMatch(text: string | null, pattern: RegExp): string | null { + if (!text) return null + const match = text.match(pattern) + return match ? match[1] : null +} + +/** Collects every Interfold git pin the guest workspace reads, so a split pin is visible. */ +function pinnedRevisions(): Record { + const manifests = [path.join(SUPPORT, 'Cargo.toml'), path.join(SUPPORT, 'methods', 'guest', 'Cargo.toml')] + const pins: Record = {} + for (const manifest of manifests) { + const text = readIfPresent(manifest) + if (!text) continue + const found = [...text.matchAll(/rev = "([0-9a-f]{40})"/g)].map((m) => m[1]) + pins[path.relative(REPO_ROOT, manifest)] = [...new Set(found)] + } + return pins +} + +/** + * The builder tag `risc0-build` will actually use, read from the pinned crate. + * + * Not derived from `RISC0_VERSION`. `risc0-build` has its own `DEFAULT_DOCKER_TAG`, which does not + * track the release version — 3.0.3 defaults to `r0.1.88.0`, not `v3.0.3` — so deriving it from the + * Dockerfile names an image the build never pulled, and the manifest would record a builder nobody + * used. + * + * Returns null when the crate source is not on this machine, which leaves the manifest incomplete + * and fails the release rather than recording a guess. + */ +function defaultBuilderTag(): string | null { + const pinned = firstMatch(readIfPresent(path.join(SUPPORT, 'Cargo.toml')), /risc0-build = \{ version = "=([0-9.]+)"/) + if (!pinned) return null + + // The vendored source, which is present wherever the guest can be built at all. + const root = path.join(process.env.CARGO_HOME ?? path.join(process.env.HOME ?? '', '.cargo'), 'registry', 'src') + if (!fs.existsSync(root)) return null + + for (const registry of fs.readdirSync(root)) { + const lib = path.join(root, registry, `risc0-build-${pinned}`, 'src', 'lib.rs') + const tag = firstMatch(readIfPresent(lib), /DEFAULT_DOCKER_TAG: &str = "([^"]+)"/) + if (tag) return tag + } + return null +} + +/** + * Resolves the RISC Zero guest builder image. + * + * The builder is selected by a mutable tag, and `RISC0_DOCKER_CONTAINER_TAG` overrides it, so the + * tag alone does not identify a build. Record the resolved digest whenever Docker can supply it. + */ +function builderImage() { + const fromCrate = defaultBuilderTag() + const tag = process.env.RISC0_DOCKER_CONTAINER_TAG ?? (fromCrate ? `risczero/risc0-guest-builder:${fromCrate}` : null) + if (!tag) return { tag: null, digest: null } + const digest = sh('docker', ['image', 'inspect', '--format', '{{index .RepoDigests 0}}', tag]) + return { tag, digest } +} + +/** Reads the guest ELF path out of the generated Elf.sol, which is not committed. */ +function guestElf() { + const elfSol = readIfPresent(path.join(SUPPORT, 'tests', 'Elf.sol')) + const elfPath = firstMatch(elfSol, /"([^"]+program\.bin)"/) + if (!elfPath) { + return { path: null, sha256: null, note: 'Elf.sol absent; build the guest first' } + } + const sha256 = sha256File(elfPath) + return { + path: elfPath, + sha256, + note: sha256 ? null : 'Elf.sol names a path that does not exist on this machine', + } +} + +/** + * How long a single RPC call may take before it counts as unresolved. + * + * `fetch` has no default timeout, and this runs in the release job. An endpoint that accepts the + * connection and never answers would hang the release rather than failing it, which is the one + * outcome a fail-closed gate must not have. + */ +const RPC_TIMEOUT_MS = 15_000 + +async function rpcCall(rpc: string, method: string, params: unknown[]): Promise { + try { + const response = await fetch(rpc, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), + signal: AbortSignal.timeout(RPC_TIMEOUT_MS), + }) + const body = (await response.json()) as { result?: string; error?: unknown } + if (body.error || typeof body.result !== 'string') return null + return body.result + } catch { + return null + } +} + +/** `keccak256("imageId()")[0..4]` and `keccak256("risc0Verifier()")[0..4]`. */ +const SELECTOR_IMAGE_ID = '0xef3f7dd5' +const SELECTOR_RISC0_VERIFIER = '0x5c9770c5' + +/** + * A `0x`-prefixed hex string of exactly `bytes` bytes, or null. + * + * An RPC result is only known to be a string. Everything downstream treats non-null as resolved, so + * a malformed answer that survives this far reads as a verified fact — which is the one thing a + * fail-closed manifest must not do. + */ +function hexOfLength(value: string | null, bytes: number): string | null { + if (!value) return null + // Lowercased, not returned as received. Hex is case-insensitive, but `onchainImageId` is later + // compared against `ImageID.sol` as a plain string — an uppercase RPC answer would read as a + // mismatch and make the manifest incomplete for no reason. + return new RegExp(`^0x[0-9a-fA-F]{${bytes * 2}}$`).test(value) ? value.toLowerCase() : null +} + +/** Any `0x`-prefixed hex string with a whole number of bytes, lowercased, or null. */ +function hexBytes(value: string | null): string | null { + if (!value) return null + return /^0x([0-9a-fA-F]{2})*$/.test(value) ? value.toLowerCase() : null +} + +async function chainFacts(rpc: string, verifier: string) { + const code = hexBytes(await rpcCall(rpc, 'eth_getCode', [verifier, 'latest'])) + const codePresent = Boolean(code && code !== '0x') + // SHA-256, not keccak256: Node's crypto has no keccak256, and any fixed digest serves the + // purpose here as long as the manifest names which one it is. + const runtimeCodeSha256 = + code && codePresent + ? `0x${createHash('sha256') + .update(Buffer.from(code.slice(2), 'hex')) + .digest('hex')}` + : null + + // Both calls return one ABI word. An image ID is that word; an address is its low 20 bytes. + const onchainImageId = hexOfLength(await rpcCall(rpc, 'eth_call', [{ to: verifier, data: SELECTOR_IMAGE_ID }, 'latest']), 32) + const underlyingWord = hexOfLength(await rpcCall(rpc, 'eth_call', [{ to: verifier, data: SELECTOR_RISC0_VERIFIER }, 'latest']), 32) + + const chainIdHex = hexBytes(await rpcCall(rpc, 'eth_chainId', [])) + const chainIdValue = chainIdHex ? Number.parseInt(chainIdHex, 16) : Number.NaN + + return { + // `Number.isSafeInteger` rather than a null check: `parseInt` yields NaN for a malformed + // answer, NaN passes `!== null`, and `JSON.stringify` then writes it as null — a manifest that + // claims to be complete while recording no chain. + chainId: Number.isSafeInteger(chainIdValue) ? chainIdValue : null, + ciphertextVerifier: verifier, + ciphertextVerifierRuntimeCodeSha256: runtimeCodeSha256, + ciphertextVerifierCodePresent: codePresent, + underlyingRisc0Verifier: underlyingWord ? `0x${underlyingWord.slice(-40)}` : null, + onchainImageId, + } +} + +async function main() { + const args = parseArgs(process.argv.slice(2)) + + const dockerfile = readIfPresent(DOCKERFILE) + const risc0Version = firstMatch(dockerfile, /^ARG RISC0_VERSION=(.*)$/m) + const risc0Toolchain = firstMatch(dockerfile, /^ARG RISC0_TOOLCHAIN=(.*)$/m) + // Lowercased for the same reason the RPC answers are: Solidity accepts either case, and this + // value is compared against `deployment.onchainImageId` as a plain string. + const committedImageId = firstMatch(readIfPresent(IMAGE_ID_SOL), /(0x[0-9a-fA-F]{64})/)?.toLowerCase() ?? null + + const elf = guestElf() + const chain = args.rpc && args.verifier ? await chainFacts(args.rpc, args.verifier) : null + + const manifest = { + schema: 'interfold.compute-provider-provenance/1', + generatedFrom: { + sourceCommit: sh('git', ['rev-parse', 'HEAD']), + sourceDescribe: sh('git', ['describe', '--tags', '--always', '--dirty']), + treeClean: sh('git', ['status', '--porcelain']) === '', + }, + build: { + risc0Version, + risc0GuestToolchain: risc0Toolchain, + hostToolchain: firstMatch(readIfPresent(path.join(REPO_ROOT, 'rust-toolchain.toml')), /channel = "([^"]+)"/), + builderImage: builderImage(), + pinnedRevisions: pinnedRevisions(), + // Why the guest is pinned where it is, and what that pin does and does not certify. A + // consumer reading only a commit hash would reasonably assume the code behind it was + // audited; for the guest, it was not. + auditBaseline: { + commit: 'c2097da61b4d07c4ce83840393ff4e9f171eefb4', + report: 'packages/interfold-contracts/audits/20260714-Interfold - Zenith Audit Report.pdf', + mitigationReviewCommit: 'c64bcfb890b596e626ea6578c5fbd53f808c3b43', + guestInAuditScope: false, + note: 'The 2026-08-17 Zenith audit covered six Solidity files and no Rust. crates/compute-provider, the RISC Zero guest, crates/zk-helpers, and Risc0BfvCiphertextVerifier.sol were outside both the audit and the mitigation review.', + }, + lockfiles: { + 'crates/support/Cargo.lock': sha256File(path.join(SUPPORT, 'Cargo.lock')), + 'crates/support/methods/guest/Cargo.lock': sha256File(path.join(SUPPORT, 'methods', 'guest', 'Cargo.lock')), + }, + }, + guest: { + elfPath: elf.path, + elfSha256: elf.sha256, + elfNote: elf.note, + // The SHA-256 of the ELF is a binary integrity check. It is NOT the image ID, which is + // computed from the loaded memory image. Both are recorded; neither substitutes for the other. + imageId: committedImageId, + }, + deployment: chain, + } + + // A deployment object exists even when every RPC call failed, so completeness is decided by the + // fields themselves. Without this a timed-out RPC would produce a manifest that reads as verified. + const deploymentResolved = + chain !== null && + chain.chainId !== null && + chain.ciphertextVerifierCodePresent && + chain.ciphertextVerifierRuntimeCodeSha256 !== null && + chain.underlyingRisc0Verifier !== null && + chain.onchainImageId !== null + + const unresolved: string[] = [] + if (!manifest.guest.elfSha256) unresolved.push('guest.elfSha256') + if (!manifest.build.builderImage.digest) unresolved.push('build.builderImage.digest') + if (!chain) { + unresolved.push('deployment (pass --rpc and --verifier)') + } else if (!deploymentResolved) { + if (chain.chainId === null) unresolved.push('deployment.chainId') + if (!chain.ciphertextVerifierCodePresent) unresolved.push('deployment.ciphertextVerifier (no code at address)') + if (chain.ciphertextVerifierRuntimeCodeSha256 === null) unresolved.push('deployment.ciphertextVerifierRuntimeCodeSha256') + if (chain.underlyingRisc0Verifier === null) unresolved.push('deployment.underlyingRisc0Verifier') + if (chain.onchainImageId === null) unresolved.push('deployment.onchainImageId') + } + + // The recorded image ID must also be the one deployed, or the manifest describes a different + // artefact from the one in use. + if (chain?.onchainImageId && committedImageId && chain.onchainImageId !== committedImageId) { + unresolved.push(`deployment.onchainImageId (${chain.onchainImageId}) does not match ImageID.sol (${committedImageId})`) + } + + const output = { ...manifest, complete: unresolved.length === 0, unresolved } + const json = `${JSON.stringify(output, null, 2)}\n` + + if (args.out) { + fs.writeFileSync(path.resolve(REPO_ROOT, args.out), json) + console.log(`provenance manifest written to ${args.out}`) + } else { + process.stdout.write(json) + } + + if (unresolved.length > 0) { + console.error( + `\n⚠️ incomplete manifest. Unresolved: ${unresolved.join(', ')}\n` + + ` A release manifest must be complete. See docs/pages/verifying-the-compute-provider.mdx.`, + ) + } +} + +main().catch((error: unknown) => { + console.error(`generate-provenance-manifest: ${(error as Error).message}`) + process.exit(1) +}) diff --git a/templates/default/Cargo.lock b/templates/default/Cargo.lock index 50f7f6eb4b..225641f3c5 100644 --- a/templates/default/Cargo.lock +++ b/templates/default/Cargo.lock @@ -1313,14 +1313,16 @@ dependencies = [ "ark-ff 0.4.2", "e3-bfv-client", "e3-fhe-params", + "fhe", "hex", "lean-imt", "light-poseidon", "num-bigint", "num-traits", - "rayon", "serde", + "sha2", "sha3 0.10.8", + "thiserror 1.0.69", "zk-kit-imt", ] diff --git a/templates/default/hardhat.config.ts b/templates/default/hardhat.config.ts index 856d06f607..7d9855ace6 100644 --- a/templates/default/hardhat.config.ts +++ b/templates/default/hardhat.config.ts @@ -145,6 +145,7 @@ const config: HardhatUserConfig = { '@interfold/contracts/contracts/test/MockDecryptionVerifier.sol', '@interfold/contracts/contracts/test/MockDkgFoldAttestationVerifier.sol', '@interfold/contracts/contracts/test/MockE3Program.sol', + '@interfold/contracts/contracts/test/MockE3ProgramHarness.sol', '@interfold/contracts/contracts/test/MockPkVerifier.sol', '@interfold/contracts/contracts/test/MockSlashingVerifier.sol', '@interfold/contracts/contracts/test/MockStableToken.sol', diff --git a/templates/default/program/src/lib.rs b/templates/default/program/src/lib.rs index ae4b4332dd..44e590fbe8 100644 --- a/templates/default/program/src/lib.rs +++ b/templates/default/program/src/lib.rs @@ -4,11 +4,23 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. -use e3_compute_provider::FHEInputs; +use e3_compute_provider::{FHEInputs, InputPolicy}; use e3_fhe_params::decode_bfv_params_arc; use fhe::bfv::Ciphertext; use fhe_traits::{DeserializeParametrized, Serialize}; +/// The input policy this E3 program requires. +/// +/// Every E3 program exports one beside its processor. The default is the historical behaviour: the +/// leaf is the ciphertext's own commitment, and every input is computed over — which matches a +/// `MyProgram.publishInput` that inserts the commitment directly. +/// +/// A program whose contract builds a different leaf, or that needs to drop inputs it cannot verify, +/// supplies its own instead. See `examples/CRISP/program` for one that does. +pub fn policy() -> InputPolicy { + InputPolicy::default() +} + /// Implementation of the CiphertextProcessor function pub fn fhe_processor(fhe_inputs: &FHEInputs) -> Vec { let params = decode_bfv_params_arc(&fhe_inputs.params).unwrap(); @@ -29,10 +41,10 @@ mod tests { use e3_fhe_params::DEFAULT_BFV_PRESET; use e3_fhe_params::{BfvParamSet, build_bfv_params_arc, encode_bfv_params}; use fhe::bfv::{Encoding, Plaintext, PublicKey, SecretKey}; - use fhe_traits::FheEncoder; + use fhe_traits::{FheDecoder, FheEncoder}; use fhe_traits::FheEncrypter; use fhe_traits::{DeserializeParametrized, FheDecrypter, Serialize}; - use rand::{rngs::OsRng, thread_rng}; + use rand::thread_rng; #[test] fn test() -> Result<()> { @@ -46,7 +58,7 @@ mod tests { params_set.error1_variance, ); - let secret_key = SecretKey::random(¶ms, &mut OsRng); + let secret_key = SecretKey::random(¶ms, &mut rng); let public_key = PublicKey::new(&secret_key, &mut rng); // 3 @@ -73,7 +85,8 @@ mod tests { // Decrypt result let decrypted = secret_key.try_decrypt(&Ciphertext::from_bytes(&result, ¶ms)?)?; - assert_eq!(decrypted.value[0], 5); + let tally = Vec::::try_decode(&decrypted, Encoding::poly())?; + assert_eq!(tally[0], 5); Ok(()) } }