From 104695c3fd06c0bd7f21c678abe8160f383a2be1 Mon Sep 17 00:00:00 2001 From: 99Kies <1290017556@qq.com> Date: Mon, 13 Jul 2026 14:31:10 +0800 Subject: [PATCH 1/2] feat: add hybrid MACI + AHE on-chain voting with cross-batch LWW fix Implements the full hybrid MACI + Additively Homomorphic ElGamal (AHE) on-chain voting flow, solving the B-line problem by ensuring the coordinator never sees individual vote contents. Key changes: Circuits (new): - Add hybrid/ Circom circuits: BallotValidity, ProcessHybridMessages, RevealVerify (client + onchain wrapper variants each) - BallotValidity proves voter eligibility and binds routing/AHE commitment without revealing stateIdx to the chain - ProcessHybridMessages implements reverse-order batch processing with an independent quinary nonce tree for cross-batch last-write-wins (LWW) - RevealVerify proves threshold partial decryption via DLEQ Contract (amaci): - Add hybrid message publishing: PublishHybridMessage validates ballot proof, curve points, and coordinator binding; stores routing + AHE ciphertexts - Add ProcessHybridBatch: reverse batch selection (chain-tail first), 11-field SHA256 inputHash including currentNonceRoot/newNonceRoot, auto-transition to Tallying when all messages processed - Add RevealHybridTally: threshold DLEQ proof verification, saves tally, transitions period to Ended - Add SetHybridKc / ConfirmHybridKc (threshold committee path) - Add HYBRID_NONCE_STATE_ROOT persistent nonce tree state - Guard StopProcessingPeriod / StopTallyingPeriod against hybrid messages - Validate vote_option_map.len() == HYBRID_M at instantiate - Add 24 multitest e2e tests covering full flow, multi-batch chaining, partial batches, all error paths, and cross-message ballot proof rejection - Remove dead HybridBatchFull error variant SDK: - Add AHE crypto primitives (ahe.ts): ElGamal encrypt/decrypt, addAhe, recoverAhe, solveDLog, DLEQ proof generation - Add buildHybridVotePayload (voter.ts): constructs routing envelope + AHE ciphertext + ballot validity proof - Add decodeHybridRouting / processHybridBatch (operator.ts): coordinator routing decryption and batch processing with nonce-tree LWW simulation - Add genHybridOnchainProcessProof (operator.ts): two-phase Groth16 proof generation for on-chain batch verification --- contracts/amaci/DEPLOYMENT_TESTNET.md | 274 ++ contracts/amaci/src/circuit_params.rs | 98 +- contracts/amaci/src/contract.rs | 1156 ++++++++- contracts/amaci/src/error.rs | 94 + contracts/amaci/src/msg.rs | 193 +- contracts/amaci/src/multitest/mod.rs | 397 ++- contracts/amaci/src/multitest/tests.rs | 2221 +++++++++++++++++ contracts/amaci/src/state.rs | 94 + contracts/registry/src/contract.rs | 3 + crates/maci-utils/src/poseidon.rs | 48 + packages/circuits/circom/circuits.json | 36 + .../circom/hybrid/lib/aheCommit.circom | 43 + .../circom/hybrid/lib/aheEncrypt.circom | 55 + .../circom/hybrid/lib/condPointAdd.circom | 40 + .../circom/hybrid/lib/dleqVerify.circom | 103 + .../circom/hybrid/lib/signedScalarMul.circom | 43 + .../circom/hybrid/power/ballotValidity.circom | 379 +++ .../hybrid/power/processHybridMessages.circom | 467 ++++ .../circom/hybrid/power/revealVerify.circom | 387 +++ .../utils/hybridMessageToCommand.circom | 86 + packages/circuits/package.json | 7 +- .../HybridBallotRoutingBinding.test.ts | 115 + .../ts/__tests__/HybridBallotValidity.test.ts | 199 ++ .../__tests__/HybridProcessMessages.test.ts | 250 ++ .../ts/__tests__/HybridRevealVerify.test.ts | 184 ++ .../ts/__tests__/HybridSdkIntegration.test.ts | 119 + packages/circuits/ts/__tests__/utils/utils.ts | 178 ++ packages/sdk/src/libs/crypto/ahe.ts | 316 +++ packages/sdk/src/libs/crypto/index.ts | 1 + packages/sdk/src/operator.ts | 273 +- packages/sdk/src/voter.ts | 183 +- 31 files changed, 8007 insertions(+), 35 deletions(-) create mode 100644 contracts/amaci/DEPLOYMENT_TESTNET.md create mode 100644 packages/circuits/circom/hybrid/lib/aheCommit.circom create mode 100644 packages/circuits/circom/hybrid/lib/aheEncrypt.circom create mode 100644 packages/circuits/circom/hybrid/lib/condPointAdd.circom create mode 100644 packages/circuits/circom/hybrid/lib/dleqVerify.circom create mode 100644 packages/circuits/circom/hybrid/lib/signedScalarMul.circom create mode 100644 packages/circuits/circom/hybrid/power/ballotValidity.circom create mode 100644 packages/circuits/circom/hybrid/power/processHybridMessages.circom create mode 100644 packages/circuits/circom/hybrid/power/revealVerify.circom create mode 100644 packages/circuits/circom/hybrid/utils/hybridMessageToCommand.circom create mode 100644 packages/circuits/ts/__tests__/HybridBallotRoutingBinding.test.ts create mode 100644 packages/circuits/ts/__tests__/HybridBallotValidity.test.ts create mode 100644 packages/circuits/ts/__tests__/HybridProcessMessages.test.ts create mode 100644 packages/circuits/ts/__tests__/HybridRevealVerify.test.ts create mode 100644 packages/circuits/ts/__tests__/HybridSdkIntegration.test.ts create mode 100644 packages/sdk/src/libs/crypto/ahe.ts diff --git a/contracts/amaci/DEPLOYMENT_TESTNET.md b/contracts/amaci/DEPLOYMENT_TESTNET.md new file mode 100644 index 0000000..8054854 --- /dev/null +++ b/contracts/amaci/DEPLOYMENT_TESTNET.md @@ -0,0 +1,274 @@ +# cw-amaci Hybrid (MACI + AHE) — Testnet Deployment Guide + +This document covers deploying the `cw-amaci` contract — including the new +**Hybrid MACI + Additively-Homomorphic-Encryption (AHE) ballot verifier** +(`QueryMsg::VerifyHybridBallot`) — to the DoraFactory testnet (`vota-testnet`). + +It assumes you already have `dorad` (or any CosmWasm-compatible chain CLI) and a +funded testnet account. It does **not** cover setting up a local devnet. + +--- + +## 1. What is actually "ready" right now + +| Area | Status | +|---|---| +| `BallotValidity` circuit + state-tree Merkle inclusion (Phase 1) | ✅ circuit compiled, SDK wired, demo verified end-to-end | +| `BallotValidityOnchain` circuit (single `inputHash`, contract-compatible) | ✅ compiled, zkey/vkey generated, local proof↔hash parity verified | +| `cw-amaci::QueryMsg::VerifyHybridBallot` on-chain verifier | ✅ implemented, unit-tested, **e2e-tested through the real wasm `query` entry point** | +| Poseidon state-leaf hashing parity (SDK ⇄ `maci-utils`) | ✅ Rust parity test (`test_hybrid_demo_state_leaf_parity`) | +| Production wasm build (default features, no `test-vkeys`) | ✅ compiles for `wasm32-unknown-unknown` | +| `test-vkeys` wasm build (adds lightweight 2-1-1-5 circuit) | ✅ compiles for `wasm32-unknown-unknown` | +| Downstream contracts (`cw-amaci-registry`, `cw-api-saas`) still build/test against the changed `cw-amaci` | ✅ no breakage | +| Aggregated homomorphic tally + threshold decryption committee flow | ⚠️ still off-chain only (`mpc-coordinator-demo`), **not** part of this contract change | +| A `ProcessHybridMessages` batch proof executed **on-chain** | ⚠️ not implemented — only the per-voter `ballotValidity` proof has an on-chain verifier today | + +So: **the ballot-integrity half of the hybrid design is genuinely on-chain and testable now.** The +homomorphic aggregation/decryption half is still a coordinator-side demo, exactly as scoped in +this phase. `VerifyHybridBallot` is a pure read-only query — it doesn't change any existing +MACI/aMACI execution paths, so it's safe to ship without affecting normal rounds. + +--- + +## 2. Test coverage (run before you deploy) + +```bash +cd maci +cargo test -p cw-amaci --lib # 52 passed, 0 failed, 2 ignored +cargo test -p cw-amaci-registry --lib # 42 passed, 0 failed +cargo test -p cw-api-saas --lib # 21 passed, 0 failed +``` + +Of the 52 `cw-amaci` tests, 3 are new **real e2e tests** for the hybrid feature +(`multitest::tests::test::e2e_verify_hybrid_ballot_*`). Unlike the lower-level +`contract::tests::hybrid_ballot_proof_*` unit tests (which call the Rust helper directly), these +go through `cw_multi_test`'s full `instantiate` → `query` dispatch — i.e. the same code path a +real chain node executes: + +- `e2e_verify_hybrid_ballot_accepts_real_proof_via_query_entrypoint` — a real Groth16 proof + (generated by `mpc-coordinator-demo/scripts/exportHybridVkeyProof.ts`) verifies as `true`. +- `e2e_verify_hybrid_ballot_rejects_tampered_state_root_via_query_entrypoint` — replaying the + same proof against a different `stateRoot` is rejected. +- `e2e_verify_hybrid_ballot_rejects_tampered_ahe_commitment_via_query_entrypoint` — replaying + against a different `aheCommitment` is rejected. + +If you want to add your own fixture, regenerate it with: + +```bash +cd mpc-coordinator-demo +npx ts-node scripts/exportHybridVkeyProof.ts # writes hybridBallotFixture.json +``` + +--- + +## 3. Building the deployable wasm + +### 3.1 Two build variants — pick one intentionally + +`match_vkeys()` in `circuit_params.rs` gates which `MaciParameters` (circuit size) an +`InstantiateMsg` is allowed to use, based on Cargo features: + +| Build | Feature flags | Accepted `MaciParameters` | Use case | +|---|---|---|---| +| **Production** | *(default, no extra flags)* | `state_tree_depth=9, int_state_tree_depth=4, vote_option_tree_depth=3, message_batch_size=125` only | Real rounds, matches what's already live on `vota-ash` / `vota-testnet` (`maciCodeId` 106 / 134) | +| **Test-vkeys demo** | `--features test-vkeys` | production size **or** `2-1-1-5` (small, fast-to-prove) | Quick hybrid-feature demos on testnet — matches the circuit size already used throughout this session's `mpc-coordinator-demo` | + +⚠️ **Do not use the `test-vkeys` build for a round that holds real value.** It accepts a +lightweight circuit whose trusted setup is a local `circomkit` dev ceremony, not an audited +production ceremony. It's fine for a labelled "demo/testnet trial" round. + +`VerifyHybridBallot` itself is unaffected by this choice — the hybrid ballot vkey +(`circuit_params::hybrid_ballot_vkey()`) is hardcoded unconditionally and works on any +successfully-instantiated contract regardless of which `MaciParameters` you chose. + +### 3.2 Recommended: official Docker optimizer (reproducible build) + +```bash +cd maci +docker run --rm -v "$(pwd)":/code \ + --mount type=volume,source="$(basename "$(pwd)")_cache",target=/code/target \ + --mount type=volume,source=registry_cache,target=/usr/local/cargo/registry \ + cosmwasm/rust-optimizer-arm64:0.12.11 # Apple Silicon host +# or cosmwasm/rust-optimizer:0.12.11 # x86_64 host +``` + +This produces `artifacts/cw_amaci-aarch64.wasm` (or `-x86_64`) plus a matching +`artifacts/checksums.txt` entry, using the **default (production)** features. + +**Known gotcha found while validating this guide**: on an Apple Silicon Docker host, the +`rust-optimizer-arm64:0.12.11` image is missing `clang`, which the `blake` crate (pulled in +transitively by the Groth16 verifier's `pairing`/`bellman-ce` fork) needs to build its C shim — +the build fails with `ToolNotFound: failed to find tool "clang"`. Two ways around it: + +- Run the optimizer step on an x86_64 host/CI runner (e.g. a GitHub Actions `ubuntu-latest` + job) using `cosmwasm/rust-optimizer:0.12.11`, **or** +- Use the newer unified `cosmwasm/optimizer:0.16.x` image, which bundles `clang` — but note it + currently only ships an `amd64` image, so on an Apple Silicon host without a native arm64 tag + it runs under emulation and can take 20+ minutes. + +### 3.3 Fallback: native build + `wasm-opt` (verified to work, faster locally) + +If you just need a working artifact quickly (e.g. to unblock a testnet trial while CI produces +the canonical reproducible build), this path was verified during this session: + +```bash +cd maci +rustup target add wasm32-unknown-unknown # if not already installed +cargo build -p cw-amaci --release --target wasm32-unknown-unknown +# add --features test-vkeys here if you want the 2-1-1-5 demo circuit too + +wasm-opt -Os --signext-lowering \ + target/wasm32-unknown-unknown/release/cw_amaci.wasm \ + -o cw_amaci_optimized.wasm +``` + +Measured sizes for this build (production features, no `test-vkeys`): + +- Unoptimized: **2,938,341 bytes** (~2.8 MB) +- After `wasm-opt -Os`: **2,242,231 bytes** (~2.1 MB) + +This is comparable to the already-live `cw-amaci` code on `vota-testnet`/`vota-ash`, so size is +not expected to be a blocker on DoraFactory's chain. + +--- + +## 4. Deploying to `vota-testnet` + +Known network parameters (from `packages/sdk/src/libs/const.ts`): + +| Field | Value | +|---|---| +| Chain ID | `vota-testnet` | +| RPC | `https://vota-testnet-rpc.dorafactory.org` | +| REST | `https://vota-testnet-rest.dorafactory.org` | +| Address prefix | `dora` | +| Fee denom | `peaka` (1 DORA = 10¹⁸ peaka) | +| Existing registry (production code, **not yours to reconfigure**) | `dora13c8aecstyxrhax9znvvh5zey89edrmd2k5va57pxvpe3fxtfsfeqlhsjnd` | + +You have two deployment shapes. For validating the hybrid feature, **(A) is what you want.** + +### A. Standalone instantiate (recommended for this trial) + +Store your own code and instantiate directly — full control, no dependency on the shared +production registry. + +```bash +# 1. Store the code +dorad tx wasm store cw_amaci_optimized.wasm \ + --from --chain-id vota-testnet \ + --node https://vota-testnet-rpc.dorafactory.org \ + --gas auto --gas-adjustment 1.3 --gas-prices 10000000000peaka \ + --broadcast-mode sync -y + +# note the code_id from the tx result / `dorad query tx ` +``` + +Instantiate (example uses the `test-vkeys` 2-1-1-5 shape; swap `parameters` to the 9-4-3-125 +production shape if you built without `test-vkeys`): + +```json +{ + "parameters": { + "state_tree_depth": "2", + "int_state_tree_depth": "1", + "message_batch_size": "5", + "vote_option_tree_depth": "1" + }, + "coordinator": { "x": "", "y": "" }, + "admin": "dora1...", + "fee_recipient": "dora1...", + "operator": "dora1...", + "vote_option_map": ["Option 1", "Option 2", "Option 3", "Option 4", "Option 5"], + "round_info": { "title": "Hybrid AHE Demo", "description": "", "link": "" }, + "voting_time": { "start_time": "", "end_time": "" }, + "circuit_type": "0", + "certification_system": "0", + "poll_id": 1, + "voice_credit_mode": { "unified": { "amount": "100" } }, + "registration_mode": { + "sign_up_with_static_whitelist": { + "whitelist": { "users": [{ "addr": "dora1...", "voice_credit_amount": null }] } + } + }, + "deactivate_enabled": false, + "message_fee": "60000000000000000", + "deactivate_fee": "10000000000000000000", + "signup_fee": "30000000000000000", + "base_delay": 200, + "message_delay": 2, + "signup_delay": 1, + "deactivate_delay": 600 +} +``` + +```bash +dorad tx wasm instantiate '' \ + --from --chain-id vota-testnet --label "hybrid-ahe-demo" \ + --no-admin \ + --node https://vota-testnet-rpc.dorafactory.org \ + --gas auto --gas-adjustment 1.3 --gas-prices 10000000000peaka -y +``` + +`certification_system: "0"` means Groth16 — this is the only verification path implemented (a +value of `1` for Plonk is reserved in the schema but has no working verifier in this codebase; +don't use it). + +### B. Via the shared registry (production-shaped flow) + +If you have operator/admin rights on the already-deployed registry, its `ExecuteMsg::CreateRound` +instantiates a `cw-amaci` round using whatever `maci_code_id` the registry is currently configured +with. To point it at *your* new code (with the hybrid verifier), you'd need to update that +registry's stored code ID — out of scope for a self-serve testnet trial unless you own that +registry's admin key. Path (A) avoids this entirely. + +--- + +## 5. Verifying the deployment + +```bash +# Sanity: round metadata +dorad query wasm contract-state smart '{"get_round_info":{}}' --node ... +dorad query wasm contract-state smart '{"get_period":{}}' --node ... +dorad query wasm contract-state smart '{"get_num_sign_up":{}}' --node ... +``` + +### Hybrid ballot verification (the new part) + +`VerifyHybridBallot` works immediately after instantiate — it doesn't depend on signups, voting +periods, or any round state, only on the hardcoded hybrid vkey baked into the contract binary. + +```bash +dorad query wasm contract-state smart '{ + "verify_hybrid_ballot": { + "kc": ["", ""], + "state_root": "", + "state_idx": "", + "pub_key": ["", ""], + "ahe_commitment": "", + "proof": { "a": "", "b": "", "c": "" } + } +}' --node https://vota-testnet-rpc.dorafactory.org +``` + +Expect `{"data":true}` for a valid proof, `{"data":false}` for any tampered public value — this +mirrors exactly the three e2e tests described in §2. You can pull ready-made sample values from +`mpc-coordinator-demo/scripts/hybridBallotFixture.json` (regenerate via +`exportHybridVkeyProof.ts` if you need a fresh one bound to your own voter/session data). + +Field naming reminder: this contract's JSON uses `snake_case` (`cw_serde` default), so it's +`state_root` / `state_idx` / `pub_key` / `ahe_commitment`, not camelCase. + +--- + +## 6. Known limitations to communicate to anyone testing this round + +- `VerifyHybridBallot` is a **read-only integrity check** for a single voter's ballot. It proves + "this ballot's committed voice-credit budget is authenticated against `stateRoot`" without + revealing the vote. It does **not** submit or tally anything on-chain. +- The actual vote content stays AHE-encrypted and is only decrypted, in aggregate, by the + off-chain threshold committee in `mpc-coordinator-demo` — this contract change doesn't move + that decryption on-chain. +- Coordinator batch processing (`ProcessHybridMessages`) still has no on-chain verifier; only the + per-voter `ballotValidity` proof does. If/when batch tallying needs to move on-chain too, it + will need its own `*Onchain`-style wrapper circuit and `QueryMsg`/`ExecuteMsg`, following the + same pattern used here. diff --git a/contracts/amaci/src/circuit_params.rs b/contracts/amaci/src/circuit_params.rs index 2ee3fe7..53ab5a4 100644 --- a/contracts/amaci/src/circuit_params.rs +++ b/contracts/amaci/src/circuit_params.rs @@ -15,6 +15,88 @@ pub struct VkeyParams { pub add_key_vkey: Groth16VkeyStr, } +// Verifying key for the Hybrid MACI + AHE on-chain ballotValidity circuit +// (BallotValidityOnchain_hybrid_2-1: stateTreeDepth=2, voteOptionTreeDepth=1). +// +// This circuit exposes a SINGLE public signal `inputHash` = +// SHA256([Kc.x, Kc.y, stateRoot, coordPubKey.x, coordPubKey.y, pollId, +// routingCommitment, aheCommitment, nullifier]) mod p +// which is exactly what `compute_input_hash` reproduces on-chain. `stateIdx`/`pubKey` +// are private witnesses inside the circuit (only the `nullifier` leaks out), and the +// circuit re-derives the ECDH shared key from `coordPubKey` to assert the decrypted +// `routing` message is consistent with `stateIdx`/`aheCommitment`, binding this proof +// to the exact routing ciphertext the voter published. It also proves +// `routingEncPubKey === ephemeralPrivKey * G` (`PrivToPubKey` in +// ballotValidity.circom), so the ephemeral key used for that ECDH derivation +// really is the one published on-chain alongside the ciphertext. +// The hex is the circomkit-generated groth16 vkey converted to the uncompressed form +// this contract's bellman verifier expects (see mpc-coordinator-demo exportHybridVkeyProof.ts). +pub fn hybrid_ballot_vkey() -> Result { + let vkey = Groth16VKeyType { + vk_alpha1: "2d4d9aa7e302d9df41749d5507949d05dbea33fbb16c643b22f599a2be6df2e214bedd503c37ceb061d8ec60209fe345ce89830a19230301f076caff004d1926".to_string(), + vk_beta_2: "0967032fcbf776d1afc985f88877f182d38480a653f2decaa9794cbc3bf3060c0e187847ad4c798374d0d6732bf501847dd68bc0e071241e0213bc7fc13db7ab304cfbd1e08a704a99f5e847d93f8c3caafddec46b7a0d379da69a4d112346a71739c1b1a457a8c7313123d24d2f9192f896b7c63eea05a9d57f06547ad0cec8".to_string(), + vk_gamma_2: "198e9393920d483a7260bfb731fb5d25f1aa493335a9e71297e485b7aef312c21800deef121f1e76426a00665e5c4479674322d4f75edadd46debd5cd992f6ed090689d0585ff075ec9e99ad690c3395bc4b313370b38ef355acdadcd122975b12c85ea5db8c6deb4aab71808dcb408fe3d1e7690c43d37b4ce6cc0166fa7daa".to_string(), + vk_delta_2: "198e9393920d483a7260bfb731fb5d25f1aa493335a9e71297e485b7aef312c21800deef121f1e76426a00665e5c4479674322d4f75edadd46debd5cd992f6ed090689d0585ff075ec9e99ad690c3395bc4b313370b38ef355acdadcd122975b12c85ea5db8c6deb4aab71808dcb408fe3d1e7690c43d37b4ce6cc0166fa7daa".to_string(), + vk_ic0: "2b2169fda817d1afd01f2e91cb76da2cd8fc0db042bab5daed1feade771f0acd20d65d9bdd874deaf6c4911753b8da2e70b19a51058452f612598457d5042185".to_string(), + vk_ic1: "300d82ede0838f73edc1b424bf5b76d9526f9c33aa60f33c7de96c0f343838782d7d6045fe2dcffd77fea063454c75e02fee2ae32c73f94be725efcd30c96403".to_string(), + }; + format_vkey(&vkey) +} + +// Verifying key for the Hybrid MACI + AHE on-chain batch-processing circuit +// (ProcessHybridMessagesOnchain_hybrid_2-1-5: stateTreeDepth=2, +// voteOptionTreeDepth=1, batchSize=5 message slots per call). +// +// This circuit exposes a SINGLE public signal `inputHash` = +// SHA256([coordPubKey.x, coordPubKey.y, batchStartHash, batchEndHash, +// currentAggCommitment, newAggCommitment, pollId, stateRoot, +// actualCount, currentNonceRoot, newNonceRoot]) mod p +// — 11 fields in total — which `compute_input_hash` reproduces on-chain, so +// the contract can verify a coordinator's LWW + homomorphic-aggregation batch +// proof against the published message hash chain, without ever decrypting a +// vote. The two nonce-root fields (`currentNonceRoot` / `newNonceRoot`) bind +// the cross-batch nonce tree evolution into the proof, preventing a malicious +// coordinator from skipping LWW across batch boundaries. `stateRoot` lets +// the circuit Merkle-authenticate each message's `voterPubKey` +// (anti-selective-censorship); `actualCount` (<= batchSize) supports partial +// batches — unused slots are padded and gated off inside the circuit (see +// `processHybridMessages.circom`'s partial-batch note) — and multi-batch +// chaining (`batchStartHash`/`currentAggCommitment`/`currentNonceRoot` each +// pick up where the previous `ProcessHybridBatch` call left off). +pub fn hybrid_process_vkey() -> Result { + let vkey = Groth16VKeyType { + vk_alpha1: "2d4d9aa7e302d9df41749d5507949d05dbea33fbb16c643b22f599a2be6df2e214bedd503c37ceb061d8ec60209fe345ce89830a19230301f076caff004d1926".to_string(), + vk_beta_2: "0967032fcbf776d1afc985f88877f182d38480a653f2decaa9794cbc3bf3060c0e187847ad4c798374d0d6732bf501847dd68bc0e071241e0213bc7fc13db7ab304cfbd1e08a704a99f5e847d93f8c3caafddec46b7a0d379da69a4d112346a71739c1b1a457a8c7313123d24d2f9192f896b7c63eea05a9d57f06547ad0cec8".to_string(), + vk_gamma_2: "198e9393920d483a7260bfb731fb5d25f1aa493335a9e71297e485b7aef312c21800deef121f1e76426a00665e5c4479674322d4f75edadd46debd5cd992f6ed090689d0585ff075ec9e99ad690c3395bc4b313370b38ef355acdadcd122975b12c85ea5db8c6deb4aab71808dcb408fe3d1e7690c43d37b4ce6cc0166fa7daa".to_string(), + vk_delta_2: "198e9393920d483a7260bfb731fb5d25f1aa493335a9e71297e485b7aef312c21800deef121f1e76426a00665e5c4479674322d4f75edadd46debd5cd992f6ed090689d0585ff075ec9e99ad690c3395bc4b313370b38ef355acdadcd122975b12c85ea5db8c6deb4aab71808dcb408fe3d1e7690c43d37b4ce6cc0166fa7daa".to_string(), + vk_ic0: "0536bb99965d0cb7d5472e75fbee6b81a8d7ff120972376ae721ee01da1b31200858dde306ffb6e727b920218afc9298b3e694ffb43383eef819fd68c575802c".to_string(), + vk_ic1: "154549fedaad184ab16b8695c498489eb59d53b9bb5b9f8af458c239d797dd1a203e3f72ab9c1c2cc7059381fb405cbf500f4046679b679132341285c36bdfab".to_string(), + }; + format_vkey(&vkey) +} + +// Verifying key for the Hybrid MACI + AHE on-chain reveal-verification circuit +// (RevealVerifyOnchain_hybrid_1-2: voteOptionTreeDepth=1 -> 5 options, +// threshold=2 committee members). +// +// This circuit exposes a SINGLE public signal `inputHash` = +// SHA256([Kc.x, Kc.y, aggCommitment, resultsCommitment, participantCommitment, pollId]) mod p +// which `compute_input_hash` reproduces on-chain (see `hybrid_reveal_input` in +// contract.rs), so `execute_reveal_hybrid_tally` can verify the committee's +// revealed results are the FAITHFUL threshold decryption of the on-chain +// aggregate ciphertext, instead of just trusting whatever is submitted. +pub fn hybrid_reveal_vkey() -> Result { + let vkey = Groth16VKeyType { + vk_alpha1: "2d4d9aa7e302d9df41749d5507949d05dbea33fbb16c643b22f599a2be6df2e214bedd503c37ceb061d8ec60209fe345ce89830a19230301f076caff004d1926".to_string(), + vk_beta_2: "0967032fcbf776d1afc985f88877f182d38480a653f2decaa9794cbc3bf3060c0e187847ad4c798374d0d6732bf501847dd68bc0e071241e0213bc7fc13db7ab304cfbd1e08a704a99f5e847d93f8c3caafddec46b7a0d379da69a4d112346a71739c1b1a457a8c7313123d24d2f9192f896b7c63eea05a9d57f06547ad0cec8".to_string(), + vk_gamma_2: "198e9393920d483a7260bfb731fb5d25f1aa493335a9e71297e485b7aef312c21800deef121f1e76426a00665e5c4479674322d4f75edadd46debd5cd992f6ed090689d0585ff075ec9e99ad690c3395bc4b313370b38ef355acdadcd122975b12c85ea5db8c6deb4aab71808dcb408fe3d1e7690c43d37b4ce6cc0166fa7daa".to_string(), + vk_delta_2: "198e9393920d483a7260bfb731fb5d25f1aa493335a9e71297e485b7aef312c21800deef121f1e76426a00665e5c4479674322d4f75edadd46debd5cd992f6ed090689d0585ff075ec9e99ad690c3395bc4b313370b38ef355acdadcd122975b12c85ea5db8c6deb4aab71808dcb408fe3d1e7690c43d37b4ce6cc0166fa7daa".to_string(), + vk_ic0: "1dbfb658feea2c7e0eb4208be95e5fc754564708a67a17b781297d371e135d050516b8944a8536b475407a7b997e7ae36afcd67aade8f3f8224ee6efb14b23f5".to_string(), + vk_ic1: "27845188b962410e7c0489e860f6d57a0d6af4be171ff7aff20819d81831dea1270197b2af3e4f7986acd0c2d8203de09d03be2e53b4d3245a8957bc10f3ebe3".to_string(), + }; + format_vkey(&vkey) +} + pub fn format_vkey(groth16_vkey: &Groth16VKeyType) -> Result { // Create a process_vkeys struct from the process_vkey in the message let groth16_vkey_formatted = Groth16VkeyStr { @@ -90,7 +172,7 @@ fn vkeys_2_1_1_5() -> Result { // Build the vkeys for the only supported production circuit: 9-4-3-125. fn vkeys_9_4_3_125() -> Result { - let groth16_process_vkey = Groth16VKeyType { + let groth16_process_vkey = Groth16VKeyType { vk_alpha1: "2d4d9aa7e302d9df41749d5507949d05dbea33fbb16c643b22f599a2be6df2e214bedd503c37ceb061d8ec60209fe345ce89830a19230301f076caff004d1926".to_string(), vk_beta_2: "0967032fcbf776d1afc985f88877f182d38480a653f2decaa9794cbc3bf3060c0e187847ad4c798374d0d6732bf501847dd68bc0e071241e0213bc7fc13db7ab304cfbd1e08a704a99f5e847d93f8c3caafddec46b7a0d379da69a4d112346a71739c1b1a457a8c7313123d24d2f9192f896b7c63eea05a9d57f06547ad0cec8".to_string(), vk_gamma_2: "198e9393920d483a7260bfb731fb5d25f1aa493335a9e71297e485b7aef312c21800deef121f1e76426a00665e5c4479674322d4f75edadd46debd5cd992f6ed090689d0585ff075ec9e99ad690c3395bc4b313370b38ef355acdadcd122975b12c85ea5db8c6deb4aab71808dcb408fe3d1e7690c43d37b4ce6cc0166fa7daa".to_string(), @@ -98,9 +180,9 @@ fn vkeys_9_4_3_125() -> Result { vk_ic0: "0f4ca042d9df017c2277a5348138072354637d7a548ed2432a62b22bd6b0793c14d190e06d249c579f2f3cb9c0d8e5223798931ac8fc645efc96739d34e3a553".to_string(), vk_ic1: "0231a17b99604840a6870d1c5ba31394ceb6683af703d083338c30bdb87b88851283ec802a494552bd00732fa61bfc619a4238a0ab6659e5c8fa91b2a5e5143b".to_string(), }; - let groth16_process_vkeys = format_vkey(&groth16_process_vkey)?; + let groth16_process_vkeys = format_vkey(&groth16_process_vkey)?; - let groth16_tally_vkey = Groth16VKeyType { + let groth16_tally_vkey = Groth16VKeyType { vk_alpha1: "2d4d9aa7e302d9df41749d5507949d05dbea33fbb16c643b22f599a2be6df2e214bedd503c37ceb061d8ec60209fe345ce89830a19230301f076caff004d1926".to_string(), vk_beta_2: "0967032fcbf776d1afc985f88877f182d38480a653f2decaa9794cbc3bf3060c0e187847ad4c798374d0d6732bf501847dd68bc0e071241e0213bc7fc13db7ab304cfbd1e08a704a99f5e847d93f8c3caafddec46b7a0d379da69a4d112346a71739c1b1a457a8c7313123d24d2f9192f896b7c63eea05a9d57f06547ad0cec8".to_string(), vk_gamma_2: "198e9393920d483a7260bfb731fb5d25f1aa493335a9e71297e485b7aef312c21800deef121f1e76426a00665e5c4479674322d4f75edadd46debd5cd992f6ed090689d0585ff075ec9e99ad690c3395bc4b313370b38ef355acdadcd122975b12c85ea5db8c6deb4aab71808dcb408fe3d1e7690c43d37b4ce6cc0166fa7daa".to_string(), @@ -108,9 +190,9 @@ fn vkeys_9_4_3_125() -> Result { vk_ic0: "1840e9af4d2094c190adf2e40468515ee760858a2a41a26aa7916bc38bd7ba9f01ea24bf4adad4d679ddf5858dbcb4587b954a6062b264288f6e191c6d697759".to_string(), vk_ic1: "1ed068e3bbc130b6a420efe22b7f26b7372686108ff1935eea7508eb814d3e2e0ab2da2095988387330108bee8d58121be18e61bb594ba9b8982a290cfc2571b".to_string(), }; - let groth16_tally_vkeys = format_vkey(&groth16_tally_vkey)?; + let groth16_tally_vkeys = format_vkey(&groth16_tally_vkey)?; - let groth16_deactivate_vkey = Groth16VKeyType { + let groth16_deactivate_vkey = Groth16VKeyType { vk_alpha1: "2d4d9aa7e302d9df41749d5507949d05dbea33fbb16c643b22f599a2be6df2e214bedd503c37ceb061d8ec60209fe345ce89830a19230301f076caff004d1926".to_string(), vk_beta_2: "0967032fcbf776d1afc985f88877f182d38480a653f2decaa9794cbc3bf3060c0e187847ad4c798374d0d6732bf501847dd68bc0e071241e0213bc7fc13db7ab304cfbd1e08a704a99f5e847d93f8c3caafddec46b7a0d379da69a4d112346a71739c1b1a457a8c7313123d24d2f9192f896b7c63eea05a9d57f06547ad0cec8".to_string(), vk_gamma_2: "198e9393920d483a7260bfb731fb5d25f1aa493335a9e71297e485b7aef312c21800deef121f1e76426a00665e5c4479674322d4f75edadd46debd5cd992f6ed090689d0585ff075ec9e99ad690c3395bc4b313370b38ef355acdadcd122975b12c85ea5db8c6deb4aab71808dcb408fe3d1e7690c43d37b4ce6cc0166fa7daa".to_string(), @@ -118,9 +200,9 @@ fn vkeys_9_4_3_125() -> Result { vk_ic0: "2341ef299bd50b06e3885f2c95e6e043ac4a9b30263fb5b212b9c5ee443ab28d17b0dd121483ee7a280fe8a82384e8bbd0e52c32be97148dfaecb953ee0b34fe".to_string(), vk_ic1: "04c3cdc1e32f4e6eae21adb419ed037dcd5daa9012bc9fe31e4ebedba31c101b00f8483d686d64d0eb7d1c3278b4409f4717dea169970f62505a52e3c08b6d4d".to_string(), }; - let groth16_deactivate_vkeys = format_vkey(&groth16_deactivate_vkey)?; + let groth16_deactivate_vkeys = format_vkey(&groth16_deactivate_vkey)?; - let groth16_add_new_key_vkey = Groth16VKeyType { + let groth16_add_new_key_vkey = Groth16VKeyType { vk_alpha1: "2d4d9aa7e302d9df41749d5507949d05dbea33fbb16c643b22f599a2be6df2e214bedd503c37ceb061d8ec60209fe345ce89830a19230301f076caff004d1926".to_string(), vk_beta_2: "0967032fcbf776d1afc985f88877f182d38480a653f2decaa9794cbc3bf3060c0e187847ad4c798374d0d6732bf501847dd68bc0e071241e0213bc7fc13db7ab304cfbd1e08a704a99f5e847d93f8c3caafddec46b7a0d379da69a4d112346a71739c1b1a457a8c7313123d24d2f9192f896b7c63eea05a9d57f06547ad0cec8".to_string(), vk_gamma_2: "198e9393920d483a7260bfb731fb5d25f1aa493335a9e71297e485b7aef312c21800deef121f1e76426a00665e5c4479674322d4f75edadd46debd5cd992f6ed090689d0585ff075ec9e99ad690c3395bc4b313370b38ef355acdadcd122975b12c85ea5db8c6deb4aab71808dcb408fe3d1e7690c43d37b4ce6cc0166fa7daa".to_string(), @@ -128,7 +210,7 @@ fn vkeys_9_4_3_125() -> Result { vk_ic0: "0eb39e5aa053d6676afa84d88c23996669b18eda44eb6d97420176ff55c276290ae7df9c85d77b9aef313093fdeada3c53cd1b0cfd7ed04460a719cfff4e5925".to_string(), vk_ic1: "200467c396f31ec6b2f3324d5fa38a28e18ce24c1a92742486553e2375204fc82e4feff4af5e2c2ac4acd05af4a64d161edee15e1feef61d2f4977860643c94b".to_string(), }; - let groth16_add_new_key_vkeys = format_vkey(&groth16_add_new_key_vkey)?; + let groth16_add_new_key_vkeys = format_vkey(&groth16_add_new_key_vkey)?; Ok(VkeyParams { process_vkey: groth16_process_vkeys, diff --git a/contracts/amaci/src/contract.rs b/contracts/amaci/src/contract.rs index 662f22d..871ac3a 100644 --- a/contracts/amaci/src/contract.rs +++ b/contracts/amaci/src/contract.rs @@ -2,27 +2,30 @@ use crate::circuit_params::match_vkeys; use crate::error::ContractError; use crate::groth16_parser::{parse_groth16_proof, parse_groth16_vkey}; use crate::msg::{ - DelayConfigResponse, ExecuteMsg, FeeConfigResponse, Groth16ProofType, InstantiateMsg, - InstantiationData, QueryMsg, RegistrationConfigInfo, RegistrationConfigUpdate, - RegistrationModeConfig, RegistrationStatus, TallyDelayInfo, VkeysResponse, WhitelistBaseConfig, + DelayConfigResponse, ExecuteMsg, FeeConfigResponse, Groth16ProofType, HybridAggResponse, + HybridKcConfirmationEntry, InstantiateMsg, InstantiationData, QueryMsg, RegistrationConfigInfo, + RegistrationConfigUpdate, RegistrationModeConfig, RegistrationStatus, TallyDelayInfo, + VkeysResponse, WhitelistBaseConfig, }; use crate::state::{ Admin, DelayConfig, DelayRecord, DelayRecords, DelayType, FeeConfig, Groth16ProofStr, - MaciParameters, MessageData, OracleWhitelistUser, Period, PeriodStatus, PubKey, - QuinaryTreeRoot, RegistrationMode, RoundInfo, StateLeaf, VoiceCreditMode, VotingTime, - Whitelist, WhitelistConfig, ADMIN, CERTSYSTEM, CIRCUITTYPE, COORDINATORHASH, - CREATE_ROUND_WINDOW, CURRENT_DEACTIVATE_COMMITMENT, CURRENT_STATE_COMMITMENT, - CURRENT_TALLY_COMMITMENT, DEACTIVATE_ENABLED, DELAY_CONFIG, DELAY_RECORDS, - DMSG_CHAIN_LENGTH, DMSG_HASHES, DNODES, FEE_CONFIG, FEE_DENOM, FEE_RECIPIENT, - FIRST_DMSG_TIMESTAMP, GROTH16_DEACTIVATE_VKEYS, GROTH16_NEWKEY_VKEYS, GROTH16_PROCESS_VKEYS, - GROTH16_TALLY_VKEYS, LEAF_IDX_0, MACIPARAMETERS, MACI_OPERATOR, - MAX_LEAVES_COUNT, MAX_VOTE_OPTIONS, MSG_CHAIN_LENGTH, MSG_HASHES, NODES, NULLIFIERS, - NUMSIGNUPS, ORACLE_WHITELIST, PENALTY_RATE, PERIOD, POLL_ID, PRE_DEACTIVATE_COORDINATOR_HASH, - PRE_DEACTIVATE_ROOT, PROCESSED_DMSG_COUNT, PROCESSED_MSG_COUNT, PROCESSED_USER_COUNT, QTR_LIB, - REGISTRATION_MODE, RESULT, ROUNDINFO, SIGNUPED, STATE_ROOT_BY_DMSG, - TALLY_DELAY_MAX_HOURS, TALLY_DELAY_MULTIPLIER, TALLY_TIMEOUT, TALLY_TIMEOUT_EXTRA_SECONDS, - TOTAL_RESULT, USED_ENC_PUB_KEYS, VOICECREDITBALANCE, VOICE_CREDIT_AMOUNT, VOICE_CREDIT_MODE, - VOTEOPTIONMAP, VOTINGTIME, WHITELIST, ZEROS, ZEROS_H10, + HybridCiphertext, HybridCommitteeConfig, HybridPublishedMessage, HybridTally, MaciParameters, + MessageData, OracleWhitelistUser, Period, PeriodStatus, PubKey, QuinaryTreeRoot, + RegistrationMode, RoundInfo, StateLeaf, VoiceCreditMode, VotingTime, Whitelist, + WhitelistConfig, ADMIN, CERTSYSTEM, CIRCUITTYPE, COORDINATORHASH, CREATE_ROUND_WINDOW, + CURRENT_DEACTIVATE_COMMITMENT, CURRENT_STATE_COMMITMENT, CURRENT_TALLY_COMMITMENT, + DEACTIVATE_ENABLED, DELAY_CONFIG, DELAY_RECORDS, DMSG_CHAIN_LENGTH, DMSG_HASHES, DNODES, + FEE_CONFIG, FEE_DENOM, FEE_RECIPIENT, FIRST_DMSG_TIMESTAMP, GROTH16_DEACTIVATE_VKEYS, + GROTH16_NEWKEY_VKEYS, GROTH16_PROCESS_VKEYS, GROTH16_TALLY_VKEYS, HYBRID_AGG_C1, HYBRID_AGG_C2, + HYBRID_COMMITTEE, HYBRID_KC, HYBRID_KC_CONFIRMATIONS, HYBRID_MESSAGES, HYBRID_MSG_CHAIN_LENGTH, + HYBRID_MSG_HASHES, HYBRID_NONCE_STATE_ROOT, HYBRID_PROCESSED_COUNT, HYBRID_TALLY, LEAF_IDX_0, MACIPARAMETERS, + MACI_OPERATOR, MAX_LEAVES_COUNT, MAX_VOTE_OPTIONS, MSG_CHAIN_LENGTH, MSG_HASHES, NODES, + NULLIFIERS, NUMSIGNUPS, ORACLE_WHITELIST, PENALTY_RATE, PERIOD, POLL_ID, + PRE_DEACTIVATE_COORDINATOR_HASH, PRE_DEACTIVATE_ROOT, PROCESSED_DMSG_COUNT, + PROCESSED_MSG_COUNT, PROCESSED_USER_COUNT, QTR_LIB, REGISTRATION_MODE, RESULT, ROUNDINFO, + SIGNUPED, STATE_ROOT_BY_DMSG, TALLY_DELAY_MAX_HOURS, TALLY_DELAY_MULTIPLIER, TALLY_TIMEOUT, + TALLY_TIMEOUT_EXTRA_SECONDS, TOTAL_RESULT, USED_ENC_PUB_KEYS, VOICECREDITBALANCE, + VOICE_CREDIT_AMOUNT, VOICE_CREDIT_MODE, VOTEOPTIONMAP, VOTINGTIME, WHITELIST, ZEROS, ZEROS_H10, }; use cosmwasm_schema::cw_serde; #[cfg(not(feature = "library"))] @@ -102,6 +105,50 @@ fn run_groth16_verify( Ok(()) } +/// Hybrid MACI + AHE: verify a voter's on-chain ballotValidity proof. +/// +/// Rebuilds the single SHA256 input hash from the committed public values in the +/// exact order the circuit folds them, then runs the Groth16 verifier with the +/// hybrid ballot verifying key. Returns Ok(true) iff the proof is valid. Any +/// verification/structural failure maps to Ok(false) so this is query-safe. +/// +/// `state_idx`/`pub_key` are deliberately NOT parameters here any more: the +/// circuit keeps them private and only outputs `nullifier` (see +/// `ballotValidity.circom`'s anonymity note), so the contract never learns +/// which registered voter a ballot belongs to. +fn verify_hybrid_ballot( + kc: &[Uint256; 2], + state_root: &Uint256, + coord_pub_key: &[Uint256; 2], + poll_id: &Uint256, + routing_commitment: &Uint256, + ahe_commitment: &Uint256, + nullifier: &Uint256, + proof: &Groth16ProofType, +) -> Result { + let input = [ + kc[0], + kc[1], + *state_root, + coord_pub_key[0], + coord_pub_key[1], + *poll_id, + *routing_commitment, + *ahe_commitment, + *nullifier, + ]; + let input_hash = compute_input_hash(&input); + + let vkey_str = crate::circuit_params::hybrid_ballot_vkey()?; + let vkey = parse_groth16_vkey::(vkey_str)?; + let pvk = prepare_verifying_key(&vkey); + + let proof_str = decode_groth16_proof(proof)?; + let pof = parse_groth16_proof::(proof_str)?; + + Ok(groth16_verify(&pvk, &pof, &[uint256_to_field(&input_hash)?]).unwrap_or(false)) +} + /// Convert a contract address to Uint256 format /// This function takes the address bytes and converts them to a Uint256 fn address_to_uint256(address: &Addr) -> Uint256 { @@ -252,6 +299,15 @@ pub fn instantiate( max_allowed: vote_option_max_amount, }); } + // The hybrid circuits (ProcessHybridMessages, RevealVerify) hard-code + // HYBRID_M vote-option slots. A mismatch would cause the ZK proofs to be + // uncomputable at processing time, so we reject it eagerly at instantiate. + if msg.vote_option_map.len() != HYBRID_M { + return Err(ContractError::HybridVoteOptionMapMismatch { + got: msg.vote_option_map.len(), + expected: HYBRID_M, + }); + } if msg.voting_time.end_time < env.block.time { return Err(ContractError::WrongTimeSet {}); @@ -543,6 +599,62 @@ pub fn instantiate( MSG_CHAIN_LENGTH.save(deps.storage, &Uint256::from_u128(0u128))?; PROCESSED_MSG_COUNT.save(deps.storage, &Uint256::from_u128(0u128))?; CURRENT_TALLY_COMMITMENT.save(deps.storage, &Uint256::from_u128(0u128))?; + + // Hybrid MACI + AHE on-chain flow: independent hash chain/state, genesis hash + // = 0 (same convention as the classic MSG_HASHES chain above). + HYBRID_MSG_HASHES.save( + deps.storage, + Uint256::from_u128(0u128).to_be_bytes().to_vec(), + &Uint256::from_u128(0u128), + )?; + HYBRID_MSG_CHAIN_LENGTH.save(deps.storage, &Uint256::from_u128(0u128))?; + HYBRID_PROCESSED_COUNT.save(deps.storage, &Uint256::from_u128(0u128))?; + + // Optional hybrid committee roster: if provided, ConfirmHybridKc becomes + // the only way to finalize Kc (SetHybridKc is disabled for this round). + if let Some(committee) = &msg.hybrid_committee { + if committee.members.is_empty() { + return Err(ContractError::HybridCommitteeConfigInvalid { + reason: "members list must not be empty".to_string(), + }); + } + if committee.threshold == 0 || (committee.threshold as usize) > committee.members.len() { + return Err(ContractError::HybridCommitteeConfigInvalid { + reason: format!( + "threshold must be between 1 and {} (member count), got {}", + committee.members.len(), + committee.threshold + ), + }); + } + // The compiled RevealVerifyOnchain_hybrid_1-2 circuit hard-codes + // exactly HYBRID_REVEAL_THRESHOLD partial-decryption shares per + // reveal proof. If committee.threshold (the number of members who + // must confirm Kc before it's finalized) doesn't match, either fewer + // members confirm Kc than the circuit needs at reveal time (reveal + // becomes uncompletable), or more members confirm than the circuit + // consumes (governance intent and cryptographic enforcement diverge). + if (committee.threshold as usize) != HYBRID_REVEAL_THRESHOLD { + return Err(ContractError::HybridCommitteeThresholdMismatch { + committee_threshold: committee.threshold, + circuit_threshold: HYBRID_REVEAL_THRESHOLD, + }); + } + let mut seen_addrs = std::collections::BTreeSet::new(); + for member in &committee.members { + if !is_on_babyjubjub_curve(member.pubkey.x, member.pubkey.y) { + return Err(ContractError::HybridCommitteeConfigInvalid { + reason: format!("member {} has an invalid pubkey", member.addr), + }); + } + if !seen_addrs.insert(member.addr.clone()) { + return Err(ContractError::HybridCommitteeConfigInvalid { + reason: format!("duplicate member address {}", member.addr), + }); + } + } + HYBRID_COMMITTEE.save(deps.storage, committee)?; + } PROCESSED_USER_COUNT.save(deps.storage, &Uint256::from_u128(0u128))?; NUMSIGNUPS.save(deps.storage, &Uint256::from_u128(0u128))?; MAX_VOTE_OPTIONS.save( @@ -847,6 +959,60 @@ pub fn execute( execute_stop_tallying_period(deps, env, info, results, salt) } ExecuteMsg::Claim {} => execute_claim(deps, env, info), + ExecuteMsg::SetHybridKc { kc } => execute_set_hybrid_kc(deps, env, info, kc), + ExecuteMsg::ConfirmHybridKc { kc } => execute_confirm_hybrid_kc(deps, env, info, kc), + ExecuteMsg::PublishHybridMessage { + routing, + enc_pub_key, + ciphertext, + coord_pub_key, + nullifier, + ballot_proof, + } => execute_publish_hybrid_message( + deps, + env, + info, + routing, + enc_pub_key, + ciphertext, + coord_pub_key, + nullifier, + ballot_proof, + ), + ExecuteMsg::ProcessHybridBatch { + coord_pub_key, + actual_count, + new_agg_c1, + new_agg_c2, + new_nonce_state_root, + groth16_proof, + } => execute_process_hybrid_batch( + deps, + env, + info, + coord_pub_key, + actual_count, + new_agg_c1, + new_agg_c2, + new_nonce_state_root, + groth16_proof, + ), + ExecuteMsg::RevealHybridTally { + results, + salt, + participant_pub_keys, + participant_indices, + reveal_proof, + } => execute_reveal_hybrid_tally( + deps, + env, + info, + results, + salt, + participant_pub_keys, + participant_indices, + reveal_proof, + ), } } @@ -1510,6 +1676,679 @@ pub fn execute_publish_message( Ok(Response::new().add_attributes(attributes)) } +// ============================================ +// Hybrid MACI + AHE on-chain flow +// ============================================ +// +// Demo scope (frozen, see maci/contracts/amaci/DEPLOYMENT_TESTNET.md and the +// hybrid_ahe plan): fixed per-call batch shape against the compiled +// ProcessHybridMessagesOnchain_hybrid_2-1-5 circuit (voteOptionTreeDepth=1 -> +// HYBRID_M=5 options, batchSize=5 message slots per call). A round's total +// published message count need not be a multiple of HYBRID_BATCH_SIZE_U128 — +// `ProcessHybridBatch` supports partial batches (the circuit pads unused +// slots, gated off by `actual_count`) and multi-batch chaining (call it +// repeatedly; each call picks up exactly where the previous one's +// `HYBRID_PROCESSED_COUNT`/aggregate left off). +// +// Cross-batch last-write-wins (LWW) is fully implemented via a persistent +// *nonce tree* and reverse processing order: +// +// • The contract processes batches newest-first (chain-tail → chain-head). +// Within each batch the circuit also processes messages newest-first. +// • An independent quinary Merkle tree ("nonce tree", depth 2) tracks each +// voter's claimed nonce across calls. Its root is stored in +// `HYBRID_NONCE_STATE_ROOT` and handed to each successive batch as +// `currentNonceRoot`; the circuit's output `newNonceRoot` is written back. +// • Rule: a message for stateIdx is a *survivor* iff +// nonce_tree.leaf(stateIdx) + 1 == cmdNonce +// When processing a voter's first (newest) message the leaf is 0 and +// cmdNonce is 1 → it passes and the leaf advances to 1. Any older +// message for the same voter (same cmdNonce=1, leaf already 1) fails +// 1+1 ≠ 1 and is discarded — even if it falls in a different batch. +// +// See `processHybridMessages.circom` and `exportHybridMultiBatchFixture.ts` +// for the cross-batch LWW test scenario. +// +// PRIVACY NOTE: The coordinator constructs the ZK proof and therefore +// *decrypts the routing envelope* for each message (stateIdx, nonce, +// aheCommitment, EdDSA signature). The coordinator can learn which +// stateIdx (i.e. which registered voter slot) each message came from +// during the Processing phase. The *vote content* (optionIdx, weight) is +// encrypted under Kc (the committee key) and remains sealed from the +// coordinator at all times — this is the B-line guarantee. Observers +// on-chain see only the nullifier at publish time, so voter identity is +// hidden from the chain and from third parties. +const HYBRID_M: usize = 5; +const HYBRID_BATCH_SIZE_U128: u128 = 5; +// Fixed by the compiled RevealVerifyOnchain_hybrid_1-2 circuit: exactly this +// many committee partial-decryption shares are combined per reveal proof. +// A deployment's `hybrid_committee.threshold` must match this for +// RevealHybridTally proofs to be constructible. +const HYBRID_REVEAL_THRESHOLD: usize = 2; + +fn hybrid_batch_size() -> Uint256 { + Uint256::from_u128(HYBRID_BATCH_SIZE_U128) +} + +// The homomorphic-aggregate identity: M copies of the BabyJubjub identity point +// (0, 1), i.e. "nothing aggregated yet". Matches the circuit's `AHE_IDENTITY`. +fn hybrid_identity_agg() -> Vec<[Uint256; 2]> { + vec![[Uint256::zero(), Uint256::from_u128(1u128)]; HYBRID_M] +} + +// Fold an aggregate ciphertext vector into ONE Poseidon commitment, exactly the +// way the circuit's `AheCommit(M)` does: flatten each option as +// [c1.x, c1.y, c2.x, c2.y], then acc_0 = 0; acc_{k+1} = hash2([acc_k, elem_k]). +fn hybrid_ahe_commit(c1: &[[Uint256; 2]], c2: &[[Uint256; 2]]) -> Uint256 { + let mut acc = Uint256::zero(); + for i in 0..c1.len() { + acc = hash2([acc, c1[i][0]]); + acc = hash2([acc, c1[i][1]]); + acc = hash2([acc, c2[i][0]]); + acc = hash2([acc, c2[i][1]]); + } + acc +} + +// Fold revealed results + salt into ONE Poseidon commitment, exactly the way +// RevealVerifyOnchain's circuit does: acc_0 = 0; acc_{k+1} = hash2([acc_k, elem_k]) +// over results[0..M-1], then salt. +fn hybrid_results_commit(results: &[Uint256], salt: Uint256) -> Uint256 { + let mut acc = Uint256::zero(); + for r in results { + acc = hash2([acc, *r]); + } + hash2([acc, salt]) +} + +// Fold the reveal proof's claimed (pubKey.x, pubKey.y, index) per participant +// into ONE Poseidon commitment, matching the circuit exactly. +fn hybrid_participant_commit(pub_keys: &[[Uint256; 2]], indices: &[Uint256]) -> Uint256 { + let mut acc = Uint256::zero(); + for i in 0..pub_keys.len() { + acc = hash2([acc, pub_keys[i][0]]); + acc = hash2([acc, pub_keys[i][1]]); + acc = hash2([acc, indices[i]]); + } + acc +} + +// Admin-only, one-time setup: bind the threshold committee's AHE public key +// (Kc) into contract storage. Must happen before any PublishHybridMessage, so +// the contract has a fixed value to check every submitted ballotValidity +// proof against (Kc is otherwise never persisted on-chain elsewhere). +pub fn execute_set_hybrid_kc( + deps: DepsMut, + _env: Env, + info: MessageInfo, + kc: [Uint256; 2], +) -> Result { + if !is_admin(deps.as_ref(), info.sender.as_ref())? { + return Err(ContractError::Unauthorized {}); + } + // If a committee roster was configured at Instantiate, Kc can only be + // finalized via threshold-many ConfirmHybridKc confirmations -- a single + // admin unilaterally setting Kc would defeat the whole point of having a + // committee (any one of them, or a compromised admin key, could pick a + // Kc whose corresponding private shares only they control). + if HYBRID_COMMITTEE.may_load(deps.storage)?.is_some() { + return Err(ContractError::HybridCommitteeConfirmationRequired {}); + } + if HYBRID_KC.may_load(deps.storage)?.is_some() { + return Err(ContractError::HybridKcAlreadySet {}); + } + if !is_on_babyjubjub_curve(kc[0], kc[1]) { + return Err(ContractError::InvalidPubKey {}); + } + + HYBRID_KC.save(deps.storage, &kc)?; + + Ok(Response::new().add_attributes(vec![ + attr("action", "set_hybrid_kc"), + attr("kc", format!("{},{}", kc[0], kc[1])), + ])) +} + +// Committee-confirmed alternative to execute_set_hybrid_kc. Each member's own +// on-chain wallet address is their authentication -- the tx signature that +// got this message here already IS a cryptographic signature over `kc` +// (produced by that member's key, verified by the chain), so there is no +// need for a separate application-level (e.g. BabyJubjub EdDSA) signature +// field. Kc is finalized the moment `threshold` distinct members have all +// confirmed the SAME value. +pub fn execute_confirm_hybrid_kc( + deps: DepsMut, + _env: Env, + info: MessageInfo, + kc: [Uint256; 2], +) -> Result { + let committee = HYBRID_COMMITTEE + .may_load(deps.storage)? + .ok_or(ContractError::HybridCommitteeNotConfigured {})?; + + if !committee.members.iter().any(|m| m.addr == info.sender) { + return Err(ContractError::HybridNotCommitteeMember {}); + } + if HYBRID_KC.may_load(deps.storage)?.is_some() { + return Err(ContractError::HybridKcAlreadySet {}); + } + if !is_on_babyjubjub_curve(kc[0], kc[1]) { + return Err(ContractError::InvalidPubKey {}); + } + + HYBRID_KC_CONFIRMATIONS.save(deps.storage, info.sender.clone(), &kc)?; + + let confirmed_count = committee + .members + .iter() + .filter(|m| { + HYBRID_KC_CONFIRMATIONS + .may_load(deps.storage, m.addr.clone()) + .ok() + .flatten() + == Some(kc) + }) + .count(); + + let finalized = confirmed_count >= committee.threshold as usize; + if finalized { + HYBRID_KC.save(deps.storage, &kc)?; + } + + Ok(Response::new().add_attributes(vec![ + attr("action", "confirm_hybrid_kc"), + attr("sender", info.sender.to_string()), + attr("kc", format!("{},{}", kc[0], kc[1])), + attr("confirmed_count", confirmed_count.to_string()), + attr("threshold", committee.threshold.to_string()), + attr("finalized", finalized.to_string()), + ])) +} + +// Publish one Hybrid routing envelope + its separately-published AHE ballot +// ciphertext. The envelope is format-identical to classic MessageData, so it +// reuses the same hash-chain algorithm (`hash_message_and_enc_pub_key`) — just +// stored in its own hybrid chain so it never interferes with the classic flow. +// Mirrors execute_publish_message's fee/voting-time checks; the vote content +// inside `ciphertext` (optionIdx/weight) is never inspected by the contract +// (or the coordinator) — only its *validity* is, via `ballot_proof`. +// +// Before this check existed, a voter could publish an arbitrary, over-budget +// ciphertext: the coordinator's ProcessHybridBatch proof only attests to +// faithful decryption/aggregation of WHATEVER was published, it never +// re-derives each voter's real voice-credit balance. `ballot_proof` (a real +// Groth16 proof of `BallotValidityOnchain`) closes that gap: it Merkle- +// authenticates `voice_credit_balance` for `(pub_key, state_idx)` against the +// contract's OWN, live state root, and proves one-hot + `weight^2 <= +// voice_credit_balance` — all without revealing optionIdx/weight to anyone, +// including this contract. +pub fn execute_publish_hybrid_message( + deps: DepsMut, + env: Env, + info: MessageInfo, + routing: MessageData, + enc_pub_key: PubKey, + ciphertext: HybridCiphertext, + coord_pub_key: [Uint256; 2], + nullifier: Uint256, + ballot_proof: Groth16ProofType, +) -> Result { + let voting_time = VOTINGTIME.load(deps.storage)?; + check_voting_time(env, voting_time)?; + + // No artificial cap on total published messages any more: `ProcessHybridBatch` + // now supports partial batches + multi-batch chaining (see its doc comment), + // so it can consume any number of published messages across however many + // calls it takes — same as classic PublishMessage/ProcessMessage, where the + // message fee is the only economic limiter. + let msg_chain_length = HYBRID_MSG_CHAIN_LENGTH.load(deps.storage)?; + + if ciphertext.c1.len() != HYBRID_M || ciphertext.c2.len() != HYBRID_M { + return Err(ContractError::HybridAggLengthMismatch { + expected: HYBRID_M, + actual: ciphertext.c1.len().max(ciphertext.c2.len()), + }); + } + + if !is_on_babyjubjub_curve(enc_pub_key.x, enc_pub_key.y) { + return Err(ContractError::InvalidEncPubKey {}); + } + + // Every published ciphertext point feeds straight into homomorphic + // aggregation (point addition); an off-curve point would let a malicious + // publisher corrupt the aggregate (or crash aggregation) without ever + // touching the Groth16-verified ballot_proof, since ballot_proof only + // attests to the *plaintext* being one-hot/in-budget, not that the + // *ciphertext points themselves* are valid curve points. + for i in 0..ciphertext.c1.len() { + if !is_on_babyjubjub_curve(ciphertext.c1[i][0], ciphertext.c1[i][1]) + || !is_on_babyjubjub_curve(ciphertext.c2[i][0], ciphertext.c2[i][1]) + { + return Err(ContractError::HybridInvalidCiphertextPoint {}); + } + } + + // The coordinator identity the proof used to decrypt-check `routing` + // in-circuit is bound into inputHash but not persisted anywhere else on + // -chain; verify the caller-supplied key matches the round's registered + // coordinator (same pattern as `execute_process_hybrid_batch`) before + // trusting it — otherwise a prover could bind the routing-consistency + // check to a fake coordinator key it controls and the check would be + // vacuous. + let coordinator_hash = COORDINATORHASH.load(deps.storage)?; + if hash2(coord_pub_key) != coordinator_hash { + return Err(ContractError::HybridCoordinatorMismatch {}); + } + + // NOTE (intentionally NOT rejecting replayed nullifiers): unlike classic + // MACI's `USED_ENC_PUB_KEYS`, `nullifier = Poseidon(stateIdx, pubKey, + // pollId)` is scoped to (voter, round) only, NOT to message content — + // see `hybridNullifier` in the SDK. A voter changing their vote (the + // whole point of MACI-style revotable ballots, resolved by LWW in + // `ProcessHybridMessages`) legitimately resubmits the SAME nullifier + // with different routing/ciphertext content every time. Rejecting + // repeat nullifiers here would silently disable revoting. Exact + // byte-for-byte replay of an already-accepted submission is a much + // smaller concern (bounded by the message fee, doesn't change the + // tally) and is intentionally out of scope for this demo — see + // hybrid_maci_ahe_fix_plan.md's own equivalent deferral for classic + // MACI's replay surface. + + // Cheap fee check BEFORE the expensive Groth16 verification below: an + // attacker who holds a valid ballot proof but sends the wrong fee would + // otherwise force the chain to pay for a full proof verification (tens + // of thousands of constraints) before being rejected. + let message_fee = FEE_CONFIG.load(deps.storage)?.message_fee; + let payment = check_fee_payment(&info, message_fee)?; + + // Verify the ballot is legal — one-hot, within the voter's real, + // Merkle-authenticated voice-credit budget, AND bound to THIS exact + // routing envelope — WITHOUT decrypting it or learning which voter it + // belongs to. `kc`, the state root and `poll_id` are read from contract + // state (never caller-supplied) so this check cannot be spoofed by + // claiming a different committee key, round, or a forged tree. + // `routing_commitment` folds `routing`/`enc_pub_key` the SAME way the + // hash chain below does (just with prevHash = 0, since this is a + // standalone per-message identity commitment, not a chain position). + let kc = HYBRID_KC + .may_load(deps.storage)? + .ok_or(ContractError::HybridKcNotSet {})?; + let state_root = state_root(deps.as_ref())?; + let poll_id = Uint256::from(POLL_ID.load(deps.storage)?); + let routing_commitment = hash_message_and_enc_pub_key(&routing, &enc_pub_key, Uint256::zero()); + let ahe_commitment = hybrid_ahe_commit(&ciphertext.c1, &ciphertext.c2); + let ballot_ok = verify_hybrid_ballot( + &kc, + &state_root, + &coord_pub_key, + &poll_id, + &routing_commitment, + &ahe_commitment, + &nullifier, + &ballot_proof, + )?; + if !ballot_ok { + return Err(ContractError::InvalidProof { + step: "ballot_validity".to_string(), + }); + } + + let old_hash = HYBRID_MSG_HASHES.load(deps.storage, msg_chain_length.to_be_bytes().to_vec())?; + let new_hash = hash_message_and_enc_pub_key(&routing, &enc_pub_key, old_hash); + let new_chain_length = msg_chain_length + .checked_add(Uint256::from_u128(1u128)) + .map_err(|_| ContractError::HybridCounterOverflow {})?; + + HYBRID_MSG_HASHES.save( + deps.storage, + new_chain_length.to_be_bytes().to_vec(), + &new_hash, + )?; + HYBRID_MESSAGES.save( + deps.storage, + new_chain_length.to_be_bytes().to_vec(), + &HybridPublishedMessage { + routing: routing.clone(), + enc_pub_key: enc_pub_key.clone(), + ciphertext, + }, + )?; + HYBRID_MSG_CHAIN_LENGTH.save(deps.storage, &new_chain_length)?; + + Ok(Response::new().add_attributes(vec![ + attr("action", "publish_hybrid_message"), + attr("index", new_chain_length.to_string()), + attr("fee_paid", format!("{}{}", payment, FEE_DENOM)), + attr( + "enc_pub_key", + format!("{},{}", enc_pub_key.x, enc_pub_key.y), + ), + attr("nullifier", nullifier.to_string()), + ])) +} + +// Coordinator submits the batch process proof: one Groth16 proof covering +// routing decryption + last-write-wins + homomorphic aggregation for the WHOLE +// (demo-scoped, single) batch, without ever revealing a single vote. The +// contract re-derives the exact inputHash the circuit folded its public values +// into and lets the Groth16 verifier be the sole access-control gate — same +// pattern as classic ProcessMessage (permissionless at the message-sender +// level; only a holder of the coordinator's private key can produce a valid +// proof, and `coord_pub_key` is checked against the round's registered +// coordinator before it is trusted). +pub fn execute_process_hybrid_batch( + deps: DepsMut, + _env: Env, + _info: MessageInfo, + coord_pub_key: [Uint256; 2], + actual_count: Uint256, + new_agg_c1: Vec<[Uint256; 2]>, + new_agg_c2: Vec<[Uint256; 2]>, + new_nonce_state_root: Uint256, + groth16_proof: Groth16ProofType, +) -> Result { + // Same lifecycle gate as classic ProcessMessage: only after voting has ended + // and StartProcessPeriod has been called. + require_period_status(deps.as_ref(), PeriodStatus::Processing)?; + + let processed_count = HYBRID_PROCESSED_COUNT + .may_load(deps.storage)? + .unwrap_or_else(Uint256::zero); + let msg_chain_length = HYBRID_MSG_CHAIN_LENGTH.load(deps.storage)?; + + if processed_count >= msg_chain_length { + return Err(ContractError::HybridBatchAlreadyProcessed {}); + } + + // Deterministic batch shape: this call must consume exactly + // min(remaining unprocessed messages, HYBRID_BATCH_SIZE_U128), so there is + // exactly one valid `actual_count` at any point in a processing run. + let remaining = msg_chain_length - processed_count; + let expected_count = if remaining < hybrid_batch_size() { + remaining + } else { + hybrid_batch_size() + }; + if actual_count != expected_count { + return Err(ContractError::HybridBatchNotReady { + expected: expected_count, + actual: actual_count, + }); + } + + if new_agg_c1.len() != HYBRID_M || new_agg_c2.len() != HYBRID_M { + return Err(ContractError::HybridAggLengthMismatch { + expected: HYBRID_M, + actual: new_agg_c1.len().max(new_agg_c2.len()), + }); + } + + // The new aggregate is persisted verbatim and folded into the NEXT + // ProcessHybridBatch's `current_agg_commitment` (and ultimately into + // RevealHybridTally's decryption input) — an off-curve point here would + // poison every downstream step, and the Groth16 proof alone does not rule + // this out (it only attests the aggregation *arithmetic* was followed + // faithfully starting from whatever points were supplied). + for i in 0..new_agg_c1.len() { + if !is_on_babyjubjub_curve(new_agg_c1[i][0], new_agg_c1[i][1]) + || !is_on_babyjubjub_curve(new_agg_c2[i][0], new_agg_c2[i][1]) + { + return Err(ContractError::HybridInvalidAggregatePoint {}); + } + } + + // The coordinator identity is bound into inputHash but not persisted anywhere + // else on-chain (classic MACI only persists COORDINATORHASH); verify the + // caller-supplied key matches it before trusting it in the proof check. + let coordinator_hash = COORDINATORHASH.load(deps.storage)?; + if hash2(coord_pub_key) != coordinator_hash { + return Err(ContractError::HybridCoordinatorMismatch {}); + } + + let new_processed_count = processed_count + .checked_add(actual_count) + .map_err(|_| ContractError::HybridCounterOverflow {})?; + + // Reverse-order batch selection (matching classic aMACI's newest-first + // processing): the FIRST call processes the LAST `actual_count` messages + // (highest chain indices), the NEXT call processes the batch just before + // those, and so on — mirrors execute_process_message's chain-tail-first + // batch enumeration. + // + // batch_end_index = msg_chain_length - processed_count + // batch_start_index = batch_end_index - actual_count + // + // `processed_count` here counts how many messages have been processed in + // total across all previous calls (monotonically increasing), so the + // remaining unprocessed messages are the OLDEST ones (lowest indices) and + // the current batch peels off the freshest unprocessed slice. + let batch_end_index = msg_chain_length - processed_count; + let batch_start_index = batch_end_index - actual_count; + let batch_start_hash = + HYBRID_MSG_HASHES.load(deps.storage, batch_start_index.to_be_bytes().to_vec())?; + let batch_end_hash = + HYBRID_MSG_HASHES.load(deps.storage, batch_end_index.to_be_bytes().to_vec())?; + + let current_agg_c1 = HYBRID_AGG_C1 + .may_load(deps.storage)? + .unwrap_or_else(hybrid_identity_agg); + let current_agg_c2 = HYBRID_AGG_C2 + .may_load(deps.storage)? + .unwrap_or_else(hybrid_identity_agg); + let current_agg_commitment = hybrid_ahe_commit(¤t_agg_c1, ¤t_agg_c2); + let new_agg_commitment = hybrid_ahe_commit(&new_agg_c1, &new_agg_c2); + + let poll_id = Uint256::from(POLL_ID.load(deps.storage)?); + // Same live state root every BallotValidityOnchain proof this round was + // checked against (frozen once voting ends, since Processing implies the + // publish window is closed) — binds `voterPubKey` Merkle authentication + // inside the circuit to the SAME root, not a caller-supplied one. + let state_root_val = state_root(deps.as_ref())?; + + let current_nonce_root = HYBRID_NONCE_STATE_ROOT + .may_load(deps.storage)? + .unwrap_or_else(Uint256::zero); + + // inputHash covers 11 fields (expanded from 9 to include nonce tree roots). + let input = [ + coord_pub_key[0], + coord_pub_key[1], + batch_start_hash, + batch_end_hash, + current_agg_commitment, + new_agg_commitment, + poll_id, + state_root_val, + actual_count, + current_nonce_root, + new_nonce_state_root, + ]; + let input_hash = compute_input_hash(&input); + + let vkey_str = crate::circuit_params::hybrid_process_vkey()?; + run_groth16_verify(vkey_str, &groth16_proof, input_hash, "process_hybrid_batch")?; + + HYBRID_AGG_C1.save(deps.storage, &new_agg_c1)?; + HYBRID_AGG_C2.save(deps.storage, &new_agg_c2)?; + HYBRID_PROCESSED_COUNT.save(deps.storage, &new_processed_count)?; + HYBRID_NONCE_STATE_ROOT.save(deps.storage, &new_nonce_state_root)?; + + let is_final = new_processed_count == msg_chain_length; + if is_final { + // Mirrors classic MACI's Processing -> Tallying transition + // (execute_stop_processing_period), folded into this call since there + // is no separate stop-processing step for hybrid rounds: once every + // published message has been consumed by some ProcessHybridBatch + // call, move on to Tallying. + PERIOD.save( + deps.storage, + &Period { + status: PeriodStatus::Tallying, + }, + )?; + } + + Ok(Response::new().add_attributes(vec![ + attr("action", "process_hybrid_batch"), + attr("actual_count", actual_count.to_string()), + attr("processed_count", new_processed_count.to_string()), + attr("msg_chain_length", msg_chain_length.to_string()), + attr("final", is_final.to_string()), + attr("new_agg_commitment", new_agg_commitment.to_string()), + attr("new_nonce_state_root", new_nonce_state_root.to_string()), + ])) +} + +// Verified reveal, like classic StopTallyingPeriod but with an actual proof +// backing it: the threshold committee combines T partial decryption shares +// off-chain and submits plaintext results + salt PLUS a `reveal_proof` +// (RevealVerifyOnchain) attesting those T shares Lagrange-combine into a +// decryption factor consistent with `results` for the on-chain aggregate +// ciphertext (HYBRID_AGG_C1/HYBRID_AGG_C2). Anyone can call this +// (permissionless), same as StopTallyingPeriod; the proof is what prevents a +// dishonest caller from submitting made-up results. +pub fn execute_reveal_hybrid_tally( + deps: DepsMut, + _env: Env, + _info: MessageInfo, + results: Vec, + salt: Uint256, + participant_pub_keys: Vec, + participant_indices: Vec, + reveal_proof: Groth16ProofType, +) -> Result { + // Mirrors classic StopTallyingPeriod's Tallying-only gate; hybrid rounds + // enter Tallying as soon as execute_process_hybrid_batch succeeds (see + // above). + require_period_status(deps.as_ref(), PeriodStatus::Tallying)?; + + let processed_count = HYBRID_PROCESSED_COUNT + .may_load(deps.storage)? + .unwrap_or_else(Uint256::zero); + let msg_chain_length = HYBRID_MSG_CHAIN_LENGTH.load(deps.storage)?; + if processed_count != msg_chain_length { + return Err(ContractError::HybridNotProcessedYet {}); + } + if HYBRID_TALLY.may_load(deps.storage)?.is_some() { + return Err(ContractError::HybridTallyAlreadyRevealed {}); + } + + if results.len() != HYBRID_M { + return Err(ContractError::HybridResultsLengthMismatch { + expected: HYBRID_M, + actual: results.len(), + }); + } + + if participant_pub_keys.len() != participant_indices.len() { + return Err(ContractError::HybridRevealParticipantLengthMismatch {}); + } + if participant_pub_keys.is_empty() { + return Err(ContractError::HybridRevealNoParticipants {}); + } + // Fixed by the compiled circuit (see HYBRID_REVEAL_THRESHOLD) — the proof + // literally cannot exist for a different participant count. + if participant_pub_keys.len() != HYBRID_REVEAL_THRESHOLD { + return Err(ContractError::HybridRevealParticipantCountMismatch { + expected: HYBRID_REVEAL_THRESHOLD, + actual: participant_pub_keys.len(), + }); + } + for k in 0..participant_indices.len() { + for l in (k + 1)..participant_indices.len() { + if participant_indices[k] == participant_indices[l] { + return Err(ContractError::HybridRevealDuplicateParticipant {}); + } + } + } + + // Validate every participant pubkey is a genuine curve point BEFORE it + // feeds into `hybrid_participant_commit`/the Groth16 public input. When + // a committee roster IS configured this is redundant (members were + // already validated on-curve at instantiate time), but in the legacy + // no-committee path nothing else checks these values — an off-curve + // point wouldn't corrupt the proof (the circuit's own arithmetic would + // simply fail to match), but rejecting it here cheaply, before ever + // reaching Groth16 verification, is still the correct defensive check. + for pk in participant_pub_keys.iter() { + if !is_on_babyjubjub_curve(pk.x, pk.y) { + return Err(ContractError::HybridInvalidParticipantPubKey {}); + } + } + + // If a committee roster is configured, every (index, pub_key) pair must + // match a REGISTERED member at that position (1-based position in + // `HYBRID_COMMITTEE.members` == its Shamir/Lagrange x-coordinate) — the + // proof alone only attests the decryption arithmetic for whichever T + // pairs are supplied, so membership/threshold POLICY is enforced here. + if let Some(committee) = HYBRID_COMMITTEE.may_load(deps.storage)? { + for k in 0..participant_pub_keys.len() { + let mut found = false; + for (pos, member) in committee.members.iter().enumerate() { + let expected_index = Uint256::from_u128((pos + 1) as u128); + if participant_indices[k] == expected_index + && participant_pub_keys[k] == member.pubkey + { + found = true; + break; + } + } + if !found { + return Err(ContractError::HybridRevealUnknownParticipant {}); + } + } + } + + let kc = HYBRID_KC.load(deps.storage)?; + let agg_c1 = HYBRID_AGG_C1 + .may_load(deps.storage)? + .unwrap_or_else(hybrid_identity_agg); + let agg_c2 = HYBRID_AGG_C2 + .may_load(deps.storage)? + .unwrap_or_else(hybrid_identity_agg); + let agg_commitment = hybrid_ahe_commit(&agg_c1, &agg_c2); + let results_commitment = hybrid_results_commit(&results, salt); + let participant_pub_keys_uint: Vec<[Uint256; 2]> = + participant_pub_keys.iter().map(|k| [k.x, k.y]).collect(); + let participant_commitment = + hybrid_participant_commit(&participant_pub_keys_uint, &participant_indices); + let poll_id = Uint256::from(POLL_ID.load(deps.storage)?); + + let input = [ + kc[0], + kc[1], + agg_commitment, + results_commitment, + participant_commitment, + poll_id, + ]; + let input_hash = compute_input_hash(&input); + + let vkey_str = crate::circuit_params::hybrid_reveal_vkey()?; + run_groth16_verify(vkey_str, &reveal_proof, input_hash, "reveal_hybrid_tally")?; + + HYBRID_TALLY.save( + deps.storage, + &HybridTally { + results: results.clone(), + salt, + }, + )?; + + PERIOD.save( + deps.storage, + &Period { + status: PeriodStatus::Ended, + }, + )?; + + Ok(Response::new().add_attributes(vec![ + attr("action", "reveal_hybrid_tally"), + attr("results", to_json_or(&results, "[]")), + attr("zk_verify", "true"), + ])) +} + // in voting pub fn execute_publish_deactivate_message( deps: DepsMut, @@ -1965,6 +2804,21 @@ pub fn execute_start_process_period( &hash2([state_root, Uint256::from_u128(0u128)]), )?; + // Initialize the hybrid nonce tree root to the all-zero quinary tree root + // (depth=2, all leaves=0). This constant is Poseidon5([Poseidon5([0,0,0,0,0])^5]). + // The circuit `ProcessHybridMessages` uses `currentNonceRoot` to authenticate + // each message's voter nonce for cross-batch LWW. + const HYBRID_NONCE_ZERO_ROOT: &str = + "19261153649140605024552417994922546473530072875902678653210025980873274131905"; + HYBRID_NONCE_STATE_ROOT.save( + deps.storage, + &HYBRID_NONCE_ZERO_ROOT.parse::().map_err(|_| { + ContractError::Std(cosmwasm_std::StdError::generic_err( + "invalid HYBRID_NONCE_ZERO_ROOT constant", + )) + })?, + )?; + // Return a success response Ok(Response::new().add_attribute("action", "start_process_period")) } @@ -2088,6 +2942,30 @@ pub fn execute_stop_processing_period( } } + // Classic and hybrid rounds share this same PERIOD state machine, but + // hybrid messages are processed via a SEPARATE counter + // (HYBRID_MSG_CHAIN_LENGTH/HYBRID_PROCESSED_COUNT) that the check above + // knows nothing about. Without this guard, a round with signups but zero + // CLASSIC messages (num_sign_ups != 0 check above trivially passes with + // 0 == 0) would let this permissionless call advance straight to + // Tallying while hybrid messages are still unprocessed — permanently + // stranding the round, since ProcessHybridBatch then rejects (wrong + // Period) and RevealHybridTally rejects (HybridNotProcessedYet), with no + // way back to Processing. + let hybrid_msg_chain_length = HYBRID_MSG_CHAIN_LENGTH + .may_load(deps.storage)? + .unwrap_or_else(Uint256::zero); + if hybrid_msg_chain_length != Uint256::zero() { + let hybrid_processed_count = HYBRID_PROCESSED_COUNT + .may_load(deps.storage)? + .unwrap_or_else(Uint256::zero); + if hybrid_processed_count != hybrid_msg_chain_length { + return Err(ContractError::HybridMsgLeftProcess { + remaining: hybrid_msg_chain_length - hybrid_processed_count, + }); + } + } + let period = Period { status: PeriodStatus::Tallying, }; @@ -2179,6 +3057,23 @@ fn execute_stop_tallying_period( ) -> Result { require_period_status(deps.as_ref(), PeriodStatus::Tallying)?; + // Classic StopTallyingPeriod is permissionless, same as StopProcessingPeriod. + // Once execute_process_hybrid_batch finishes the last batch it moves this + // round straight to Tallying (see below) — but that result is only final + // once RevealHybridTally's Groth16 proof lands HYBRID_TALLY (which also + // advances PERIOD to Ended itself). If this round published ANY hybrid + // messages, it must be finalized through RevealHybridTally: allowing the + // classic path here would let anyone reach Ended with classic (possibly + // all-zero) RESULT/TOTAL_RESULT, discarding the AHE-protected hybrid + // aggregate and making RevealHybridTally permanently unreachable (it + // requires PeriodStatus::Tallying too). + let hybrid_msg_chain_length = HYBRID_MSG_CHAIN_LENGTH + .may_load(deps.storage)? + .unwrap_or_else(Uint256::zero); + if hybrid_msg_chain_length != Uint256::zero() { + return Err(ContractError::HybridTallyNotYetRevealed {}); + } + // Get the final signup count and message count let num_sign_ups = NUMSIGNUPS.load(deps.storage)?; let msg_chain_length = MSG_CHAIN_LENGTH.load(deps.storage)?; @@ -2849,11 +3744,234 @@ pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { }; to_json_binary(&vkeys) } + QueryMsg::VerifyHybridBallot { + kc, + state_root, + coord_pub_key, + poll_id, + routing_commitment, + ahe_commitment, + nullifier, + proof, + } => { + let ok = verify_hybrid_ballot( + &kc, + &state_root, + &coord_pub_key, + &poll_id, + &routing_commitment, + &ahe_commitment, + &nullifier, + &proof, + ) + .map_err(|e| cosmwasm_std::StdError::generic_err(e.to_string()))?; + to_json_binary::(&ok) + } + QueryMsg::GetHybridKc {} => { + to_json_binary::>(&HYBRID_KC.may_load(deps.storage)?) + } + QueryMsg::GetHybridMsgChainLength {} => to_json_binary::( + &HYBRID_MSG_CHAIN_LENGTH + .may_load(deps.storage)? + .unwrap_or_default(), + ), + QueryMsg::GetHybridMessage { index } => { + let message = HYBRID_MESSAGES.may_load(deps.storage, index.to_be_bytes().to_vec())?; + to_json_binary::>(&message) + } + QueryMsg::GetHybridProcessed {} => { + let processed_count = HYBRID_PROCESSED_COUNT + .may_load(deps.storage)? + .unwrap_or_default(); + let msg_chain_length = HYBRID_MSG_CHAIN_LENGTH + .may_load(deps.storage)? + .unwrap_or_default(); + to_json_binary::(&(processed_count == msg_chain_length)) + } + QueryMsg::GetHybridProcessedCount {} => to_json_binary::( + &HYBRID_PROCESSED_COUNT + .may_load(deps.storage)? + .unwrap_or_default(), + ), + QueryMsg::GetHybridAggCiphertext {} => { + let agg_c1 = HYBRID_AGG_C1 + .may_load(deps.storage)? + .unwrap_or_else(hybrid_identity_agg); + let agg_c2 = HYBRID_AGG_C2 + .may_load(deps.storage)? + .unwrap_or_else(hybrid_identity_agg); + to_json_binary(&HybridAggResponse { agg_c1, agg_c2 }) + } + QueryMsg::GetHybridTally {} => { + to_json_binary::>(&HYBRID_TALLY.may_load(deps.storage)?) + } + QueryMsg::GetHybridCommittee {} => to_json_binary::>( + &HYBRID_COMMITTEE.may_load(deps.storage)?, + ), + QueryMsg::GetHybridKcConfirmations {} => { + let entries = match HYBRID_COMMITTEE.may_load(deps.storage)? { + Some(committee) => committee + .members + .into_iter() + .filter_map(|m| { + HYBRID_KC_CONFIRMATIONS + .may_load(deps.storage, m.addr.clone()) + .ok() + .flatten() + .map(|kc| HybridKcConfirmationEntry { addr: m.addr, kc }) + }) + .collect(), + None => vec![], + }; + to_json_binary::>(&entries) + } + QueryMsg::GetHybridNonceStateRoot {} => { + const HYBRID_NONCE_ZERO_ROOT: &str = + "19261153649140605024552417994922546473530072875902678653210025980873274131905"; + let root = HYBRID_NONCE_STATE_ROOT + .may_load(deps.storage)? + .unwrap_or_else(|| { + HYBRID_NONCE_ZERO_ROOT + .parse::() + .unwrap_or(Uint256::zero()) + }); + to_json_binary::(&root) + } } } #[cfg(test)] -mod tests {} +mod tests { + use super::*; + use crate::msg::Groth16ProofType; + use std::str::FromStr; + + // Fixture generated by mpc-coordinator-demo/scripts/exportHybridVkeyProof.ts: + // a REAL BallotValidityOnchain_hybrid_2-1 proof (voter 0, balance 20, weight 4) + // plus the committed public values it binds. This exercises the exact on-chain + // Groth16 verification path (compute_input_hash + hybrid ballot vkey). `stateIdx` + // and `pubKey` are private witnesses inside the circuit now -- only `nullifier` + // (and `routingCommitment`, binding this proof to the published routing message) + // leak out as public signals. + fn fixture() -> ( + [Uint256; 2], + Uint256, + [Uint256; 2], + Uint256, + Uint256, + Uint256, + Uint256, + Groth16ProofType, + ) { + let kc = [ + Uint256::from_str( + "12638030528432806444680310326288043858520366543569780948011195983100888895424", + ) + .unwrap(), + Uint256::from_str( + "2874222432609678237186489396330648906556209135055008837139779509259876658697", + ) + .unwrap(), + ]; + let state_root = Uint256::from_str( + "5678080290181519019462063264557879497227859626642914837890549155568452727972", + ) + .unwrap(); + let coord_pub_key = [ + Uint256::from_str( + "17818764514199701705904019818885983240053494442260857190906103833655526972635", + ) + .unwrap(), + Uint256::from_str( + "20375192377076156497071259932898465702799792208881711741092920179651260965396", + ) + .unwrap(), + ]; + let poll_id = Uint256::from_str("1").unwrap(); + let routing_commitment = Uint256::from_str( + "545801352749069181543411248051632631362783976751764448836496158801522369895", + ) + .unwrap(); + let ahe_commitment = Uint256::from_str( + "20990238927326986410874817686633306890997895366666698647275523482875779866434", + ) + .unwrap(); + let nullifier = Uint256::from_str( + "2580633464407632082114628746467550975790407483227975409759994334022189489676", + ) + .unwrap(); + let proof = Groth16ProofType { + a: "2819f51368bc29c2c763ed1272dc872916180b662fbe943aee78aea8e87c2238201b4c077ac54d7d5ebdaa1c7a096bfcf6cab5af90784b795b490d56b53a200f".to_string(), + b: "2e35dafe0bac4f0fe23ceff1b5b90e13357dcae977994b1044941249285849842ea4ce0fd8529f345f3e6bb37bb6738a7ddf43511072250549063e386906fb47268809c6c6b23aede6bdbe58e5b043161a257fe39173d380c8d41ddc37a9afab01962b1401b32124d29fa0669d63436ecbf83ae5d8bc5ac6e4af6595c56094b7".to_string(), + c: "1bef1f9801dfe73635bfebe1f7086d19979d4bd1f7d75a7f8c9aa595c092960d027265baf4d1d35ea22bad8fba70c18405f18f7757630714ef3ac76175bc2027".to_string(), + }; + ( + kc, + state_root, + coord_pub_key, + poll_id, + routing_commitment, + ahe_commitment, + nullifier, + proof, + ) + } + + #[test] + fn hybrid_ballot_proof_verifies_on_chain() { + let ( + kc, + state_root, + coord_pub_key, + poll_id, + routing_commitment, + ahe_commitment, + nullifier, + proof, + ) = fixture(); + let ok = verify_hybrid_ballot( + &kc, + &state_root, + &coord_pub_key, + &poll_id, + &routing_commitment, + &ahe_commitment, + &nullifier, + &proof, + ) + .unwrap(); + assert!(ok, "a valid hybrid ballot proof must verify on-chain"); + } + + #[test] + fn hybrid_ballot_proof_rejects_tampered_public_input() { + // Flip the committed balance-bearing state root: input hash no longer + // matches the proof, so verification must fail. + let ( + kc, + _state_root, + coord_pub_key, + poll_id, + routing_commitment, + ahe_commitment, + nullifier, + proof, + ) = fixture(); + let bad_root = Uint256::from_u128(12345u128); + let ok = verify_hybrid_ballot( + &kc, + &bad_root, + &coord_pub_key, + &poll_id, + &routing_commitment, + &ahe_commitment, + &nullifier, + &proof, + ) + .unwrap(); + assert!(!ok, "a tampered public input must be rejected on-chain"); + } +} // Check if the operator has processed all deactivate messages within 15 minutes pub fn check_operator_process_time(deps: Deps, env: Env) -> Result { diff --git a/contracts/amaci/src/error.rs b/contracts/amaci/src/error.rs index edd094a..20e2ef3 100644 --- a/contracts/amaci/src/error.rs +++ b/contracts/amaci/src/error.rs @@ -233,4 +233,98 @@ pub enum ContractError { #[error("A round with no signups must finalize with all-zero results")] InvalidEmptyRoundResult {}, + + // Hybrid MACI + AHE on-chain flow errors + // `ProcessHybridBatch`'s `actual_count` must exactly equal + // min(remaining unprocessed messages, HYBRID_BATCH_SIZE) — the contract + // computes this deterministically so there's exactly one valid batch + // shape at any point in a (possibly multi-call) processing run. + #[error("Hybrid batch actual_count mismatch: expected {expected} (min(remaining, batch_size)), got {actual}")] + HybridBatchNotReady { expected: Uint256, actual: Uint256 }, + + #[error("Hybrid batch has already been processed")] + HybridBatchAlreadyProcessed {}, + + #[error( + "Hybrid aggregate ciphertext must have exactly {expected} option entries, got {actual}" + )] + HybridAggLengthMismatch { expected: usize, actual: usize }, + + #[error( + "The submitted coordinator public key does not match the round's registered coordinator" + )] + HybridCoordinatorMismatch {}, + + #[error("Hybrid batch has not been processed yet, nothing to reveal")] + HybridNotProcessedYet {}, + + #[error("Hybrid tally has already been revealed")] + HybridTallyAlreadyRevealed {}, + + #[error("Hybrid committee AHE public key (Kc) has already been set for this round")] + HybridKcAlreadySet {}, + + #[error("Hybrid committee AHE public key (Kc) has not been set yet; call SetHybridKc first")] + HybridKcNotSet {}, + + #[error("Hybrid AHE ciphertext contains a point that is not on the BabyJubjub curve")] + HybridInvalidCiphertextPoint {}, + + #[error("Hybrid aggregate ciphertext contains a point that is not on the BabyJubjub curve")] + HybridInvalidAggregatePoint {}, + + #[error("Hybrid committee config is invalid: {reason}")] + HybridCommitteeConfigInvalid { reason: String }, + + #[error("vote_option_map length {got} does not match the compiled hybrid circuit's fixed option count {expected}; redeploy with exactly {expected} vote options")] + HybridVoteOptionMapMismatch { got: usize, expected: usize }, + + #[error("This round requires committee confirmation (ConfirmHybridKc); the admin-only SetHybridKc path is disabled")] + HybridCommitteeConfirmationRequired {}, + + #[error("This round has no committee configured; use SetHybridKc instead of ConfirmHybridKc")] + HybridCommitteeNotConfigured {}, + + #[error("Sender is not a member of this round's hybrid committee")] + HybridNotCommitteeMember {}, + + #[error( + "RevealHybridTally results length mismatch: expected {expected} options, got {actual}" + )] + HybridResultsLengthMismatch { expected: usize, actual: usize }, + + #[error("RevealHybridTally requires at least one participant share")] + HybridRevealNoParticipants {}, + + #[error( + "RevealHybridTally participant_pub_keys and participant_indices must have the same length" + )] + HybridRevealParticipantLengthMismatch {}, + + #[error("RevealHybridTally requires exactly {expected} participant shares (this round's committee threshold), got {actual}")] + HybridRevealParticipantCountMismatch { expected: usize, actual: usize }, + + #[error("RevealHybridTally participant indices must be pairwise distinct")] + HybridRevealDuplicateParticipant {}, + + #[error("RevealHybridTally participant (index, pub_key) pair is not a registered hybrid committee member")] + HybridRevealUnknownParticipant {}, + + #[error("Cannot stop the classic processing period: this round has {remaining} hybrid message(s) left to process via ProcessHybridBatch")] + HybridMsgLeftProcess { remaining: Uint256 }, + + #[error("Cannot stop the classic tallying period: this round's hybrid tally has not been revealed via RevealHybridTally yet")] + HybridTallyNotYetRevealed {}, + + #[error("Hybrid committee threshold ({committee_threshold}) must equal the compiled RevealVerify circuit's threshold ({circuit_threshold})")] + HybridCommitteeThresholdMismatch { + committee_threshold: u32, + circuit_threshold: usize, + }, + + #[error("Hybrid participant public key is not a valid BabyJubjub curve point")] + HybridInvalidParticipantPubKey {}, + + #[error("Arithmetic overflow while updating hybrid round counters")] + HybridCounterOverflow {}, } diff --git a/contracts/amaci/src/msg.rs b/contracts/amaci/src/msg.rs index 053b154..b0791d1 100644 --- a/contracts/amaci/src/msg.rs +++ b/contracts/amaci/src/msg.rs @@ -1,7 +1,8 @@ #[allow(unused_imports)] // DelayRecords is used by the #[returns] proc-macro attribute use crate::state::{ - DelayRecords, Groth16VkeyStr, MaciParameters, MessageData, PeriodStatus, PubKey, - RegistrationMode, RoundInfo, VoiceCreditMode, VotingTime, + DelayRecords, Groth16VkeyStr, HybridCiphertext, HybridCommitteeConfig, HybridPublishedMessage, + HybridTally, MaciParameters, MessageData, PeriodStatus, PubKey, RegistrationMode, RoundInfo, + VoiceCreditMode, VotingTime, }; use cosmwasm_schema::{cw_serde, QueryResponses}; use cosmwasm_std::{Addr, Timestamp, Uint128, Uint256}; @@ -57,6 +58,14 @@ pub struct InstantiateMsg { pub signup_delay: u64, // operator window to process deactivate messages (from first msg received) pub deactivate_delay: u64, + + // ── Hybrid MACI + AHE on-chain flow (optional) ──────────────────────────── + // If set, `SetHybridKc` (admin-only) is disabled and Kc can only be + // finalized via `ConfirmHybridKc` once `threshold` listed members agree on + // the same value. If omitted, the legacy admin-only `SetHybridKc` path is + // used (single-coordinator demos/tests are unaffected). + #[serde(default)] + pub hybrid_committee: Option, } #[cw_serde] @@ -186,6 +195,100 @@ pub enum ExecuteMsg { salt: Uint256, }, Claim {}, + + // ── Hybrid MACI + AHE on-chain flow ────────────────────────────────────── + // Admin-only, one-time setup: bind the threshold committee's AHE public key + // (Kc) into contract storage. Must be set before any PublishHybridMessage + // call, since every ballotValidity proof is bound to Kc — without a + // contract-tracked Kc, the contract has no fixed value to check submitted + // proofs against. + SetHybridKc { + kc: [Uint256; 2], + }, + // Committee-confirmed alternative to `SetHybridKc`, used when + // `hybrid_committee` was configured at Instantiate. Each listed committee + // member calls this (from their own on-chain address — the tx signature + // itself is the "signature confirming this value") with the SAME `kc`; + // once `threshold` members have confirmed the same value, it is finalized + // into `HYBRID_KC` exactly like `SetHybridKc` would. Rejected if no + // committee is configured, if the sender isn't a listed member, or if Kc + // has already been finalized. + ConfirmHybridKc { + kc: [Uint256; 2], + }, + // Publish one Hybrid message: a routing envelope (Poseidon-encrypted to the + // coordinator, format-identical to classic MessageData) plus the separately + // published AHE ballot ciphertext (per-option, committee-encrypted — the + // coordinator can never decrypt vote content). Reuses `message_fee` and the + // classic hash-chain algorithm, but stores into its own hybrid chain so it + // never interferes with classic PublishMessage/ProcessMessage. + // + // Anonymity: the signed-up voter this ballot belongs to is NOT revealed in + // plaintext any more. `BallotValidityOnchain`'s `stateIdx`/`pubKey` are now + // private witnesses; the proof instead outputs `nullifier` (an unlinkable- + // per-round identifier, see `ballotValidity.circom`), which is all the + // contract ever sees. `coord_pub_key` is the coordinator key the proof used + // to decrypt-check `routing` in-circuit (see `BallotValidity`'s routing + // binding); the contract cross-checks it against `COORDINATORHASH` so a + // prover cannot bind to a fake coordinator. Together with `ballot_proof`, + // this lets the contract independently verify — via the on-chain + // `BallotValidityOnchain` circuit — that the ballot is one-hot, within the + // voter's real Merkle-authenticated voice-credit budget, AND consistent + // with the SAME `routing` envelope being published here, all WITHOUT ever + // decrypting the vote content or learning which voter it came from. + PublishHybridMessage { + routing: MessageData, + enc_pub_key: PubKey, + ciphertext: HybridCiphertext, + coord_pub_key: [Uint256; 2], + nullifier: Uint256, + ballot_proof: Groth16ProofType, + }, + // Coordinator submits ONE Groth16 proof (from ProcessHybridMessagesOnchain) + // covering one batch (up to HYBRID_BATCH_SIZE messages) of the hybrid + // round: it proves the coordinator faithfully decrypted routing + Merkle- + // authenticated each message's voterPubKey against the live state root + // (preventing selective censorship) + ran last-write-wins + homomorphically + // aggregated the (still-sealed) ballots into `new_agg_c1`/`new_agg_c2`, + // without ever seeing any vote's content. + // + // Partial batch + multi-batch chaining: a round's real message count need + // not be an exact multiple of HYBRID_BATCH_SIZE, and may exceed it — this + // message can be submitted repeatedly, each call picking up exactly where + // the previous one left off (tracked by HYBRID_PROCESSED_COUNT), until all + // published messages are processed. `actual_count` is the number of REAL + // messages in THIS call's batch (<= HYBRID_BATCH_SIZE, <= remaining + // unprocessed messages); the circuit pads the rest of its fixed-size + // batch with unconstrained witnesses gated off by `actual_count` (see + // `processHybridMessages.circom`'s partial-batch note). + ProcessHybridBatch { + coord_pub_key: [Uint256; 2], + actual_count: Uint256, + new_agg_c1: Vec<[Uint256; 2]>, + new_agg_c2: Vec<[Uint256; 2]>, + /// New nonce tree root after this batch (circuit output). The contract + /// stores it and passes it as `currentNonceRoot` to the NEXT batch's + /// proof, enforcing cross-batch LWW nonce consistency. + new_nonce_state_root: Uint256, + groth16_proof: Groth16ProofType, + }, + // Threshold committee reveal: T participants each contributed a partial + // decryption share of the final aggregate, and the submitted + // `reveal_proof` (RevealVerifyOnchain) proves those T shares Lagrange- + // combine into a decryption factor consistent with `results`/`salt` — + // i.e. that `results` really is what the on-chain aggregate ciphertext + // decrypts to, not just whatever a coordinator/committee member claims. + // `participant_pub_keys`/`participant_indices` identify WHICH T + // registered `HybridCommitteeConfig` members' shares were used (checked + // against the committee roster below; the proof only attests the + // decryption ARITHMETIC for whichever pairs are supplied here). + RevealHybridTally { + results: Vec, + salt: Uint256, + participant_pub_keys: Vec, + participant_indices: Vec, + reveal_proof: Groth16ProofType, + }, } #[cw_serde] @@ -323,6 +426,92 @@ pub enum QueryMsg { /// Returns the stored Groth16 verifying keys for all circuits. #[returns(VkeysResponse)] GetVkeys {}, + + /// Hybrid MACI + AHE: verify a voter's on-chain ballotValidity proof. + /// Reconstructs the single SHA256 input hash from the committed public values + /// (Kc, stateRoot, coordPubKey, pollId, routingCommitment, aheCommitment, + /// nullifier) and runs the Groth16 verifier with the hybrid ballot verifying + /// key. Returns true iff the proof is valid — i.e. the voter's voice-credit + /// budget is Merkle-authenticated against stateRoot AND the ballot is bound + /// to the given routing envelope, without revealing the vote OR which voter + /// it belongs to. + #[returns(bool)] + VerifyHybridBallot { + kc: [Uint256; 2], + state_root: Uint256, + coord_pub_key: [Uint256; 2], + poll_id: Uint256, + routing_commitment: Uint256, + ahe_commitment: Uint256, + nullifier: Uint256, + proof: Groth16ProofType, + }, + + // ── Hybrid MACI + AHE on-chain flow ────────────────────────────────────── + /// The threshold committee's AHE public key (Kc), if `SetHybridKc` has been + /// called yet. Every ballotValidity proof is bound to this value. + #[returns(Option<[Uint256; 2]>)] + GetHybridKc {}, + + /// Number of Hybrid messages published so far (routing-envelope hash chain). + #[returns(Uint256)] + GetHybridMsgChainLength {}, + + /// A published Hybrid message at `index` (1-based, like classic GetMsgHash), + /// i.e. the routing envelope + enc pub key + AHE ciphertext anyone can use to + /// independently rebuild the ProcessHybridBatch witness. + #[returns(Option)] + GetHybridMessage { index: Uint256 }, + + /// Whether ALL published hybrid messages have been processed yet (i.e. + /// `GetHybridProcessedCount == GetHybridMsgChainLength`). A round may take + /// several `ProcessHybridBatch` calls to reach this. + #[returns(bool)] + GetHybridProcessed {}, + + /// Number of published hybrid messages processed so far, chained across + /// (possibly multiple) `ProcessHybridBatch` calls. + #[returns(Uint256)] + GetHybridProcessedCount {}, + + /// The current running homomorphic aggregate ciphertext (still sealed; only + /// the threshold committee holding Kc's shares can decrypt it), one point per + /// vote option. Before ProcessHybridBatch this is the identity aggregate. + #[returns(HybridAggResponse)] + GetHybridAggCiphertext {}, + + /// The revealed hybrid tally (plaintext results + salt), if any. + #[returns(Option)] + GetHybridTally {}, + + /// The committee roster + threshold configured at Instantiate, if any. + /// `None` means this round uses the legacy admin-only `SetHybridKc` path. + #[returns(Option)] + GetHybridCommittee {}, + + /// Per-member Kc confirmations submitted so far via `ConfirmHybridKc` + /// (only meaningful once a committee is configured and before Kc is + /// finalized; confirmations aren't cleared after finalization). + #[returns(Vec)] + GetHybridKcConfirmations {}, + + /// Current nonce tree root for cross-batch LWW tracking. + /// Returns the all-zero tree root before the first ProcessHybridBatch call, + /// and evolves with each successful call thereafter. + #[returns(Uint256)] + GetHybridNonceStateRoot {}, +} + +#[cw_serde] +pub struct HybridKcConfirmationEntry { + pub addr: Addr, + pub kc: [Uint256; 2], +} + +#[cw_serde] +pub struct HybridAggResponse { + pub agg_c1: Vec<[Uint256; 2]>, + pub agg_c2: Vec<[Uint256; 2]>, } // Response type for GetRegistrationConfig query diff --git a/contracts/amaci/src/multitest/mod.rs b/contracts/amaci/src/multitest/mod.rs index 5adc4c2..969dadb 100644 --- a/contracts/amaci/src/multitest/mod.rs +++ b/contracts/amaci/src/multitest/mod.rs @@ -6,8 +6,8 @@ mod tests; use anyhow::Result as AnyResult; use crate::state::{ - DelayRecords, MaciParameters, MessageData, Period, PubKey, RoundInfo, VoiceCreditMode, - VotingTime, FEE_DENOM, + DelayRecords, HybridCiphertext, HybridCommitteeConfig, HybridPublishedMessage, HybridTally, + MaciParameters, MessageData, Period, PubKey, RoundInfo, VoiceCreditMode, VotingTime, FEE_DENOM, }; use crate::{ contract::{execute, instantiate, query}, @@ -74,7 +74,14 @@ pub fn create_app() -> App { .with_api(dora_mock_api()) .with_stargate(StargateAccepting) .build(|router, _, storage| { - for addr in [user1(), user2(), user3(), owner(), operator(), fee_recipient()] { + for addr in [ + user1(), + user2(), + user3(), + owner(), + operator(), + fee_recipient(), + ] { router .bank .init_balance(storage, &addr, coins(TEST_USER_BALANCE, "peaka")) @@ -407,6 +414,7 @@ impl MaciContract { signup_delay: PER_SIGNUP_DELAY, deactivate_delay: DEACTIVATE_DELAY, deactivate_enabled: false, // Default: disabled + hybrid_committee: None, }; app.instantiate_contract( @@ -479,6 +487,7 @@ impl MaciContract { signup_delay: PER_SIGNUP_DELAY, deactivate_delay: DEACTIVATE_DELAY, deactivate_enabled: true, // ENABLED for deactivate and add_new_key tests + hybrid_committee: None, }; app.instantiate_contract( @@ -891,6 +900,197 @@ impl MaciContract { .query_wasm_smart(self.addr(), &QueryMsg::GetRoundInfo {}) } + /// Hybrid MACI + AHE: query the contract's on-chain ballotValidity verifier + /// through the REAL wasm entry point (`query` dispatch), not a direct Rust + /// call — this is what a testnet/mainnet client would actually invoke. + #[allow(clippy::too_many_arguments)] + pub fn verify_hybrid_ballot( + &self, + app: &App, + kc: [Uint256; 2], + state_root: Uint256, + coord_pub_key: [Uint256; 2], + poll_id: Uint256, + routing_commitment: Uint256, + ahe_commitment: Uint256, + nullifier: Uint256, + proof: Groth16ProofType, + ) -> StdResult { + app.wrap().query_wasm_smart( + self.addr(), + &QueryMsg::VerifyHybridBallot { + kc, + state_root, + coord_pub_key, + poll_id, + routing_commitment, + ahe_commitment, + nullifier, + proof, + }, + ) + } + + // ── Hybrid MACI + AHE on-chain flow ────────────────────────────────────── + #[track_caller] + pub fn set_hybrid_kc( + &self, + app: &mut App, + sender: Addr, + kc: [Uint256; 2], + ) -> AnyResult { + app.execute_contract(sender, self.addr(), &ExecuteMsg::SetHybridKc { kc }, &[]) + } + + #[allow(clippy::too_many_arguments)] + #[track_caller] + pub fn publish_hybrid_message( + &self, + app: &mut App, + sender: Addr, + routing: MessageData, + enc_pub_key: PubKey, + ciphertext: HybridCiphertext, + coord_pub_key: [Uint256; 2], + nullifier: Uint256, + ballot_proof: Groth16ProofType, + ) -> AnyResult { + app.execute_contract( + sender, + self.addr(), + &ExecuteMsg::PublishHybridMessage { + routing, + enc_pub_key, + ciphertext, + coord_pub_key, + nullifier, + ballot_proof, + }, + &coins(MESSAGE_FEE.u128(), FEE_DENOM), + ) + } + + #[track_caller] + pub fn get_hybrid_kc(&self, app: &App) -> StdResult> { + app.wrap() + .query_wasm_smart(self.addr(), &QueryMsg::GetHybridKc {}) + } + + #[track_caller] + pub fn confirm_hybrid_kc( + &self, + app: &mut App, + sender: Addr, + kc: [Uint256; 2], + ) -> AnyResult { + app.execute_contract( + sender, + self.addr(), + &ExecuteMsg::ConfirmHybridKc { kc }, + &[], + ) + } + + pub fn get_hybrid_committee(&self, app: &App) -> StdResult> { + app.wrap() + .query_wasm_smart(self.addr(), &QueryMsg::GetHybridCommittee {}) + } + + pub fn get_hybrid_kc_confirmations( + &self, + app: &App, + ) -> StdResult> { + app.wrap() + .query_wasm_smart(self.addr(), &QueryMsg::GetHybridKcConfirmations {}) + } + + #[track_caller] + #[allow(clippy::too_many_arguments)] + pub fn process_hybrid_batch( + &self, + app: &mut App, + sender: Addr, + coord_pub_key: [Uint256; 2], + actual_count: Uint256, + new_agg_c1: Vec<[Uint256; 2]>, + new_agg_c2: Vec<[Uint256; 2]>, + new_nonce_state_root: Uint256, + groth16_proof: Groth16ProofType, + ) -> AnyResult { + app.execute_contract( + sender, + self.addr(), + &ExecuteMsg::ProcessHybridBatch { + coord_pub_key, + actual_count, + new_agg_c1, + new_agg_c2, + new_nonce_state_root, + groth16_proof, + }, + &[], + ) + } + + #[track_caller] + pub fn reveal_hybrid_tally( + &self, + app: &mut App, + sender: Addr, + results: Vec, + salt: Uint256, + participant_pub_keys: Vec, + participant_indices: Vec, + reveal_proof: Groth16ProofType, + ) -> AnyResult { + app.execute_contract( + sender, + self.addr(), + &ExecuteMsg::RevealHybridTally { + results, + salt, + participant_pub_keys, + participant_indices, + reveal_proof, + }, + &[], + ) + } + + pub fn get_hybrid_msg_chain_length(&self, app: &App) -> StdResult { + app.wrap() + .query_wasm_smart(self.addr(), &QueryMsg::GetHybridMsgChainLength {}) + } + + pub fn get_hybrid_message( + &self, + app: &App, + index: Uint256, + ) -> StdResult> { + app.wrap() + .query_wasm_smart(self.addr(), &QueryMsg::GetHybridMessage { index }) + } + + pub fn get_hybrid_processed(&self, app: &App) -> StdResult { + app.wrap() + .query_wasm_smart(self.addr(), &QueryMsg::GetHybridProcessed {}) + } + + pub fn get_hybrid_processed_count(&self, app: &App) -> StdResult { + app.wrap() + .query_wasm_smart(self.addr(), &QueryMsg::GetHybridProcessedCount {}) + } + + pub fn get_hybrid_agg_ciphertext(&self, app: &App) -> StdResult { + app.wrap() + .query_wasm_smart(self.addr(), &QueryMsg::GetHybridAggCiphertext {}) + } + + pub fn get_hybrid_tally(&self, app: &App) -> StdResult> { + app.wrap() + .query_wasm_smart(self.addr(), &QueryMsg::GetHybridTally {}) + } + #[track_caller] pub fn amaci_sign_up( &self, @@ -1501,6 +1701,7 @@ impl MaciContract { signup_delay: PER_SIGNUP_DELAY, deactivate_delay: DEACTIVATE_DELAY, deactivate_enabled: false, // Default: disabled + hybrid_committee: None, }; app.instantiate_contract( @@ -1559,6 +1760,173 @@ impl MaciContract { ) } + // Like instantiate_default, but with a caller-supplied coordinator pubkey — + // needed for the Hybrid MACI + AHE e2e test, whose real Groth16 proof (and + // COORDINATORHASH check in ProcessHybridBatch) is bound to a specific + // coordinator keypair (see mpc-coordinator-demo/scripts/ + // exportHybridProcessVkeyProof.ts). + #[track_caller] + pub fn instantiate_hybrid_default(app: &mut App, coordinator: PubKey) -> AnyResult { + Self::instantiate_hybrid_with_committee(app, coordinator, None) + } + + // Same as `instantiate_hybrid_default`, but lets tests configure a + // committee roster (see `HybridCommitteeConfig`) to exercise the + // `ConfirmHybridKc` threshold-confirmation path instead of the legacy + // admin-only `SetHybridKc`. + pub fn instantiate_hybrid_with_committee( + app: &mut App, + coordinator: PubKey, + hybrid_committee: Option, + ) -> AnyResult { + let code_id = MaciCodeId::store_code(app); + let parameters = MaciParameters { + state_tree_depth: Uint256::from_u128(2u128), + int_state_tree_depth: Uint256::from_u128(1u128), + message_batch_size: Uint256::from_u128(5u128), + vote_option_tree_depth: Uint256::from_u128(1u128), + }; + let round_info = RoundInfo { + title: String::from("HybridTestRound"), + description: String::from("Test Description"), + link: String::from("https://github.com"), + }; + let voting_time = VotingTime { + start_time: Timestamp::from_nanos(1571797424879000000), + end_time: Timestamp::from_nanos(1571797424879000000).plus_minutes(11), + }; + let init_msg = InstantiateMsg { + parameters, + coordinator, + vote_option_map: vec![ + "Option 1".to_string(), + "Option 2".to_string(), + "Option 3".to_string(), + "Option 4".to_string(), + "Option 5".to_string(), + ], + round_info, + voting_time, + circuit_type: Uint256::from_u128(0), // 1p1v + certification_system: Uint256::from_u128(0), // groth16 + operator: operator(), + admin: owner(), + fee_recipient: fee_recipient(), + poll_id: 1u64, + voice_credit_mode: VoiceCreditMode::Unified { + amount: Uint256::from_u128(100u128), + }, + registration_mode: RegistrationModeConfig::SignUpWithStaticWhitelist { + // The 3 demo voters used by e2e_hybrid_full_onchain_flow — the + // hybrid_publish_fixture ballot proofs are Merkle-bound to the + // state root these 3 sign_up() calls (in this exact order) + // produce against a fresh depth-2 state tree. + whitelist: WhitelistBase { + users: vec![ + WhitelistBaseConfig { + addr: user1(), + voice_credit_amount: None, + }, + WhitelistBaseConfig { + addr: user2(), + voice_credit_amount: None, + }, + WhitelistBaseConfig { + addr: user3(), + voice_credit_amount: None, + }, + // Not signed up by the existing 3-voter fixtures/tests + // (they only call sign_up for user1-3) -- see user4()'s + // doc comment. + WhitelistBaseConfig { + addr: user4(), + voice_credit_amount: None, + }, + ], + }, + }, + message_fee: MESSAGE_FEE, + deactivate_fee: DEACTIVATE_FEE, + signup_fee: SIGNUP_FEE, + base_delay: BASE_DELAY, + message_delay: PER_MESSAGE_DELAY, + signup_delay: PER_SIGNUP_DELAY, + deactivate_delay: DEACTIVATE_DELAY, + deactivate_enabled: false, + hybrid_committee, + }; + + app.instantiate_contract( + code_id.0, + Addr::unchecked(owner()), + &init_msg, + &[], + "Hybrid MACI Contract", + None, + ) + .map(Self::from) + } + + // Attempt to instantiate a hybrid round with a custom (potentially wrong) + // `vote_option_map`. Returns `Err` if the contract rejects it (e.g. + // HybridVoteOptionMapMismatch). Used to test the instantiate-time guard. + pub fn try_instantiate_hybrid_with_vote_option_map( + app: &mut App, + coordinator: PubKey, + vote_option_map: Vec, + ) -> AnyResult { + let code_id = MaciCodeId::store_code(app); + let init_msg = InstantiateMsg { + parameters: MaciParameters { + state_tree_depth: Uint256::from_u128(2u128), + int_state_tree_depth: Uint256::from_u128(1u128), + message_batch_size: Uint256::from_u128(5u128), + vote_option_tree_depth: Uint256::from_u128(1u128), + }, + coordinator, + vote_option_map, + round_info: RoundInfo { + title: String::from("HybridOptionTest"), + description: String::from("test"), + link: String::from("https://example.com"), + }, + voting_time: VotingTime { + start_time: Timestamp::from_nanos(1571797424879000000), + end_time: Timestamp::from_nanos(1571797424879000000).plus_minutes(11), + }, + circuit_type: Uint256::from_u128(0), + certification_system: Uint256::from_u128(0), + operator: operator(), + admin: owner(), + fee_recipient: fee_recipient(), + poll_id: 1u64, + voice_credit_mode: VoiceCreditMode::Unified { + amount: Uint256::from_u128(100u128), + }, + registration_mode: RegistrationModeConfig::SignUpWithStaticWhitelist { + whitelist: WhitelistBase { users: vec![] }, + }, + message_fee: MESSAGE_FEE, + deactivate_fee: DEACTIVATE_FEE, + signup_fee: SIGNUP_FEE, + base_delay: BASE_DELAY, + message_delay: PER_MESSAGE_DELAY, + signup_delay: PER_SIGNUP_DELAY, + deactivate_delay: DEACTIVATE_DELAY, + deactivate_enabled: false, + hybrid_committee: None, + }; + app.instantiate_contract( + code_id.0, + Addr::unchecked(owner()), + &init_msg, + &[], + "HybridOptionTest", + None, + ) + .map(Self::from) + } + // Helper function to instantiate with deactivate enabled #[track_caller] pub fn instantiate_with_deactivate_enabled(app: &mut App, whitelist: bool) -> AnyResult { @@ -1638,6 +2006,7 @@ impl MaciContract { signup_delay: PER_SIGNUP_DELAY, deactivate_delay: DEACTIVATE_DELAY, deactivate_enabled: true, // ENABLED! + hybrid_committee: None, }; app.instantiate_contract( @@ -1670,6 +2039,16 @@ pub fn user3() -> Addr { dora_mock_api().addr_make("user3") } +// A 4th whitelisted (but not signed-up by default) hybrid demo voter -- +// see e2e_hybrid_multi_batch_chaining_preserves_earlier_batch_votes, which +// signs them up alongside user1-3 to get a real 4th state-tree leaf for its +// 2nd batch (phase2-multibatch-ec-add's regression coverage for the +// currentAggC1/C2 chaining fix). Purely additive: existing hybrid tests that +// only sign up user1-3 are unaffected by this whitelist addition. +pub fn user4() -> Addr { + dora_mock_api().addr_make("user4") +} + pub fn owner() -> Addr { Addr::unchecked("owner") } @@ -1682,6 +2061,18 @@ pub fn operator() -> Addr { Addr::unchecked("operator") } +pub fn committee1() -> Addr { + dora_mock_api().addr_make("committee1") +} + +pub fn committee2() -> Addr { + dora_mock_api().addr_make("committee2") +} + +pub fn committee3() -> Addr { + dora_mock_api().addr_make("committee3") +} + // Test data for oracle mode pub fn test_pubkey1() -> PubKey { PubKey { diff --git a/contracts/amaci/src/multitest/tests.rs b/contracts/amaci/src/multitest/tests.rs index c4d110c..a910c35 100644 --- a/contracts/amaci/src/multitest/tests.rs +++ b/contracts/amaci/src/multitest/tests.rs @@ -3125,6 +3125,7 @@ mod test { signup_delay: PER_SIGNUP_DELAY, deactivate_delay: DEACTIVATE_DELAY, deactivate_enabled: false, + hybrid_committee: None, }; let contract_addr = app @@ -4246,3 +4247,2223 @@ mod test { ); } } +#[cfg(test)] +#[cfg(test)] +mod test2 { + use super::*; + use crate::error::ContractError; + use crate::msg::{Groth16ProofType, HybridKcConfirmationEntry}; + use crate::multitest::{ + committee1, committee2, committee3, create_app, owner, + uint256_from_decimal_string, user1, user2, user3, user4, + MaciContract, MESSAGE_FEE, + }; + use crate::state::{ + HybridCiphertext, HybridCommitteeConfig, HybridCommitteeMember, + MessageData, Period, PeriodStatus, PubKey, + }; + use cosmwasm_std::{Timestamp, Uint256}; + use cw_multi_test::next_block; + + // ==== GENERATED from hybridPublishFixture.json ==== + // 5-message full batch: 3 demo voters, REAL ProcessHybridMessagesOnchain proof, + // reverse-processed (batchStartHash=0, batchEndHash=hash_of_5_messages). + #[allow(clippy::type_complexity)] + fn hybrid_process_fixture() -> ( + [Uint256; 2], + Uint256, + Vec<(MessageData, PubKey, HybridCiphertext)>, + Vec<[Uint256; 2]>, + Vec<[Uint256; 2]>, + Uint256, + Groth16ProofType, + ) { + let coord_pub_key = [uint256_from_decimal_string("17818764514199701705904019818885983240053494442260857190906103833655526972635"), uint256_from_decimal_string("20375192377076156497071259932898465702799792208881711741092920179651260965396")]; + let actual_count = uint256_from_decimal_string("5"); + let new_nonce_state_root = uint256_from_decimal_string("20128522001198644075866775738845734504280609575926869493402635345420650640973"); + let messages: Vec<(MessageData, PubKey, HybridCiphertext)> = vec![ + ( + MessageData { + data: [ + uint256_from_decimal_string("19323753134812290448977012820645815381722037629053323873932776781640149585853"), + uint256_from_decimal_string("6577485388851806139587390474635641176081753120926965821775866509531712017367"), + uint256_from_decimal_string("6609237872544388949930210862829325091949050967447409051014233611471866036723"), + uint256_from_decimal_string("15543713482533218079266724173269956061558254034488328934591833537627675241949"), + uint256_from_decimal_string("1856004130584969164438652448343695754782494084600419367928604583579441980736"), + uint256_from_decimal_string("1015067735320440470907325593769441580360076156569848069956366262887109579285"), + uint256_from_decimal_string("1735871256619541014778753719362642929617947793635989908089305624867288569876"), + uint256_from_decimal_string("16391874010187818554030244007078414467059749320327613640390767632240704568463"), + uint256_from_decimal_string("7039511349642748765617028423217698466426932998067908790566838227334007819739"), + uint256_from_decimal_string("2646334706840862092498536675592663916335049035625898044841717233734123762344"), + ], + }, + PubKey { + x: uint256_from_decimal_string("2546741955217233081554601807872742035607415578130217930704207158659885430355"), + y: uint256_from_decimal_string("14943641239591230339789050886791047156284292986398540424400798433610827333580"), + }, + HybridCiphertext { + c1: vec![ + [uint256_from_decimal_string("15735668071173373282177824300776162891633967736382577112447833465634956522850"), uint256_from_decimal_string("3943884891387927407867549462402326590602256097116543660660769300028884003691")], + [uint256_from_decimal_string("19575184157001080864902861130482342686012142229403095823280834619599771872550"), uint256_from_decimal_string("8605266468738053123426831961253141557113277908318259776790402109240772710079")], + [uint256_from_decimal_string("19582932849700648946846868814571757072768506822495209742420133892492865986671"), uint256_from_decimal_string("8303488532966820219297690759281040986556856790312913812129085103832636483831")], + [uint256_from_decimal_string("10700375025820288764209455678588425690495179439374809420490762434368198856810"), uint256_from_decimal_string("1451283563812168685437355578627080838199409075433158725103442579625721632266")], + [uint256_from_decimal_string("20668817776542066533727810631975214011876154790258447157253967594060334408674"), uint256_from_decimal_string("8488693502622302686373014573753809675527501409565995345040879949794458039097")], + ], + c2: vec![ + [uint256_from_decimal_string("7138382701541243626664028208025466112057606123386645236209346638283202299362"), uint256_from_decimal_string("6420329529938090916003238628600158953122138162429889400334466836585160233346")], + [uint256_from_decimal_string("21603385148735717774019300090721995855733503163587814326237459319398301231771"), uint256_from_decimal_string("13421570901642367243932377823982603073565684162970149395236338609228623529167")], + [uint256_from_decimal_string("10464372249567611705581397290183414845332346122429624775829479795316903305515"), uint256_from_decimal_string("9580570229175059777136281127594844770529540824623273306048352139038743918915")], + [uint256_from_decimal_string("13270080454424902302072916668219548268910423947204836051797214537220884962413"), uint256_from_decimal_string("21377411568307512517941375718732594935132236682903873644294310792991008885072")], + [uint256_from_decimal_string("11708857047934950778419991161004960907829642618283348255534160472393024398798"), uint256_from_decimal_string("10795329357811414653006181475206030941787395849641128883598222762787898962323")], + ], + }, + ), + ( + MessageData { + data: [ + uint256_from_decimal_string("12141472915706837803809751588472303097646024621628842645154072522333387258436"), + uint256_from_decimal_string("15416307809717038273945831115430773339363595062074831048516343521769998663212"), + uint256_from_decimal_string("6806296921683178095682322136087205438366522249999585078963806394275636185878"), + uint256_from_decimal_string("13557358014934618684962417908705737821811996009491687513369821067338818320466"), + uint256_from_decimal_string("20726262514486925746522180247158820264086911169158715808268022114772831946046"), + uint256_from_decimal_string("308434216610071916455877070256210542356813052182723476427455375395090369356"), + uint256_from_decimal_string("8640418663547263030625196981611471882248251769372679315594466904861214226531"), + uint256_from_decimal_string("17655979499784305946570319020183079404119546843502174285842671899402225170418"), + uint256_from_decimal_string("20445579002481693263709225882377235466292343919801451330024847476601970042702"), + uint256_from_decimal_string("3309992858675142785323966245292838329220595535112169685766798619857354696541"), + ], + }, + PubKey { + x: uint256_from_decimal_string("15188429855583247224715394475928004584363727240469329416207902810937790450610"), + y: uint256_from_decimal_string("20535129993178442149412981152333114920760215749115921058724775051695280650553"), + }, + HybridCiphertext { + c1: vec![ + [uint256_from_decimal_string("18969711245398418209676382269219036747924128469766694734686522790364503865009"), uint256_from_decimal_string("2846414774713636689764340500904075649243044118358472839469405496978795557612")], + [uint256_from_decimal_string("5906571681868820504614017507611299355664884025392429840239667809571749596088"), uint256_from_decimal_string("8699629817467531911738918955619674286530162333576493380164468157725199496856")], + [uint256_from_decimal_string("1930770952408380166492880771027904836270866745032614533180123132343671915916"), uint256_from_decimal_string("4700109742967734925707604077525816303864822196239222066030492120092262270532")], + [uint256_from_decimal_string("3233197051233619453350673869102387083596159163627426599199366703791606557725"), uint256_from_decimal_string("17961247148919188260866166384591697424128366264618431289888204890301060131026")], + [uint256_from_decimal_string("6630603863119810327782676430074738757635894261966969141320806891957147400380"), uint256_from_decimal_string("17982422259016449023814861332403302632770225780553715039759899525205683126637")], + ], + c2: vec![ + [uint256_from_decimal_string("11675974265334837415927871205733771449380344547648835650978381258274070965153"), uint256_from_decimal_string("13161836563437279149946075183351141064968130041845742132619748875664597116962")], + [uint256_from_decimal_string("13767013018976992156556221611240413350294199161003286155903200753618463093164"), uint256_from_decimal_string("13642210910593875215877708498315455664477446672371923464915133599550199246745")], + [uint256_from_decimal_string("21730695011066368757615913277555695713831581562151206521666815795535790918042"), uint256_from_decimal_string("17468972356207755155292087697830978807897924096574257362740867382446597954657")], + [uint256_from_decimal_string("9199684018381572899746085977903619294107821054057428672652383095812676688526"), uint256_from_decimal_string("19068131953156008923693094459114693165143594725285507408521550451990943734728")], + [uint256_from_decimal_string("2645165413130242349434851584895406490410754927784512165594713823866761216879"), uint256_from_decimal_string("11247706649381204846143535070125228548008203958088493220614314191552204447536")], + ], + }, + ), + ( + MessageData { + data: [ + uint256_from_decimal_string("11168524138510015769017210592161775237745799837413310842989016642312746370248"), + uint256_from_decimal_string("9224772230411416458575322902979272349272366908410557951262839698195710322365"), + uint256_from_decimal_string("12643886482904903071251816223249358581703947466567640433924909115199859057015"), + uint256_from_decimal_string("21853037951285509213289622580110114277421375325069632417034858610908558855346"), + uint256_from_decimal_string("4993366690023471402553322268379203220058240396363098523740418228995516763814"), + uint256_from_decimal_string("12455467725288673994073328227481742539440169850202498692455181032168640726623"), + uint256_from_decimal_string("15120633039633814019375167454833439121151879933642845356053658681393003925637"), + uint256_from_decimal_string("21305492215438054927528830991901076509130407353345685093955694767292789102521"), + uint256_from_decimal_string("20182439372811560488993171261296186383860472629643759701027745770425365898259"), + uint256_from_decimal_string("20611770656965617898863151501223781764157229383736763104416573562803334660877"), + ], + }, + PubKey { + x: uint256_from_decimal_string("8360512711486551468738994761674761600446016961544392811958997066034514309358"), + y: uint256_from_decimal_string("1821451542806860596997399823049799954468175885248351018367505724882272473542"), + }, + HybridCiphertext { + c1: vec![ + [uint256_from_decimal_string("14284179753836990670891448455656586485224186334541270976009305727700622340945"), uint256_from_decimal_string("20149994414243657010844135116168816329525460802249124599510852857092806496101")], + [uint256_from_decimal_string("14852384162189054725064271560165572377950201826403393006072101640060963195666"), uint256_from_decimal_string("8125844095485980597062890283862499145408527609455700231590007406097154097027")], + [uint256_from_decimal_string("20535447861320865124357049576391476188903248957614543364975630780447952692919"), uint256_from_decimal_string("17751280304666591724456273282501581385831012036827858846380613242746720166112")], + [uint256_from_decimal_string("19006093266648352079518645286049458494128758446441749570769084761869480107732"), uint256_from_decimal_string("15097065034184636443755149528055466431943124232174299193925481737906740449296")], + [uint256_from_decimal_string("6536039184682080849770831061880595110413016586571845981098258839065913090457"), uint256_from_decimal_string("17439405588341070499823405909401717209669299538107462127048904258929546689750")], + ], + c2: vec![ + [uint256_from_decimal_string("17975492453685636788005087780055243077126889175455085685654828395730676469441"), uint256_from_decimal_string("7834665311237716937320318147947924822743787697298831673354347568915929741400")], + [uint256_from_decimal_string("6606989549298566751457283496462013338958535839063702069820007537901927404726"), uint256_from_decimal_string("14259462798005647535852259525546298983292728063740412688320545009960037409357")], + [uint256_from_decimal_string("20805231787344216735487841637622491522045709477024305434739330345534446517149"), uint256_from_decimal_string("12772858238264658364549911991159307710605588374599981791192069270886222661377")], + [uint256_from_decimal_string("18498708796418957952927528338693653408634766556899645963663271136855521224826"), uint256_from_decimal_string("12035083009467019453295994625777397641310095624192257919442649372013965656549")], + [uint256_from_decimal_string("9945728641924740301483610326005111005945617360323732331497860481312523713278"), uint256_from_decimal_string("742895569785321543685284643244951200437277651352405912537888672499167676605")], + ], + }, + ), + ( + MessageData { + data: [ + uint256_from_decimal_string("15227715013250977590174574146340677286630235904740673298972411237612304985656"), + uint256_from_decimal_string("7956971823456212676662313027170553524698800332519872423929359286485993209895"), + uint256_from_decimal_string("5872809758674637105739555723735849658806167981659447076546849126069820690494"), + uint256_from_decimal_string("17405245880750120667892419460126286115967579098113335492622982712453172903345"), + uint256_from_decimal_string("1239880646671118216266508587325978705207639290420294948419340069288696449082"), + uint256_from_decimal_string("20076003284230683033482970600325625620111664672137607126888864269258981326484"), + uint256_from_decimal_string("10438943173306443008561445696889399658109793893142597475420080114748236841131"), + uint256_from_decimal_string("15159206728654243082514242069124903974753379272427022713616739907003300583499"), + uint256_from_decimal_string("15541569528334932386895970647048932185494147908421701831126223002413537582410"), + uint256_from_decimal_string("2323827636204541981127951635442776279120064416828695308525843195088362615171"), + ], + }, + PubKey { + x: uint256_from_decimal_string("1208349006477542086469685606806428272677896837371535087490700229278596529776"), + y: uint256_from_decimal_string("8274812794570909368608284937462001620275337728715986127359626071543386231240"), + }, + HybridCiphertext { + c1: vec![ + [uint256_from_decimal_string("2435759392247599406038287338376228214005816740246812499770324742746879701200"), uint256_from_decimal_string("21069224971896892328350267992232501339125400854975699491718584433875188523505")], + [uint256_from_decimal_string("4955734511916393172336336527611555540548821421573530959411841196687246032495"), uint256_from_decimal_string("16662474484705735764154215734668777100837203534754981671478704848427319432468")], + [uint256_from_decimal_string("12110035562510350960795713030058962034576584170910014705540318721259354115173"), uint256_from_decimal_string("11741372020577319065710868029020660135644146442337373404164521650770179960317")], + [uint256_from_decimal_string("10778665057388549923157703608069825373365372183903207763039661141800639635406"), uint256_from_decimal_string("3101315868718061607825728391840618607667638106807220141553184308491908556885")], + [uint256_from_decimal_string("14509025981760961409095545733537701838565137136648164824370971172455625108066"), uint256_from_decimal_string("17018771398641926301211319464987077001885833595122529680453051513768375930345")], + ], + c2: vec![ + [uint256_from_decimal_string("9603631378556012026753152096145127501319599454358383494893051383243455333512"), uint256_from_decimal_string("463945869538288165850318034288029983959926363834777014313661545762002941984")], + [uint256_from_decimal_string("19564849913201793831329632635768788135438492512178224169566159997514100437096"), uint256_from_decimal_string("4757993036002729569574665614436721061257436731317203246544642179633809522993")], + [uint256_from_decimal_string("7126595291286101768471015889276738867147889210697761572691550739674286802905"), uint256_from_decimal_string("9381636427643527374828382142200368535295361956118245697400788864598249749478")], + [uint256_from_decimal_string("19394196991557501257167273163020318870728263062872516162130786038276280367028"), uint256_from_decimal_string("4957858954200554003251751465601161036511317717481063598520894133853866612917")], + [uint256_from_decimal_string("6426608897822085081878324794974472127593479290030314595106961173750860566634"), uint256_from_decimal_string("10225482789653168223102210295404218715785104212038538159184579566163330155285")], + ], + }, + ), + ( + MessageData { + data: [ + uint256_from_decimal_string("12390014834144469085084013539185354561468172029989069367987158267745459656978"), + uint256_from_decimal_string("6021130965313512368817930529994703065989232429761499996693137483730251954878"), + uint256_from_decimal_string("15943474441052536413667578932931004220564679695585643886402275453894173693083"), + uint256_from_decimal_string("11440322970940667588953698453603088545210281909918290668091022971736972736774"), + uint256_from_decimal_string("8886043290713814877014572394923769401697959711886515162929559009312948707547"), + uint256_from_decimal_string("16244312057926941298339316130574615848513547310022525707457086452445663981289"), + uint256_from_decimal_string("15733161453096095753541020299092620032601585574017485405946166876045965545250"), + uint256_from_decimal_string("19160889449787218538637630872845573875330893645593817501100869040061710437769"), + uint256_from_decimal_string("3506237961011693392828291026568708321674661135835626142134449662039938599241"), + uint256_from_decimal_string("21411647965339863661091482911552913731520561929974617368850542611268192822411"), + ], + }, + PubKey { + x: uint256_from_decimal_string("21128881609259697929323677011636934859631283610644381420967154899990905243279"), + y: uint256_from_decimal_string("19024071174410862211202103977753739585761058926320775628466245155767206067315"), + }, + HybridCiphertext { + c1: vec![ + [uint256_from_decimal_string("18280207745970465030272477349976479319451399036174101819238689725208891240692"), uint256_from_decimal_string("4045721990151762478296827261683137105712036327962558181747562259054657792861")], + [uint256_from_decimal_string("13751732155869430804612727253188659729037424183279728673501872844674541281556"), uint256_from_decimal_string("4591876025870867025124403310320366037374033068523066817310089582742541610217")], + [uint256_from_decimal_string("11427221380828464252805787403914627600688284607572699745563699498150875617506"), uint256_from_decimal_string("6796306097112958796646494365377243557995268598995291908537606821674551431783")], + [uint256_from_decimal_string("4250766443804506233376597723368781383909171352525804548186268136498048140927"), uint256_from_decimal_string("20638212748227609117375314454645633786586792046517928921056193139926681808114")], + [uint256_from_decimal_string("18836940996967659072358084628330702183967916382972313483112922046769640347233"), uint256_from_decimal_string("13141037623858171176033206958447661763436559179848859228975817454043162238388")], + ], + c2: vec![ + [uint256_from_decimal_string("4324330947519498653604719411453818054530747952469275531750567324529548656382"), uint256_from_decimal_string("12901662539344912115713356996085282861339126622572391808023079781027661435552")], + [uint256_from_decimal_string("19351222175942977316509947850720809194335978551155384729111574521707237013158"), uint256_from_decimal_string("11670498007436844294740227910643680390550606675171978315206055074516736126245")], + [uint256_from_decimal_string("17080040978910710236348854398280685976460692090109521051781865258453118973763"), uint256_from_decimal_string("16916131774282438126558444800471066415095291682435075339049982581886679911211")], + [uint256_from_decimal_string("3969420884544890940708756511111929768571069728803576837752094869116356953152"), uint256_from_decimal_string("11808202459667458740391250210561702547605444696757299429594157759590770709580")], + [uint256_from_decimal_string("15320992549420004508778248914337245012442972441311279586302305385033088335121"), uint256_from_decimal_string("15164227709382675805626825865158082083104999587564128142501987929781797179126")], + ], + }, + ), + ]; + let new_agg_c1: Vec<[Uint256; 2]> = vec![ + [uint256_from_decimal_string("21353118912712494902094989052002630177575497435268087374977782546058739756167"), uint256_from_decimal_string("12717497031218412688108725240154451891272156815428992884758936524504108198760")], + [uint256_from_decimal_string("5837135309037545693406020272819241040021408602166992968138308355752256783417"), uint256_from_decimal_string("16900926400208356515502703826256117533205245248581657377549870108933259872590")], + [uint256_from_decimal_string("18867473642911813644119112646516833595781018869834259917951035067645880878909"), uint256_from_decimal_string("7060985260580460257445144941461095420558808221724358317438166069027043681726")], + [uint256_from_decimal_string("13694374942239508796802723363861444040925492047057494164740510188827764816039"), uint256_from_decimal_string("5015285135289590630695247254738192198281586340162662941325636744484761571878")], + [uint256_from_decimal_string("21579512647931630091784877022156199526622002938276615816987427206494360819587"), uint256_from_decimal_string("12009060728429388130487016385461389534012177853511539284359643859499774723017")], + ]; + let new_agg_c2: Vec<[Uint256; 2]> = vec![ + [uint256_from_decimal_string("15433024022039582410199288073342174780224872235342900888464731771934363681199"), uint256_from_decimal_string("20214895638631493210708555403405616435515973208881490456068200703424732545889")], + [uint256_from_decimal_string("11390990270597461443505417573994469008665665121486870159211731212336388261045"), uint256_from_decimal_string("2987976167574867104589626460671677631508801364954443163259260328756664028777")], + [uint256_from_decimal_string("21683423679628040782457705338666027178208375131731408057525773702240405364492"), uint256_from_decimal_string("18810953064272673056734379733869311901030616103510063650728947383612409579034")], + [uint256_from_decimal_string("18528636745148584524410524892587541830102325654342839176635698815232735107691"), uint256_from_decimal_string("4751599032973252874865678732976292254185089607407059822114507588867608671400")], + [uint256_from_decimal_string("1142202568136611606939028497860771866604826016185521277605698494986314998990"), uint256_from_decimal_string("20927989001416762265396946294226791383766623993033761043075630876666482749462")], + ]; + let process_proof = Groth16ProofType { + a: "19c1671955c46afb146b09764e75336f6a2cef491ea6fa48355a944b646342f703c5aed4d618c1b02ed88b907af20e4abb5ac7337ca86f0304f6f0897f12918b".to_string(), + b: "033b6b5fe7e36e8484f9a0eba771aa69f7358df0cb79e7e3582823a5244b9f022782b4827051aff65c41ecd60b65c872ad384dafaaddb114974317e13149cb9d08c231661f12903b728f52c14aab3971272394b5c01faf60592edaad7cf1d9a514b66baa5b460cef2cf66baef65fc5393805190d25b048728a87abf0197a8d2c".to_string(), + c: "028bd84da2fa56633b371bf6bda754bbb262f08763612d584727ed49fbcbb3e915a32ceaa465a6a997212f92fc3215e4309bcea7af90e36f7d885d6f600b9b56".to_string(), + }; + (coord_pub_key, actual_count, messages, new_agg_c1, new_agg_c2, new_nonce_state_root, process_proof) + } + + // Companion fixture: Kc, voter pubkeys, ballot proofs (nullifier + Groth16) + // all bound to the state root produced by 3x sign_up in instantiate_hybrid_default. + #[allow(clippy::type_complexity)] + fn hybrid_publish_fixture() -> ( + [Uint256; 2], + Vec, + Vec<(Uint256, Groth16ProofType)>, + ) { + let kc = [uint256_from_decimal_string("12638030528432806444680310326288043858520366543569780948011195983100888895424"), uint256_from_decimal_string("2874222432609678237186489396330648906556209135055008837139779509259876658697")]; + let voter_pubs: Vec = vec![ + PubKey { x: uint256_from_decimal_string("8265454795666596656125240298071702470195051017739827855382555757562575706874"), y: uint256_from_decimal_string("17651022999329762667923757296737370771210222197193308584995277648551857814833") }, + PubKey { x: uint256_from_decimal_string("4715974401250111127699083746268308937561418528846266811736423255542344838416"), y: uint256_from_decimal_string("13441991001099811142680021829094172471601907169188638446991109797401190293411") }, + PubKey { x: uint256_from_decimal_string("4924048713913963616383743613637955931969970144272870671762354653678414365399"), y: uint256_from_decimal_string("6826320094766762023406344969915301433742981344912570860537799597515856473618") }, + ]; + let ballots: Vec<(Uint256, Groth16ProofType)> = vec![ + (uint256_from_decimal_string("2580633464407632082114628746467550975790407483227975409759994334022189489676"), Groth16ProofType { + a: "2dc0b2fcf8bc3f2c2e0db689f8fe500e9f8fe33ec7939186b23f3abef071316e1d6447164f20ede29836a09577f37b989b3de409c1382ed40c6973179618a9cd".to_string(), + b: "092819e21bcb5bcc6bce8634df5d5cacda9861103e7fd94eba4931ead2d28f7202ce3f5b3512e67cb9b23e35748f50afba3c31deab447490a6db045620ea7007303634f5ebcf1de7b53f9ab5c60a28fcd9004de13f3ea746c9898378b0b1bd812a91fe23511a468d79a4e93ccc65d143b21dcc8d885de363d9fd879c528420e2".to_string(), + c: "0c35c610aedfe2b27f616f3f3558df508794d8ceee9d8d50e68a7b5d9f98587023efd6ca6088030628f0410c5bf7e1547bd1adfc0cea97467ab5b54c0187b0e3".to_string(), + }), + (uint256_from_decimal_string("1494773118021553136665987072180867352028503343412743619374115035813552507712"), Groth16ProofType { + a: "0db8d754d3e501a287ce2cf5cd28b8bb4ffcde20085221eaa9f67d72a198465e08dcd80742b03b27c1425e053a67e8a2b508647c450341e77d8dbe2870f63355".to_string(), + b: "09349de9a0f8dbb457bc23ac1ef636548bf1115a7b3d56a0d8cd08b29859e84e2d45466af009af6ccef264049f8ed240f29def421afab8a0a7736c0648deba372b759ff7a6615b36f01e39279bc240fcfba6fd48b80e3892657948012858dda1066278c834e0c15ef1f1af4610fb5f333f9b0dbb40766cd23cfe5b29d3414833".to_string(), + c: "036d953b5c07709b417701acec80b004cf66cb6358f9224f54b15fd604af8723145a1a22bcccad1c15f8d4a9965899b79f8f8cb0eca0ced0a2f017c2d816b997".to_string(), + }), + (uint256_from_decimal_string("15475179083673864531071507418452211031370842053881701839777175213612611804486"), Groth16ProofType { + a: "0209a2f8c003e6877026324f58984eb5791f616a6fd407201ef17fb7ff7d376c0fbe3da1cba75be142a51bc5da043cc49def9e0573795e091d2a65a542bbaeab".to_string(), + b: "1116da74a533570ea98689dfbfa23e00f824e8b3392cd71c2b8131ff2437669e036c4992ee633ccf2269974bc178ffd65ba008f63ccb77422a0765931dff81db18f1ecd0c5e2ca24b78d80a677b9af5ac086054120c584982089ceef32ab749511598b92a44491db41bfb2fb2c82bd0cb7d65e549151d1decd81b3733a4efdb9".to_string(), + c: "0303462e5c2022875f0f947e737603eea1367189bfb71e97efa2dcb61961befd1f1b608d679d581b2cee3231666b9446ba41b853826fd595b80a6911f6e709c1".to_string(), + }), + (uint256_from_decimal_string("2580633464407632082114628746467550975790407483227975409759994334022189489676"), Groth16ProofType { + a: "0af38c4f11858b4bab5de875e06ca50ae6d9cd6195b05ccb670002039d77c2cb21507022fffe58dc1438432ca1d580dff7d6e835b2e8304ee563c0ba21018a08".to_string(), + b: "2f4eb094454449b8eea5bc04cd36516708857c8f2dacf97f6eeeb98af2b0fbd42f5f1b1f11ac27631ce18f2711d766ba68d594ac281d22b983ce9ec42ff2ea881d989fa7c4645215b40c988561721cff720f74d25a88c464dd4cca1faa483b4e06ffe419b81eeeaebc952a4419de9b4271de2c659032e28006d086840e936708".to_string(), + c: "061fcf4161cdd408a7df346daf475210dc19ee77a179c8da4fd4bfb62e4b14d60706eb0cae49a39fd1aeb9f78aaf0ca937e4d7be9b203c2a749bb6a30871dd99".to_string(), + }), + (uint256_from_decimal_string("1494773118021553136665987072180867352028503343412743619374115035813552507712"), Groth16ProofType { + a: "14ef332532a57e0d2b8e239ccae4beae2b46b9f6b136be777a54c5d7f788fd8e1bda1223f45e5c51eeb09ca79edb5fe9dda98dd8fb6c4935d650244db2cd8827".to_string(), + b: "1ef9e41f3327d5be921efeba9fac5f7c61b47c6255beecfcad89261dae10adea07236925348bc4a2ff5da731b1f32b62cf212f4e2f9adc2978fc863688fbd1870b76f4a10c6a6314b12b1dc56d3dc1cd924f630681d9733259ebe8a423673f9428a41ecff43467e303f761b3162857bc45b8c34e94445f8203d9383067f261aa".to_string(), + c: "23b412ef24ad36b778d77b414d5da0de1dbeaa0fe58daff7b95ec349d7e2c92a07af91753f25dc204c51b1c3d53de02f45940072d1d2d13f7fcae4c3699b950e".to_string(), + }), + ]; + (kc, voter_pubs, ballots) + } + + // ==== GENERATED from hybridPartialBatchFixture.json ==== + // 3-message partial batch (< HYBRID_BATCH_SIZE=5); REAL proof covers isReal-masked padding. + #[allow(clippy::type_complexity)] + fn hybrid_partial_batch_fixture() -> ( + [Uint256; 2], + Uint256, + Vec<(MessageData, PubKey, HybridCiphertext)>, + Vec<[Uint256; 2]>, + Vec<[Uint256; 2]>, + Uint256, + Groth16ProofType, + Vec<(Uint256, Groth16ProofType)>, + ) { + let coord_pub_key = [uint256_from_decimal_string("17818764514199701705904019818885983240053494442260857190906103833655526972635"), uint256_from_decimal_string("20375192377076156497071259932898465702799792208881711741092920179651260965396")]; + let actual_count = uint256_from_decimal_string("3"); + let new_nonce_state_root = uint256_from_decimal_string("20128522001198644075866775738845734504280609575926869493402635345420650640973"); + let messages: Vec<(MessageData, PubKey, HybridCiphertext)> = vec![ + ( + MessageData { + data: [ + uint256_from_decimal_string("10294229071745935308016294368632471691109189538832959829842699192771044458535"), + uint256_from_decimal_string("15674093725266719859735424480496471340877135108325760755408490475965572389249"), + uint256_from_decimal_string("9155887371177250388674230314188265568896500901997693970068474014557491438402"), + uint256_from_decimal_string("20139867493980593068392240034881520139296101540514703059452318092852140218774"), + uint256_from_decimal_string("6268764725933472515346262163719299096106622156217038873850062169202549404974"), + uint256_from_decimal_string("6465106538024223805742452525214784913395196840054783279715489933377844066636"), + uint256_from_decimal_string("18003320082478706911675785660412962893918689955819417294040742536881197258750"), + uint256_from_decimal_string("3998781932602721162836253585806163004016199751081736295598237189836182649773"), + uint256_from_decimal_string("483772643971609456833587586065274430741549889588237968442464658135389023184"), + uint256_from_decimal_string("3882778891068720537279465732633905782848608712321356127723462258798273990742"), + ], + }, + PubKey { + x: uint256_from_decimal_string("21806447871571599376599414582656441336150564498518374553024666697464543314283"), + y: uint256_from_decimal_string("1663321004139982725421910986320245103703176180057425147602544498383150096169"), + }, + HybridCiphertext { + c1: vec![ + [uint256_from_decimal_string("14792512805293473053170688501924516783874338284866579339596801646978169473135"), uint256_from_decimal_string("2324909642017276331221629323944440539939455880283013267819097951535789603506")], + [uint256_from_decimal_string("6767131003027681493448330680257790530024480702546194077894680926239968855954"), uint256_from_decimal_string("14319779363752862419932258756524959582672814502463001657393258239037926347230")], + [uint256_from_decimal_string("13712609532489882229015937442395299703352673008110997379135013140723503013403"), uint256_from_decimal_string("17883936973200741890463949239737804050711448081311023304761621514327735688217")], + [uint256_from_decimal_string("15558348998615196349703887111952623631366642216644106956918581647918927010284"), uint256_from_decimal_string("6367943311864611716219162694748628356221835252156359579279865292610332679249")], + [uint256_from_decimal_string("295351784417946996002654980411749167213339771990010122748878487504576084030"), uint256_from_decimal_string("20192458534292568096826813181971698253880361605840743123533435511783884688740")], + ], + c2: vec![ + [uint256_from_decimal_string("12225425842960622309617755224886091695120047980933066511036199555583651533350"), uint256_from_decimal_string("5347930138830744662715073462077691554037293366234838500737771466944503178747")], + [uint256_from_decimal_string("1773414314973700930222968986971341110964503392176380781412688484549579675795"), uint256_from_decimal_string("2828394341591764679235692530861923871599835648822879336499398585557114886540")], + [uint256_from_decimal_string("5739107674232835863428974178743064581889968483040659401518380340349661582327"), uint256_from_decimal_string("21783307925916601802402433235724432273154778612488777379793196597917235748055")], + [uint256_from_decimal_string("10817417027397989204549278567672980182198637625485118154909051969745937728421"), uint256_from_decimal_string("9749472494802647530192571027701748517910409012618984762117057238902269990232")], + [uint256_from_decimal_string("10464620551992780600666579395938814688997564038189773101307860360591359487190"), uint256_from_decimal_string("12064617271582666876046050559355648195040368145327438281264136507045057015261")], + ], + }, + ), + ( + MessageData { + data: [ + uint256_from_decimal_string("21871764060658171705199111178009168531904177589680335259057176960953398331893"), + uint256_from_decimal_string("12561564200703419844650063382149230808363010571461575500292601285497060674776"), + uint256_from_decimal_string("13277570150099056590517362538951634816326221996999497184032369864202672783140"), + uint256_from_decimal_string("4928203458096677528602495206271198895865240339577332368146455873307535305899"), + uint256_from_decimal_string("11596282653808294493715353606647325529811221151543687755355213840711645278684"), + uint256_from_decimal_string("9089167893565688781071386428250491480574613044403444772668656635616255357979"), + uint256_from_decimal_string("3323524802891079988425857104148519393072766190376725445120200724791120616779"), + uint256_from_decimal_string("18229371055834726622852039533018084671904148745777288355553385525967035692674"), + uint256_from_decimal_string("3748349723496246949763573077759764394784870624285235023803617270460352190025"), + uint256_from_decimal_string("5970540934688117300023031388825018315054461627530207156676274581413099603052"), + ], + }, + PubKey { + x: uint256_from_decimal_string("3679008096724006980854218964280184152367391926049129615798826806146175977305"), + y: uint256_from_decimal_string("14121256492372085842477594079279575920032910996297421980567542209405041390602"), + }, + HybridCiphertext { + c1: vec![ + [uint256_from_decimal_string("828456308484178694486591996617489881679399394582621584008361385531166159230"), uint256_from_decimal_string("1955909508378990443790819579646739272496606466470245258308721835991428535759")], + [uint256_from_decimal_string("2435851791156420905848960019639076434347612090823553960254954573066107706073"), uint256_from_decimal_string("1799148370417967121360523807395433618775953285753408221382910684784342061965")], + [uint256_from_decimal_string("1184309326354645514232410326741790673838352089374368190543524599334766938810"), uint256_from_decimal_string("3953956605339463707316548316714023316934075270055394804284269339490936200266")], + [uint256_from_decimal_string("10069756485157078273006203901500326817185149754137414135291008501506280976496"), uint256_from_decimal_string("5935161458263842959828090527401337472586552020489759558146263414172869094089")], + [uint256_from_decimal_string("17372890541046177720556871216376772098884130386673925299267676547251865316303"), uint256_from_decimal_string("15509073885954676738689862289086190202684918476332493670532001321500368030686")], + ], + c2: vec![ + [uint256_from_decimal_string("11961939032521276211095763603572625786905426894170721020359076419671737127982"), uint256_from_decimal_string("20753282774366173268810059642739688192284599856930486277591664024687230296910")], + [uint256_from_decimal_string("3949281078264895013707999737432422644733337272218034421165074251628687319025"), uint256_from_decimal_string("13765654612050835592931013412009385938564487368088771840335111324221554950593")], + [uint256_from_decimal_string("3396116073006669131803469294260054529583460577282538988525382470764777300386"), uint256_from_decimal_string("21266600612242797969659687360541237065063333954011305977922097533832304691840")], + [uint256_from_decimal_string("17873126671950408152700107928439162968617981640036218373327274294320288072756"), uint256_from_decimal_string("11575960813978714715553514315866357090921073555707494821873465208481187197865")], + [uint256_from_decimal_string("6891091433163092368814399925106482071430814553431417692775949201437009574749"), uint256_from_decimal_string("11675237368396900845483109968772706696396252244533727974886744578240010290536")], + ], + }, + ), + ( + MessageData { + data: [ + uint256_from_decimal_string("15403288080333806351205198937672420994916203422037902976936838915410492820143"), + uint256_from_decimal_string("20395168945088752779094840339612128540775304842550757786125317783702188744348"), + uint256_from_decimal_string("19481913397272004268109697077564493583194693258542216927644277896554009048198"), + uint256_from_decimal_string("13369797013100037211381374233792588770760712742307627406152548799172425297137"), + uint256_from_decimal_string("12290306380758212485131378098712812023374315986585178438118101794691488792335"), + uint256_from_decimal_string("20818538282785006990465960354811449951554075081171134200184031074860632541488"), + uint256_from_decimal_string("17984551768784843715658938008399799095798335815661884998726428200811657870514"), + uint256_from_decimal_string("11748257650093584804522018810073969274642440031772770860157620489410553229669"), + uint256_from_decimal_string("3591563580550751547725907284066478413664970182820355319548922279668787047283"), + uint256_from_decimal_string("616828953413119299532794420386259100160009190337974909501955431048562962511"), + ], + }, + PubKey { + x: uint256_from_decimal_string("21748304266848698653074754193171743445455510195802999864788783500458581498200"), + y: uint256_from_decimal_string("20052126941519596635354796379473393465605840142807232488325908923689518694136"), + }, + HybridCiphertext { + c1: vec![ + [uint256_from_decimal_string("21655300962170703268654291399143616970673075815316497012330328484960156419316"), uint256_from_decimal_string("8107161917787986634118687263812015662070479600304810656069047466208432783056")], + [uint256_from_decimal_string("9936361083445924600291200906673890756979533138046792577014972623936924576256"), uint256_from_decimal_string("10617092247075115737982861158954889166976269628050090886430155201079955734535")], + [uint256_from_decimal_string("7538656130806874408049130349651027074654289390372501216580643390678130875821"), uint256_from_decimal_string("3605408612260957860758177401729299578369995241019654138499307941825841106461")], + [uint256_from_decimal_string("6741235411136513784151469862618225513091323958802356903394992167420968741647"), uint256_from_decimal_string("3198902424105360264383361776290929529033385914198827849460804643059507527001")], + [uint256_from_decimal_string("16158854304071697725120368899033268248651978023621677687140909917689709390798"), uint256_from_decimal_string("8689846573218488186890046083738043235799469061542150362600546612482466911576")], + ], + c2: vec![ + [uint256_from_decimal_string("5196985699299344606991544869586636675482178581422295115652076733128351273836"), uint256_from_decimal_string("10731151919679331238110709541819931302656366454252836796079323974938915101770")], + [uint256_from_decimal_string("17055138647377328888629009751091705004881034783943989796782707873230647592297"), uint256_from_decimal_string("19346478944387090790939981476115826393275769592459679111661621537272619340584")], + [uint256_from_decimal_string("21199052252885759788991234015660630435784314509895461006180885047524749599916"), uint256_from_decimal_string("10298324946612765251013704323238658339241230125017100827420020442099283729078")], + [uint256_from_decimal_string("9572346117358177865166405835805136509098587933022808925382420519861757041098"), uint256_from_decimal_string("50269375197542728634791458152956967166845249609470722825024441884314430222")], + [uint256_from_decimal_string("11585266490918483949266088088215538628543149489537230793789405245844052590386"), uint256_from_decimal_string("2815845213973395961110010058534846881569554834508999683352136838213063763838")], + ], + }, + ), + ]; + let new_agg_c1: Vec<[Uint256; 2]> = vec![ + [uint256_from_decimal_string("9263775176223943462174757525510672259419914631579532049695566683167479440482"), uint256_from_decimal_string("2094146996096095012646684625389842360448135322061046813532574398024957365153")], + [uint256_from_decimal_string("14783971865467986875018777934854253284956345822331671093748601462928405826944"), uint256_from_decimal_string("6530433043063106409131983931195390800366505305141250386918725239425852562367")], + [uint256_from_decimal_string("11472597665816637281300344517803632625865599749300686589719012414132031194607"), uint256_from_decimal_string("1536236895163664304090172420464373221181223051581864894569190712732742886893")], + [uint256_from_decimal_string("9856627819505765241333085745459103081619564481707319384206128933529530457535"), uint256_from_decimal_string("4412464987303950971010138493446458379734959966244850207573953215483842346363")], + [uint256_from_decimal_string("5501532796864967895612501308987781120481190435241967465284721896893578511298"), uint256_from_decimal_string("18616868706466721689821648553038141624830079297647541644538338841421868088410")], + ]; + let new_agg_c2: Vec<[Uint256; 2]> = vec![ + [uint256_from_decimal_string("21812025898356049411043316942736388090270071159245894820545525068607497354173"), uint256_from_decimal_string("4243215849187604851307610571847447541462880874203902277500702954220289745911")], + [uint256_from_decimal_string("17236560224464053823136930214082343516700902289362461424878425584226309429432"), uint256_from_decimal_string("15318628895691677642326144504421798488212139551648568876554856235096654507317")], + [uint256_from_decimal_string("14706928772097746119349856731119337139215570690465558077533192931724208333206"), uint256_from_decimal_string("20174576991223995351475187333006247873392081987330698100143626215839102482163")], + [uint256_from_decimal_string("13001490852927498264512803680368621734378460120684273879719908520101105027808"), uint256_from_decimal_string("8116438161910601049201599943513643664314375117272478654638829794143306052917")], + [uint256_from_decimal_string("12075528894604476174439252577919721368656216158372422735391492550274914516870"), uint256_from_decimal_string("20097352307970461236064663770156895248125590509410580863146950847770213703880")], + ]; + let process_proof = Groth16ProofType { + a: "079a04dde0bc64e5ecbcca2410ab784dfd3250633d3c6b0a1708accc230399901eda7fea22da50622d404b4254da0579c9240bb50276f2dc2a026645002a8465".to_string(), + b: "2e71ffc10d0af3ea5c91fa867730a562e4752f20819c1c63ae6d793eb39f02c10a48980234a1d0ed994c51aaff4ba26fa50c6c6b3c24d8484632abce4956ba5c0b2cf6f444b232cfe9b104ce3a52ae7690bd151200fc1b702d6e7c010bf39aeb1f197feb59ccc8a1057cc8dc69dc92b8b51b914dd78ff5b01fca0dec7c85905b".to_string(), + c: "1e76da8187d5350f9f3440464634141b4000e083b94e264b4579f2f5c0e9c8451612bd9307b148f18a7ea8d04274b0f99a9b2609ab22ae0238b7a2254476706a".to_string(), + }; + let ballots: Vec<(Uint256, Groth16ProofType)> = vec![ + (uint256_from_decimal_string("2580633464407632082114628746467550975790407483227975409759994334022189489676"), Groth16ProofType { + a: "1a713a3f4aa7806349f5cd15231fc880929865ac0ad681dc367979b8aa9d5b490c20af6f2a205e78b3df94d73c4439b8175d8797b73f8ef024e4a33dec29a2d9".to_string(), + b: "0445ce80aea126f0ee2ab1733daa04bfa21a67888111e75c7c07a738f4771da02e405aee925461b031832c0feddfb5d2ffb4e8c8a6a82a1580994ce74deacc7f0336389425d56a1c14954234d6be921955b57e23335bb1a00d8b08e6388bd8000fa12ae9747e5d47beca00bac7184f5fd6d1e913f12981e621e7fa637045f584".to_string(), + c: "0d50b82473248ab2ab49773252792104d0d0d24f610057f31278106e9438fd9e02ac5c6f84618146fa0c2c4c506972db601473861410d70770579f96e75dd736".to_string(), + }), + (uint256_from_decimal_string("1494773118021553136665987072180867352028503343412743619374115035813552507712"), Groth16ProofType { + a: "2e2318257fcbfe8f6b7a3b305e137d49311e4c4aa38dd221b0f7590f78be82a7237dda28906eefe31698e09a948170a6cc92c76cc9b1010374fd3c22249c8d27".to_string(), + b: "2a74d29325c5ca2816f6ba0a2470b476e78db56d180b42334a9f66ae597b297c0f094bdd61a841a30ea5b2ff287c9f2021722b2e7288fd5cc7032840704ebac004142457d1a59dd4b131c4b8b6fbc7d8f3863e64e10a9fe029dd2ef759077aeb0f304a1b55084aa9b8dc221b16002c451257842897a9273564dcc2dc7975f7ae".to_string(), + c: "22707e341fc9f3f41aebc3f14f004fecb4f0e9a2c6eee64f70c0d8a3f1431a16119781fcb0f43447952d350ee290d7b4c7b0a5521e5877cf4481a166c9022786".to_string(), + }), + (uint256_from_decimal_string("15475179083673864531071507418452211031370842053881701839777175213612611804486"), Groth16ProofType { + a: "18fc7add3a1839311fa41d86c6bd25d7a9bc835888b8ae4025fbd9601dad4359192d82e35a0505029f274dd3dadda2b50cba7b0feae546d6dfa12b8073c2192a".to_string(), + b: "24375b57509004628dba8b76e931c8d1866f7d90aad8df0ea36a1fc21d10abf42db812673aaf67b7241092203e3a97404668e3e8c1ba18472ad68a9933cab12e1edd5fab91a94c37aee7db7e334d74cf2d1edc99d150229c13bee53f3468d89609bb775391181c61c780be92cbe6088e94cb0f6f7fedc70e29a19ea3ccf8212d".to_string(), + c: "2bbd8e9cbc0cc7870dac42ff76f27476624893223642fc57c3bff7a666cdeea323290759ab091741f5b87089155863618890947cc28dd2320ea8dacfd690891e".to_string(), + }), + ]; + (coord_pub_key, actual_count, messages, new_agg_c1, new_agg_c2, new_nonce_state_root, process_proof, ballots) + } + + // ==== GENERATED from hybridMultiBatchFixture.json ==== + // 6 messages, 4 voters, TWO chained ProcessHybridBatch calls (reverse order). + // batch1 (first contract call): processes messages[1..5] (chain indices 1-5) + // batch2 (second contract call): processes message[0] (chain index 0) + #[allow(clippy::type_complexity)] + fn hybrid_multi_batch_fixture() -> ( + [Uint256; 2], + Vec, + Vec<(Uint256, Groth16ProofType)>, + [Uint256; 2], + (Uint256, Vec<(MessageData, PubKey, HybridCiphertext)>, Vec<[Uint256; 2]>, Vec<[Uint256; 2]>, Uint256, Groth16ProofType), + (Uint256, Vec<(MessageData, PubKey, HybridCiphertext)>, Vec<[Uint256; 2]>, Vec<[Uint256; 2]>, Uint256, Groth16ProofType), + (Vec, Uint256, Vec, Vec, Groth16ProofType), + ) { + let kc = [uint256_from_decimal_string("12638030528432806444680310326288043858520366543569780948011195983100888895424"), uint256_from_decimal_string("2874222432609678237186489396330648906556209135055008837139779509259876658697")]; + let coord_pub_key = [uint256_from_decimal_string("17818764514199701705904019818885983240053494442260857190906103833655526972635"), uint256_from_decimal_string("20375192377076156497071259932898465702799792208881711741092920179651260965396")]; + let voter_pubs: Vec = vec![ + PubKey { x: uint256_from_decimal_string("8265454795666596656125240298071702470195051017739827855382555757562575706874"), y: uint256_from_decimal_string("17651022999329762667923757296737370771210222197193308584995277648551857814833") }, + PubKey { x: uint256_from_decimal_string("4715974401250111127699083746268308937561418528846266811736423255542344838416"), y: uint256_from_decimal_string("13441991001099811142680021829094172471601907169188638446991109797401190293411") }, + PubKey { x: uint256_from_decimal_string("4924048713913963616383743613637955931969970144272870671762354653678414365399"), y: uint256_from_decimal_string("6826320094766762023406344969915301433742981344912570860537799597515856473618") }, + PubKey { x: uint256_from_decimal_string("3581606918980777415203546809988471586420866133795555059730862275475402405019"), y: uint256_from_decimal_string("1205222093914345685219448539799520963033816985811888907604794234934443298335") }, + ]; + let ballots: Vec<(Uint256, Groth16ProofType)> = vec![ + (uint256_from_decimal_string("2580633464407632082114628746467550975790407483227975409759994334022189489676"), Groth16ProofType { + a: "2fc62033b2db5583a3a5686ae90946b94c783a7f6cfda118675557b8e489cd260e9c159637b74364148106643abb319ebdba862c29d81426312cc0464426f8b9".to_string(), + b: "2544477e60491cbd1046849625b7755cb537a9d2a849409a63a6616df3ded04b17ea6353793284981da807692c42ac5bc8f859b2b4bb99c064c01220cdeab7182acbd0becdcb4afb99df07c91fc66bd92892784244afd1c1687dc27fddeacada087804152b0b25a53c3fd9fd1738be98ab23accf74dd5875db05314c11aa24c9".to_string(), + c: "076d0d166d88510a1bd74ee2b49adfa075cc3874745094c77a37af61b86d32c12031b3ef5d80e64bcd515c7c7571a308bdbc8262664e4ac53fb325393a0330da".to_string(), + }), + (uint256_from_decimal_string("1494773118021553136665987072180867352028503343412743619374115035813552507712"), Groth16ProofType { + a: "14ea83f5b122a58d91db239e42cf9ee8d584d762922d9993b74beb57c5ba3d410753616465c8c4af7a3023066c8749f3fa0de1d58e316760b1e36ce551d73806".to_string(), + b: "2e343be820adcc13211558cae761228b50450180cc0f2b823a0073927960c12528acf38464b3c6fb2794408af9067a0fc539c38410486091ee1edfaaa048624d1e5b3cd6c7d79f560097c247b7a7aa2f6470df60f0c5f5c80858a65f37a4ea590d851bb5d0a04e79eee1e41ee37caa5bb645eb09a10b241f8b8e092de2106dc7".to_string(), + c: "1c563305985185e91e20f3bc17f601c9f31e1cad1352342cfb9ad0a9e23c663d1b709322bd60e9bd8be932a7940ff4ef73962c0e263413c4a579624007021426".to_string(), + }), + (uint256_from_decimal_string("15475179083673864531071507418452211031370842053881701839777175213612611804486"), Groth16ProofType { + a: "0d757f3eed756f12827ebd494bc24c163a92dc4553ace0612ab1efbc5b6dbe952b5f791f899372ce7b1707ab3811b20a495d303cbd19bd75d2e126a5c5d682d6".to_string(), + b: "2ea4295c705ad5ff7e937dcaf9a1cfe60ce67b0e3837943b65aa25161fc1677f10314e5c40f5afbe43e5f96eb27f9a19389973c2e0b762028f72ddaeada97dad2da6331ed891a798872720afbc14501f3ffa1534f9bd15c2f797b9a2a9ff912519347781162631df68a9eac8d5b2095431c4aded9d0a00835ff877e24703670c".to_string(), + c: "124df891b93b3e1a430c038388e2e0365797c23636bd2e41b5e17d2a3a3ee7ae0b9504520f6f2af3d6e2ecbbc73a7e218ea0e2db589fd73ab88eb31388324d6c".to_string(), + }), + (uint256_from_decimal_string("2580633464407632082114628746467550975790407483227975409759994334022189489676"), Groth16ProofType { + a: "129d9d87a06921cad0e59341194520fb39e599e9bb4387e34bd6e6568f9acc5a1d21c893ceb6c00a0a1fe9ef68ff938b409122ea1073a5545ba4e7cce9500288".to_string(), + b: "08cbe37d25498ce538e58021585d3868eb4bb36cfd5e375413c6b0bd5387b0ef178eab697adb7903404f45798cecc15bb356ded939d2e917fade8498b7ce7f072903629c3cb8bceaa956af41fee390a5b24286e88949fd13a0cc553d1d83eecf258458fe477683ee73f6d6e0b9a69252411aa43aefb07dc9db04c442a138d3f8".to_string(), + c: "012de2ef19ddb55d5ba3b3c2fd1e1ea3c1c436e73959da21fc55830635d1f4ba1bb947d59b83c2796e6ebea36ffa0d1f731b0812133887ebf6ee61092a7817eb".to_string(), + }), + (uint256_from_decimal_string("1494773118021553136665987072180867352028503343412743619374115035813552507712"), Groth16ProofType { + a: "05dce6adfd0bf07ee357922e01322a5cbf1604059bcd126de062c69089ac1d601372fdbd09aa753f44fd3fce95aa55052f83559797d780e826c9242287014341".to_string(), + b: "2d68956c6d42aacdb3838d9e1f425b62bf33f3f26462d62455c8c7b5ea949fc10b6fd95261cbd6d557b91f30aeed7625a99231ebd30d504f3a5693352e589c66002992676078383e01d59db2b522a32fcd3ab433472f5e8204d65f134a53ec110ca9a085c365b44c64bb663b42be2d1f12560f7a741a21c150942c69ae67fac1".to_string(), + c: "304b42f67e0adf48df60159f6963a6c0493c991a08a56d1e99af981c45bd390a035392f840d484cb18695de30c7374e8a7b6d03674663ef777344854fd599c7e".to_string(), + }), + (uint256_from_decimal_string("13341661616625141516965270844803228748611428302074781154070356745683997744114"), Groth16ProofType { + a: "1f43071f58d76c8d18cd65aac375714af46fdbc0d91544e5fcc40922bef5322b2b7a941521748c1a3b10936202a7fdfb0fe3090de0d47a46b424da94d905453d".to_string(), + b: "21d0679bd405833bef2da4911021d2f94e86291afd99bd446652e533c41ed1552534c98efab26133a28bb66d0ad5825cdeae0e25b0a3ccf755bda72a17aa4dc7124369d643288c3fc3f322090c788854b3f94a606e5d63e7e3807aa45ebdeb7e01cfc188f3e6efaac2ef1425c13d92367d99a5d7fc97eeda3cd20543e36597c7".to_string(), + c: "2c8070c49b70136e788dad5f2f8e919cf74515da8bfa0146a03c1d0699aa18cc28205873ae5e523671211c0624ab45610f0ef42e66c1e2be6074314f8485c38b".to_string(), + }), + ]; + let batch1_messages: Vec<(MessageData, PubKey, HybridCiphertext)> = vec![ + ( + MessageData { + data: [ + uint256_from_decimal_string("15361052787779267578763355591646376491683357565488799803321412830346703686909"), + uint256_from_decimal_string("18338546295702357455093398239109469973030961568809955132072620310652927741123"), + uint256_from_decimal_string("12702991607925650149562548000479679316935702531823331807361978613875096899656"), + uint256_from_decimal_string("7367266679828962333863240388889604518365407205765154856868885822360123693722"), + uint256_from_decimal_string("20631656225411843742186392897753072873472549256256344158486708130546915603798"), + uint256_from_decimal_string("16444954188407636006803071192139310703153358924917041522356057910188264967220"), + uint256_from_decimal_string("4672476154048649683116132025856737852767722411447165059934774179529760915752"), + uint256_from_decimal_string("19933045683280554937056196642232325126438985940607172243511214327620663175827"), + uint256_from_decimal_string("20524818055337240312229425269396896703577885105639991321343010938625078748140"), + uint256_from_decimal_string("13663741680275964578350979565972412526362852854912354947424484757324722579579"), + ], + }, + PubKey { + x: uint256_from_decimal_string("11588708557663978104598098671159408753010087697745530648134135784095902951511"), + y: uint256_from_decimal_string("19966279161372773222733926433144353785465523506107634732689070421166827846218"), + }, + HybridCiphertext { + c1: vec![ + [uint256_from_decimal_string("16737737133567867554723900788241959359279593632430439732141579139795323259165"), uint256_from_decimal_string("5002414145689061709764324180989933665207463134342398157724277647828051269327")], + [uint256_from_decimal_string("2168951973384652647454009898843804304643076349896055004588889716788657755139"), uint256_from_decimal_string("1769011926787020108003183992796454841784532796717220727670573188516066272644")], + [uint256_from_decimal_string("21497560054847515017581662122641266799530781983632623209210216471607766611343"), uint256_from_decimal_string("14438458888346550759610271510475735345456326537813949074145164352888479488978")], + [uint256_from_decimal_string("2613046418982843023225426118027356469841185030387703868560733590560040359629"), uint256_from_decimal_string("7466190452967609471967475440116254558602311335777887518083058590586595560356")], + [uint256_from_decimal_string("17393665415898028278789622902238125334166339573720297656688121557973223991831"), uint256_from_decimal_string("8912956703081840994348275131783700528567672628248819252888917005114996676868")], + ], + c2: vec![ + [uint256_from_decimal_string("18043938386949157460652721672392557728914092717740882010935413323026493243082"), uint256_from_decimal_string("13378246770261847716888059808756246905520257266690236228502107312366462122186")], + [uint256_from_decimal_string("16267239088190327809053004712580074284039963429733604964566941086572925487817"), uint256_from_decimal_string("13428736415088189224224216758845210233549668428240367760346459541133499238093")], + [uint256_from_decimal_string("16216429569943423234395885355878078009375052792658997611823633938979233347013"), uint256_from_decimal_string("14377658009124046334067226588139663436017138417643663202623379901924086153225")], + [uint256_from_decimal_string("12963426102046190852755196031637285821978020250190466819286344757431757522007"), uint256_from_decimal_string("6677910613060703211055487231249394630858963586165383109423102899019167848173")], + [uint256_from_decimal_string("7759307813321593097805868980132556341692911920311776246589263597267566364600"), uint256_from_decimal_string("10096330077008559606496904871371764209129761303653400163706090551326256224745")], + ], + }, + ), + ( + MessageData { + data: [ + uint256_from_decimal_string("14920160724720734229403856187819314559902117915531695463335958511605296700556"), + uint256_from_decimal_string("3194830633217569644881353790148777430826950724311265789807149646884944449620"), + uint256_from_decimal_string("8462347659660077314505052628821569701725426780649547772222802272199958504347"), + uint256_from_decimal_string("2981598032833307030124038620473112687545905123409493934823769870619113935019"), + uint256_from_decimal_string("10491717139718794177224677129012045588434025004316448631003932858228765248848"), + uint256_from_decimal_string("20051936325141867047813678690962818533082356794539403864725553564177752378423"), + uint256_from_decimal_string("1736614699265547795729147446721109663959018565814478923731886376401056571868"), + uint256_from_decimal_string("3969862885785567218806615163160520587172127501695304344034892679809169275610"), + uint256_from_decimal_string("3592000956580525590754620209159448998838803136018465915995008702375444707807"), + uint256_from_decimal_string("12407107216701769067236110526500719738897363126854251398880567389810362516814"), + ], + }, + PubKey { + x: uint256_from_decimal_string("5623063952036543217384842492026855375396504982396064346483494442340402795576"), + y: uint256_from_decimal_string("7933716341029488854321384765203354592389515346674055366964807906138135194397"), + }, + HybridCiphertext { + c1: vec![ + [uint256_from_decimal_string("2208885768904908065103933799175218494399424262627942831807714108639009845132"), uint256_from_decimal_string("7338651452868365341293449123566674361682196498057043694476767592092732128262")], + [uint256_from_decimal_string("9550584425984695462647562001424812519092577843337705597828861139077162313693"), uint256_from_decimal_string("3150933255728705945519447117592127940524120196237863695260531212758896758170")], + [uint256_from_decimal_string("1208016324865762201757704442147376596231321007832812246016795270390878020869"), uint256_from_decimal_string("11276198470359887638102644187129486656543836520533489170859883395402262633946")], + [uint256_from_decimal_string("2441621359056379752189966687138594196904826051027232732019136751866881726540"), uint256_from_decimal_string("11672411092307760191033058090972376452589906361764195699077168536364863924512")], + [uint256_from_decimal_string("9913069212536769907087000060114218889743040323882108055326258090794136991259"), uint256_from_decimal_string("17180435406075355777008666824486175322307758814389338109947797858392132274987")], + ], + c2: vec![ + [uint256_from_decimal_string("12633674242654333750784000246791871477818934386561125720702159388538833898516"), uint256_from_decimal_string("20318202786889595019637543228315854039237601542347756648721609218735974718978")], + [uint256_from_decimal_string("17324284357345397313754768093919556010430824342769224050552097620905223185000"), uint256_from_decimal_string("7988996435791974420309816235460505813564492759619049838831409808640648072379")], + [uint256_from_decimal_string("12683895573097433305976050678943521711630112204727558399322891043943488557152"), uint256_from_decimal_string("20628911494354694539465847655638317397731568680071472529759179416597531201593")], + [uint256_from_decimal_string("18299063461160754409654552113034723524894063177823503805312791842426066419131"), uint256_from_decimal_string("4353052738881144882517493793874308607176622664434203587753822732203438337410")], + [uint256_from_decimal_string("18116507502320069768870126989375742369396560126808603181497507502795762067048"), uint256_from_decimal_string("3073033107686659221075290453967437155713414197987808888013476655775618190161")], + ], + }, + ), + ( + MessageData { + data: [ + uint256_from_decimal_string("13470165309493230601319346350284850664151452852491959853924150198579970912287"), + uint256_from_decimal_string("8215777036460325904848024588605541876140562567648964908098996437902332138203"), + uint256_from_decimal_string("2481354824480980441484219292063891717509017941357180917920418975818250776076"), + uint256_from_decimal_string("20192526298016137687360080329235071364433927115369165905318220044535471709570"), + uint256_from_decimal_string("13928589728842271269207910552605566340283278138618107381791209688073736282323"), + uint256_from_decimal_string("8166385400913380459794418401580821384237223287211707062432212405324911133042"), + uint256_from_decimal_string("15970733414491099324442762968693946252520001300453053038651978353465711780693"), + uint256_from_decimal_string("17852839430155147038449172323152969674656944583690718378778212010681152774083"), + uint256_from_decimal_string("3565827948349357467976098242749243133818565856112720060449060488185984976056"), + uint256_from_decimal_string("19485805217851381131069583959191035012980815121356193175091351998825458787023"), + ], + }, + PubKey { + x: uint256_from_decimal_string("18284790631244386868523900874694522658410198044413604717437988362871986068850"), + y: uint256_from_decimal_string("14225452694280308090641811825701113915701088622682326458721317089305174247158"), + }, + HybridCiphertext { + c1: vec![ + [uint256_from_decimal_string("18876845247114075811587648786045112475702112592870162130183363993595728829223"), uint256_from_decimal_string("6615470953720768096615817011865204482269501962918388821880713241290318667162")], + [uint256_from_decimal_string("11024927749785155202019052379473411979014935118766038297027263764757923094920"), uint256_from_decimal_string("15895154765728230347174485383397369610586639324922256370388059478679113250324")], + [uint256_from_decimal_string("18622149650474477504118241186110284751878924614690005253001464106576806677350"), uint256_from_decimal_string("12523401831122855510273570441563149876860419819299950738796822237172859925682")], + [uint256_from_decimal_string("10876732066363837226336817889105313678497846281999446254088965776104393912457"), uint256_from_decimal_string("2187870594224512877813495903768296715149594792475816024857856326446793831117")], + [uint256_from_decimal_string("8886777133065092905313980761442306136343122936181869238598314531795035686565"), uint256_from_decimal_string("2899630629006138533947319543176975941875534917286134032359948666729915167043")], + ], + c2: vec![ + [uint256_from_decimal_string("13103006173163865123253350372700780954033961996031821841444070814976954164669"), uint256_from_decimal_string("17301692245808913736869622779881572742323702966277176769267766854910108025011")], + [uint256_from_decimal_string("9742793958796631344066553168247284714396147519814566838447852624575004026802"), uint256_from_decimal_string("10398065110174044921807112827083485716493290222760033540475841541274175188901")], + [uint256_from_decimal_string("20180450794044267652493178528423088346710090147678316040294172757096659210228"), uint256_from_decimal_string("19962169718349003138330540483000569775639192212948207478922555047089521330103")], + [uint256_from_decimal_string("21056980290842172265391379286769425643972269711072845948422610217029856212889"), uint256_from_decimal_string("21184760186848585054829279259627139716589374624926950366455776552792255406340")], + [uint256_from_decimal_string("11799746373639559610037648346064927675804759005199205910011099596505461828942"), uint256_from_decimal_string("2552411107542848583250346973956666778534466405036785102403258457031114096296")], + ], + }, + ), + ( + MessageData { + data: [ + uint256_from_decimal_string("15665676974928173260031571238555619575665508686552849506791367275382140445981"), + uint256_from_decimal_string("12346815450399866985512416310923626735535821302773742774833303143749111300709"), + uint256_from_decimal_string("8295325172825016675706420185343173784525952094117444830246713883597021383150"), + uint256_from_decimal_string("16653656995885191735657701516661195793442832512198984522719933071530729665752"), + uint256_from_decimal_string("5096678001680311198139990132342047176086734591192569024682811932565541190564"), + uint256_from_decimal_string("7586885933598373228299283144146488966813030323178537294346749172416755824734"), + uint256_from_decimal_string("11726240154735702634951336412099490595930399436931345776252059637837659121541"), + uint256_from_decimal_string("19992084601970186413639737014440262285432064880922271153564784298364211073320"), + uint256_from_decimal_string("14837144808343498953026630176013466535534430342317489637550176982482258209106"), + uint256_from_decimal_string("1794918552805581964304007582227443539498612599907714520165683972156281039573"), + ], + }, + PubKey { + x: uint256_from_decimal_string("16101005712718833580030137503930526019737104521349926639351489573895386992387"), + y: uint256_from_decimal_string("7493672997838706327162524129862195933589697087694354391998736663244123878768"), + }, + HybridCiphertext { + c1: vec![ + [uint256_from_decimal_string("9958606523733252716799161199638695685919276749543832715575276553045704795165"), uint256_from_decimal_string("14046670276564807684864583591086215428657769064862404069297551986393417886202")], + [uint256_from_decimal_string("7398466843247643288781816814062079482808427922032375714024558278085023478879"), uint256_from_decimal_string("7580719992038396864264953613309270542316858287902161253636724811826566949365")], + [uint256_from_decimal_string("16785207345230351892939029081782477552240880804827593692858514617975875732968"), uint256_from_decimal_string("17496695409256525485901112422499816775080630375634680527309082320444777514688")], + [uint256_from_decimal_string("7506561997467154302112166909128392242480251232230177212387542650070272652101"), uint256_from_decimal_string("8950951897244649354992328410856091842757107075763663587935870458272523115913")], + [uint256_from_decimal_string("9848268307411251820502106144173389027277069207497330299852876781083292687649"), uint256_from_decimal_string("11529010723822507436447978844260855925427153871746005701958082238288918871319")], + ], + c2: vec![ + [uint256_from_decimal_string("9684256096667248583034901569816999248190944090141558429787407042159575038800"), uint256_from_decimal_string("14866192760218140203311544740329932441030061086067532196046541173692034184173")], + [uint256_from_decimal_string("11563495444489697706314667107768813993342879381575349450782006256353838379186"), uint256_from_decimal_string("13964657677288766196089685855667432545151355094268088503492198139623458877047")], + [uint256_from_decimal_string("13673122266541191259666456707285271677186283144387988033253112332722982627008"), uint256_from_decimal_string("6834893909325460135705477169408278908675951035509983304139925761451012868614")], + [uint256_from_decimal_string("5311698325425574306479943687140599311349848881042388570463422212561719090756"), uint256_from_decimal_string("10063109231782134305638357472106942708033672249805233652672828699231061628021")], + [uint256_from_decimal_string("12685185573326174466149999151014479527311853637061427241583089610513618957020"), uint256_from_decimal_string("13290832975984922986170325775867778831820526769722108817752122040977311273264")], + ], + }, + ), + ( + MessageData { + data: [ + uint256_from_decimal_string("7846491504612541174032516387772309569066360222972356464610385236268856435147"), + uint256_from_decimal_string("7837526224950483069112617517307730143073457772571348115170006358033111133371"), + uint256_from_decimal_string("8208820314474903949232373938249094149940911650101223340958688943857830480382"), + uint256_from_decimal_string("3714815585621626870214544942431026977396838444469835587787426045352250631143"), + uint256_from_decimal_string("7626521361639601828936089527178220656664094556946419429897298138803590065913"), + uint256_from_decimal_string("12086301515024118383124812098121764740391393791754028130818350157943094726765"), + uint256_from_decimal_string("9939087649567293417637876547291528677564416386089624074979007626624583922269"), + uint256_from_decimal_string("19662335793053698137895932028164291920616027807170177617903967948302795628682"), + uint256_from_decimal_string("18401078097686230313596556654019157837233577826009306162704541465377919295330"), + uint256_from_decimal_string("18547404445569535669190117063742088067717477141941779929629755548639620833082"), + ], + }, + PubKey { + x: uint256_from_decimal_string("19563052593097547964019577982636037853687670411668715887679455993390467161151"), + y: uint256_from_decimal_string("12981573145955590969572627461483108809957382507343341339343054936033979260476"), + }, + HybridCiphertext { + c1: vec![ + [uint256_from_decimal_string("11382800068794892853454899414538213928890006474865762132422540984607285566696"), uint256_from_decimal_string("16337927713371052641790023059753762135633345264559674881534157361684579960192")], + [uint256_from_decimal_string("4892736485782708879831773084825171341496753064917864322655889753220010992827"), uint256_from_decimal_string("4251388400006715606301688402085916622791444930274752413650665510848507946999")], + [uint256_from_decimal_string("13218289063881306472994294086458744443009962282686315072691293976490952254363"), uint256_from_decimal_string("5410889814178328326172951581090401952803780583674274515103545955039132874962")], + [uint256_from_decimal_string("18980997055164074614819470330743805433807557252009604394815612395333138617115"), uint256_from_decimal_string("12534164006487452265319408585949154684937375719534883563715386270468148919390")], + [uint256_from_decimal_string("786898048705536724809265574536939871587245680746011953425593675239398148167"), uint256_from_decimal_string("8195571398650213107956734410521762232688816481905047954784558494349267205932")], + ], + c2: vec![ + [uint256_from_decimal_string("1066614757411474743199596225429378504754645764729749323832820039805048528887"), uint256_from_decimal_string("3614326627670708630448828449927312877128099144954942932047945248358769132866")], + [uint256_from_decimal_string("14999771351147586087383772132946682558125824441642255137344098903393884727481"), uint256_from_decimal_string("13613318350632517230901612482669680815502011673185048683365642841455686719292")], + [uint256_from_decimal_string("985673999516404573948218253762040336970922104199123890430186873399231842847"), uint256_from_decimal_string("7614191747217980544298830231785475938614927367702122550074895542355804674283")], + [uint256_from_decimal_string("4489626669478150915606848098250347593643604372475378117150264777242269060995"), uint256_from_decimal_string("20670381314793531173003717527770153704234304773550947413353137039069268035061")], + [uint256_from_decimal_string("2802621134126558500584160416957423256249457778446781892998881979504056581907"), uint256_from_decimal_string("19681955580736580393777223599408729178005754730929596041899761949133247040333")], + ], + }, + ), + ]; + let batch2_messages: Vec<(MessageData, PubKey, HybridCiphertext)> = vec![ + ( + MessageData { + data: [ + uint256_from_decimal_string("11861690016408697376107237785672653347809406641859473645152032701145717166969"), + uint256_from_decimal_string("15407159841980614893023429500140298786272631710958833571175837750266634512907"), + uint256_from_decimal_string("17773414062563792538534714782036762359586621161940906060687587156452519782440"), + uint256_from_decimal_string("17393797110162914771864516309393285772033092678852830442701356299554324435038"), + uint256_from_decimal_string("9019912788061523565422244443689484504621559778774287328561244064450475662962"), + uint256_from_decimal_string("20819803669237628185096676899799786020460091580519179137650895243080230039557"), + uint256_from_decimal_string("12247185859716468930200537661292406915224298418028359964822695374824245312692"), + uint256_from_decimal_string("8591297959217091254038367842545129900442470886713970559185522671105019253557"), + uint256_from_decimal_string("18124707510347294348150314130297328204734966633909596264819025614294351229805"), + uint256_from_decimal_string("8342081294751947163300024727201194905396321312360589112229669907409292551201"), + ], + }, + PubKey { + x: uint256_from_decimal_string("7444146713229551520939111881119484598439798951424524047112444218252664907879"), + y: uint256_from_decimal_string("19151847245046800912892877292106914615873112039152837893941202055343431126957"), + }, + HybridCiphertext { + c1: vec![ + [uint256_from_decimal_string("12923791214146336770686271912363001741640995402464310400490184553301271270440"), uint256_from_decimal_string("11585939033838614282018337052099575437066539537654449139089612096207628536325")], + [uint256_from_decimal_string("9332256602426931941708265664715958510734447658196325233110066117987084371029"), uint256_from_decimal_string("17845545685932987732595584332367091350174730019392626653131117947166937935690")], + [uint256_from_decimal_string("19869464950349908424814050720542921575803343612894351648015478130677827684682"), uint256_from_decimal_string("10709633057148488484227314707416818139865188181599941760090025567220998902355")], + [uint256_from_decimal_string("11066738495296487466981224493573941111976086413907412588816636942620436621323"), uint256_from_decimal_string("6254748354910251771520684881708780479053988998146947802313145956605896761925")], + [uint256_from_decimal_string("19069400777777106940587362317028395405886252306309373376880898153853562100125"), uint256_from_decimal_string("10234545158248186186818709465033546716943681781809359691644627364454788096875")], + ], + c2: vec![ + [uint256_from_decimal_string("17529338855281011253179567986127963742909803941819270365708171415793583870656"), uint256_from_decimal_string("2828752334431442517063846749061378436912276475701350508733087018866515292054")], + [uint256_from_decimal_string("6321480683570394989879277317829947596786203833442659968161195019619237174785"), uint256_from_decimal_string("8258811016220728669554336774326052564731348999857190748137588429327595837585")], + [uint256_from_decimal_string("275311830773608158793894164136617398914766972596538229999924087799671377868"), uint256_from_decimal_string("2815744113786400634091991189021634237964636851515034507302770440454538808279")], + [uint256_from_decimal_string("5458151519419157806674910301314556440173165620547928576196868052769544112750"), uint256_from_decimal_string("14151336450642976526809062965946017023453116226374562627182989024422051870917")], + [uint256_from_decimal_string("17556586086433878843138631701781979862693190741117159939154749247893303671828"), uint256_from_decimal_string("6458712833944399377829076229940574169824397336877849956174076805347699078364")], + ], + }, + ), + ]; + let batch1_new_agg_c1: Vec<[Uint256; 2]> = vec![ + [uint256_from_decimal_string("8887329975027991769313617239522807251201571830935979001966414170441014017415"), uint256_from_decimal_string("19412398536065588325125474826245420426887509616887671266097694503834898861189")], + [uint256_from_decimal_string("1596896186105365827644783522136656051647197684694932161676108427460757981727"), uint256_from_decimal_string("11026261704651203665074380434352308712680579483453269946406113272673984601589")], + [uint256_from_decimal_string("6439727071518704195135680769444836258529396690189172296543817613508097081311"), uint256_from_decimal_string("14072919669951335099987775311427809349815209209885655579096410908561494814161")], + [uint256_from_decimal_string("10503816332525045308661314145897551143991090084104417156226107755585710514742"), uint256_from_decimal_string("781646362790015731246664720808007316303509476746358175962346329202732370094")], + [uint256_from_decimal_string("20674891951066992208397276068460721200827232468154887193883649027207094376233"), uint256_from_decimal_string("7478145369029027957032223830997151315878423430683244392917807569264386452900")], + ]; + let batch1_new_agg_c2: Vec<[Uint256; 2]> = vec![ + [uint256_from_decimal_string("9509682384629541015189739457653618629969167105967937697428966477544921604188"), uint256_from_decimal_string("7647835155488730050352189613748366144484185297623466265425479524377310039067")], + [uint256_from_decimal_string("6842909070135535966010824991939063610224086544888209020490189847402590227148"), uint256_from_decimal_string("2275784037861917424508027286678030327941405160310538350138724405002710714482")], + [uint256_from_decimal_string("8515834730829254193058608570588340451080146607919088151871878737585928615496"), uint256_from_decimal_string("9275954837520953508779899136871173849703402085725700237251894296610791404052")], + [uint256_from_decimal_string("20912386973352995883544024612654297974285423313438223960983588789890689485345"), uint256_from_decimal_string("14974801117368709343816898527290038499108600099392076413381618744120516498796")], + [uint256_from_decimal_string("2433320334967814840432060231799446002304694872903144146121141482912048289191"), uint256_from_decimal_string("11295733906826603903797861294090993806605796276443744575090175602937797458735")], + ]; + let batch2_new_agg_c1: Vec<[Uint256; 2]> = vec![ + [uint256_from_decimal_string("8887329975027991769313617239522807251201571830935979001966414170441014017415"), uint256_from_decimal_string("19412398536065588325125474826245420426887509616887671266097694503834898861189")], + [uint256_from_decimal_string("1596896186105365827644783522136656051647197684694932161676108427460757981727"), uint256_from_decimal_string("11026261704651203665074380434352308712680579483453269946406113272673984601589")], + [uint256_from_decimal_string("6439727071518704195135680769444836258529396690189172296543817613508097081311"), uint256_from_decimal_string("14072919669951335099987775311427809349815209209885655579096410908561494814161")], + [uint256_from_decimal_string("10503816332525045308661314145897551143991090084104417156226107755585710514742"), uint256_from_decimal_string("781646362790015731246664720808007316303509476746358175962346329202732370094")], + [uint256_from_decimal_string("20674891951066992208397276068460721200827232468154887193883649027207094376233"), uint256_from_decimal_string("7478145369029027957032223830997151315878423430683244392917807569264386452900")], + ]; + let batch2_new_agg_c2: Vec<[Uint256; 2]> = vec![ + [uint256_from_decimal_string("9509682384629541015189739457653618629969167105967937697428966477544921604188"), uint256_from_decimal_string("7647835155488730050352189613748366144484185297623466265425479524377310039067")], + [uint256_from_decimal_string("6842909070135535966010824991939063610224086544888209020490189847402590227148"), uint256_from_decimal_string("2275784037861917424508027286678030327941405160310538350138724405002710714482")], + [uint256_from_decimal_string("8515834730829254193058608570588340451080146607919088151871878737585928615496"), uint256_from_decimal_string("9275954837520953508779899136871173849703402085725700237251894296610791404052")], + [uint256_from_decimal_string("20912386973352995883544024612654297974285423313438223960983588789890689485345"), uint256_from_decimal_string("14974801117368709343816898527290038499108600099392076413381618744120516498796")], + [uint256_from_decimal_string("2433320334967814840432060231799446002304694872903144146121141482912048289191"), uint256_from_decimal_string("11295733906826603903797861294090993806605796276443744575090175602937797458735")], + ]; + let batch1_actual_count = uint256_from_decimal_string("5"); + let batch1_new_nonce_state_root = uint256_from_decimal_string("2833166335706068362997727908011245257485718383501093387389025037101697700872"); + let batch1_proof = Groth16ProofType { + a: "05f1ccfcd05f52e08195e381d9861cd75ff2274d688cdb50f5ce6b49bd25c54b09aacea44a282ba6965765abed755afe3ed94a23a84324e1dc3f2ba64652eec8".to_string(), + b: "086de078976d59b12f66610dafafe952a8c2f7cb36c4015fa40fc0d108a7409e114a05e0c0b5e9347448c13442f63504c9663036e66c7ae34a395b197a62ad051b2d8c10ca041694867cb88ad797898bd69c1f91b71ddaff10d1a7b5e0569b5a044c8935596f4ed49e92054bf92cf9dcf5433db793641eac290d41fafad317f5".to_string(), + c: "147d72f91f8eb074b04e96558d7b1db769bcc7fea5beaa08fad0b32910017a2128663c17dba35852efd7e458c35fc3b113483f00989d505e3a9e7235106ebb27".to_string(), + }; + let batch2_actual_count = uint256_from_decimal_string("1"); + let batch2_new_nonce_state_root = uint256_from_decimal_string("2833166335706068362997727908011245257485718383501093387389025037101697700872"); + let batch2_proof = Groth16ProofType { + a: "1687c7da63f2a920ff7ab2c19e8f34b398e688d87864d2266a7b1a2c3010a316137ae6f73021d4d7385cd5d6a3c1a17e512ef22473a746fc2e57ee71c3ffe1cc".to_string(), + b: "1b664c9f59447de3dfff74cd15311bec106ee81b194501169c468c940abc7b2e042d8649b1b5403ec1184012e513a1ffc592b28770ea0d860febb3471294fe101e65294bc9ab93b0422601c4b52e814ca688b2ed5fd851334f04b1576a3d4f4a21167d2d22aef72d2c4eb8a071304c9b2c66e9362d9426aeede53b972ff5534c".to_string(), + c: "2106a6f04acf4c6d823e4bdd5bbb9c304df8b7035fb8bef9a89fc6f1778231cc18876413f3a902e5ceadca0dc302040c84368fbf25cc0e41d11027a2bb19b249".to_string(), + }; + let reveal_results: Vec = vec![ + uint256_from_decimal_string("5"), + uint256_from_decimal_string("0"), + uint256_from_decimal_string("4"), + uint256_from_decimal_string("1"), + uint256_from_decimal_string("2"), + ]; + let reveal_salt = uint256_from_decimal_string("4242"); + let reveal_participant_pub_keys: Vec = vec![ + PubKey { x: uint256_from_decimal_string("4864899991292966285039326886114995530797438431053360503065080873169221193412"), y: uint256_from_decimal_string("6853265169388957555803862987705973148067710644793822381168216039376923050656") }, + PubKey { x: uint256_from_decimal_string("21644658723485080274236649396451253763983307540799052914711536473333816570612"), y: uint256_from_decimal_string("10532070064212391985634680642919861126702695449055773362657974530711355717278") }, + ]; + let reveal_participant_indices: Vec = vec![ + uint256_from_decimal_string("1"), + uint256_from_decimal_string("2"), + ]; + let reveal_proof = Groth16ProofType { + a: "2606f70de8d372aef330e3586a83797dc31801700e6e4ea532d0a16007ca315d07fd99f51bcb7947007a35f5efcb72abc8709968ac0f68077b5a54a708a78433".to_string(), + b: "03a3b218f4cc7067df7ce7a269eec775338ea66c7aba9d202a2f11fdef20a6331f5acc857a7ddaffd54c6f2a5c969e66f805c782863a920b94571d8de9db221321c61d79d360c1e68370d524a2052a8622a315d406aa90f80e90355517faeed424e475c95efa8eca1dab2f2887c7ada65424bca7c702467c854e25953e6ab534".to_string(), + c: "1554d48732a4805632f228a7f9c86d524906e37e5f4d5a3543f97172bcde9dc401ee41cf021f0b9bd2eb23ae83075f09c4f18caa9059b37b63dae9252c7a8995".to_string(), + }; + ( + kc, + voter_pubs, + ballots, + coord_pub_key, + (batch1_actual_count, batch1_messages, batch1_new_agg_c1, batch1_new_agg_c2, batch1_new_nonce_state_root, batch1_proof), + (batch2_actual_count, batch2_messages, batch2_new_agg_c1, batch2_new_agg_c2, batch2_new_nonce_state_root, batch2_proof), + (reveal_results, reveal_salt, reveal_participant_pub_keys, reveal_participant_indices, reveal_proof), + ) + } + + // ==== GENERATED from hybridRevealE2eFixture.json ==== + // REAL RevealVerifyOnchain_hybrid_1-2 proof (2-of-3 committee threshold). + fn hybrid_reveal_fixture() -> ( + Vec, + Uint256, + Vec, + Vec, + Groth16ProofType, + ) { + let results: Vec = vec![ + uint256_from_decimal_string("0"), + uint256_from_decimal_string("0"), + uint256_from_decimal_string("4"), + uint256_from_decimal_string("1"), + uint256_from_decimal_string("2"), + ]; + let salt = uint256_from_decimal_string("777"); + let participant_pub_keys: Vec = vec![ + PubKey { x: uint256_from_decimal_string("4864899991292966285039326886114995530797438431053360503065080873169221193412"), y: uint256_from_decimal_string("6853265169388957555803862987705973148067710644793822381168216039376923050656") }, + PubKey { x: uint256_from_decimal_string("21644658723485080274236649396451253763983307540799052914711536473333816570612"), y: uint256_from_decimal_string("10532070064212391985634680642919861126702695449055773362657974530711355717278") }, + ]; + let participant_indices: Vec = vec![ + uint256_from_decimal_string("1"), + uint256_from_decimal_string("2"), + ]; + let proof = Groth16ProofType { + a: "03a2187a734e9f379b83e5a9fa347aa930b2a0ac8a79fdae37d9b1e01c3180d62b3a556b67e2c5bae70dc0500cf36abf874f3643f1be24eda7967ce235a5d8f3".to_string(), + b: "0008457f50433452208164b5416ca359774cd7067692e0e96ec9dc5c40d31aba161720e3527a919e2eab68818b369b58a4393bb7c4e88adf44614ec27ce6d0f9224df73de4a08073ca022fe240f723d8c50df286e7d586ba8c274ad19af3938510d2902dfa0b3f724e69c6d12990e47a8d45edf054892c2e89524e7c17a4efe9".to_string(), + c: "2cdf3ac522b5667f81354ea3c1d21f9246a8e7ecddb610d7dd1663300cc2ad40275f0209d5adcb4cf3c04cbceffae5db2e9b524ed65696e4ea8c3d7404114038".to_string(), + }; + (results, salt, participant_pub_keys, participant_indices, proof) + } + + // ==== GENERATED from hybridBallotFixture.json ==== + // Single ballot for VerifyHybridBallot query entrypoint test. + fn hybrid_ballot_fixture() -> ( + [Uint256; 2], + Uint256, + [Uint256; 2], + Uint256, + Uint256, + Uint256, + Uint256, + Groth16ProofType, + ) { + let kc = [uint256_from_decimal_string("12638030528432806444680310326288043858520366543569780948011195983100888895424"), uint256_from_decimal_string("2874222432609678237186489396330648906556209135055008837139779509259876658697")]; + let state_root = uint256_from_decimal_string("5678080290181519019462063264557879497227859626642914837890549155568452727972"); + let coord_pub_key = [uint256_from_decimal_string("17818764514199701705904019818885983240053494442260857190906103833655526972635"), uint256_from_decimal_string("20375192377076156497071259932898465702799792208881711741092920179651260965396")]; + let poll_id = uint256_from_decimal_string("1"); + let routing_commitment = uint256_from_decimal_string("545801352749069181543411248051632631362783976751764448836496158801522369895"); + let ahe_commitment = uint256_from_decimal_string("20990238927326986410874817686633306890997895366666698647275523482875779866434"); + let nullifier = uint256_from_decimal_string("2580633464407632082114628746467550975790407483227975409759994334022189489676"); + let proof = Groth16ProofType { + a: "2819f51368bc29c2c763ed1272dc872916180b662fbe943aee78aea8e87c2238201b4c077ac54d7d5ebdaa1c7a096bfcf6cab5af90784b795b490d56b53a200f".to_string(), + b: "2e35dafe0bac4f0fe23ceff1b5b90e13357dcae977994b1044941249285849842ea4ce0fd8529f345f3e6bb37bb6738a7ddf43511072250549063e386906fb47268809c6c6b23aede6bdbe58e5b043161a257fe39173d380c8d41ddc37a9afab01962b1401b32124d29fa0669d63436ecbf83ae5d8bc5ac6e4af6595c56094b7".to_string(), + c: "1bef1f9801dfe73635bfebe1f7086d19979d4bd1f7d75a7f8c9aa595c092960d027265baf4d1d35ea22bad8fba70c18405f18f7757630714ef3ac76175bc2027".to_string(), + }; + (kc, state_root, coord_pub_key, poll_id, routing_commitment, ahe_commitment, nullifier, proof) + } + + // 3-member committee (threshold 2) reusing voter pubkeys from hybrid_publish_fixture. + fn hybrid_committee_fixture() -> HybridCommitteeConfig { + let (_, voter_pubs, _) = hybrid_publish_fixture(); + HybridCommitteeConfig { + members: vec![ + HybridCommitteeMember { + addr: committee1(), + pubkey: voter_pubs[0].clone(), + }, + HybridCommitteeMember { + addr: committee2(), + pubkey: voter_pubs[1].clone(), + }, + HybridCommitteeMember { + addr: committee3(), + pubkey: voter_pubs[2].clone(), + }, + ], + threshold: 2, + } + } + + #[test] + fn e2e_hybrid_full_onchain_flow() { + let mut app = create_app(); + let (coord_pub_key, actual_count, messages, new_agg_c1, new_agg_c2, new_nonce_state_root, process_proof) = + hybrid_process_fixture(); + let (kc, voter_pubs, ballots) = hybrid_publish_fixture(); + let coordinator = PubKey { + x: coord_pub_key[0], + y: coord_pub_key[1], + }; + let contract = MaciContract::instantiate_hybrid_default(&mut app, coordinator).unwrap(); + + // Before voting starts, publishing must be rejected. + let (routing0, enc_pub_key0, ciphertext0) = messages[0].clone(); + let (nullifier0, ballot_proof0) = ballots[0].clone(); + let too_early = contract + .publish_hybrid_message( + &mut app, + user1(), + routing0.clone(), + enc_pub_key0.clone(), + ciphertext0.clone(), + coord_pub_key, + nullifier0, + ballot_proof0.clone(), + ) + .unwrap_err(); + assert_eq!(ContractError::PeriodError {}, too_early.downcast().unwrap()); + + app.update_block(next_block); // Start Voting + + // Publishing must be rejected before Kc is set. + let kc_not_set = contract + .publish_hybrid_message( + &mut app, + user1(), + routing0.clone(), + enc_pub_key0.clone(), + ciphertext0.clone(), + coord_pub_key, + nullifier0, + ballot_proof0.clone(), + ) + .unwrap_err(); + assert_eq!(ContractError::HybridKcNotSet {}, kc_not_set.downcast().unwrap()); + + // Only admin can bind Kc, and only once. + let not_admin = contract.set_hybrid_kc(&mut app, user1(), kc).unwrap_err(); + assert_eq!(ContractError::Unauthorized {}, not_admin.downcast().unwrap()); + contract.set_hybrid_kc(&mut app, owner(), kc).unwrap(); + assert_eq!(contract.get_hybrid_kc(&app).unwrap(), Some(kc)); + let already_set = contract.set_hybrid_kc(&mut app, owner(), kc).unwrap_err(); + assert_eq!(ContractError::HybridKcAlreadySet {}, already_set.downcast().unwrap()); + + // Sign up the 3 demo voters. + contract.sign_up(&mut app, user1(), voter_pubs[0].clone()).unwrap(); + contract.sign_up(&mut app, user2(), voter_pubs[1].clone()).unwrap(); + contract.sign_up(&mut app, user3(), voter_pubs[2].clone()).unwrap(); + + assert_eq!(contract.get_hybrid_msg_chain_length(&app).unwrap(), Uint256::zero()); + let identity_agg = contract.get_hybrid_agg_ciphertext(&app).unwrap(); + assert_eq!(identity_agg.agg_c1, vec![[Uint256::zero(), Uint256::from_u128(1u128)]; 5]); + assert_eq!(identity_agg.agg_c2, vec![[Uint256::zero(), Uint256::from_u128(1u128)]; 5]); + + // Publish all 5 hybrid messages. + for (i, ((routing, enc_pub_key, ciphertext), (nullifier, ballot_proof))) in messages + .iter() + .cloned() + .zip(ballots.iter().cloned()) + .enumerate() + { + contract + .publish_hybrid_message( + &mut app, + user1(), + routing, + enc_pub_key, + ciphertext, + coord_pub_key, + nullifier, + ballot_proof, + ) + .unwrap(); + assert_eq!( + contract.get_hybrid_msg_chain_length(&app).unwrap(), + Uint256::from_u128((i + 1) as u128) + ); + } + + // ProcessHybridBatch must wait for StartProcessPeriod. + let too_soon = contract + .process_hybrid_batch( + &mut app, + owner(), + coord_pub_key, + actual_count, + new_agg_c1.clone(), + new_agg_c2.clone(), + new_nonce_state_root, + process_proof.clone(), + ) + .unwrap_err(); + assert_eq!(ContractError::PeriodError {}, too_soon.downcast().unwrap()); + + app.update_block(|block| { + block.time = Timestamp::from_nanos(1571797424879000000).plus_minutes(12); + }); + contract.start_process(&mut app, owner()).unwrap(); + + // Wrong coordinator must be rejected. + let forged_coord_pub_key = [Uint256::from_u128(1u128), Uint256::from_u128(2u128)]; + let err = contract + .process_hybrid_batch( + &mut app, + owner(), + forged_coord_pub_key, + actual_count, + new_agg_c1.clone(), + new_agg_c2.clone(), + new_nonce_state_root, + process_proof.clone(), + ) + .unwrap_err(); + assert_eq!(ContractError::HybridCoordinatorMismatch {}, err.downcast().unwrap()); + + // Real proof accepted by anyone (permissionless). + contract + .process_hybrid_batch( + &mut app, + user2(), + coord_pub_key, + actual_count, + new_agg_c1.clone(), + new_agg_c2.clone(), + new_nonce_state_root, + process_proof.clone(), + ) + .unwrap(); + assert!(contract.get_hybrid_processed(&app).unwrap()); + let agg = contract.get_hybrid_agg_ciphertext(&app).unwrap(); + assert_eq!(agg.agg_c1, new_agg_c1); + assert_eq!(agg.agg_c2, new_agg_c2); + + // Re-processing is rejected (round already advanced to Tallying). + let err = contract + .process_hybrid_batch( + &mut app, + owner(), + coord_pub_key, + actual_count, + new_agg_c1.clone(), + new_agg_c2.clone(), + new_nonce_state_root, + process_proof.clone(), + ) + .unwrap_err(); + assert_eq!(ContractError::PeriodError {}, err.downcast().unwrap()); + + // RevealHybridTally with a REAL proof. + let (results, salt, participant_pub_keys, participant_indices, reveal_proof) = + hybrid_reveal_fixture(); + contract + .reveal_hybrid_tally( + &mut app, + owner(), + results.clone(), + salt, + participant_pub_keys, + participant_indices, + reveal_proof, + ) + .unwrap(); + let tally = contract.get_hybrid_tally(&app).unwrap().expect("tally should be revealed"); + assert_eq!(tally.results, results); + assert_eq!(tally.salt, salt); + + // Revealing twice is rejected -- period has transitioned to Ended after first reveal. + let (results2, salt2, pp2, pi2, proof2) = hybrid_reveal_fixture(); + let replay = contract + .reveal_hybrid_tally(&mut app, owner(), results2, salt2, pp2, pi2, proof2) + .unwrap_err(); + assert_eq!(ContractError::PeriodError {}, replay.downcast().unwrap()); + } + + #[test] + fn e2e_hybrid_process_partial_batch() { + let mut app = create_app(); + let (coord_pub_key, actual_count, messages, new_agg_c1, new_agg_c2, new_nonce_state_root, process_proof, ballots) = + hybrid_partial_batch_fixture(); + let (kc, voter_pubs, _) = hybrid_publish_fixture(); + let contract = MaciContract::instantiate_hybrid_default( + &mut app, + PubKey { x: coord_pub_key[0], y: coord_pub_key[1] }, + ) + .unwrap(); + + app.update_block(|block| { + block.time = Timestamp::from_nanos(1571797424879000000); + }); + + contract.set_hybrid_kc(&mut app, owner(), kc).unwrap(); + contract.sign_up(&mut app, user1(), voter_pubs[0].clone()).unwrap(); + contract.sign_up(&mut app, user2(), voter_pubs[1].clone()).unwrap(); + contract.sign_up(&mut app, user3(), voter_pubs[2].clone()).unwrap(); + + for (i, (routing, enc_pub_key, ciphertext)) in messages.iter().cloned().enumerate() { + let (nullifier, ballot_proof) = ballots[i].clone(); + contract + .publish_hybrid_message( + &mut app, + user1(), + routing, + enc_pub_key, + ciphertext, + coord_pub_key, + nullifier, + ballot_proof, + ) + .unwrap(); + } + assert_eq!( + contract.get_hybrid_msg_chain_length(&app).unwrap(), + actual_count + ); + assert!(!contract.get_hybrid_processed(&app).unwrap()); + + app.update_block(|block| { + block.time = Timestamp::from_nanos(1571797424879000000).plus_minutes(12); + }); + contract.start_process(&mut app, owner()).unwrap(); + + contract + .process_hybrid_batch( + &mut app, + user2(), + coord_pub_key, + actual_count, + new_agg_c1.clone(), + new_agg_c2.clone(), + new_nonce_state_root, + process_proof, + ) + .unwrap(); + assert!(contract.get_hybrid_processed(&app).unwrap()); + assert_eq!(contract.get_hybrid_processed_count(&app).unwrap(), actual_count); + let agg = contract.get_hybrid_agg_ciphertext(&app).unwrap(); + assert_eq!(agg.agg_c1, new_agg_c1); + assert_eq!(agg.agg_c2, new_agg_c2); + } + + #[test] + fn e2e_hybrid_multi_batch_chaining_preserves_prior_aggregate() { + let mut app = create_app(); + let ( + kc, + voter_pubs, + ballots, + coord_pub_key, + (batch1_actual_count, batch1_messages, batch1_new_agg_c1, batch1_new_agg_c2, batch1_new_nonce_state_root, batch1_proof), + (batch2_actual_count, batch2_messages, batch2_new_agg_c1, batch2_new_agg_c2, batch2_new_nonce_state_root, batch2_proof), + (reveal_results, reveal_salt, reveal_participant_pub_keys, reveal_participant_indices, reveal_proof), + ) = hybrid_multi_batch_fixture(); + use cosmwasm_std::coins; + app.sudo(cw_multi_test::SudoMsg::Bank( + cw_multi_test::BankSudo::Mint { + to_address: user4().to_string(), + amount: coins(100_000_000_000_000_000_000, "peaka"), + }, + )) + .unwrap(); + let contract = MaciContract::instantiate_hybrid_default( + &mut app, + PubKey { x: coord_pub_key[0], y: coord_pub_key[1] }, + ) + .unwrap(); + + app.update_block(|block| { + block.time = Timestamp::from_nanos(1571797424879000000); + }); + + contract.set_hybrid_kc(&mut app, owner(), kc).unwrap(); + // 4 voters: first 3 for user1-3, 4th for user4 + contract.sign_up(&mut app, user1(), voter_pubs[0].clone()).unwrap(); + contract.sign_up(&mut app, user2(), voter_pubs[1].clone()).unwrap(); + contract.sign_up(&mut app, user3(), voter_pubs[2].clone()).unwrap(); + contract.sign_up(&mut app, user4(), voter_pubs[3].clone()).unwrap(); + + // Publish in allMessages order: batch2_messages[0] (allMessages[0]) first, + // then batch1_messages (allMessages[1..5]), to match the fixture hash chain. + { + let (routing, enc_pub_key, ciphertext) = batch2_messages[0].clone(); + let (nullifier, ballot_proof) = ballots[0].clone(); + contract + .publish_hybrid_message( + &mut app, + user1(), + routing, + enc_pub_key, + ciphertext, + coord_pub_key, + nullifier, + ballot_proof, + ) + .unwrap(); + } + for (i, (routing, enc_pub_key, ciphertext)) in batch1_messages.iter().cloned().enumerate() { + let (nullifier, ballot_proof) = ballots[i + 1].clone(); + contract + .publish_hybrid_message( + &mut app, + user1(), + routing, + enc_pub_key, + ciphertext, + coord_pub_key, + nullifier, + ballot_proof, + ) + .unwrap(); + } + assert_eq!( + contract.get_hybrid_msg_chain_length(&app).unwrap(), + Uint256::from_u128(6u128) + ); + + app.update_block(|block| { + block.time = Timestamp::from_nanos(1571797424879000000).plus_minutes(12); + }); + contract.start_process(&mut app, owner()).unwrap(); + + // First contract call: process messages[1..5] (5 messages, reverse order) + contract + .process_hybrid_batch( + &mut app, + owner(), + coord_pub_key, + batch1_actual_count, + batch1_new_agg_c1.clone(), + batch1_new_agg_c2.clone(), + batch1_new_nonce_state_root, + batch1_proof, + ) + .unwrap(); + // Only 5 of 6 processed -- round still in Processing. + assert!(!contract.get_hybrid_processed(&app).unwrap()); + assert_eq!( + contract.get_hybrid_processed_count(&app).unwrap(), + Uint256::from_u128(5u128) + ); + assert_eq!( + contract.get_period(&app).unwrap(), + Period { status: PeriodStatus::Processing } + ); + + // Second contract call: process message[0] (1 message) + contract + .process_hybrid_batch( + &mut app, + owner(), + coord_pub_key, + batch2_actual_count, + batch2_new_agg_c1.clone(), + batch2_new_agg_c2.clone(), + batch2_new_nonce_state_root, + batch2_proof, + ) + .unwrap(); + assert!(contract.get_hybrid_processed(&app).unwrap()); + let agg = contract.get_hybrid_agg_ciphertext(&app).unwrap(); + assert_eq!(agg.agg_c1, batch2_new_agg_c1); + assert_eq!(agg.agg_c2, batch2_new_agg_c2); + + // Reveal the final tally. + contract + .reveal_hybrid_tally( + &mut app, + owner(), + reveal_results.clone(), + reveal_salt, + reveal_participant_pub_keys, + reveal_participant_indices, + reveal_proof, + ) + .unwrap(); + let tally = contract.get_hybrid_tally(&app).unwrap().expect("tally should be revealed"); + assert_eq!(tally.results, reveal_results); + assert_eq!(tally.salt, reveal_salt); + } + + #[test] + fn e2e_hybrid_process_rejects_wrong_actual_count_mid_chain() { + let mut app = create_app(); + let ( + kc, + voter_pubs, + ballots, + coord_pub_key, + (batch1_actual_count, batch1_messages, batch1_new_agg_c1, batch1_new_agg_c2, batch1_new_nonce_state_root, batch1_proof), + (_, batch2_messages, _, _, _, _), + _, + ) = hybrid_multi_batch_fixture(); + use cosmwasm_std::coins; + app.sudo(cw_multi_test::SudoMsg::Bank( + cw_multi_test::BankSudo::Mint { + to_address: user4().to_string(), + amount: coins(100_000_000_000_000_000_000, "peaka"), + }, + )) + .unwrap(); + let contract = MaciContract::instantiate_hybrid_default( + &mut app, + PubKey { x: coord_pub_key[0], y: coord_pub_key[1] }, + ) + .unwrap(); + + app.update_block(|block| { + block.time = Timestamp::from_nanos(1571797424879000000); + }); + + contract.set_hybrid_kc(&mut app, owner(), kc).unwrap(); + contract.sign_up(&mut app, user1(), voter_pubs[0].clone()).unwrap(); + contract.sign_up(&mut app, user2(), voter_pubs[1].clone()).unwrap(); + contract.sign_up(&mut app, user3(), voter_pubs[2].clone()).unwrap(); + contract.sign_up(&mut app, user4(), voter_pubs[3].clone()).unwrap(); + + // Publish in allMessages order: batch2_messages[0] (allMessages[0]) first, + // then batch1_messages (allMessages[1..5]), to match the fixture hash chain. + { + let (routing, enc_pub_key, ciphertext) = batch2_messages[0].clone(); + let (nullifier, ballot_proof) = ballots[0].clone(); + contract + .publish_hybrid_message( + &mut app, + user1(), + routing, + enc_pub_key, + ciphertext, + coord_pub_key, + nullifier, + ballot_proof, + ) + .unwrap(); + } + for (i, (routing, enc_pub_key, ciphertext)) in batch1_messages.iter().cloned().enumerate() { + let (nullifier, ballot_proof) = ballots[i + 1].clone(); + contract + .publish_hybrid_message( + &mut app, + user1(), + routing, + enc_pub_key, + ciphertext, + coord_pub_key, + nullifier, + ballot_proof, + ) + .unwrap(); + } + assert_eq!( + contract.get_hybrid_msg_chain_length(&app).unwrap(), + Uint256::from_u128(6u128) + ); + + app.update_block(|block| { + block.time = Timestamp::from_nanos(1571797424879000000).plus_minutes(12); + }); + contract.start_process(&mut app, owner()).unwrap(); + + // First chained call (reverse processing): consumes messages[1..5] + contract + .process_hybrid_batch( + &mut app, + user2(), + coord_pub_key, + batch1_actual_count, + batch1_new_agg_c1.clone(), + batch1_new_agg_c2.clone(), + batch1_new_nonce_state_root, + batch1_proof, + ) + .unwrap(); + assert!(!contract.get_hybrid_processed(&app).unwrap()); + assert_eq!( + contract.get_hybrid_processed_count(&app).unwrap(), + Uint256::from_u128(5u128) + ); + assert_eq!( + contract.get_period(&app).unwrap(), + Period { status: PeriodStatus::Processing } + ); + + // Second call with wrong count (2 instead of 1) must be rejected. + let dummy_proof = Groth16ProofType { + a: "0".repeat(130), + b: "0".repeat(258), + c: "0".repeat(130), + }; + let identity_agg: Vec<[Uint256; 2]> = vec![[Uint256::zero(), Uint256::from_u128(1u128)]; 5]; + let err = contract + .process_hybrid_batch( + &mut app, + user2(), + coord_pub_key, + Uint256::from_u128(2u128), + identity_agg.clone(), + identity_agg, + batch1_new_nonce_state_root, + dummy_proof, + ) + .unwrap_err(); + assert_eq!( + ContractError::HybridBatchNotReady { + expected: Uint256::from_u128(1u128), + actual: Uint256::from_u128(2u128), + }, + err.downcast().unwrap() + ); + // Round remains in Processing. + assert!(!contract.get_hybrid_processed(&app).unwrap()); + assert_eq!( + contract.get_hybrid_processed_count(&app).unwrap(), + Uint256::from_u128(5u128) + ); + } + + #[test] + fn e2e_hybrid_publish_allows_beyond_batch_size() { + // A round should accept MORE than batch_size (5) published messages -- + // multi-batch processing handles the remainder in follow-up calls. + let mut app = create_app(); + let ( + kc, + voter_pubs, + ballots, + coord_pub_key, + (batch1_actual_count, batch1_messages, batch1_new_agg_c1, batch1_new_agg_c2, batch1_new_nonce_state_root, batch1_proof), + (_, batch2_messages, _, _, _, _), + _, + ) = hybrid_multi_batch_fixture(); + use cosmwasm_std::coins; + app.sudo(cw_multi_test::SudoMsg::Bank( + cw_multi_test::BankSudo::Mint { + to_address: user4().to_string(), + amount: coins(100_000_000_000_000_000_000, "peaka"), + }, + )) + .unwrap(); + let contract = MaciContract::instantiate_hybrid_default( + &mut app, + PubKey { x: coord_pub_key[0], y: coord_pub_key[1] }, + ) + .unwrap(); + + app.update_block(|block| { + block.time = Timestamp::from_nanos(1571797424879000000); + }); + + contract.set_hybrid_kc(&mut app, owner(), kc).unwrap(); + contract.sign_up(&mut app, user1(), voter_pubs[0].clone()).unwrap(); + contract.sign_up(&mut app, user2(), voter_pubs[1].clone()).unwrap(); + contract.sign_up(&mut app, user3(), voter_pubs[2].clone()).unwrap(); + contract.sign_up(&mut app, user4(), voter_pubs[3].clone()).unwrap(); + + // Publish in allMessages order: batch2_messages[0] first (allMessages[0]) then + // batch1_messages (allMessages[1..5]). First 5 messages fill one batch. + { + let (routing, enc_pub_key, ciphertext) = batch2_messages[0].clone(); + let (nullifier, ballot_proof) = ballots[0].clone(); + contract + .publish_hybrid_message( + &mut app, + user1(), + routing, + enc_pub_key, + ciphertext, + coord_pub_key, + nullifier, + ballot_proof, + ) + .unwrap(); + } + for (i, (routing, enc_pub_key, ciphertext)) in batch1_messages[..4].iter().cloned().enumerate() { + let (nullifier, ballot_proof) = ballots[i + 1].clone(); + contract + .publish_hybrid_message( + &mut app, + user1(), + routing, + enc_pub_key, + ciphertext, + coord_pub_key, + nullifier, + ballot_proof, + ) + .unwrap(); + } + assert_eq!( + contract.get_hybrid_msg_chain_length(&app).unwrap(), + Uint256::from_u128(5u128) + ); + + // Publish the 6th message (beyond batch_size=5) -- must succeed. + let (routing, enc_pub_key, ciphertext) = batch1_messages[4].clone(); + let (nullifier, ballot_proof) = ballots[5].clone(); + contract + .publish_hybrid_message( + &mut app, + user1(), + routing, + enc_pub_key, + ciphertext, + coord_pub_key, + nullifier, + ballot_proof, + ) + .unwrap(); + assert_eq!( + contract.get_hybrid_msg_chain_length(&app).unwrap(), + Uint256::from_u128(6u128) + ); + } + + #[test] + fn e2e_hybrid_publish_rejects_batch_full() { + // A voter who didn't publish during voting can't publish during processing. + let mut app = create_app(); + let (coord_pub_key, actual_count, messages, new_agg_c1, new_agg_c2, new_nonce_state_root, process_proof) = + hybrid_process_fixture(); + let (kc, voter_pubs, ballots) = hybrid_publish_fixture(); + let contract = MaciContract::instantiate_hybrid_default( + &mut app, + PubKey { x: coord_pub_key[0], y: coord_pub_key[1] }, + ) + .unwrap(); + + app.update_block(|block| { + block.time = Timestamp::from_nanos(1571797424879000000); + }); + + contract.set_hybrid_kc(&mut app, owner(), kc).unwrap(); + contract.sign_up(&mut app, user1(), voter_pubs[0].clone()).unwrap(); + contract.sign_up(&mut app, user2(), voter_pubs[1].clone()).unwrap(); + contract.sign_up(&mut app, user3(), voter_pubs[2].clone()).unwrap(); + + for (i, (routing, enc_pub_key, ciphertext)) in messages.iter().cloned().enumerate() { + let (nullifier, ballot_proof) = ballots[i].clone(); + contract + .publish_hybrid_message( + &mut app, + user1(), + routing, + enc_pub_key, + ciphertext, + coord_pub_key, + nullifier, + ballot_proof, + ) + .unwrap(); + } + + // Advance past voting window and start processing. + app.update_block(|block| { + block.time = Timestamp::from_nanos(1571797424879000000).plus_minutes(12); + }); + contract.start_process(&mut app, owner()).unwrap(); + + // Attempt to publish during processing -- must be rejected. + let (routing0, enc_pub_key0, ciphertext0) = messages[0].clone(); + let (nullifier0, ballot_proof0) = ballots[0].clone(); + let err = contract + .publish_hybrid_message( + &mut app, + user1(), + routing0, + enc_pub_key0, + ciphertext0, + coord_pub_key, + nullifier0, + ballot_proof0, + ) + .unwrap_err(); + assert_eq!(ContractError::PeriodError {}, err.downcast().unwrap()); + } + + #[test] + fn e2e_hybrid_publish_rejects_off_curve_ciphertext_point() { + let mut app = create_app(); + let (coord_pub_key, _, messages, _, _, _, _) = hybrid_process_fixture(); + let (kc, voter_pubs, ballots) = hybrid_publish_fixture(); + let contract = MaciContract::instantiate_hybrid_default( + &mut app, + PubKey { x: coord_pub_key[0], y: coord_pub_key[1] }, + ) + .unwrap(); + + app.update_block(|block| { + block.time = Timestamp::from_nanos(1571797424879000000); + }); + + contract.set_hybrid_kc(&mut app, owner(), kc).unwrap(); + contract.sign_up(&mut app, user1(), voter_pubs[0].clone()).unwrap(); + contract.sign_up(&mut app, user2(), voter_pubs[1].clone()).unwrap(); + contract.sign_up(&mut app, user3(), voter_pubs[2].clone()).unwrap(); + + // Craft a message with an off-curve ciphertext point: (1,1) not on BabyJubjub. + let (routing, enc_pub_key, mut ciphertext) = messages[0].clone(); + ciphertext.c1[0] = [Uint256::from_u128(1u128), Uint256::from_u128(1u128)]; + let (nullifier, ballot_proof) = ballots[0].clone(); + let err = contract + .publish_hybrid_message( + &mut app, + user1(), + routing, + enc_pub_key, + ciphertext, + coord_pub_key, + nullifier, + ballot_proof, + ) + .unwrap_err(); + assert_eq!(ContractError::HybridInvalidCiphertextPoint {}, err.downcast().unwrap()); + } + + #[test] + fn e2e_hybrid_process_rejects_off_curve_aggregate_point() { + let mut app = create_app(); + let (coord_pub_key, actual_count, messages, new_agg_c1, new_agg_c2, new_nonce_state_root, process_proof) = + hybrid_process_fixture(); + let (kc, voter_pubs, ballots) = hybrid_publish_fixture(); + let contract = MaciContract::instantiate_hybrid_default( + &mut app, + PubKey { x: coord_pub_key[0], y: coord_pub_key[1] }, + ) + .unwrap(); + + app.update_block(|block| { + block.time = Timestamp::from_nanos(1571797424879000000); + }); + + contract.set_hybrid_kc(&mut app, owner(), kc).unwrap(); + contract.sign_up(&mut app, user1(), voter_pubs[0].clone()).unwrap(); + contract.sign_up(&mut app, user2(), voter_pubs[1].clone()).unwrap(); + contract.sign_up(&mut app, user3(), voter_pubs[2].clone()).unwrap(); + + for (i, (routing, enc_pub_key, ciphertext)) in messages.iter().cloned().enumerate() { + let (nullifier, ballot_proof) = ballots[i].clone(); + contract + .publish_hybrid_message( + &mut app, + user1(), + routing, + enc_pub_key, + ciphertext, + coord_pub_key, + nullifier, + ballot_proof, + ) + .unwrap(); + } + + app.update_block(|block| { + block.time = Timestamp::from_nanos(1571797424879000000).plus_minutes(12); + }); + contract.start_process(&mut app, owner()).unwrap(); + + // Submit off-curve agg_c1 point. + let mut corrupted_agg_c1 = new_agg_c1.clone(); + corrupted_agg_c1[0] = [Uint256::from_u128(1u128), Uint256::from_u128(1u128)]; + let err = contract + .process_hybrid_batch( + &mut app, + user2(), + coord_pub_key, + actual_count, + corrupted_agg_c1, + new_agg_c2, + new_nonce_state_root, + process_proof, + ) + .unwrap_err(); + assert_eq!(ContractError::HybridInvalidAggregatePoint {}, err.downcast().unwrap()); + } + + #[test] + fn e2e_hybrid_confirm_kc_rejects_non_member() { + let mut app = create_app(); + let (coord_pub_key, ..) = hybrid_process_fixture(); + let contract = MaciContract::instantiate_hybrid_with_committee( + &mut app, + PubKey { x: coord_pub_key[0], y: coord_pub_key[1] }, + Some(hybrid_committee_fixture()), + ) + .unwrap(); + + let (kc, ..) = hybrid_publish_fixture(); + let err = contract.confirm_hybrid_kc(&mut app, user1(), kc).unwrap_err(); + assert_eq!(ContractError::HybridNotCommitteeMember {}, err.downcast().unwrap()); + } + + #[test] + fn e2e_hybrid_confirm_kc_rejects_when_no_committee_configured() { + let mut app = create_app(); + let (coord_pub_key, ..) = hybrid_process_fixture(); + let contract = MaciContract::instantiate_hybrid_default( + &mut app, + PubKey { x: coord_pub_key[0], y: coord_pub_key[1] }, + ) + .unwrap(); + + let (kc, ..) = hybrid_publish_fixture(); + let err = contract.confirm_hybrid_kc(&mut app, committee1(), kc).unwrap_err(); + assert_eq!(ContractError::HybridCommitteeNotConfigured {}, err.downcast().unwrap()); + } + + #[test] + fn e2e_hybrid_set_kc_rejected_when_committee_configured() { + let mut app = create_app(); + let (coord_pub_key, ..) = hybrid_process_fixture(); + let contract = MaciContract::instantiate_hybrid_with_committee( + &mut app, + PubKey { x: coord_pub_key[0], y: coord_pub_key[1] }, + Some(hybrid_committee_fixture()), + ) + .unwrap(); + + let (kc, ..) = hybrid_publish_fixture(); + let err = contract.set_hybrid_kc(&mut app, owner(), kc).unwrap_err(); + assert_eq!(ContractError::HybridCommitteeConfirmationRequired {}, err.downcast().unwrap()); + } + + #[test] + fn e2e_hybrid_confirm_kc_finalizes_at_threshold_and_full_flow_proceeds() { + let mut app = create_app(); + let (coord_pub_key, actual_count, messages, new_agg_c1, new_agg_c2, new_nonce_state_root, process_proof) = + hybrid_process_fixture(); + let (kc, voter_pubs, ballots) = hybrid_publish_fixture(); + let contract = MaciContract::instantiate_hybrid_with_committee( + &mut app, + PubKey { x: coord_pub_key[0], y: coord_pub_key[1] }, + Some(hybrid_committee_fixture()), + ) + .unwrap(); + + app.update_block(|block| { + block.time = Timestamp::from_nanos(1571797424879000000); + }); + + // First committee member confirms Kc -- not yet at threshold (2). + contract.confirm_hybrid_kc(&mut app, committee1(), kc).unwrap(); + assert_eq!(contract.get_hybrid_kc(&app).unwrap(), None); + + // Second committee member's confirmation reaches threshold -- Kc is finalized. + contract.confirm_hybrid_kc(&mut app, committee2(), kc).unwrap(); + assert_eq!(contract.get_hybrid_kc(&app).unwrap(), Some(kc)); + + // Further confirmations return HybridKcAlreadySet once threshold is reached. + let already = contract.confirm_hybrid_kc(&mut app, committee3(), kc).unwrap_err(); + assert_eq!(ContractError::HybridKcAlreadySet {}, already.downcast().unwrap()); + + // Sign up and publish. + contract.sign_up(&mut app, user1(), voter_pubs[0].clone()).unwrap(); + contract.sign_up(&mut app, user2(), voter_pubs[1].clone()).unwrap(); + contract.sign_up(&mut app, user3(), voter_pubs[2].clone()).unwrap(); + + for (i, (routing, enc_pub_key, ciphertext)) in messages.iter().cloned().enumerate() { + let (nullifier, ballot_proof) = ballots[i].clone(); + contract + .publish_hybrid_message( + &mut app, + user1(), + routing, + enc_pub_key, + ciphertext, + coord_pub_key, + nullifier, + ballot_proof, + ) + .unwrap(); + } + + app.update_block(|block| { + block.time = Timestamp::from_nanos(1571797424879000000).plus_minutes(12); + }); + contract.start_process(&mut app, owner()).unwrap(); + + contract + .process_hybrid_batch( + &mut app, + user2(), + coord_pub_key, + actual_count, + new_agg_c1, + new_agg_c2, + new_nonce_state_root, + process_proof, + ) + .unwrap(); + assert!(contract.get_hybrid_processed(&app).unwrap()); + } + + #[test] + fn e2e_hybrid_committee_threshold_mismatch_rejected_at_instantiate() { + let mut app = create_app(); + let (coord_pub_key, ..) = hybrid_process_fixture(); + let mut committee = hybrid_committee_fixture(); + committee.threshold = 3; // HYBRID_REVEAL_THRESHOLD is 2 + let err = MaciContract::instantiate_hybrid_with_committee( + &mut app, + PubKey { x: coord_pub_key[0], y: coord_pub_key[1] }, + Some(committee), + ) + .unwrap_err(); + assert_eq!( + ContractError::HybridCommitteeThresholdMismatch { + committee_threshold: 3, + circuit_threshold: 2, + }, + err.downcast().unwrap() + ); + } + + #[test] + fn e2e_hybrid_stop_processing_period_rejected_with_unprocessed_hybrid_messages() { + // Publishing 1 hybrid message then trying to call stop_processing + // (classic StopProcessingPeriod) must be rejected because the hybrid + // message hasn't been processed yet via ProcessHybridBatch. + let mut app = create_app(); + let (coord_pub_key, _, messages, _, _, _, _) = hybrid_process_fixture(); + let (kc, voter_pubs, ballots) = hybrid_publish_fixture(); + let contract = MaciContract::instantiate_hybrid_default( + &mut app, + PubKey { x: coord_pub_key[0], y: coord_pub_key[1] }, + ) + .unwrap(); + + app.update_block(|block| { + block.time = Timestamp::from_nanos(1571797424879000000); + }); + + contract.set_hybrid_kc(&mut app, owner(), kc).unwrap(); + contract.sign_up(&mut app, user1(), voter_pubs[0].clone()).unwrap(); + contract.sign_up(&mut app, user2(), voter_pubs[1].clone()).unwrap(); + contract.sign_up(&mut app, user3(), voter_pubs[2].clone()).unwrap(); + + // Publish just 1 message. + let (routing0, enc_pub_key0, ciphertext0) = messages[0].clone(); + let (nullifier0, ballot_proof0) = ballots[0].clone(); + contract + .publish_hybrid_message( + &mut app, + user1(), + routing0, + enc_pub_key0, + ciphertext0, + coord_pub_key, + nullifier0, + ballot_proof0, + ) + .unwrap(); + + app.update_block(|block| { + block.time = Timestamp::from_nanos(1571797424879000000).plus_minutes(12); + }); + contract.start_process(&mut app, owner()).unwrap(); + + // stop_processing while hybrid messages are unprocessed must be rejected. + let err = contract.stop_processing(&mut app, user1()).unwrap_err(); + assert_eq!( + ContractError::HybridMsgLeftProcess { + remaining: Uint256::from_u128(1u128), + }, + err.downcast().unwrap() + ); + } + + #[test] + fn e2e_hybrid_stop_tallying_period_rejected_before_reveal() { + let mut app = create_app(); + let (coord_pub_key, actual_count, messages, new_agg_c1, new_agg_c2, new_nonce_state_root, process_proof) = + hybrid_process_fixture(); + let (kc, voter_pubs, ballots) = hybrid_publish_fixture(); + let contract = MaciContract::instantiate_hybrid_default( + &mut app, + PubKey { x: coord_pub_key[0], y: coord_pub_key[1] }, + ) + .unwrap(); + + app.update_block(|block| { + block.time = Timestamp::from_nanos(1571797424879000000); + }); + + contract.set_hybrid_kc(&mut app, owner(), kc).unwrap(); + contract.sign_up(&mut app, user1(), voter_pubs[0].clone()).unwrap(); + contract.sign_up(&mut app, user2(), voter_pubs[1].clone()).unwrap(); + contract.sign_up(&mut app, user3(), voter_pubs[2].clone()).unwrap(); + + for (i, (routing, enc_pub_key, ciphertext)) in messages.iter().cloned().enumerate() { + let (nullifier, ballot_proof) = ballots[i].clone(); + contract + .publish_hybrid_message( + &mut app, + user1(), + routing, + enc_pub_key, + ciphertext, + coord_pub_key, + nullifier, + ballot_proof, + ) + .unwrap(); + } + + app.update_block(|block| { + block.time = Timestamp::from_nanos(1571797424879000000).plus_minutes(12); + }); + contract.start_process(&mut app, owner()).unwrap(); + + // Full batch processing automatically advances to Tallying. + contract + .process_hybrid_batch( + &mut app, + owner(), + coord_pub_key, + actual_count, + new_agg_c1, + new_agg_c2, + new_nonce_state_root, + process_proof, + ) + .unwrap(); + + // Round is now in Tallying but HYBRID_TALLY not yet revealed. + // stop_tallying must be blocked until RevealHybridTally is called. + let err = contract + .stop_tallying(&mut app, user2(), vec![Uint256::zero(); 5], Uint256::zero()) + .unwrap_err(); + assert_eq!(ContractError::HybridTallyNotYetRevealed {}, err.downcast().unwrap()); + } + + #[test] + fn e2e_hybrid_reveal_rejects_off_curve_participant_pubkey() { + let mut app = create_app(); + let (coord_pub_key, actual_count, messages, new_agg_c1, new_agg_c2, new_nonce_state_root, process_proof) = + hybrid_process_fixture(); + let (kc, voter_pubs, ballots) = hybrid_publish_fixture(); + let contract = MaciContract::instantiate_hybrid_default( + &mut app, + PubKey { x: coord_pub_key[0], y: coord_pub_key[1] }, + ) + .unwrap(); + + app.update_block(|block| { + block.time = Timestamp::from_nanos(1571797424879000000); + }); + + contract.set_hybrid_kc(&mut app, owner(), kc).unwrap(); + contract.sign_up(&mut app, user1(), voter_pubs[0].clone()).unwrap(); + contract.sign_up(&mut app, user2(), voter_pubs[1].clone()).unwrap(); + contract.sign_up(&mut app, user3(), voter_pubs[2].clone()).unwrap(); + + for (i, (routing, enc_pub_key, ciphertext)) in messages.iter().cloned().enumerate() { + let (nullifier, ballot_proof) = ballots[i].clone(); + contract + .publish_hybrid_message( + &mut app, + user1(), + routing, + enc_pub_key, + ciphertext, + coord_pub_key, + nullifier, + ballot_proof, + ) + .unwrap(); + } + + app.update_block(|block| { + block.time = Timestamp::from_nanos(1571797424879000000).plus_minutes(12); + }); + contract.start_process(&mut app, owner()).unwrap(); + contract + .process_hybrid_batch( + &mut app, + owner(), + coord_pub_key, + actual_count, + new_agg_c1, + new_agg_c2, + new_nonce_state_root, + process_proof, + ) + .unwrap(); + + let (results, salt, mut participant_pub_keys, participant_indices, reveal_proof) = + hybrid_reveal_fixture(); + // Corrupt first participant's pubkey to off-curve point. + participant_pub_keys[0] = PubKey { + x: Uint256::from_u128(1u128), + y: Uint256::from_u128(1u128), + }; + let err = contract + .reveal_hybrid_tally( + &mut app, + owner(), + results, + salt, + participant_pub_keys, + participant_indices, + reveal_proof, + ) + .unwrap_err(); + assert_eq!(ContractError::HybridInvalidParticipantPubKey {}, err.downcast().unwrap()); + } + + #[test] + fn e2e_hybrid_reveal_rejects_duplicate_participant_indices() { + let mut app = create_app(); + let (coord_pub_key, actual_count, messages, new_agg_c1, new_agg_c2, new_nonce_state_root, process_proof) = + hybrid_process_fixture(); + let (kc, voter_pubs, ballots) = hybrid_publish_fixture(); + let contract = MaciContract::instantiate_hybrid_default( + &mut app, + PubKey { x: coord_pub_key[0], y: coord_pub_key[1] }, + ) + .unwrap(); + + app.update_block(|block| { + block.time = Timestamp::from_nanos(1571797424879000000); + }); + + contract.set_hybrid_kc(&mut app, owner(), kc).unwrap(); + contract.sign_up(&mut app, user1(), voter_pubs[0].clone()).unwrap(); + contract.sign_up(&mut app, user2(), voter_pubs[1].clone()).unwrap(); + contract.sign_up(&mut app, user3(), voter_pubs[2].clone()).unwrap(); + + for (i, (routing, enc_pub_key, ciphertext)) in messages.iter().cloned().enumerate() { + let (nullifier, ballot_proof) = ballots[i].clone(); + contract + .publish_hybrid_message( + &mut app, + user1(), + routing, + enc_pub_key, + ciphertext, + coord_pub_key, + nullifier, + ballot_proof, + ) + .unwrap(); + } + + app.update_block(|block| { + block.time = Timestamp::from_nanos(1571797424879000000).plus_minutes(12); + }); + contract.start_process(&mut app, owner()).unwrap(); + contract + .process_hybrid_batch( + &mut app, + owner(), + coord_pub_key, + actual_count, + new_agg_c1, + new_agg_c2, + new_nonce_state_root, + process_proof, + ) + .unwrap(); + + let (results, salt, participant_pub_keys, mut participant_indices, reveal_proof) = + hybrid_reveal_fixture(); + // Make indices duplicate. + participant_indices[1] = participant_indices[0].clone(); + let err = contract + .reveal_hybrid_tally( + &mut app, + owner(), + results, + salt, + participant_pub_keys, + participant_indices, + reveal_proof, + ) + .unwrap_err(); + assert_eq!(ContractError::HybridRevealDuplicateParticipant {}, err.downcast().unwrap()); + } + + #[test] + fn e2e_hybrid_instantiate_rejects_wrong_vote_option_count() { + let mut app = create_app(); + let (coord_pub_key, ..) = hybrid_process_fixture(); + let coordinator = PubKey { x: coord_pub_key[0], y: coord_pub_key[1] }; + + // 3 options — must be rejected (HYBRID_M = 5) + let err = MaciContract::try_instantiate_hybrid_with_vote_option_map( + &mut app, + coordinator.clone(), + vec!["A".to_string(), "B".to_string(), "C".to_string()], + ) + .unwrap_err(); + let msg = format!("{err:?}"); + assert!( + msg.contains("vote_option_map length") || msg.contains("HybridVoteOptionMapMismatch"), + "expected vote_option_map length mismatch for 3 options, got: {err:?}" + ); + + // 6 options — also rejected (hits MaxVoteOptionsExceeded before our check, + // since 6 > 5^vote_option_tree_depth=1=5, but the important thing is it fails) + let mut app2 = create_app(); + MaciContract::try_instantiate_hybrid_with_vote_option_map( + &mut app2, + coordinator.clone(), + vec!["A".to_string(), "B".to_string(), "C".to_string(), + "D".to_string(), "E".to_string(), "F".to_string()], + ) + .expect_err("6 options must be rejected"); + + // Exactly 5 options — must succeed + let mut app3 = create_app(); + MaciContract::try_instantiate_hybrid_with_vote_option_map( + &mut app3, + coordinator, + vec!["A".to_string(), "B".to_string(), "C".to_string(), + "D".to_string(), "E".to_string()], + ) + .expect("5 options must be accepted"); + } + + #[test] + fn e2e_hybrid_publish_rejects_cross_message_ballot_proof() { + // ballot_validity proof is bound to (routingCommitment, aheCommitment, + // stateRoot, Kc). Reusing a proof generated for message[0] to publish + // message[1]'s content must fail the on-chain verifier. + let mut app = create_app(); + let (kc, voter_pubs, ballots) = hybrid_publish_fixture(); + let (coord_pub_key, _, messages, _, _, _, _) = hybrid_process_fixture(); + let contract = MaciContract::instantiate_hybrid_default( + &mut app, + PubKey { x: coord_pub_key[0], y: coord_pub_key[1] }, + ) + .unwrap(); + + app.update_block(next_block); + + contract.set_hybrid_kc(&mut app, owner(), kc).unwrap(); + contract.sign_up(&mut app, user1(), voter_pubs[0].clone()).unwrap(); + contract.sign_up(&mut app, user2(), voter_pubs[1].clone()).unwrap(); + contract.sign_up(&mut app, user3(), voter_pubs[2].clone()).unwrap(); + + // Use message[1]'s routing + ciphertext but ballot proof from message[0]. + // The proof commits to message[0]'s routingCommitment/aheCommitment, so + // the verifier will reject the mismatch. + let (routing1, enc_pub_key1, ciphertext1) = messages[1].clone(); + let (nullifier0, ballot_proof0) = ballots[0].clone(); // proof for message[0] + + let err = contract + .publish_hybrid_message( + &mut app, + user1(), + routing1, + enc_pub_key1, + ciphertext1, + coord_pub_key, + nullifier0, + ballot_proof0, + ) + .unwrap_err(); + // The groth16 verifier rejects a mismatched proof. + assert!( + format!("{err:?}").contains("HybridBallotProofInvalid") + || format!("{err:?}").contains("verify failed"), + "expected ballot proof rejection, got: {err:?}" + ); + } + + #[test] + fn e2e_verify_hybrid_ballot_accepts_real_proof_via_query_entrypoint() { + let mut app = create_app(); + let contract = MaciContract::instantiate_default(&mut app, true).unwrap(); + + let (kc, state_root, coord_pub_key, poll_id, routing_commitment, ahe_commitment, nullifier, proof) = + hybrid_ballot_fixture(); + let ok = contract + .verify_hybrid_ballot( + &app, + kc, + state_root, + coord_pub_key, + poll_id, + routing_commitment, + ahe_commitment, + nullifier, + proof, + ) + .unwrap(); + assert!(ok, "a valid hybrid ballot proof must verify through the query entry point"); + } + + #[test] + fn e2e_verify_hybrid_ballot_rejects_tampered_state_root_via_query_entrypoint() { + let mut app = create_app(); + let contract = MaciContract::instantiate_default(&mut app, true).unwrap(); + + let (kc, _state_root, coord_pub_key, poll_id, routing_commitment, ahe_commitment, nullifier, proof) = + hybrid_ballot_fixture(); + let ok = contract + .verify_hybrid_ballot( + &app, + kc, + Uint256::from_u128(999_999u128), + coord_pub_key, + poll_id, + routing_commitment, + ahe_commitment, + nullifier, + proof, + ) + .unwrap(); + assert!(!ok, "a proof replayed against a different state root must be rejected"); + } + + #[test] + fn e2e_verify_hybrid_ballot_rejects_tampered_ahe_commitment_via_query_entrypoint() { + let mut app = create_app(); + let contract = MaciContract::instantiate_default(&mut app, true).unwrap(); + + let (kc, state_root, coord_pub_key, poll_id, routing_commitment, _ahe_commitment, nullifier, proof) = + hybrid_ballot_fixture(); + let ok = contract + .verify_hybrid_ballot( + &app, + kc, + state_root, + coord_pub_key, + poll_id, + routing_commitment, + Uint256::from_u128(1u128), + nullifier, + proof, + ) + .unwrap(); + assert!(!ok, "a proof replayed against a different aheCommitment must be rejected"); + } +} diff --git a/contracts/amaci/src/state.rs b/contracts/amaci/src/state.rs index da5de14..8bded5d 100644 --- a/contracts/amaci/src/state.rs +++ b/contracts/amaci/src/state.rs @@ -231,6 +231,100 @@ pub const PROCESSED_USER_COUNT: Item = Item::new("processed_user_count" // Storage for tracking used enc_pub_keys to ensure uniqueness pub const USED_ENC_PUB_KEYS: Map, bool> = Map::new("used_enc_pub_keys"); +// ============================================ +// Hybrid MACI + AHE on-chain flow (routing messages + homomorphic aggregation) +// ============================================ +// +// Routing envelopes are format-identical to classic `MessageData` ([Uint256; 10], +// Poseidon-encrypted), so they reuse the same hash-chain algorithm +// (`hash_message_and_enc_pub_key`) the classic flow already uses — this hybrid chain +// is just stored separately so it doesn't interfere with classic PublishMessage/ +// ProcessMessage. The AHE ballot ciphertext (per-option, committee-encrypted; the +// coordinator can never decrypt it) travels alongside each routing envelope so +// anyone can re-derive `ProcessHybridBatch`'s witness directly from chain state. +#[cw_serde] +pub struct HybridCiphertext { + pub c1: Vec<[Uint256; 2]>, + pub c2: Vec<[Uint256; 2]>, +} + +#[cw_serde] +pub struct HybridPublishedMessage { + pub routing: MessageData, + pub enc_pub_key: PubKey, + pub ciphertext: HybridCiphertext, +} + +// Committee-submitted, salted plaintext tally for the hybrid round. +// `execute_reveal_hybrid_tally` only persists this AFTER verifying a +// `RevealVerifyOnchain` Groth16 proof that these exact `results` are the +// threshold-decrypted plaintext of the on-chain aggregate ciphertext — unlike +// classic StopTallyingPeriod (which never re-derives its `results` from +// anything on-chain either), this is no longer a bare trust commitment: the +// contract itself checks the decryption arithmetic before recording it. +#[cw_serde] +pub struct HybridTally { + pub results: Vec, + pub salt: Uint256, +} + +// One committee member: `addr` is their on-chain wallet, used purely for +// ConfirmHybridKc authentication (a tx signed by this address already IS a +// cryptographic signature check, performed by the chain before the message +// ever reaches this contract — no need to reinvent a second, app-level +// signature scheme). `pubkey` is their DKG/threshold-decryption verification +// key (BabyJubjub), kept alongside the addr so later stages (e.g. a +// RevealHybridTally verification circuit) have one canonical place to read +// "who is on the committee" from, instead of maintaining two separate lists. +#[cw_serde] +pub struct HybridCommitteeMember { + pub addr: Addr, + pub pubkey: PubKey, +} + +// Instantiate-time, immutable committee roster + threshold for confirming +// Kc (see `ConfirmHybridKc`). Optional: rounds that don't configure this +// keep the legacy admin-only `SetHybridKc` path, so existing single-admin +// demos/tests are unaffected. +#[cw_serde] +pub struct HybridCommitteeConfig { + pub members: Vec, + pub threshold: u32, +} + +pub const HYBRID_COMMITTEE: Item = Item::new("hybrid_committee"); +// Each committee member's currently-confirmed Kc value (overwritten if they +// re-confirm with a different value before threshold is reached). Kc is +// finalized once `threshold` members agree on the SAME value. +pub const HYBRID_KC_CONFIRMATIONS: Map = Map::new("hybrid_kc_confirmations"); + +// The threshold committee's AHE public key. Set once — either admin-only via +// `SetHybridKc` (no committee configured) or via `ConfirmHybridKc` reaching +// threshold (committee configured) — before any message can be published, +// since every ballotValidity proof binds to this exact value — without it, +// the contract has nothing fixed to check submitted proofs against. +pub const HYBRID_KC: Item<[Uint256; 2]> = Item::new("hybrid_kc"); +pub const HYBRID_MSG_HASHES: Map, Uint256> = Map::new("hybrid_msg_hashes"); +pub const HYBRID_MSG_CHAIN_LENGTH: Item = Item::new("hybrid_msg_chain_length"); +pub const HYBRID_MESSAGES: Map, HybridPublishedMessage> = Map::new("hybrid_messages"); +// Number of published hybrid messages processed so far (monotonically +// increasing, chained across possibly-multiple ProcessHybridBatch calls — see +// contract.rs). A round is fully processed once this equals +// HYBRID_MSG_CHAIN_LENGTH; defaults to 0 (via `may_load`) before the first +// ProcessHybridBatch call. +pub const HYBRID_PROCESSED_COUNT: Item = Item::new("hybrid_processed_count"); +// Running homomorphic aggregate (still-encrypted; only the threshold committee that +// holds Kc's shares can decrypt it), one BabyJubjub point per vote option. +pub const HYBRID_AGG_C1: Item> = Item::new("hybrid_agg_c1"); +pub const HYBRID_AGG_C2: Item> = Item::new("hybrid_agg_c2"); +pub const HYBRID_TALLY: Item = Item::new("hybrid_tally"); +// Root of the per-voter nonce tree (quinary, depth = stateTreeDepth, leaves +// are raw nonce values initialized to zero). Persists across +// ProcessHybridBatch calls so the circuit can enforce cross-batch LWW via +// the classic MACI "currentNonce+1" check. Initialized to the all-zero +// quinary tree root in execute_start_process_period. +pub const HYBRID_NONCE_STATE_ROOT: Item = Item::new("hybrid_nonce_state_root"); + pub const DMSG_CHAIN_LENGTH: Item = Item::new("dmsg_chain_length"); pub const DMSG_HASHES: Map, Uint256> = Map::new("dmsg_hashes"); pub const STATE_ROOT_BY_DMSG: Map, Uint256> = Map::new("state_root_by_dmsg"); diff --git a/contracts/registry/src/contract.rs b/contracts/registry/src/contract.rs index ea9f2c2..9fc0612 100644 --- a/contracts/registry/src/contract.rs +++ b/contracts/registry/src/contract.rs @@ -242,6 +242,9 @@ pub fn execute_create_round( message_delay: delay_config.message_delay, signup_delay: delay_config.signup_delay, deactivate_delay: delay_config.deactivate_delay, + // Registry-created rounds don't configure a hybrid committee roster + // yet; they keep the legacy admin-only SetHybridKc path. + hybrid_committee: None, }; let amaci_code_id = AMACI_CODE_ID.load(deps.storage)?; diff --git a/crates/maci-utils/src/poseidon.rs b/crates/maci-utils/src/poseidon.rs index d85583f..7a648b0 100644 --- a/crates/maci-utils/src/poseidon.rs +++ b/crates/maci-utils/src/poseidon.rs @@ -156,6 +156,54 @@ mod tests { assert_eq!(result1, result2); } + // Parity check for the Hybrid MACI + AHE demo: the demo builds voter state + // leaves in TypeScript as poseidon([pubKey.x, pubKey.y, voiceCreditBalance, + // voteOptionTreeRoot=0, nonce=0]) via @dorafactory/maci-sdk. cw-amaci's + // StateLeaf::hash_state_leaf uses exactly the same hash5 layout, so the demo + // state root is byte-compatible with what the contract would compute. The + // expected leaf values below are the SDK outputs for the demo voters + // (secret keys 100001/200002/300003, balances 40/30/20). + #[test] + fn test_hybrid_demo_state_leaf_parity() { + use std::str::FromStr; + let cases: [([&str; 3], &str); 3] = [ + ( + [ + "8265454795666596656125240298071702470195051017739827855382555757562575706874", + "17651022999329762667923757296737370771210222197193308584995277648551857814833", + "40", + ], + "1127296107445638511534984671231187737636842790717989554546154860239580059575", + ), + ( + [ + "4715974401250111127699083746268308937561418528846266811736423255542344838416", + "13441991001099811142680021829094172471601907169188638446991109797401190293411", + "30", + ], + "4456751364484491928250178809162904196156502392539018532689614272111314165779", + ), + ( + [ + "4924048713913963616383743613637955931969970144272870671762354653678414365399", + "6826320094766762023406344969915301433742981344912570860537799597515856473618", + "20", + ], + "8179429274750431279080959931091974060399109775891513209620577539512613902117", + ), + ]; + for ([x, y, balance], expected) in cases { + let leaf = hash5([ + Uint256::from_str(x).unwrap(), + Uint256::from_str(y).unwrap(), + Uint256::from_str(balance).unwrap(), + Uint256::zero(), + Uint256::zero(), + ]); + assert_eq!(leaf, Uint256::from_str(expected).unwrap()); + } + } + #[test] fn test_hash5_consistency() { let data = [ diff --git a/packages/circuits/circom/circuits.json b/packages/circuits/circom/circuits.json index 001c4ff..dba521f 100644 --- a/packages/circuits/circom/circuits.json +++ b/packages/circuits/circom/circuits.json @@ -58,5 +58,41 @@ "template": "TallyVotes", "params": [9, 4, 3], "pubs": ["inputHash"] + }, + "BallotValidity_hybrid_2-1": { + "file": "./hybrid/power/ballotValidity", + "template": "BallotValidity", + "params": [2, 1], + "pubs": ["Kc", "stateRoot", "coordPubKey", "pollId"] + }, + "BallotValidityOnchain_hybrid_2-1": { + "file": "./hybrid/power/ballotValidity", + "template": "BallotValidityOnchain", + "params": [2, 1], + "pubs": [] + }, + "ProcessHybridMessages_hybrid_2-1-5": { + "file": "./hybrid/power/processHybridMessages", + "template": "ProcessHybridMessages", + "params": [2, 1, 5], + "pubs": ["message", "encPubKey", "encC1", "encC2", "stateRoot", "actualCount"] + }, + "ProcessHybridMessagesOnchain_hybrid_2-1-5": { + "file": "./hybrid/power/processHybridMessages", + "template": "ProcessHybridMessagesOnchain", + "params": [2, 1, 5], + "pubs": [] + }, + "RevealVerify_hybrid_1-2": { + "file": "./hybrid/power/revealVerify", + "template": "RevealVerify", + "params": [1, 2], + "pubs": ["Kc", "aggC1", "aggC2", "results", "salt", "participantPubKey", "participantIndex"] + }, + "RevealVerifyOnchain_hybrid_1-2": { + "file": "./hybrid/power/revealVerify", + "template": "RevealVerifyOnchain", + "params": [1, 2], + "pubs": [] } } diff --git a/packages/circuits/circom/hybrid/lib/aheCommit.circom b/packages/circuits/circom/hybrid/lib/aheCommit.circom new file mode 100644 index 0000000..5dbf27c --- /dev/null +++ b/packages/circuits/circom/hybrid/lib/aheCommit.circom @@ -0,0 +1,43 @@ +pragma circom 2.0.0; + +include "../../utils/hasherPoseidon.circom"; + +/** + * Commit to a vote's AHE ciphertext vector so the voter's signature can bind to + * it and the coordinator can check the published ciphertext matches the signed + * command WITHOUT ever decrypting the vote content. + * + * Flatten order (must match the SDK's TS implementation exactly): + * for opt in 0..M: c1[opt].x, c1[opt].y, c2[opt].x, c2[opt].y + * + * Folded 2-to-1 with Poseidon (HashLeftRight): + * acc_0 = 0 ; acc_{k+1} = Poseidon(acc_k, elem_k) ; commitment = acc_N + */ +template AheCommit(M) { + signal input c1[M][2]; + signal input c2[M][2]; + signal output commitment; + + var N = 4 * M; + + // Flatten in the canonical order. + signal flat[N]; + for (var opt = 0; opt < M; opt++) { + flat[opt * 4 + 0] <== c1[opt][0]; + flat[opt * 4 + 1] <== c1[opt][1]; + flat[opt * 4 + 2] <== c2[opt][0]; + flat[opt * 4 + 3] <== c2[opt][1]; + } + + component h[N]; + signal acc[N + 1]; + acc[0] <== 0; + for (var i = 0; i < N; i++) { + h[i] = HashLeftRight(); + h[i].left <== acc[i]; + h[i].right <== flat[i]; + acc[i + 1] <== h[i].hash; + } + + commitment <== acc[N]; +} diff --git a/packages/circuits/circom/hybrid/lib/aheEncrypt.circom b/packages/circuits/circom/hybrid/lib/aheEncrypt.circom new file mode 100644 index 0000000..9581771 --- /dev/null +++ b/packages/circuits/circom/hybrid/lib/aheEncrypt.circom @@ -0,0 +1,55 @@ +pragma circom 2.0.0; + +include "../../../node_modules/circomlib/circuits/babyjub.circom"; +include "../../../node_modules/circomlib/circuits/bitify.circom"; +include "../../../node_modules/circomlib/circuits/escalarmulany.circom"; + +/** + * Additively-homomorphic (exponential) ElGamal encryption on BabyJubjub. + * + * A weight `v` is encoded as the point `v*G`, which makes the scheme additively + * homomorphic: Enc(a) + Enc(b) = Enc(a+b). The committee public key `Kc` is the + * joint threshold key; only the committee (t-of-n) can decrypt the aggregate. + * + * c1 = r*G + * c2 = v*G + r*Kc + * + * G is the BabyJubjub Base8 generator (same as circomlib BabyPbk uses). + */ +template AheEncrypt() { + signal input v; // plaintext weight + signal input r; // encryption randomness (scalar) + signal input Kc[2]; // committee joint public key + + signal output c1[2]; + signal output c2[2]; + + // c1 = r*G + component rG = BabyPbk(); + rG.in <== r; + c1[0] <== rG.Ax; + c1[1] <== rG.Ay; + + // vG = v*G + component vG = BabyPbk(); + vG.in <== v; + + // rKc = r*Kc + component rBits = Num2Bits(253); + rBits.in <== r; + component rKc = EscalarMulAny(253); + rKc.p[0] <== Kc[0]; + rKc.p[1] <== Kc[1]; + for (var i = 0; i < 253; i++) { + rKc.e[i] <== rBits.out[i]; + } + + // c2 = vG + rKc + component add = BabyAdd(); + add.x1 <== vG.Ax; + add.y1 <== vG.Ay; + add.x2 <== rKc.out[0]; + add.y2 <== rKc.out[1]; + c2[0] <== add.xout; + c2[1] <== add.yout; +} diff --git a/packages/circuits/circom/hybrid/lib/condPointAdd.circom b/packages/circuits/circom/hybrid/lib/condPointAdd.circom new file mode 100644 index 0000000..4c47ae4 --- /dev/null +++ b/packages/circuits/circom/hybrid/lib/condPointAdd.circom @@ -0,0 +1,40 @@ +pragma circom 2.0.0; + +include "../../../node_modules/circomlib/circuits/babyjub.circom"; +include "../../../node_modules/circomlib/circuits/mux1.circom"; + +/** + * Conditionally add a BabyJubjub point into an accumulator. + * + * out = acc + (sel ? p : IDENTITY) + * + * IDENTITY is the twisted-Edwards neutral element (0, 1); adding it is a no-op, + * so a non-surviving ballot contributes nothing to the homomorphic tally while + * keeping the circuit's control flow data-independent (sel stays private). + */ +template CondPointAdd() { + signal input acc[2]; + signal input p[2]; + signal input sel; + + signal output out[2]; + + component mx = Mux1(); + mx.c[0] <== 0; // IDENTITY.x + mx.c[1] <== p[0]; + mx.s <== sel; + + component my = Mux1(); + my.c[0] <== 1; // IDENTITY.y + my.c[1] <== p[1]; + my.s <== sel; + + component add = BabyAdd(); + add.x1 <== acc[0]; + add.y1 <== acc[1]; + add.x2 <== mx.out; + add.y2 <== my.out; + + out[0] <== add.xout; + out[1] <== add.yout; +} diff --git a/packages/circuits/circom/hybrid/lib/dleqVerify.circom b/packages/circuits/circom/hybrid/lib/dleqVerify.circom new file mode 100644 index 0000000..db1dc42 --- /dev/null +++ b/packages/circuits/circom/hybrid/lib/dleqVerify.circom @@ -0,0 +1,103 @@ +pragma circom 2.0.0; + +include "../../../node_modules/circomlib/circuits/babyjub.circom"; +include "../../../node_modules/circomlib/circuits/bitify.circom"; +include "../../../node_modules/circomlib/circuits/escalarmulany.circom"; +include "../../utils/hasherPoseidon.circom"; + +/** + * Chaum-Pedersen DLEQ verifier: checks that a committee member's partial + * decryption share Y = x*H was produced with the SAME secret x as their + * registered public key X = x*G, without learning x. This is the exact + * proof structure `mpc-coordinator-demo/src/crypto/dleq.ts` already generates + * for every partial decryption (see `proveDleq`/`verifyDleq`); this template + * moves that (previously off-chain-only) verification into a circuit so + * `RevealHybridTally` can be checked on-chain instead of trusted blindly. + * + * G is the fixed BabyJubjub Base8 generator (same constant `BabyPbk` uses). + * The Fiat-Shamir challenge folds the full public transcript with this + * project's canonical 12-ary Poseidon hasher (`Hasher12`, mirrored by the SDK's + * `hash12` — see `maci/packages/sdk/src/libs/crypto/hashing.ts`): + * + * e = hash12([G, H, X, Y, a, b]) + * check1: z*G == a + e*X + * check2: z*H == b + e*Y + * + * `z` is produced off-circuit as `w + e*x mod L` (L = BabyJubjub subgroup + * order), so it always fits in 253 bits like every other raw scalar in this + * circuit family. `e` is a raw Poseidon output and is NOT reduced mod L + * before use — group exponentiation by any integer scalar s already equals + * exponentiation by (s mod ord(point)), so no explicit mod-L reduction is + * needed; e only needs enough bits (254) to cover the full SNARK field so + * Num2Bits never fails on a hash output larger than 2^253. + */ +template DleqVerify() { + signal input H[2]; + signal input X[2]; + signal input Y[2]; + signal input a[2]; + signal input b[2]; + signal input z; + + var G[2] = [ + 5299619240641551281634865583518297030282874472190772894086521144482721001553, + 16950150798460657717958625567821834550301663161624707787222815936182638968203 + ]; + + component challenge = Hasher12(); + challenge.in[0] <== G[0]; + challenge.in[1] <== G[1]; + challenge.in[2] <== H[0]; + challenge.in[3] <== H[1]; + challenge.in[4] <== X[0]; + challenge.in[5] <== X[1]; + challenge.in[6] <== Y[0]; + challenge.in[7] <== Y[1]; + challenge.in[8] <== a[0]; + challenge.in[9] <== a[1]; + challenge.in[10] <== b[0]; + challenge.in[11] <== b[1]; + + component zBits = Num2Bits(253); + zBits.in <== z; + component eBits = Num2Bits(254); + eBits.in <== challenge.hash; + + // check1: z*G == a + e*X + component zG = BabyPbk(); + zG.in <== z; + + component eX = EscalarMulAny(254); + eX.p[0] <== X[0]; + eX.p[1] <== X[1]; + for (var i = 0; i < 254; i++) { eX.e[i] <== eBits.out[i]; } + + component rhs1 = BabyAdd(); + rhs1.x1 <== a[0]; + rhs1.y1 <== a[1]; + rhs1.x2 <== eX.out[0]; + rhs1.y2 <== eX.out[1]; + + zG.Ax === rhs1.xout; + zG.Ay === rhs1.yout; + + // check2: z*H == b + e*Y + component zH = EscalarMulAny(253); + zH.p[0] <== H[0]; + zH.p[1] <== H[1]; + for (var i = 0; i < 253; i++) { zH.e[i] <== zBits.out[i]; } + + component eY = EscalarMulAny(254); + eY.p[0] <== Y[0]; + eY.p[1] <== Y[1]; + for (var i = 0; i < 254; i++) { eY.e[i] <== eBits.out[i]; } + + component rhs2 = BabyAdd(); + rhs2.x1 <== b[0]; + rhs2.y1 <== b[1]; + rhs2.x2 <== eY.out[0]; + rhs2.y2 <== eY.out[1]; + + zH.out[0] === rhs2.xout; + zH.out[1] === rhs2.yout; +} diff --git a/packages/circuits/circom/hybrid/lib/signedScalarMul.circom b/packages/circuits/circom/hybrid/lib/signedScalarMul.circom new file mode 100644 index 0000000..55c7222 --- /dev/null +++ b/packages/circuits/circom/hybrid/lib/signedScalarMul.circom @@ -0,0 +1,43 @@ +pragma circom 2.0.0; + +include "../../../node_modules/circomlib/circuits/bitify.circom"; +include "../../../node_modules/circomlib/circuits/escalarmulany.circom"; + +/** + * Scalar-multiply an arbitrary BabyJubjub point by a SIGNED integer given as + * (magnitude, sign), magnitude bounded to `bits` bits. + * + * Needed for Lagrange-combining threshold decryption shares: the "clear + * denominators" trick used by `RevealVerify` (see that file's comments) + * produces small integer coefficients that can be negative (e.g. + * `participantIndex[m] - participantIndex[i]`). Circom's native field + * arithmetic represents a negative small integer as `p - |v|` — a value + * close to the full field size — which `Num2Bits(bits)` (bits << 254) cannot + * decompose. So negative coefficients are passed in as explicit + * (sign, magnitude) witnesses instead, and this template does + * `mag * p`, then conditionally negates the resulting point (cheap on a + * twisted Edwards curve: negating (x, y) is just (-x, y), no extra scalar + * mult) if `sign == 1`. + */ +template SignedScalarMul(bits) { + signal input p[2]; + signal input mag; + signal input sign; // must be boolean: 0 = non-negative, 1 = negative + signal output out[2]; + + sign * (1 - sign) === 0; + + component magBits = Num2Bits(bits); + magBits.in <== mag; + + component mul = EscalarMulAny(bits); + mul.p[0] <== p[0]; + mul.p[1] <== p[1]; + for (var i = 0; i < bits; i++) { mul.e[i] <== magBits.out[i]; } + + signal negX; + negX <== 0 - mul.out[0]; + + out[0] <== mul.out[0] + sign * (negX - mul.out[0]); + out[1] <== mul.out[1]; +} diff --git a/packages/circuits/circom/hybrid/power/ballotValidity.circom b/packages/circuits/circom/hybrid/power/ballotValidity.circom new file mode 100644 index 0000000..528a542 --- /dev/null +++ b/packages/circuits/circom/hybrid/power/ballotValidity.circom @@ -0,0 +1,379 @@ +pragma circom 2.0.0; + +include "../../../node_modules/circomlib/circuits/comparators.circom"; +include "../../../node_modules/circomlib/circuits/bitify.circom"; +include "../../utils/hasherPoseidon.circom"; +include "../../utils/hasherSha256.circom"; +include "../../utils/trees/incrementalQuinTree.circom"; +include "../../utils/ecdh.circom"; +include "../../utils/privToPubKey.circom"; +include "../../utils/unpackElement.circom"; +include "../../utils/lib/poseidonDecrypt.circom"; +include "../../utils/messageHasher.circom"; +include "../lib/aheEncrypt.circom"; +include "../lib/aheCommit.circom"; + +/** + * Client-side ballot validity proof. + * + * The voter proves, WITHOUT revealing the vote, that the published AHE + * ciphertext vector: + * 0. the voice-credit budget comes from the voter's REAL state leaf, proven + * via a quinary state-tree Merkle inclusion against the public stateRoot + * (so the voter cannot forge their voiceCreditBalance), + * 1. correctly encrypts the private weight vector to the committee key Kc, + * 2. is one-hot (weight placed on exactly one option), + * 3. stays within that authenticated budget (quadratic cost: sum of squares), + * 4. hashes to the aheCommitment that the voter signs in the routing envelope, + * 5. is bound to the SAME routing envelope that will be published alongside + * it: `stateIdx`/`aheCommitment` are re-derived by decrypting `routing` + * in-circuit and asserted equal to this proof's own values, AND + * `routingEncPubKey` (the ephemeral pubkey actually published on-chain) + * is proven to be `ephemeralPrivKey * G` -- otherwise a prover could + * satisfy the in-circuit ECDH decryption with one ephemeral key while + * publishing a DIFFERENT `routingEncPubKey` on-chain, so the + * coordinator's real ECDH (using its own `coordPrivKey` and the + * published `routingEncPubKey`) would derive a different shared key + * than the one this proof reasoned about, decrypting to garbage. + * + * The state leaf mirrors MACI's classic layout: + * [pubKey.x, pubKey.y, voiceCreditBalance, voteOptionTreeRoot, nonce] + * hashed with Hasher5 (Poseidon T6), then wrapped as + * hash2([hash5(leaf), DEACTIVATE_CONSTANT]) exactly like cw-amaci's + * `StateLeaf::hash_decativate_state_leaf` (used unconditionally by the contract's + * SignUp handler), and included in a quinary (arity-5) tree exactly like + * ProcessMessages. This lets the Merkle inclusion proof authenticate against a REAL + * on-chain state root built from real SignUp transactions, not just a locally + * simulated one. + * + * Anonymity: `stateIdx`/`pubKey` are PRIVATE witnesses (not public signals) — + * the contract never learns which registered voter this ballot belongs to. + * Instead the circuit outputs a `nullifier = Hasher4(stateIdx, pubKey, pollId)`, + * an unlinkable-per-round identifier that carries no information about the + * real state index. Binding to the actual routing envelope (rather than trusting + * the SDK to keep stateIdx consistent between the two messages) is done + * in-circuit: the voter re-derives the ECDH shared key from the SAME ephemeral + * private key used to Poseidon-encrypt `routing` to the coordinator, decrypts + * it, and asserts the embedded stateIdx/aheCommitment match this proof's own + * stateIdx/aheCommitment. Without this, a malicious prover could authenticate + * one (stateIdx, pubKey) leaf here while the published routing envelope + * actually points at a different voter's state index. + * + * Public signals: Kc, stateRoot, coordPubKey, pollId. Public outputs: the + * ciphertext (c1/c2), aheCommitment, nullifier, routingCommitment. Private + * signals: voteWeights, r, the state-leaf fields, the Merkle path, stateIdx, + * pubKey, ephemeralPrivKey, routing and its ephemeral pubkey. + * + * WEIGHT_BITS bounds each weight so squares stay small enough for the tally's + * baby-step-giant-step discrete-log recovery. + * + * Options follow MACI's vote-option-tree convention: the number of options is + * derived from a quinary (arity-5) tree depth, M = 5^voteOptionTreeDepth, so + * this matches ProcessMessages/TallyVotes parameterisation (e.g. depth 1 -> 5). + */ +template BallotValidity(stateTreeDepth, voteOptionTreeDepth) { + var TREE_ARITY = 5; + var M = 1; + for (var d = 0; d < voteOptionTreeDepth; d++) { M *= TREE_ARITY; } + + var WEIGHT_BITS = 16; // max weight 65535 + var ROUTING_LENGTH = 10; + + var LEAVES_PER_PATH_LEVEL = TREE_ARITY - 1; + var STATE_LEAF_PUB_X_IDX = 0; + var STATE_LEAF_PUB_Y_IDX = 1; + var STATE_LEAF_VOICE_CREDIT_BALANCE_IDX = 2; + var STATE_LEAF_VO_ROOT_IDX = 3; + var STATE_LEAF_NONCE_IDX = 4; + + // Private + signal input voteWeights[M]; + signal input r[M]; + // State-leaf witness (authenticated below via Merkle inclusion). + signal input voiceCreditBalance; + signal input voteOptionTreeRoot; + signal input slNonce; + signal input pathElements[stateTreeDepth][LEAVES_PER_PATH_LEVEL]; + // Voter identity — private (see anonymity note above); still used for leaf + // reconstruction/Merkle inclusion and folded into `nullifier` below. + signal input stateIdx; + signal input pubKey[2]; + // Routing-binding witnesses: the ephemeral private key the voter used to + // Poseidon-encrypt `routing` to the coordinator, the routing ciphertext + // itself, and its (public) ephemeral key — all needed to re-derive the + // ECDH shared key and decrypt `routing` in-circuit. + signal input ephemeralPrivKey; + signal input routing[ROUTING_LENGTH]; + signal input routingEncPubKey[2]; + + // Public + signal input Kc[2]; + signal input stateRoot; + signal input coordPubKey[2]; + signal input pollId; + + // Public outputs (the published ballot) + signal output c1[M][2]; + signal output c2[M][2]; + signal output aheCommitment; + signal output nullifier; + signal output routingCommitment; + + // 0. Authenticate voiceCreditBalance: rebuild the state leaf from the + // private pubKey/stateIdx and private balance/voRoot/nonce, then prove + // it is the leaf at stateIdx under stateRoot. A forged balance (or + // wrong pubKey) breaks Merkle inclusion. + component leafHasher = Hasher5(); + leafHasher.in[STATE_LEAF_PUB_X_IDX] <== pubKey[0]; + leafHasher.in[STATE_LEAF_PUB_Y_IDX] <== pubKey[1]; + leafHasher.in[STATE_LEAF_VOICE_CREDIT_BALANCE_IDX] <== voiceCreditBalance; + leafHasher.in[STATE_LEAF_VO_ROOT_IDX] <== voteOptionTreeRoot; + leafHasher.in[STATE_LEAF_NONCE_IDX] <== slNonce; + + // cw-amaci's `StateLeaf::hash_decativate_state_leaf` (called unconditionally by + // `execute_sign_up`, regardless of whether the deactivate feature is enabled) does + // NOT commit the raw Hasher5 leaf hash directly — it wraps it as + // hash2([hash5(leaf), DEACTIVATE_CONSTANT]), where DEACTIVATE_CONSTANT = + // hash5([0,0,0,0,0]) (a nothing-up-my-sleeve constant, see + // packages/circuits/docs/StateLeaf-Update-Rules.md). To authenticate a Merkle + // inclusion proof against a REAL on-chain state root, we must reproduce that exact + // wrap; otherwise every real signed-up leaf would fail inclusion here. + var DEACTIVATE_CONSTANT = 14655542659562014735865511769057053982292279840403315552050801315682099828156; + component wrappedLeaf = HashLeftRight(); + wrappedLeaf.left <== leafHasher.hash; + wrappedLeaf.right <== DEACTIVATE_CONSTANT; + + component pathIndices = QuinGeneratePathIndices(stateTreeDepth); + pathIndices.in <== stateIdx; + + component inclusion = QuinTreeInclusionProof(stateTreeDepth); + inclusion.leaf <== wrappedLeaf.hash; + for (var lvl = 0; lvl < stateTreeDepth; lvl++) { + inclusion.path_index[lvl] <== pathIndices.out[lvl]; + for (var k = 0; k < LEAVES_PER_PATH_LEVEL; k++) { + inclusion.path_elements[lvl][k] <== pathElements[lvl][k]; + } + } + inclusion.root === stateRoot; + + // The budget now derives from the authenticated leaf, not a free input. + signal voiceCredits; + voiceCredits <== voiceCreditBalance; + + // 1. Encrypt each option's weight. + component enc[M]; + for (var opt = 0; opt < M; opt++) { + enc[opt] = AheEncrypt(); + enc[opt].v <== voteWeights[opt]; + enc[opt].r <== r[opt]; + enc[opt].Kc[0] <== Kc[0]; + enc[opt].Kc[1] <== Kc[1]; + c1[opt][0] <== enc[opt].c1[0]; + c1[opt][1] <== enc[opt].c1[1]; + c2[opt][0] <== enc[opt].c2[0]; + c2[opt][1] <== enc[opt].c2[1]; + } + + // 2. Range-check every weight (< 2^WEIGHT_BITS) and one-hot constraint. + component rangeCheck[M]; + component isZero[M]; + signal nz[M]; + signal nzAcc[M + 1]; + nzAcc[0] <== 0; + for (var opt = 0; opt < M; opt++) { + rangeCheck[opt] = Num2Bits(WEIGHT_BITS); + rangeCheck[opt].in <== voteWeights[opt]; + + isZero[opt] = IsZero(); + isZero[opt].in <== voteWeights[opt]; + nz[opt] <== 1 - isZero[opt].out; + nzAcc[opt + 1] <== nzAcc[opt] + nz[opt]; + } + // Exactly one option carries a non-zero weight. + nzAcc[M] === 1; + + // 3. Budget: sum of squares <= voiceCredits (quadratic voting cost). + signal sq[M]; + signal sqAcc[M + 1]; + sqAcc[0] <== 0; + for (var opt = 0; opt < M; opt++) { + sq[opt] <== voteWeights[opt] * voteWeights[opt]; + sqAcc[opt + 1] <== sqAcc[opt] + sq[opt]; + } + component budget = LessEqThan(64); + budget.in[0] <== sqAcc[M]; + budget.in[1] <== voiceCredits; + budget.out === 1; + + // 4. Commitment binding. + component commit = AheCommit(M); + for (var opt = 0; opt < M; opt++) { + commit.c1[opt][0] <== c1[opt][0]; + commit.c1[opt][1] <== c1[opt][1]; + commit.c2[opt][0] <== c2[opt][0]; + commit.c2[opt][1] <== c2[opt][1]; + } + aheCommitment <== commit.commitment; + + // 5. Routing binding: decrypt `routing` with the ECDH shared key derived + // from `ephemeralPrivKey`/`coordPubKey` (the SAME shared key the voter + // used to encrypt it — ECDH is symmetric: privKey_A * pubKey_B == + // privKey_B * pubKey_A), then assert its embedded stateIdx/pollId/ + // aheCommitment match this proof's own values. This is what makes the + // nullifier below actually correspond to whoever published `routing`, + // instead of relying on SDK convention alone. + // 5a. Prove `routingEncPubKey` (the ephemeral pubkey the contract will + // see on-chain) is ACTUALLY `ephemeralPrivKey * G`, not just some + // unrelated point the prover chose. Without this, the ECDH + // decryption below only proves self-consistency against whatever + // ephemeralPrivKey the prover picked -- it says nothing about the + // ephemeral key that ends up published, which is what the + // coordinator's own ECDH (coordPrivKey * routingEncPubKey) actually + // uses to decrypt `routing` on-chain. + component ephPub = PrivToPubKey(); + ephPub.privKey <== ephemeralPrivKey; + ephPub.pubKey[0] === routingEncPubKey[0]; + ephPub.pubKey[1] === routingEncPubKey[1]; + + component routingEcdh = Ecdh(); + routingEcdh.privKey <== ephemeralPrivKey; + routingEcdh.pubKey[0] <== coordPubKey[0]; + routingEcdh.pubKey[1] <== coordPubKey[1]; + + component routingDecryptor = PoseidonDecryptWithoutCheck(7); + routingDecryptor.key[0] <== routingEcdh.sharedKey[0]; + routingDecryptor.key[1] <== routingEcdh.sharedKey[1]; + routingDecryptor.nonce <== 0; + for (var i = 0; i < ROUTING_LENGTH; i++) { + routingDecryptor.ciphertext[i] <== routing[i]; + } + + // UnpackElement(3) on decrypted[0] mirrors hybridMessageToCommand.circom: + // out[0] = pollId, out[1] = stateIdx, out[2] = nonce. + component routingUnpack = UnpackElement(3); + routingUnpack.in <== routingDecryptor.decrypted[0]; + routingUnpack.out[0] === pollId; + routingUnpack.out[1] === stateIdx; + // decrypted[3] is the aheCommitment the voter signed into the routing + // envelope; it must be the SAME commitment as the ciphertext published + // alongside it (computed above from c1/c2). + routingDecryptor.decrypted[3] === aheCommitment; + + // 6. Nullifier: replaces the plaintext stateIdx/pubKey public signals with + // an unlinkable-per-round identifier, so the contract (and any on-chain + // observer) can no longer tell WHICH signed-up voter this ballot + // belongs to. Salted with pollId so nullifiers don't correlate across + // rounds. + component nullifierHasher = Hasher4(); + nullifierHasher.in[0] <== stateIdx; + nullifierHasher.in[1] <== pubKey[0]; + nullifierHasher.in[2] <== pubKey[1]; + nullifierHasher.in[3] <== pollId; + nullifier <== nullifierHasher.hash; + + // 7. Fold the routing ciphertext + its ephemeral pubkey into ONE + // commitment, reusing the SAME Hasher13 algorithm (with prevHash = 0) + // as cw-amaci's `hash_message_and_enc_pub_key`. The contract already + // receives `routing`/`enc_pub_key` directly in `PublishHybridMessage`, + // so it can recompute this cheaply instead of re-hashing 10 raw field + // elements through SHA256 again in BallotValidityOnchain. + component routingHasher = MessageHasher(); + for (var i = 0; i < ROUTING_LENGTH; i++) { + routingHasher.in[i] <== routing[i]; + } + routingHasher.encPubKey[0] <== routingEncPubKey[0]; + routingHasher.encPubKey[1] <== routingEncPubKey[1]; + routingHasher.prevHash <== 0; + routingCommitment <== routingHasher.hash; +} + +/** + * On-chain ballotValidity: identical constraints to BallotValidity, but wrapped + * so the ENTIRE public interface is a single SHA256 `inputHash` — matching the + * cw-amaci Groth16 verifier, whose stored verifying key only supports one public + * input (ic0/ic1) and whose `compute_input_hash` packs all public values with + * SHA256 (mod the BN254 scalar field). + * + * The bound public values, in the SAME order the contract must repack, are: + * [ Kc.x, Kc.y, stateRoot, coordPubKey.x, coordPubKey.y, pollId, + * routingCommitment, aheCommitment, nullifier ] + * + * `stateIdx`/`pubKey` are NOT part of this list any more (see the anonymity + * note on `BallotValidity` above) — the contract only ever learns `nullifier`. + * The AHE ciphertext vector (c1/c2) is likewise not exposed: it travels in the + * routing message and is bound here by `aheCommitment` (AheCommit over c1/c2), + * so a contract that reconstructs inputHash from the message's committed + * values can verify the proof with a single public input. + */ +template BallotValidityOnchain(stateTreeDepth, voteOptionTreeDepth) { + var TREE_ARITY = 5; + var M = 1; + for (var d = 0; d < voteOptionTreeDepth; d++) { M *= TREE_ARITY; } + var LEAVES_PER_PATH_LEVEL = TREE_ARITY - 1; + var ROUTING_LENGTH = 10; + + // Private witness (everything the client circuit needs). + signal input voteWeights[M]; + signal input r[M]; + signal input voiceCreditBalance; + signal input voteOptionTreeRoot; + signal input slNonce; + signal input pathElements[stateTreeDepth][LEAVES_PER_PATH_LEVEL]; + signal input stateIdx; + signal input pubKey[2]; + signal input ephemeralPrivKey; + signal input routing[ROUTING_LENGTH]; + signal input routingEncPubKey[2]; + signal input Kc[2]; + signal input stateRoot; + signal input coordPubKey[2]; + signal input pollId; + + // Single public signal. + signal output inputHash; + + // Reuse the full client-side validity circuit (Merkle-authenticated budget, + // one-hot, encryption, commitment, routing binding, nullifier). Its + // aheCommitment/nullifier/routingCommitment are outputs. + component bv = BallotValidity(stateTreeDepth, voteOptionTreeDepth); + for (var opt = 0; opt < M; opt++) { + bv.voteWeights[opt] <== voteWeights[opt]; + bv.r[opt] <== r[opt]; + } + bv.voiceCreditBalance <== voiceCreditBalance; + bv.voteOptionTreeRoot <== voteOptionTreeRoot; + bv.slNonce <== slNonce; + for (var lvl = 0; lvl < stateTreeDepth; lvl++) { + for (var k = 0; k < LEAVES_PER_PATH_LEVEL; k++) { + bv.pathElements[lvl][k] <== pathElements[lvl][k]; + } + } + bv.stateIdx <== stateIdx; + bv.pubKey[0] <== pubKey[0]; + bv.pubKey[1] <== pubKey[1]; + bv.ephemeralPrivKey <== ephemeralPrivKey; + for (var i = 0; i < ROUTING_LENGTH; i++) { + bv.routing[i] <== routing[i]; + } + bv.routingEncPubKey[0] <== routingEncPubKey[0]; + bv.routingEncPubKey[1] <== routingEncPubKey[1]; + bv.Kc[0] <== Kc[0]; + bv.Kc[1] <== Kc[1]; + bv.stateRoot <== stateRoot; + bv.coordPubKey[0] <== coordPubKey[0]; + bv.coordPubKey[1] <== coordPubKey[1]; + bv.pollId <== pollId; + + // Fold all public values into one SHA256 input hash. + component ih = Sha256Hasher(9); + ih.in[0] <== Kc[0]; + ih.in[1] <== Kc[1]; + ih.in[2] <== stateRoot; + ih.in[3] <== coordPubKey[0]; + ih.in[4] <== coordPubKey[1]; + ih.in[5] <== pollId; + ih.in[6] <== bv.routingCommitment; + ih.in[7] <== bv.aheCommitment; + ih.in[8] <== bv.nullifier; + inputHash <== ih.hash; +} diff --git a/packages/circuits/circom/hybrid/power/processHybridMessages.circom b/packages/circuits/circom/hybrid/power/processHybridMessages.circom new file mode 100644 index 0000000..e3e901f --- /dev/null +++ b/packages/circuits/circom/hybrid/power/processHybridMessages.circom @@ -0,0 +1,467 @@ +pragma circom 2.0.0; + +include "../../../node_modules/circomlib/circuits/comparators.circom"; +include "../utils/hybridMessageToCommand.circom"; +include "../../utils/messageHasher.circom"; +include "../../utils/privToPubKey.circom"; +include "../../utils/hasherSha256.circom"; +include "../../utils/hasherPoseidon.circom"; +include "../../utils/trees/incrementalQuinTree.circom"; +include "../lib/aheCommit.circom"; +include "../lib/condPointAdd.circom"; + +/** + * Hybrid MACI + AHE message processing — cross-batch LWW fix. + * + * The coordinator holds coordPrivKey and, inside the circuit: + * 1. ECDH-decrypts each message's routing envelope (stateIdx, nonce, sig, + * aheCommitment) and verifies the voter's signature, + * 2. binds the separately-published AHE ciphertext to the signed commitment, + * 3. Merkle-authenticates each message's `voterPubKey` against the SAME + * public `stateRoot` used at publish time, at the decrypted `stateIdx` + * (anti-selective-censorship), + * 4. selects the surviving ballot per state leaf via CLASSIC MACI-style + * last-write-wins: a dedicated nonce tree evolves in REVERSE processing + * order (newest message first, oldest last — matching classic aMACI's + * reverse-batch + reverse-within-batch semantics). A message survives iff + * its `cmdNonce == currentNonce[stateIdx] + 1` in the evolving nonce tree. + * This gives cross-batch LWW correctness: a voter's revote (submitted + * LATER, so at a HIGHER chain index) is processed FIRST, sets the nonce, + * and the earlier vote at a lower chain index subsequently fails the nonce + * check — both within and across batch boundaries. + * 5. homomorphically aggregates surviving ballots per option (BabyAdd). + * + * Nonce tree: a separate quinary Merkle tree of depth `stateTreeDepth` whose + * leaves are raw nonce values (one per stateIdx, initially all zero). It is + * independent of the registration `stateRoot` (which is static and only used + * for voterPubKey authentication). The nonce tree's root evolves with each + * successfully processed message and is carried across batches via + * `currentNonceRoot` → `newNonceRoot` — exactly the way classic MACI carries + * `currentStateCommitment` → `newStateCommitment`. The initial root is the + * all-zeros quinary tree root (`ZeroRoot(stateTreeDepth)`). + * + * Processing order (reverse, matching classic aMACI): + * - Contract processes batches from chain-tail to chain-head (newest first). + * - Within a batch the circuit processes slots i = B-1 downto 0 (highest + * chain-index message first), updating the nonce tree after each step. + * This mirrors classic ProcessMessages's inner reverse loop and ensures + * the latest-submitted valid message "claims" the nonce slot before any + * earlier message for the same voter can be seen. + * + * `voterPubKey[i]` is a PRIVATE witness — it must never appear in this + * proof's public signals; see `circuits.json` for the pubs list. + * + * Partial-batch support (`actualCount`): slots [actualCount, batchSize) are + * padding. isReal[i] = i < actualCount gates all real-slot-only constraints. + */ +// M = 5^voteOptionTreeDepth options; batchSize = fixed witness slot count. +template ProcessHybridMessages(stateTreeDepth, voteOptionTreeDepth, batchSize) { + var TREE_ARITY = 5; + var B = batchSize; + var M = 1; + for (var d = 0; d < voteOptionTreeDepth; d++) { M *= TREE_ARITY; } + + var LEAVES_PER_PATH_LEVEL = TREE_ARITY - 1; + var STATE_LEAF_PUB_X_IDX = 0; + var STATE_LEAF_PUB_Y_IDX = 1; + var STATE_LEAF_VOICE_CREDIT_BALANCE_IDX = 2; + var STATE_LEAF_VO_ROOT_IDX = 3; + var STATE_LEAF_NONCE_IDX = 4; + var DEACTIVATE_CONSTANT = 14655542659562014735865511769057053982292279840403315552050801315682099828156; + + signal input coordPrivKey; + + signal input message[B][10]; + signal input encPubKey[B][2]; + signal input voterPubKey[B][2]; + + // Registration-tree witnesses: authenticate voterPubKey[i] at stateIdx[i] + // under the STATIC `stateRoot` (same layout as BallotValidity). + signal input voiceCreditBalance[B]; + signal input voteOptionTreeRoot[B]; + signal input slNonce[B]; + signal input pathElements[B][stateTreeDepth][LEAVES_PER_PATH_LEVEL]; + signal input stateRoot; + + // Nonce-tree witnesses: track per-voter nonce across batches (cross-batch LWW). + // `currentNonce[i]` is the prover-supplied current nonce for message i's + // voter in the evolving nonce tree; constrained by Merkle inclusion below. + signal input currentNonceRoot; + signal input noncePathElements[B][stateTreeDepth][LEAVES_PER_PATH_LEVEL]; + signal input currentNonce[B]; + + // Partial-batch support. + signal input actualCount; + + // Published ballot ciphertexts (committee-encrypted; coordinator cannot decrypt). + signal input encC1[B][M][2]; + signal input encC2[B][M][2]; + + // Aggregate ciphertext per option (committee decrypts after the round). + signal output aggC1[M][2]; + signal output aggC2[M][2]; + // Gate the message hash-chain advance in ProcessHybridMessagesOnchain. + signal output isReal[B]; + // New nonce tree root after processing this batch (carried to next batch). + signal output newNonceRoot; + + // ---- 0. isReal[i] = i < actualCount ---- + component isRealCmp[B]; + for (var i = 0; i < B; i++) { + isRealCmp[i] = LessThan(32); + isRealCmp[i].in[0] <== i; + isRealCmp[i].in[1] <== actualCount; + isReal[i] <== isRealCmp[i].out; + } + + // ---- 1. Decrypt routing + verify signature + bind ciphertext ---- + // ---- Anti-censorship: Merkle-authenticate voterPubKey[i] against stateRoot ---- + component m2c[B]; + component commit[B]; + signal stateIdx[B]; + signal nonce[B]; + signal sigValid[B]; + + component leafHasher[B]; + component wrappedLeaf[B]; + component pathIndices[B]; + component inclusion[B]; + // Mask padding slots' garbage stateIdx to 0 so QuinGeneratePathIndices + // never sees an out-of-range input. + signal safeStateIdx[B]; + + for (var i = 0; i < B; i++) { + m2c[i] = HybridMessageToCommand(); + m2c[i].encPrivKey <== coordPrivKey; + for (var k = 0; k < 10; k++) { + m2c[i].message[k] <== message[i][k]; + } + m2c[i].encPubKey[0] <== encPubKey[i][0]; + m2c[i].encPubKey[1] <== encPubKey[i][1]; + m2c[i].voterPubKey[0] <== voterPubKey[i][0]; + m2c[i].voterPubKey[1] <== voterPubKey[i][1]; + + stateIdx[i] <== m2c[i].stateIndex; + nonce[i] <== m2c[i].nonce; + sigValid[i] <== m2c[i].sigValid; + + // Ciphertext must hash to the signed commitment (real slots only). + commit[i] = AheCommit(M); + for (var opt = 0; opt < M; opt++) { + commit[i].c1[opt][0] <== encC1[i][opt][0]; + commit[i].c1[opt][1] <== encC1[i][opt][1]; + commit[i].c2[opt][0] <== encC2[i][opt][0]; + commit[i].c2[opt][1] <== encC2[i][opt][1]; + } + (commit[i].commitment - m2c[i].aheCommitment) * isReal[i] === 0; + + // Registration-tree Merkle inclusion: voterPubKey[i] is the registered + // key for stateIdx[i] under stateRoot (real slots only). + leafHasher[i] = Hasher5(); + leafHasher[i].in[STATE_LEAF_PUB_X_IDX] <== voterPubKey[i][0]; + leafHasher[i].in[STATE_LEAF_PUB_Y_IDX] <== voterPubKey[i][1]; + leafHasher[i].in[STATE_LEAF_VOICE_CREDIT_BALANCE_IDX] <== voiceCreditBalance[i]; + leafHasher[i].in[STATE_LEAF_VO_ROOT_IDX] <== voteOptionTreeRoot[i]; + leafHasher[i].in[STATE_LEAF_NONCE_IDX] <== slNonce[i]; + + wrappedLeaf[i] = HashLeftRight(); + wrappedLeaf[i].left <== leafHasher[i].hash; + wrappedLeaf[i].right <== DEACTIVATE_CONSTANT; + + safeStateIdx[i] <== stateIdx[i] * isReal[i]; + pathIndices[i] = QuinGeneratePathIndices(stateTreeDepth); + pathIndices[i].in <== safeStateIdx[i]; + + inclusion[i] = QuinTreeInclusionProof(stateTreeDepth); + inclusion[i].leaf <== wrappedLeaf[i].hash; + for (var lvl = 0; lvl < stateTreeDepth; lvl++) { + inclusion[i].path_index[lvl] <== pathIndices[i].out[lvl]; + for (var k = 0; k < LEAVES_PER_PATH_LEVEL; k++) { + inclusion[i].path_elements[lvl][k] <== pathElements[i][lvl][k]; + } + } + (inclusion[i].root - stateRoot) * isReal[i] === 0; + } + + // ---- 2. Nonce-tree LWW (reverse order, O(B * depth)) ---- + // + // Process messages from HIGHEST submission index downto LOWEST (newest + // first), exactly as classic aMACI processes its batches: messages are + // passed to the contract in reverse submission order so that the latest + // one occupies the HIGHEST slot and is therefore processed first. + // + // The nonce tree rolls forward: + // nonceRoots[B] = currentNonceRoot (input from previous batch / initial) + // for i = B-1 downto 0: + // verify currentNonce[i] in nonceRoots[i+1] at safeStateIdx[i] + // survivor[i] = isReal[i] AND sigValid[i] AND (nonce[i] == currentNonce[i]+1) + // if survivor[i]: update leaf → nonceRoots[i] = updated root + // else: nonceRoots[i] = nonceRoots[i+1] (no change) + // newNonceRoot = nonceRoots[0] + // + // Path indices for the nonce tree are the SAME as for the registration + // tree (same safeStateIdx, same depth) so we reuse pathIndices[i].out. + // Path ELEMENTS for the nonce tree are separate: noncePathElements[i] + // reflect the nonce tree's state at the moment slot i is processed + // (i.e., AFTER slots B-1..i+1 have already been applied). The coordinator + // must pre-simulate this reverse order when constructing the witness. + + signal nonceRoots[B + 1]; + nonceRoots[B] <== currentNonceRoot; + + component nonceRead[B]; + component nonceWrite[B]; + component isNonceValidCmp[B]; + signal isNonceValid[B]; + signal sigAndNonce[B]; + signal survivor[B]; + + for (var i = B - 1; i >= 0; i--) { + // Read: verify currentNonce[i] is the leaf at safeStateIdx[i] in + // nonceRoots[i+1]. Gated by isReal (padding exempt). + nonceRead[i] = QuinTreeInclusionProof(stateTreeDepth); + nonceRead[i].leaf <== currentNonce[i]; + for (var lvl = 0; lvl < stateTreeDepth; lvl++) { + nonceRead[i].path_index[lvl] <== pathIndices[i].out[lvl]; + for (var k = 0; k < LEAVES_PER_PATH_LEVEL; k++) { + nonceRead[i].path_elements[lvl][k] <== noncePathElements[i][lvl][k]; + } + } + (nonceRead[i].root - nonceRoots[i + 1]) * isReal[i] === 0; + + // Nonce validity: classic MACI rule — cmdNonce must be currentNonce+1. + isNonceValidCmp[i] = IsEqual(); + isNonceValidCmp[i].in[0] <== nonce[i]; + isNonceValidCmp[i].in[1] <== currentNonce[i] + 1; + isNonceValid[i] <== isNonceValidCmp[i].out; + + // Survivor: real AND valid signature AND valid nonce. + sigAndNonce[i] <== sigValid[i] * isNonceValid[i]; + survivor[i] <== isReal[i] * sigAndNonce[i]; + + // Write: compute new nonce root with leaf at safeStateIdx[i] = nonce[i]. + // Use the SAME path (same stateIdx, same depth) as the read. + nonceWrite[i] = QuinTreeInclusionProof(stateTreeDepth); + nonceWrite[i].leaf <== nonce[i]; + for (var lvl = 0; lvl < stateTreeDepth; lvl++) { + nonceWrite[i].path_index[lvl] <== pathIndices[i].out[lvl]; + for (var k = 0; k < LEAVES_PER_PATH_LEVEL; k++) { + nonceWrite[i].path_elements[lvl][k] <== noncePathElements[i][lvl][k]; + } + } + + // Conditional update (linear-combination Mux): + // nonceRoots[i] = survivor ? nonceWrite.root : nonceRoots[i+1] + nonceRoots[i] <== nonceRoots[i + 1] + survivor[i] * (nonceWrite[i].root - nonceRoots[i + 1]); + } + + newNonceRoot <== nonceRoots[0]; + + // ---- 3. Homomorphic aggregation of surviving ballots ---- + component condC1[M][B]; + component condC2[M][B]; + signal aggC1Acc[M][B + 1][2]; + signal aggC2Acc[M][B + 1][2]; + + for (var opt = 0; opt < M; opt++) { + aggC1Acc[opt][0][0] <== 0; + aggC1Acc[opt][0][1] <== 1; + aggC2Acc[opt][0][0] <== 0; + aggC2Acc[opt][0][1] <== 1; + + for (var i = 0; i < B; i++) { + condC1[opt][i] = CondPointAdd(); + condC1[opt][i].acc[0] <== aggC1Acc[opt][i][0]; + condC1[opt][i].acc[1] <== aggC1Acc[opt][i][1]; + condC1[opt][i].p[0] <== encC1[i][opt][0]; + condC1[opt][i].p[1] <== encC1[i][opt][1]; + condC1[opt][i].sel <== survivor[i]; + aggC1Acc[opt][i + 1][0] <== condC1[opt][i].out[0]; + aggC1Acc[opt][i + 1][1] <== condC1[opt][i].out[1]; + + condC2[opt][i] = CondPointAdd(); + condC2[opt][i].acc[0] <== aggC2Acc[opt][i][0]; + condC2[opt][i].acc[1] <== aggC2Acc[opt][i][1]; + condC2[opt][i].p[0] <== encC2[i][opt][0]; + condC2[opt][i].p[1] <== encC2[i][opt][1]; + condC2[opt][i].sel <== survivor[i]; + aggC2Acc[opt][i + 1][0] <== condC2[opt][i].out[0]; + aggC2Acc[opt][i + 1][1] <== condC2[opt][i].out[1]; + } + + aggC1[opt][0] <== aggC1Acc[opt][B][0]; + aggC1[opt][1] <== aggC1Acc[opt][B][1]; + aggC2[opt][0] <== aggC2Acc[opt][B][0]; + aggC2[opt][1] <== aggC2Acc[opt][B][1]; + } +} + +/** + * On-chain wrapper for ProcessHybridMessages. + * + * Closes the gap between what the inner circuit proves and what was published + * on-chain, using the same two binding techniques as classic cw-amaci: + * + * 1. Message hash-chain binding: `batchStartHash` / `batchEndHash` pin this + * proof to exactly the messages published between those two chain positions. + * 2. Coordinator key binding: prove knowledge of `coordPrivKey` matching + * `coordPubKey`. + * + * In addition, the nonce-tree root is carried across batches: + * `currentNonceRoot` → (proven evolution) → `newNonceRoot` (= p.newNonceRoot) + * so each batch call picks up where the previous one's nonce state left off. + * + * Public values bound into inputHash (SHA256, mod BN254 scalar field), in order: + * [ coordPubKey.x, coordPubKey.y, batchStartHash, batchEndHash, + * currentAggCommitment, newAggCommitment, pollId, stateRoot, actualCount, + * currentNonceRoot, newNonceRoot ] + */ +template ProcessHybridMessagesOnchain(stateTreeDepth, voteOptionTreeDepth, batchSize) { + var TREE_ARITY = 5; + var B = batchSize; + var M = 1; + for (var d = 0; d < voteOptionTreeDepth; d++) { M *= TREE_ARITY; } + var LEAVES_PER_PATH_LEVEL = TREE_ARITY - 1; + + // Private witnesses (all inputs the inner circuit needs). + signal input coordPrivKey; + signal input message[B][10]; + signal input encPubKey[B][2]; + signal input voterPubKey[B][2]; + signal input voiceCreditBalance[B]; + signal input voteOptionTreeRoot[B]; + signal input slNonce[B]; + signal input pathElements[B][stateTreeDepth][LEAVES_PER_PATH_LEVEL]; + signal input encC1[B][M][2]; + signal input encC2[B][M][2]; + // Nonce-tree private witnesses. + signal input noncePathElements[B][stateTreeDepth][LEAVES_PER_PATH_LEVEL]; + signal input currentNonce[B]; + + // Public values (all folded into inputHash below). + signal input coordPubKey[2]; + signal input batchStartHash; + signal input currentAggC1[M][2]; + signal input currentAggC2[M][2]; + signal input pollId; + signal input stateRoot; + signal input actualCount; + signal input currentNonceRoot; + + // Single public signal. + signal output inputHash; + + // 1. Prove knowledge of coordPrivKey matching the publicly-bound coordPubKey. + component derivedPubKey = PrivToPubKey(); + derivedPubKey.privKey <== coordPrivKey; + derivedPubKey.pubKey[0] === coordPubKey[0]; + derivedPubKey.pubKey[1] === coordPubKey[1]; + + // 2. Run LWW + homomorphic aggregation (also computes isReal[i] and newNonceRoot). + component p = ProcessHybridMessages(stateTreeDepth, voteOptionTreeDepth, batchSize); + p.coordPrivKey <== coordPrivKey; + p.stateRoot <== stateRoot; + p.actualCount <== actualCount; + p.currentNonceRoot <== currentNonceRoot; + for (var i = 0; i < B; i++) { + for (var k = 0; k < 10; k++) { + p.message[i][k] <== message[i][k]; + } + p.encPubKey[i][0] <== encPubKey[i][0]; + p.encPubKey[i][1] <== encPubKey[i][1]; + p.voterPubKey[i][0] <== voterPubKey[i][0]; + p.voterPubKey[i][1] <== voterPubKey[i][1]; + p.voiceCreditBalance[i] <== voiceCreditBalance[i]; + p.voteOptionTreeRoot[i] <== voteOptionTreeRoot[i]; + p.slNonce[i] <== slNonce[i]; + p.currentNonce[i] <== currentNonce[i]; + for (var lvl = 0; lvl < stateTreeDepth; lvl++) { + for (var k = 0; k < LEAVES_PER_PATH_LEVEL; k++) { + p.pathElements[i][lvl][k] <== pathElements[i][lvl][k]; + p.noncePathElements[i][lvl][k] <== noncePathElements[i][lvl][k]; + } + } + for (var opt = 0; opt < M; opt++) { + p.encC1[i][opt][0] <== encC1[i][opt][0]; + p.encC1[i][opt][1] <== encC1[i][opt][1]; + p.encC2[i][opt][0] <== encC2[i][opt][0]; + p.encC2[i][opt][1] <== encC2[i][opt][1]; + } + } + + // 3. Re-derive the message hash chain (real slots only advance the chain). + component msgHasher[B]; + signal chainHash[B + 1]; + chainHash[0] <== batchStartHash; + for (var i = 0; i < B; i++) { + msgHasher[i] = MessageHasher(); + for (var k = 0; k < 10; k++) { + msgHasher[i].in[k] <== message[i][k]; + } + msgHasher[i].encPubKey[0] <== encPubKey[i][0]; + msgHasher[i].encPubKey[1] <== encPubKey[i][1]; + msgHasher[i].prevHash <== chainHash[i]; + chainHash[i + 1] <== chainHash[i] + p.isReal[i] * (msgHasher[i].hash - chainHash[i]); + } + signal batchEndHash; + batchEndHash <== chainHash[B]; + + // 4. Homomorphically accumulate prior batches' aggregate with this batch's + // survivors (same BabyAdd-based approach as before). + component newAggAdd1[M]; + component newAggAdd2[M]; + signal newAggC1[M][2]; + signal newAggC2[M][2]; + for (var opt = 0; opt < M; opt++) { + newAggAdd1[opt] = BabyAdd(); + newAggAdd1[opt].x1 <== currentAggC1[opt][0]; + newAggAdd1[opt].y1 <== currentAggC1[opt][1]; + newAggAdd1[opt].x2 <== p.aggC1[opt][0]; + newAggAdd1[opt].y2 <== p.aggC1[opt][1]; + newAggC1[opt][0] <== newAggAdd1[opt].xout; + newAggC1[opt][1] <== newAggAdd1[opt].yout; + + newAggAdd2[opt] = BabyAdd(); + newAggAdd2[opt].x1 <== currentAggC2[opt][0]; + newAggAdd2[opt].y1 <== currentAggC2[opt][1]; + newAggAdd2[opt].x2 <== p.aggC2[opt][0]; + newAggAdd2[opt].y2 <== p.aggC2[opt][1]; + newAggC2[opt][0] <== newAggAdd2[opt].xout; + newAggC2[opt][1] <== newAggAdd2[opt].yout; + } + + // 5. Fold running and new aggregate into single Poseidon commitments. + component curCommit = AheCommit(M); + component newCommit = AheCommit(M); + for (var opt = 0; opt < M; opt++) { + curCommit.c1[opt][0] <== currentAggC1[opt][0]; + curCommit.c1[opt][1] <== currentAggC1[opt][1]; + curCommit.c2[opt][0] <== currentAggC2[opt][0]; + curCommit.c2[opt][1] <== currentAggC2[opt][1]; + + newCommit.c1[opt][0] <== newAggC1[opt][0]; + newCommit.c1[opt][1] <== newAggC1[opt][1]; + newCommit.c2[opt][0] <== newAggC2[opt][0]; + newCommit.c2[opt][1] <== newAggC2[opt][1]; + } + signal currentAggCommitment; + signal newAggCommitment; + currentAggCommitment <== curCommit.commitment; + newAggCommitment <== newCommit.commitment; + + // 6. Fold every public value into one SHA256 inputHash (11 fields). + // Order matches what the contract's compute_input_hash must reproduce. + component ih = Sha256Hasher(11); + ih.in[0] <== coordPubKey[0]; + ih.in[1] <== coordPubKey[1]; + ih.in[2] <== batchStartHash; + ih.in[3] <== batchEndHash; + ih.in[4] <== currentAggCommitment; + ih.in[5] <== newAggCommitment; + ih.in[6] <== pollId; + ih.in[7] <== stateRoot; + ih.in[8] <== actualCount; + ih.in[9] <== currentNonceRoot; + ih.in[10] <== p.newNonceRoot; + inputHash <== ih.hash; +} diff --git a/packages/circuits/circom/hybrid/power/revealVerify.circom b/packages/circuits/circom/hybrid/power/revealVerify.circom new file mode 100644 index 0000000..302bca3 --- /dev/null +++ b/packages/circuits/circom/hybrid/power/revealVerify.circom @@ -0,0 +1,387 @@ +pragma circom 2.0.0; + +include "../../../node_modules/circomlib/circuits/babyjub.circom"; +include "../../../node_modules/circomlib/circuits/bitify.circom"; +include "../../../node_modules/circomlib/circuits/comparators.circom"; +include "../../utils/hasherPoseidon.circom"; +include "../../utils/hasherSha256.circom"; +include "../lib/dleqVerify.circom"; +include "../lib/signedScalarMul.circom"; +include "../lib/aheCommit.circom"; + +/** + * Hybrid MACI + AHE: verify a threshold committee's revealed tally against the + * on-chain aggregate ciphertext, closing the trust gap in + * `execute_reveal_hybrid_tally` (see hybrid_maci_ahe_fix_plan.md, phase 3) — + * before this circuit, the contract just recorded whatever `results`/`salt` + * anyone submitted, with no way to tell a faithful decryption from a lie. + * + * For each of the `threshold` participants i (their registered + * `participantPubKey[i] = x_i*G`) and each vote option j: + * - `partial[i][j] = x_i * aggC1[j]` is participant i's share of option j's + * decryption factor, + * - a Chaum-Pedersen DLEQ proof (`DleqVerify`) shows `partial[i][j]` was + * computed with the SAME secret `x_i` as `participantPubKey[i]`, without + * revealing `x_i` — this is exactly the check + * `mpc-coordinator-demo/src/crypto/dleq.ts`'s `verifyDleq` already does + * off-chain for the live demo UI; here it becomes a circuit constraint. + * + * The T verified partials are then Lagrange-combined (at x=0, using the + * PUBLIC `participantIndex[T]` as the Shamir x-coordinates) into the full + * decryption factor for each option, and the circuit checks + * results[j]*G == aggC2[j] - D[j] + * i.e. `results[j]` is exactly the plaintext AHE decrypts to. + * + * Lagrange coefficients WITHOUT modular inverse: this circuit's native field + * is the SNARK scalar field (~2^254), but Shamir shares/Lagrange coefficients + * are conceptually elements of Z_L (L = BabyJubjub subgroup order, ~2^251.4, + * a DIFFERENT, smaller prime). Computing "mod L" reduction/inverse using + * native (mod SNARK-field) circuit arithmetic would silently produce the + * WRONG value (inverses differ across different moduli). Instead, this + * circuit clears denominators: for T participant indices x_0..x_{T-1}, + * num_i = prod_{m != i} (-x_m) + * den_i = prod_{m != i} (x_i - x_m) + * lambda_i_int = num_i * prod_{k != i} den_k (an exact, tiny INTEGER) + * common = prod_k den_k (also a tiny INTEGER) + * satisfy `lambda_i_int == lambda_i * common` for the TRUE (rational) + * Lagrange coefficient lambda_i, for any index values — no inverse needed, + * only small-magnitude multiplication/subtraction, safe from field overflow + * for realistic committee sizes. The equation actually checked is therefore + * `common . aggC2[j] == common . (results[j]*G) + sum_i lambda_i_int . partial[i][j]`, + * which holds iff `aggC2[j] == results[j]*G + D[j]` (scaling both sides of a + * true equation by the same nonzero integer preserves it, and `common` is + * nonzero whenever the T indices are pairwise distinct). + * `lambda_i_int`/`common` can be negative; `SignedScalarMul` handles that via + * explicit (sign, magnitude) witnesses checked against the in-circuit value. + * + * `Kc` and `salt` are not used in the arithmetic above (Kc identifies which + * committee key this decryption used, salt is carried through with the + * revealed results); both are still PUBLIC so the on-chain wrapper's + * inputHash binds a submitted proof to specific Kc/salt/results values and + * the contract can cross-check Kc against its own stored `HYBRID_KC`. + * `participantPubKey`/`participantIndex` are public so the contract can + * independently check they are registered `HybridCommitteeConfig` members — + * membership/threshold POLICY lives on-chain; this circuit only proves the + * decryption ARITHMETIC for whichever T (pubkey, index) pairs are supplied. + */ +template RevealVerify(voteOptionTreeDepth, threshold) { + var TREE_ARITY = 5; + var M = 1; + for (var d = 0; d < voteOptionTreeDepth; d++) { M *= TREE_ARITY; } + var T = threshold; + // Generous bound for Lagrange coefficient magnitudes: safe for committees + // with up to ~6 members and index values up to a few hundred, far beyond + // this demo's scope (T=2, N=3), while staying much cheaper than the + // 253/254-bit mults used for the actual curve scalars above. + var LAMBDA_BITS = 64; + + // ---- Public ---- + signal input Kc[2]; + signal input aggC1[M][2]; + signal input aggC2[M][2]; + signal input results[M]; + signal input salt; + signal input participantPubKey[T][2]; + signal input participantIndex[T]; + + // ---- Private ---- + signal input partial[T][M][2]; + signal input dleqA[T][M][2]; + signal input dleqB[T][M][2]; + signal input dleqZ[T][M]; + signal input lambdaSign[T]; + signal input lambdaMag[T]; + signal input commonSign; + signal input commonMag; + + // 0. Participant indices must be pairwise distinct. Without this, a + // prover could submit a repeated index so that `den[i]` (the product + // of `participantIndex[i] - participantIndex[m]` over m != i) is + // forced to 0 for some i, making `common` (the product of all + // `den[i]`) 0 too. `commonSign`/`commonMag`/`lambdaSign`/`lambdaMag` + // are prover-supplied private witnesses, and `common === (1 - + // 2*commonSign) * commonMag` / `lambdaInt[i] === (1-2*lambdaSign[i]) + // * lambdaMag[i]` are satisfiable with 0 == 0 regardless of what + // `commonMag`/`lambdaMag` are otherwise unconstrained to — collapsing + // step 5's check to `0 == 0`, which holds for ANY `results`/`aggC2`, + // letting a prover claim arbitrary tally results. `participantIndex` + // is PUBLIC (entirely attacker-controlled input), so this must be + // enforced in-circuit, not just trusted from the caller. + component idxDup[T][T]; + for (var i = 0; i < T; i++) { + for (var m = i + 1; m < T; m++) { + idxDup[i][m] = IsZero(); + idxDup[i][m].in <== participantIndex[i] - participantIndex[m]; + idxDup[i][m].out === 0; + } + } + + // 1. Every participant's partial decryption share must be consistent with + // their registered public key, for every option. + component dleq[T][M]; + for (var i = 0; i < T; i++) { + for (var j = 0; j < M; j++) { + dleq[i][j] = DleqVerify(); + dleq[i][j].H[0] <== aggC1[j][0]; + dleq[i][j].H[1] <== aggC1[j][1]; + dleq[i][j].X[0] <== participantPubKey[i][0]; + dleq[i][j].X[1] <== participantPubKey[i][1]; + dleq[i][j].Y[0] <== partial[i][j][0]; + dleq[i][j].Y[1] <== partial[i][j][1]; + dleq[i][j].a[0] <== dleqA[i][j][0]; + dleq[i][j].a[1] <== dleqA[i][j][1]; + dleq[i][j].b[0] <== dleqB[i][j][0]; + dleq[i][j].b[1] <== dleqB[i][j][1]; + dleq[i][j].z <== dleqZ[i][j]; + } + } + + // 2. Denominator-cleared Lagrange-at-zero coefficients from the PUBLIC + // participant indices (see file doc comment above). + signal numAcc[T][T + 1]; + signal denAcc[T][T + 1]; + signal den[T]; + for (var i = 0; i < T; i++) { + numAcc[i][0] <== 1; + denAcc[i][0] <== 1; + for (var m = 0; m < T; m++) { + if (m == i) { + numAcc[i][m + 1] <== numAcc[i][m]; + denAcc[i][m + 1] <== denAcc[i][m]; + } else { + numAcc[i][m + 1] <== numAcc[i][m] * (0 - participantIndex[m]); + denAcc[i][m + 1] <== denAcc[i][m] * (participantIndex[i] - participantIndex[m]); + } + } + den[i] <== denAcc[i][T]; + } + + signal commonAcc[T + 1]; + commonAcc[0] <== 1; + for (var i = 0; i < T; i++) { + commonAcc[i + 1] <== commonAcc[i] * den[i]; + } + signal common; + common <== commonAcc[T]; + + signal lambdaIntAcc[T][T + 1]; + signal lambdaInt[T]; + for (var i = 0; i < T; i++) { + lambdaIntAcc[i][0] <== numAcc[i][T]; + for (var k = 0; k < T; k++) { + if (k == i) { + lambdaIntAcc[i][k + 1] <== lambdaIntAcc[i][k]; + } else { + lambdaIntAcc[i][k + 1] <== lambdaIntAcc[i][k] * den[k]; + } + } + lambdaInt[i] <== lambdaIntAcc[i][T]; + } + + // 3. Bind the (sign, magnitude) witnesses to the in-circuit integer + // values computed above. + for (var i = 0; i < T; i++) { + lambdaSign[i] * (1 - lambdaSign[i]) === 0; + lambdaInt[i] === (1 - 2 * lambdaSign[i]) * lambdaMag[i]; + } + commonSign * (1 - commonSign) === 0; + common === (1 - 2 * commonSign) * commonMag; + + // 4. D'[j] = sum_i lambda_i_int . partial_i[j] == common . D[j]. + component lambdaMul[T][M]; + component dAdd[T][M]; + signal dAcc[M][T + 1][2]; + for (var j = 0; j < M; j++) { + dAcc[j][0][0] <== 0; + dAcc[j][0][1] <== 1; + for (var i = 0; i < T; i++) { + lambdaMul[i][j] = SignedScalarMul(LAMBDA_BITS); + lambdaMul[i][j].p[0] <== partial[i][j][0]; + lambdaMul[i][j].p[1] <== partial[i][j][1]; + lambdaMul[i][j].mag <== lambdaMag[i]; + lambdaMul[i][j].sign <== lambdaSign[i]; + + dAdd[i][j] = BabyAdd(); + dAdd[i][j].x1 <== dAcc[j][i][0]; + dAdd[i][j].y1 <== dAcc[j][i][1]; + dAdd[i][j].x2 <== lambdaMul[i][j].out[0]; + dAdd[i][j].y2 <== lambdaMul[i][j].out[1]; + dAcc[j][i + 1][0] <== dAdd[i][j].xout; + dAcc[j][i + 1][1] <== dAdd[i][j].yout; + } + } + + // 5. Check common . aggC2[j] == common . (results[j]*G) + D'[j]. + component resG[M]; + component commonAggC2[M]; + component commonResG[M]; + component rhs[M]; + for (var j = 0; j < M; j++) { + resG[j] = BabyPbk(); + resG[j].in <== results[j]; + + commonAggC2[j] = SignedScalarMul(LAMBDA_BITS); + commonAggC2[j].p[0] <== aggC2[j][0]; + commonAggC2[j].p[1] <== aggC2[j][1]; + commonAggC2[j].mag <== commonMag; + commonAggC2[j].sign <== commonSign; + + commonResG[j] = SignedScalarMul(LAMBDA_BITS); + commonResG[j].p[0] <== resG[j].Ax; + commonResG[j].p[1] <== resG[j].Ay; + commonResG[j].mag <== commonMag; + commonResG[j].sign <== commonSign; + + rhs[j] = BabyAdd(); + rhs[j].x1 <== commonResG[j].out[0]; + rhs[j].y1 <== commonResG[j].out[1]; + rhs[j].x2 <== dAcc[j][T][0]; + rhs[j].y2 <== dAcc[j][T][1]; + + rhs[j].xout === commonAggC2[j].out[0]; + rhs[j].yout === commonAggC2[j].out[1]; + } +} + +/** + * On-chain wrapper for RevealVerify: identical constraints, but the ENTIRE + * public interface is folded into a single SHA256 `inputHash`, matching the + * cw-amaci Groth16 verifier's single-public-input support (same pattern as + * `BallotValidityOnchain`/`ProcessHybridMessagesOnchain`). + * + * The bound public values, in the SAME order the contract must repack: + * [ Kc.x, Kc.y, + * aggCommitment, // Poseidon AheCommit(aggC1, aggC2) + * resultsCommitment, // Poseidon fold of results[0..M-1], salt + * participantCommitment, // Poseidon fold of (pubKey.x, pubKey.y, index) per participant + * pollId ] + * + * None of `aggC1`/`aggC2`/`results`/`salt`/`participantPubKey`/`participantIndex` + * are exposed directly (that would need M*4 + M + 1 + 3*T field elements — + * expensive to SHA256 and to repack on-chain). They are folded into three + * Poseidon commitments first, using the SAME HashLeftRight-chain construction + * `AheCommit` already uses for ciphertexts (acc_0 = 0, acc_{k+1} = + * hash2([acc_k, elem_k])), so the contract can recompute each commitment from + * values it already has (its own `HYBRID_AGG_C1`/`HYBRID_AGG_C2`, and the + * `results`/`salt`/`participant*` arguments of the `RevealHybridTally` call) + * without needing them as separate SHA256 inputs. + */ +template RevealVerifyOnchain(voteOptionTreeDepth, threshold) { + var TREE_ARITY = 5; + var M = 1; + for (var d = 0; d < voteOptionTreeDepth; d++) { M *= TREE_ARITY; } + var T = threshold; + + // Private witness (everything the inner circuit needs). + signal input partial[T][M][2]; + signal input dleqA[T][M][2]; + signal input dleqB[T][M][2]; + signal input dleqZ[T][M]; + signal input lambdaSign[T]; + signal input lambdaMag[T]; + signal input commonSign; + signal input commonMag; + signal input aggC1[M][2]; + signal input aggC2[M][2]; + + // Public (folded into inputHash below). + signal input Kc[2]; + signal input results[M]; + signal input salt; + signal input participantPubKey[T][2]; + signal input participantIndex[T]; + signal input pollId; + + // Single public signal. + signal output inputHash; + + component rv = RevealVerify(voteOptionTreeDepth, threshold); + rv.Kc[0] <== Kc[0]; + rv.Kc[1] <== Kc[1]; + rv.salt <== salt; + for (var j = 0; j < M; j++) { + rv.aggC1[j][0] <== aggC1[j][0]; + rv.aggC1[j][1] <== aggC1[j][1]; + rv.aggC2[j][0] <== aggC2[j][0]; + rv.aggC2[j][1] <== aggC2[j][1]; + rv.results[j] <== results[j]; + for (var i = 0; i < T; i++) { + rv.partial[i][j][0] <== partial[i][j][0]; + rv.partial[i][j][1] <== partial[i][j][1]; + rv.dleqA[i][j][0] <== dleqA[i][j][0]; + rv.dleqA[i][j][1] <== dleqA[i][j][1]; + rv.dleqB[i][j][0] <== dleqB[i][j][0]; + rv.dleqB[i][j][1] <== dleqB[i][j][1]; + rv.dleqZ[i][j] <== dleqZ[i][j]; + } + } + for (var i = 0; i < T; i++) { + rv.participantPubKey[i][0] <== participantPubKey[i][0]; + rv.participantPubKey[i][1] <== participantPubKey[i][1]; + rv.participantIndex[i] <== participantIndex[i]; + rv.lambdaSign[i] <== lambdaSign[i]; + rv.lambdaMag[i] <== lambdaMag[i]; + } + rv.commonSign <== commonSign; + rv.commonMag <== commonMag; + + // Fold the (still-encrypted) aggregate ciphertext into ONE commitment, + // exactly like ProcessHybridMessagesOnchain's current/new aggregates. + component aggCommit = AheCommit(M); + for (var j = 0; j < M; j++) { + aggCommit.c1[j][0] <== aggC1[j][0]; + aggCommit.c1[j][1] <== aggC1[j][1]; + aggCommit.c2[j][0] <== aggC2[j][0]; + aggCommit.c2[j][1] <== aggC2[j][1]; + } + + // Fold results[0..M-1] + salt: acc_0 = 0; acc_{k+1} = hash2([acc_k, elem_k]). + component resultsHash[M + 1]; + signal resultsAcc[M + 2]; + resultsAcc[0] <== 0; + for (var j = 0; j < M; j++) { + resultsHash[j] = HashLeftRight(); + resultsHash[j].left <== resultsAcc[j]; + resultsHash[j].right <== results[j]; + resultsAcc[j + 1] <== resultsHash[j].hash; + } + resultsHash[M] = HashLeftRight(); + resultsHash[M].left <== resultsAcc[M]; + resultsHash[M].right <== salt; + resultsAcc[M + 1] <== resultsHash[M].hash; + signal resultsCommitment; + resultsCommitment <== resultsAcc[M + 1]; + + // Fold (pubKey.x, pubKey.y, index) per participant, same construction. + component partHash[3 * T]; + signal partAcc[3 * T + 1]; + partAcc[0] <== 0; + for (var i = 0; i < T; i++) { + partHash[3 * i] = HashLeftRight(); + partHash[3 * i].left <== partAcc[3 * i]; + partHash[3 * i].right <== participantPubKey[i][0]; + partAcc[3 * i + 1] <== partHash[3 * i].hash; + + partHash[3 * i + 1] = HashLeftRight(); + partHash[3 * i + 1].left <== partAcc[3 * i + 1]; + partHash[3 * i + 1].right <== participantPubKey[i][1]; + partAcc[3 * i + 2] <== partHash[3 * i + 1].hash; + + partHash[3 * i + 2] = HashLeftRight(); + partHash[3 * i + 2].left <== partAcc[3 * i + 2]; + partHash[3 * i + 2].right <== participantIndex[i]; + partAcc[3 * i + 3] <== partHash[3 * i + 2].hash; + } + signal participantCommitment; + participantCommitment <== partAcc[3 * T]; + + component ih = Sha256Hasher(6); + ih.in[0] <== Kc[0]; + ih.in[1] <== Kc[1]; + ih.in[2] <== aggCommit.commitment; + ih.in[3] <== resultsCommitment; + ih.in[4] <== participantCommitment; + ih.in[5] <== pollId; + + inputHash <== ih.hash; +} diff --git a/packages/circuits/circom/hybrid/utils/hybridMessageToCommand.circom b/packages/circuits/circom/hybrid/utils/hybridMessageToCommand.circom new file mode 100644 index 0000000..39c2f01 --- /dev/null +++ b/packages/circuits/circom/hybrid/utils/hybridMessageToCommand.circom @@ -0,0 +1,86 @@ +pragma circom 2.0.0; + +include "../../utils/ecdh.circom"; +include "../../utils/unpackElement.circom"; +include "../../utils/hasherPoseidon.circom"; +include "../../utils/verifySignature.circom"; +include "../../utils/lib/poseidonDecrypt.circom"; + +/** + * Decrypt a Hybrid routing envelope and verify its signature. + * + * Unlike MACI's messageToCommand, the decrypted command does NOT contain the + * vote content (optionIdx / weight). Those live in a separate AHE ciphertext the + * coordinator can never decrypt. Here the coordinator only recovers routing + * data and the aheCommitment that binds the (separately published) ballot. + * + * Routing command (7 elements, Poseidon-encrypted to a 10-element ciphertext): + * [ packed, newPubKey_x, newPubKey_y, aheCommitment, sigR8_x, sigR8_y, sigS ] + * + * packed (from SDK packHybrid): nonce(bits 0-31) | stateIdx(32-63) | pollId(64-95) + * + * The signature preimage is [packed, newPubKey_x, newPubKey_y, aheCommitment], + * so the voter authorizes exactly this ballot (via aheCommitment) and this key + * rotation (via newPubKey). + */ +template HybridMessageToCommand() { + var MSG_LENGTH = 10; + var CMD_LENGTH = 7; + + signal input message[MSG_LENGTH]; + signal input encPrivKey; // coordinator private key + signal input encPubKey[2]; // message ephemeral public key + signal input voterPubKey[2]; // voter's current registered key (state leaf) + + signal output stateIndex; + signal output nonce; + signal output pollId; + signal output newPubKey[2]; + signal output aheCommitment; + signal output sigValid; + + // 1. ECDH shared key (coordinator only). + component ecdh = Ecdh(); + ecdh.privKey <== encPrivKey; + ecdh.pubKey[0] <== encPubKey[0]; + ecdh.pubKey[1] <== encPubKey[1]; + + // 2. Decrypt routing command. + component decryptor = PoseidonDecryptWithoutCheck(CMD_LENGTH); + decryptor.key[0] <== ecdh.sharedKey[0]; + decryptor.key[1] <== ecdh.sharedKey[1]; + decryptor.nonce <== 0; + for (var i = 0; i < MSG_LENGTH; i++) { + decryptor.ciphertext[i] <== message[i]; + } + + // 3. Unpack routing scalars from decrypted[0]. + // UnpackElement outputs HIGH -> LOW: out[0]=bits64-95=pollId, + // out[1]=bits32-63=stateIdx, out[2]=bits0-31=nonce. + component unpack = UnpackElement(3); + unpack.in <== decryptor.decrypted[0]; + pollId <== unpack.out[0]; + stateIndex <== unpack.out[1]; + nonce <== unpack.out[2]; + + newPubKey[0] <== decryptor.decrypted[1]; + newPubKey[1] <== decryptor.decrypted[2]; + aheCommitment <== decryptor.decrypted[3]; + + // 4. Verify signature over [packed, newPubKey_x, newPubKey_y, aheCommitment]. + component msgHash = Hasher4(); + msgHash.in[0] <== decryptor.decrypted[0]; + msgHash.in[1] <== decryptor.decrypted[1]; + msgHash.in[2] <== decryptor.decrypted[2]; + msgHash.in[3] <== decryptor.decrypted[3]; + + component verifier = EdDSAPoseidonVerifier_patched(); + verifier.Ax <== voterPubKey[0]; + verifier.Ay <== voterPubKey[1]; + verifier.R8x <== decryptor.decrypted[4]; + verifier.R8y <== decryptor.decrypted[5]; + verifier.S <== decryptor.decrypted[6]; + verifier.M <== msgHash.hash; + + sigValid <== verifier.valid; +} diff --git a/packages/circuits/package.json b/packages/circuits/package.json index 8f83f57..ebbd479 100644 --- a/packages/circuits/package.json +++ b/packages/circuits/package.json @@ -56,7 +56,12 @@ "test:processMessagesAmaciSecurity": "pnpm run mocha-test ts/__tests__/ProcessMessagesAmaciSecurity.test.ts", "test:processMessagesAmaciSync": "pnpm run mocha-test ts/__tests__/ProcessMessagesAmaciSync.test.ts", "test:processMessagesAmaciIntegration": "pnpm run mocha-test ts/__tests__/ProcessMessagesAmaciIntegration.test.ts", - "test:deactivateStatusDetection": "pnpm run mocha-test ts/__tests__/DeactivateStatusDetection.test.ts" + "test:deactivateStatusDetection": "pnpm run mocha-test ts/__tests__/DeactivateStatusDetection.test.ts", + "test:hybridBallotValidity": "pnpm run mocha-test ts/__tests__/HybridBallotValidity.test.ts", + "test:hybridBallotRoutingBinding": "pnpm run mocha-test ts/__tests__/HybridBallotRoutingBinding.test.ts", + "test:hybridProcessMessages": "pnpm run mocha-test ts/__tests__/HybridProcessMessages.test.ts", + "test:hybridSdkIntegration": "pnpm run mocha-test ts/__tests__/HybridSdkIntegration.test.ts", + "test:hybridRevealVerify": "pnpm run mocha-test ts/__tests__/HybridRevealVerify.test.ts" }, "dependencies": { "circomkit": "^0.3.4", diff --git a/packages/circuits/ts/__tests__/HybridBallotRoutingBinding.test.ts b/packages/circuits/ts/__tests__/HybridBallotRoutingBinding.test.ts new file mode 100644 index 0000000..f5c5b61 --- /dev/null +++ b/packages/circuits/ts/__tests__/HybridBallotRoutingBinding.test.ts @@ -0,0 +1,115 @@ +import { + VoterClient, + OperatorClient, + Tree, + poseidon, + hashLeftRight +} from '@dorafactory/maci-sdk'; +import { expect } from 'chai'; +import { type WitnessTester } from 'circomkit'; + +import { circomkitInstance } from './utils/utils'; + +/** + * BallotValidity circuit -- routing-binding tests (Phase 3 of + * hybrid_maci+ahe_审查问题修复计划). + * + * Circuit: packages/circuits/circom/hybrid/power/ballotValidity.circom + * + * Before this fix, the circuit re-derived the ECDH shared key from + * `ephemeralPrivKey`/`coordPubKey` and asserted the DECRYPTED `routing` + * envelope was self-consistent, but never checked that `routingEncPubKey` + * (the ephemeral pubkey actually published on-chain alongside the + * ciphertext) equals `ephemeralPrivKey * G`. A prover could satisfy every + * in-circuit assertion with one ephemeral key while publishing a completely + * DIFFERENT `routingEncPubKey` on-chain -- the coordinator's real ECDH + * (using its own `coordPrivKey` and the published `routingEncPubKey`) would + * then derive a different shared key than the one this proof reasoned + * about, decrypting the real on-chain envelope to garbage. + * + * This suite proves: a valid ballot (where `routingEncPubKey` really is + * `ephemeralPrivKey * G`) is accepted, and swapping in an unrelated + * `routingEncPubKey` (still able to satisfy every OTHER constraint, since + * the routing ciphertext itself is untouched) makes witness generation + * fail. + */ +describe('BallotValidity circuit -- routing binding (Hybrid MACI + AHE)', function test() { + this.timeout(300000); + + const STATE_TREE_DEPTH = 2; + const M = 5; // options (voteOptionTreeDepth = 1) + const UNIFIED_BALANCE = 100n; + const DEACTIVATE_CONSTANT = + 14655542659562014735865511769057053982292279840403315552050801315682099828156n; + + const Kc: [bigint, bigint] = [ + 12638030528432806444680310326288043858520366543569780948011195983100888895424n, + 2874222432609678237186489396330648906556209135055008837139779509259876658697n + ]; + const coordinator = new OperatorClient({ network: 'testnet', secretKey: 424242n }); + const coordPubKey = coordinator.getSigner().getPublicKey().toPoints() as [bigint, bigint]; + const voter = new VoterClient({ network: 'testnet', secretKey: 100001n }); + const pubKey = voter.getSigner().getPublicKey().toPoints() as [bigint, bigint]; + const pollId = 1; + const stateIdx = 0; + + const leaves = [pubKey].map((pk) => + hashLeftRight(poseidon([pk[0], pk[1], UNIFIED_BALANCE, 0n, 0n]), DEACTIVATE_CONSTANT) + ); + const tree = new Tree(5, STATE_TREE_DEPTH, 0n); + tree.initLeaves(leaves); + const stateRoot = tree.root as bigint; + const pathElements = tree.pathElementOf(stateIdx) as bigint[][]; + + const message = voter.genHybridMessageFactory(coordPubKey, Kc, pollId, M)(stateIdx, 1, 2, 4); + + const baseInputs = { + voteWeights: message.weights, + r: message.randomness, + voiceCreditBalance: UNIFIED_BALANCE, + voteOptionTreeRoot: 0n, + slNonce: 0n, + pathElements, + stateIdx: BigInt(stateIdx), + pubKey, + ephemeralPrivKey: message.ephemeralPrivKey, + routing: message.routing, + routingEncPubKey: message.encPubKey, + Kc, + stateRoot, + coordPubKey, + pollId: BigInt(pollId) + }; + + let circuit: WitnessTester; + + before(async () => { + circuit = await circomkitInstance.WitnessTester('BallotValidity_hybrid_2-1', { + file: 'hybrid/power/ballotValidity', + template: 'BallotValidity', + params: [STATE_TREE_DEPTH, 1] + }); + }); + + it('accepts a ballot whose routingEncPubKey really is ephemeralPrivKey * G', async () => { + const witness = await circuit.calculateWitness(baseInputs); + await circuit.expectConstraintPass(witness); + }); + + it('rejects a ballot whose routingEncPubKey does NOT match ephemeralPrivKey * G', async () => { + // Swap in an unrelated ephemeral pubkey (the COORDINATOR's, just to have + // some other valid curve point handy) -- everything else about the + // witness (the routing ciphertext, the decrypted stateIdx/aheCommitment + // it must match) is untouched, so only the NEW PrivToPubKey constraint + // can catch this. + const tampered = { ...baseInputs, routingEncPubKey: coordPubKey }; + let threw = false; + try { + const witness = await circuit.calculateWitness(tampered); + await circuit.expectConstraintPass(witness); + } catch { + threw = true; + } + expect(threw).to.equal(true); + }); +}); diff --git a/packages/circuits/ts/__tests__/HybridBallotValidity.test.ts b/packages/circuits/ts/__tests__/HybridBallotValidity.test.ts new file mode 100644 index 0000000..9288694 --- /dev/null +++ b/packages/circuits/ts/__tests__/HybridBallotValidity.test.ts @@ -0,0 +1,199 @@ +import { + VoterClient, + OperatorClient, + deriveCommitteeKey, + buildBallotVector, + encryptBallot +} from '@dorafactory/maci-sdk'; +import { expect } from 'chai'; +import { type WitnessTester } from 'circomkit'; + +import { getSignal, circomkitInstance, buildHybridVoterTree } from './utils/utils'; + +/** + * BallotValidity circuit tests. + * + * Circuit: packages/circuits/circom/hybrid/power/ballotValidity.circom + * + * The voter proves, without revealing the vote, that the published AHE + * ciphertext vector correctly encrypts a one-hot weighted ballot within a + * budget authenticated from the voter's REAL state leaf (via Merkle + * inclusion against `stateRoot`), and hashes to the committed value that the + * routing signature binds. See `HybridBallotRoutingBinding.test.ts` for the + * dedicated routing-binding (`routingEncPubKey === ephemeralPrivKey * G`) + * regression tests. + */ +describe('BallotValidity circuit (Hybrid MACI + AHE)', function test() { + this.timeout(300000); + + const STATE_TREE_DEPTH = 2; + const M = 5; // voteOptionTreeDepth = 1 -> 5 options + const UNIFIED_BALANCE = 100n; + const pollId = 1; + const stateIdx = 0; + + // A demo committee key Kc = kc * G (single-party stand-in for the threshold key). + const kc = 987654321n; + const Kc = deriveCommitteeKey(kc); + + const coordinator = new OperatorClient({ network: 'testnet', secretKey: 424242n }); + const coordPubKey = coordinator.getSigner().getPublicKey().toPoints() as [bigint, bigint]; + const voter = new VoterClient({ network: 'testnet', secretKey: 100001n }); + const pubKey = voter.getSigner().getPublicKey().toPoints() as [bigint, bigint]; + + const { tree, stateRoot } = buildHybridVoterTree([{ pubKey, balance: UNIFIED_BALANCE }], STATE_TREE_DEPTH); + const pathElements = tree.pathElementOf(stateIdx) as bigint[][]; + + let circuit: WitnessTester; + + before(async () => { + circuit = await circomkitInstance.WitnessTester('BallotValidity_hybrid_2-1', { + file: 'hybrid/power/ballotValidity', + template: 'BallotValidity', + params: [STATE_TREE_DEPTH, 1] + }); + }); + + it('accepts a valid one-hot ballot and matches SDK ciphertext/commitment', async () => { + const voIdx = 1; + const weight = 5; // 5^2 = 25 <= 100 + + const message = voter.genHybridMessageFactory(coordPubKey, Kc, pollId, M)(stateIdx, 1, voIdx, weight); + + const inputs = { + voteWeights: message.weights, + r: message.randomness, + voiceCreditBalance: UNIFIED_BALANCE, + voteOptionTreeRoot: 0n, + slNonce: 0n, + pathElements, + stateIdx: BigInt(stateIdx), + pubKey, + ephemeralPrivKey: message.ephemeralPrivKey, + routing: message.routing, + routingEncPubKey: message.encPubKey, + Kc, + stateRoot, + coordPubKey, + pollId: BigInt(pollId) + }; + + const witness = await circuit.calculateWitness(inputs); + await circuit.expectConstraintPass(witness); + + // Circuit-produced ciphertext must equal the SDK's. + for (let opt = 0; opt < M; opt++) { + expect(await getSignal(circuit, witness, `c1[${opt}][0]`)).to.equal(message.ciphertexts[opt].c1[0]); + expect(await getSignal(circuit, witness, `c1[${opt}][1]`)).to.equal(message.ciphertexts[opt].c1[1]); + expect(await getSignal(circuit, witness, `c2[${opt}][0]`)).to.equal(message.ciphertexts[opt].c2[0]); + expect(await getSignal(circuit, witness, `c2[${opt}][1]`)).to.equal(message.ciphertexts[opt].c2[1]); + } + expect(await getSignal(circuit, witness, 'aheCommitment')).to.equal(message.aheCommitment); + }); + + it('rejects a non one-hot ballot (weight on two options)', async () => { + const voIdx = 1; + const weight = 5; + const message = voter.genHybridMessageFactory(coordPubKey, Kc, pollId, M)(stateIdx, 1, voIdx, weight); + + // Tamper: swap in a non-one-hot weight vector (two non-zero entries). This + // also desyncs the recomputed aheCommitment from the one the routing + // envelope was signed for, but that's fine -- either way the witness must + // fail to satisfy the circuit. + const weights = [3n, 4n, 0n, 0n, 0n]; + const { randomness } = encryptBallot(weights, Kc); + + let threw = false; + try { + const witness = await circuit.calculateWitness({ + voteWeights: weights, + r: randomness, + voiceCreditBalance: UNIFIED_BALANCE, + voteOptionTreeRoot: 0n, + slNonce: 0n, + pathElements, + stateIdx: BigInt(stateIdx), + pubKey, + ephemeralPrivKey: message.ephemeralPrivKey, + routing: message.routing, + routingEncPubKey: message.encPubKey, + Kc, + stateRoot, + coordPubKey, + pollId: BigInt(pollId) + }); + await circuit.expectConstraintPass(witness); + } catch { + threw = true; + } + expect(threw).to.equal(true); + }); + + it('rejects a ballot that exceeds the voice-credit budget', async () => { + const voIdx = 0; + const weight = 11; // 11^2 = 121 > 100 + const message = voter.genHybridMessageFactory(coordPubKey, Kc, pollId, M)(stateIdx, 1, voIdx, weight); + + const weights = buildBallotVector(voIdx, weight, M); + const { randomness } = encryptBallot(weights, Kc); + + let threw = false; + try { + const witness = await circuit.calculateWitness({ + voteWeights: weights, + r: randomness, + voiceCreditBalance: UNIFIED_BALANCE, + voteOptionTreeRoot: 0n, + slNonce: 0n, + pathElements, + stateIdx: BigInt(stateIdx), + pubKey, + ephemeralPrivKey: message.ephemeralPrivKey, + routing: message.routing, + routingEncPubKey: message.encPubKey, + Kc, + stateRoot, + coordPubKey, + pollId: BigInt(pollId) + }); + await circuit.expectConstraintPass(witness); + } catch { + threw = true; + } + expect(threw).to.equal(true); + }); + + it('rejects a ballot authenticated against the WRONG state root (forged voice-credit balance)', async () => { + const voIdx = 2; + const weight = 5; + const message = voter.genHybridMessageFactory(coordPubKey, Kc, pollId, M)(stateIdx, 1, voIdx, weight); + + // Tamper: claim a much larger balance than the leaf actually committed to + // (a forged budget). The Merkle inclusion check must reject this since + // the leaf hash would no longer match any leaf under `stateRoot`. + let threw = false; + try { + const witness = await circuit.calculateWitness({ + voteWeights: message.weights, + r: message.randomness, + voiceCreditBalance: 999999n, + voteOptionTreeRoot: 0n, + slNonce: 0n, + pathElements, + stateIdx: BigInt(stateIdx), + pubKey, + ephemeralPrivKey: message.ephemeralPrivKey, + routing: message.routing, + routingEncPubKey: message.encPubKey, + Kc, + stateRoot, + coordPubKey, + pollId: BigInt(pollId) + }); + await circuit.expectConstraintPass(witness); + } catch { + threw = true; + } + expect(threw).to.equal(true); + }); +}); diff --git a/packages/circuits/ts/__tests__/HybridProcessMessages.test.ts b/packages/circuits/ts/__tests__/HybridProcessMessages.test.ts new file mode 100644 index 0000000..dd81a0a --- /dev/null +++ b/packages/circuits/ts/__tests__/HybridProcessMessages.test.ts @@ -0,0 +1,250 @@ +import { + VoterClient, + OperatorClient, + deriveCommitteeKey, + committeePartial, + recoverAhe, + solveDLog, + aggregateAhe, + AheCiphertext +} from '@dorafactory/maci-sdk'; +import { expect } from 'chai'; +import { type WitnessTester } from 'circomkit'; + +import { getSignal, circomkitInstance, buildHybridVoterTree, buildProcessHybridMessagesInput } from './utils/utils'; + +/** + * ProcessHybridMessages circuit tests. + * + * Circuit: packages/circuits/circom/hybrid/power/processHybridMessages.circom + * + * The coordinator decrypts only routing (stateIdx / nonce / sig), applies + * plaintext last-write-wins, and homomorphically aggregates surviving ballots + * without ever seeing optionIdx / weight -- the ciphertext vector is only + * addressed to the committee key Kc. It also Merkle-authenticates each real + * message's `voterPubKey` against the public `stateRoot` (anti-selective- + * censorship: the coordinator cannot silently drop a real voter's message + * and claim a smaller `actualCount` without changing the batch's real + * survivors/aggregate). The committee decrypts only the per-option aggregate. + */ +describe('ProcessHybridMessages circuit (Hybrid MACI + AHE)', function test() { + this.timeout(600000); + + const STATE_TREE_DEPTH = 2; + const B = 5; // batch size (ProcessHybridMessages_hybrid_2-1-5) + const M = 5; // vote options (voteOptionTreeDepth = 1) + const UNIFIED_BALANCE = 100n; + const pollId = 1; + const DLOG_BOUND = 1_000_000; + + const kc = 55555555n; + const Kc = deriveCommitteeKey(kc); + + const coord = new OperatorClient({ network: 'testnet', secretKey: 24680n }); + const coordPubKey = coord.getSigner().getPublicKey().toPoints() as [bigint, bigint]; + const voterA = new VoterClient({ network: 'testnet', secretKey: 111111n }); + const voterB = new VoterClient({ network: 'testnet', secretKey: 222222n }); + const pubA = voterA.getSigner().getPublicKey().toPoints() as [bigint, bigint]; + const pubB = voterB.getSigner().getPublicKey().toPoints() as [bigint, bigint]; + + const { tree, stateRoot } = buildHybridVoterTree( + [ + { pubKey: pubA, balance: UNIFIED_BALANCE }, + { pubKey: pubB, balance: UNIFIED_BALANCE } + ], + STATE_TREE_DEPTH + ); + const leafOf = (stateIdx: number) => ({ + voiceCreditBalance: UNIFIED_BALANCE, + voteOptionTreeRoot: 0n, + slNonce: 0n, + pathElements: tree.pathElementOf(stateIdx) as bigint[][] + }); + + let circuit: WitnessTester; + + before(async () => { + circuit = await circomkitInstance.WitnessTester('ProcessHybridMessages_hybrid_2-1-5', { + file: 'hybrid/power/processHybridMessages', + template: 'ProcessHybridMessages', + params: [STATE_TREE_DEPTH, 1, B] + }); + }); + + it('decrypts routing, applies LWW, and homomorphically tallies (with a revote)', async () => { + // Scenario (all nonces=1, forward submission order): + // msg0: voter A (stateIdx 0, nonce 1) -> option 0, weight 3 (superseded: processed LAST) + // msg1: voter A (stateIdx 0, nonce 1) -> option 1, weight 5 (survives: processed 2nd, nonce OK) + // msg2: voter B (stateIdx 1, nonce 1) -> option 0, weight 4 (survives: processed FIRST) + // Circuit reverses: i=2 first (V2 nonce=1, currentNonce[1]=0 → VALID), + // i=1 next (V0 nonce=1, currentNonce[0]=0 → VALID, sets nonce[0]=1), + // i=0 last (V0 nonce=1, currentNonce[0]=1 → INVALID: 1≠2). + // Expected tally: option0 = 4 (V2), option1 = 5 (V0 revote), rest = 0. + const genA = voterA.genHybridMessageFactory(coordPubKey, Kc, pollId, M); + const genB = voterB.genHybridMessageFactory(coordPubKey, Kc, pollId, M); + const specs = [ + { pub: pubA, stateIdx: 0, nonce: 1, msg: genA(0, 1, 0, 3) }, + { pub: pubA, stateIdx: 0, nonce: 1, msg: genA(0, 1, 1, 5) }, + { pub: pubB, stateIdx: 1, nonce: 1, msg: genB(1, 1, 0, 4) } + ]; + + const input = buildProcessHybridMessagesInput({ + coordPrivKey: coord.getSigner().getFormatedPrivKey(), + messages: specs.map((s) => s.msg), + voterPubKeys: specs.map((s) => s.pub), + leaves: specs.map((s) => leafOf(s.stateIdx)), + stateRoot, + messageNonces: specs.map((s) => ({ stateIdx: s.stateIdx, nonce: s.nonce })), + batchSize: B + }); + + const witness = await circuit.calculateWitness(input); + await circuit.expectConstraintPass(witness); + + // Read the aggregate ciphertext per option and threshold-decrypt (single kc). + const decrypted: bigint[] = []; + for (let opt = 0; opt < M; opt++) { + const aggC1: [bigint, bigint] = [ + await getSignal(circuit, witness, `aggC1[${opt}][0]`), + await getSignal(circuit, witness, `aggC1[${opt}][1]`) + ]; + const aggC2: [bigint, bigint] = [ + await getSignal(circuit, witness, `aggC2[${opt}][0]`), + await getSignal(circuit, witness, `aggC2[${opt}][1]`) + ]; + const shared = committeePartial(aggC1, kc); + const vG = recoverAhe({ c1: aggC1, c2: aggC2 }, shared); + const total = solveDLog(vG, DLOG_BOUND); + decrypted.push(total ?? -1n); + } + + expect(decrypted).to.deep.equal([4n, 5n, 0n, 0n, 0n]); + }); + + it('matches an off-circuit reference tally computed via SDK LWW + homomorphic sum', async () => { + const coord2 = new OperatorClient({ network: 'testnet', secretKey: 13579n }); + const coordPubKey2 = coord2.getSigner().getPublicKey().toPoints() as [bigint, bigint]; + const genA = voterA.genHybridMessageFactory(coordPubKey2, Kc, pollId, M); + const genB = voterB.genHybridMessageFactory(coordPubKey2, Kc, pollId, M); + + // All nonces=1, forward submission order. V2 revotes (last submission + // for stateIdx 1) wins because circuit processes in reverse (newest first). + const specs = [ + { pub: pubB, stateIdx: 1, nonce: 1, msg: genB(1, 1, 2, 2) }, // V2 original (superseded) + { pub: pubA, stateIdx: 0, nonce: 1, msg: genA(0, 1, 0, 7) }, // V1 vote (survives) + { pub: pubB, stateIdx: 1, nonce: 1, msg: genB(1, 1, 1, 6) } // V2 revote (survives, nonce was 2) + ]; + + // Off-circuit reference: last submission per stateIdx wins (highest chain + // index = highest spec index), then homomorphically sum surviving ballots. + const survivorByState = new Map(); + specs.forEach((s, i) => survivorByState.set(s.stateIdx, i)); // last write per stateIdx + const survivors = [...survivorByState.values()]; + const refDecrypted: bigint[] = []; + for (let opt = 0; opt < M; opt++) { + const cts: AheCiphertext[] = survivors.map((i) => specs[i].msg.ciphertexts[opt]); + const agg = aggregateAhe(cts); + const shared = committeePartial(agg.c1, kc); + refDecrypted.push(solveDLog(recoverAhe(agg, shared), DLOG_BOUND) ?? -1n); + } + // Sanity on the reference itself: A(opt0)=7, B survivor idx2 (opt1)=6. + expect(refDecrypted).to.deep.equal([7n, 6n, 0n, 0n, 0n]); + + const input = buildProcessHybridMessagesInput({ + coordPrivKey: coord2.getSigner().getFormatedPrivKey(), + messages: specs.map((s) => s.msg), + voterPubKeys: specs.map((s) => s.pub), + leaves: specs.map((s) => leafOf(s.stateIdx)), + stateRoot, + messageNonces: specs.map((s) => ({ stateIdx: s.stateIdx, nonce: s.nonce })), + batchSize: B + }); + + const witness = await circuit.calculateWitness(input); + await circuit.expectConstraintPass(witness); + + for (let opt = 0; opt < M; opt++) { + const aggC1: [bigint, bigint] = [ + await getSignal(circuit, witness, `aggC1[${opt}][0]`), + await getSignal(circuit, witness, `aggC1[${opt}][1]`) + ]; + const aggC2: [bigint, bigint] = [ + await getSignal(circuit, witness, `aggC2[${opt}][0]`), + await getSignal(circuit, witness, `aggC2[${opt}][1]`) + ]; + const shared = committeePartial(aggC1, kc); + const total = solveDLog(recoverAhe({ c1: aggC1, c2: aggC2 }, shared), DLOG_BOUND); + expect(total).to.equal(refDecrypted[opt], `option ${opt}: circuit vs reference`); + } + }); + + it('rejects a message whose published ciphertext does not match the signed commitment', async () => { + const coord3 = new OperatorClient({ network: 'testnet', secretKey: 2468n }); + const coordPubKey3 = coord3.getSigner().getPublicKey().toPoints() as [bigint, bigint]; + const genA = voterA.genHybridMessageFactory(coordPubKey3, Kc, pollId, M); + const genB = voterB.genHybridMessageFactory(coordPubKey3, Kc, pollId, M); + + const specs = [ + { pub: pubA, stateIdx: 0, nonce: 1, msg: genA(0, 1, 0, 3) }, + { pub: pubB, stateIdx: 1, nonce: 1, msg: genB(1, 1, 1, 2) }, + { pub: pubB, stateIdx: 1, nonce: 1, msg: genB(1, 1, 2, 1) } + ]; + + // Tamper: swap msg0's first ciphertext coordinate so it no longer matches + // the signed aheCommitment. + const FIELD_P = 21888242871839275222246405745257275088696311157297823662689037894645226208583n; + const tamperedMessages = specs.map((s, i) => { + if (i !== 0) return s.msg; + const ciphertexts = s.msg.ciphertexts.map((ct) => ({ c1: [...ct.c1] as [bigint, bigint], c2: ct.c2 })); + ciphertexts[0].c1[0] = (ciphertexts[0].c1[0] + 1n) % FIELD_P; + return { ...s.msg, ciphertexts }; + }); + + const input = buildProcessHybridMessagesInput({ + coordPrivKey: coord3.getSigner().getFormatedPrivKey(), + messages: tamperedMessages, + voterPubKeys: specs.map((s) => s.pub), + leaves: specs.map((s) => leafOf(s.stateIdx)), + stateRoot, + messageNonces: specs.map((s) => ({ stateIdx: s.stateIdx, nonce: s.nonce })), + batchSize: B + }); + + let threw = false; + try { + const witness = await circuit.calculateWitness(input); + await circuit.expectConstraintPass(witness); + } catch { + threw = true; + } + expect(threw).to.equal(true); + }); + + it('rejects a message whose voterPubKey is NOT the real registered key at stateIdx under stateRoot', async () => { + const coord4 = new OperatorClient({ network: 'testnet', secretKey: 987123n }); + const coordPubKey4 = coord4.getSigner().getPublicKey().toPoints() as [bigint, bigint]; + const genA = voterA.genHybridMessageFactory(coordPubKey4, Kc, pollId, M); + + const specs = [{ pub: pubA, stateIdx: 0, nonce: 1, msg: genA(0, 1, 0, 3) }]; + + const input = buildProcessHybridMessagesInput({ + coordPrivKey: coord4.getSigner().getFormatedPrivKey(), + messages: specs.map((s) => s.msg), + // Claim voter B's pubkey authenticates stateIdx 0 (which is really voter A's slot). + voterPubKeys: [pubB], + leaves: specs.map((s) => leafOf(s.stateIdx)), + stateRoot, + messageNonces: specs.map((s) => ({ stateIdx: s.stateIdx, nonce: s.nonce })), + batchSize: B + }); + + let threw = false; + try { + const witness = await circuit.calculateWitness(input); + await circuit.expectConstraintPass(witness); + } catch { + threw = true; + } + expect(threw).to.equal(true); + }); +}); diff --git a/packages/circuits/ts/__tests__/HybridRevealVerify.test.ts b/packages/circuits/ts/__tests__/HybridRevealVerify.test.ts new file mode 100644 index 0000000..fff30ea --- /dev/null +++ b/packages/circuits/ts/__tests__/HybridRevealVerify.test.ts @@ -0,0 +1,184 @@ +import { deriveCommitteeKey, ahePointMul, encryptAhe, hash12, AHE_G, type AhePoint } from '@dorafactory/maci-sdk'; +import { expect } from 'chai'; +import { type WitnessTester } from 'circomkit'; + +import { circomkitInstance } from './utils/utils'; + +/** + * RevealVerify circuit tests. + * + * Circuit: packages/circuits/circom/hybrid/power/revealVerify.circom + * + * Phase 1 of hybrid_maci+ahe_审查问题修复计划: `participantIndex` (the Shamir + * x-coordinates used to Lagrange-combine the T partial decryptions) is a + * PUBLIC, entirely prover-controlled input. Before this fix, a prover could + * submit a repeated index so the denominator-cleared `common`/`lambdaInt[i]` + * degenerate to 0, letting the final check collapse to the vacuous `0 == 0` + * -- passing for ANY `results`, regardless of the real aggregate ciphertext. + * This suite exercises exactly that: a valid 2-of-2 reveal must pass, and a + * proof reusing the SAME index twice must fail at witness-generation time. + */ +describe('RevealVerify circuit (Hybrid MACI + AHE)', function test() { + this.timeout(300000); + + const M = 5; // vote options (voteOptionTreeDepth = 1) + const T = 2; // threshold + const pollId = 1n; + + // BabyJubjub prime-subgroup order -- Shamir shares/DLEQ nonces live in Z_L, + // a DIFFERENT (smaller) prime than the SNARK scalar field (see the circuit + // file's doc comment for why this matters). + const L = 2736030358979909402780800718157159386076813972158567259200215660948447373041n; + const mod = (a: bigint, m: bigint = L): bigint => ((a % m) + m) % m; + let nonceCounter = 1n; + const randScalarL = (): bigint => mod((nonceCounter += 1n) * 999999999999999n + 424242n); + + const proveDleq = (x: bigint, H: AhePoint, X: AhePoint) => { + const Y = ahePointMul(H, x); + const w = randScalarL(); + const a = ahePointMul(AHE_G, w); + const b = ahePointMul(H, w); + const e = hash12([AHE_G[0], AHE_G[1], H[0], H[1], X[0], X[1], Y[0], Y[1], a[0], a[1], b[0], b[1]]); + const z = mod(w + e * x); + return { Y, a, b, z }; + }; + + const lagrangeInts = (idx: bigint[]) => { + const t = idx.length; + const num: bigint[] = []; + const den: bigint[] = []; + for (let i = 0; i < t; i += 1) { + let n = 1n; + let d = 1n; + for (let m = 0; m < t; m += 1) { + if (m === i) continue; + n *= -idx[m]; + d *= idx[i] - idx[m]; + } + num.push(n); + den.push(d); + } + let common = 1n; + for (let i = 0; i < t; i += 1) common *= den[i]; + const lambdaInt = num.map((n, i) => { + let prod = n; + for (let k = 0; k < t; k += 1) if (k !== i) prod *= den[k]; + return prod; + }); + return { lambdaInt, common }; + }; + const signMag = (v: bigint) => (v < 0n ? { sign: 1n, mag: -v } : { sign: 0n, mag: v }); + + // Committee: secret k (Kc = k*G), 2-of-N Shamir shares. + const k = 1234567n; + const Kc = deriveCommitteeKey(k); + const c1Coeff = 987654321n; + const shareAt = (x: bigint) => mod(k + c1Coeff * x); + + const buildInputs = (participantIndex: bigint[]) => { + const shares = participantIndex.map(shareAt); + const participantPubKey = shares.map((s) => ahePointMul(AHE_G, s) as AhePoint); + + const results = [4n, 1n, 2n, 0n, 3n]; + const salt = 777n; + const randomness = results.map(() => randScalarL()); + const aggCiphertexts = results.map((r, j) => encryptAhe(r, Kc, randomness[j])); + const aggC1 = aggCiphertexts.map((c) => c.c1) as AhePoint[]; + const aggC2 = aggCiphertexts.map((c) => c.c2) as AhePoint[]; + + const partial: bigint[][][] = []; + const dleqA: bigint[][][] = []; + const dleqB: bigint[][][] = []; + const dleqZ: bigint[][] = []; + for (let i = 0; i < T; i += 1) { + partial.push([]); + dleqA.push([]); + dleqB.push([]); + dleqZ.push([]); + for (let j = 0; j < M; j += 1) { + const { Y, a, b, z } = proveDleq(shares[i], aggC1[j], participantPubKey[i]); + partial[i].push(Y); + dleqA[i].push(a); + dleqB[i].push(b); + dleqZ[i].push(z); + } + } + + const { lambdaInt, common } = lagrangeInts(participantIndex); + const lambdaSignMag = lambdaInt.map(signMag); + const commonSignMag = signMag(common); + + return { + inputs: { + Kc, + aggC1, + aggC2, + results, + salt, + participantPubKey, + participantIndex, + partial, + dleqA, + dleqB, + dleqZ, + lambdaSign: lambdaSignMag.map((x) => x.sign), + lambdaMag: lambdaSignMag.map((x) => x.mag), + commonSign: commonSignMag.sign, + commonMag: commonSignMag.mag + }, + results + }; + }; + + let circuit: WitnessTester< + [ + 'Kc', + 'aggC1', + 'aggC2', + 'results', + 'salt', + 'participantPubKey', + 'participantIndex', + 'partial', + 'dleqA', + 'dleqB', + 'dleqZ', + 'lambdaSign', + 'lambdaMag', + 'commonSign', + 'commonMag' + ] + >; + + before(async () => { + circuit = await circomkitInstance.WitnessTester('RevealVerify_hybrid_1-2', { + file: 'hybrid/power/revealVerify', + template: 'RevealVerify', + params: [1, T] + }); + }); + + it('accepts a valid 2-of-2 reveal with distinct participant indices', async () => { + const { inputs } = buildInputs([1n, 2n]); + const witness = await circuit.calculateWitness(inputs); + await circuit.expectConstraintPass(witness); + }); + + it('rejects a proof that reuses the SAME participant index twice', async () => { + // Before the phase-1 fix, this degenerates `common`/`lambdaInt[i]` to 0 + // (den[i] = participantIndex[i] - participantIndex[m] = 0 for the + // repeated pair), collapsing the final check to a vacuous 0 == 0 that + // passes regardless of `results`/`aggC2` -- i.e. ANY claimed tally would + // verify. The pairwise-distinctness constraint must make witness + // calculation itself fail instead. + const { inputs } = buildInputs([1n, 1n]); + let threw = false; + try { + const witness = await circuit.calculateWitness(inputs); + await circuit.expectConstraintPass(witness); + } catch { + threw = true; + } + expect(threw).to.equal(true); + }); +}); diff --git a/packages/circuits/ts/__tests__/HybridSdkIntegration.test.ts b/packages/circuits/ts/__tests__/HybridSdkIntegration.test.ts new file mode 100644 index 0000000..072a625 --- /dev/null +++ b/packages/circuits/ts/__tests__/HybridSdkIntegration.test.ts @@ -0,0 +1,119 @@ +import { VoterClient, OperatorClient, deriveCommitteeKey } from '@dorafactory/maci-sdk'; +import { expect } from 'chai'; +import { type WitnessTester } from 'circomkit'; + +import { getSignal, circomkitInstance, buildHybridVoterTree, buildProcessHybridMessagesInput } from './utils/utils'; + +/** + * End-to-end SDK integration test for Hybrid MACI + AHE. + * + * Exercises the full off-chain flow through the SDK clients and cross-checks it + * against the ZK circuit: + * VoterClient.genHybridMessageFactory (encrypt ballot + sign routing) + * -> OperatorClient.processHybridBatch (decrypt routing, LWW, homomorphic sum) + * -> OperatorClient.decryptHybridAggregate (committee threshold decrypt) + * and the SAME messages fed into ProcessHybridMessages must yield the same tally. + */ +describe('Hybrid MACI + AHE SDK integration', function test() { + this.timeout(600000); + + const STATE_TREE_DEPTH = 2; + const B = 5; // batch size (ProcessHybridMessages_hybrid_2-1-5) + const M = 5; // vote options (voteOptionTreeDepth = 1) + const UNIFIED_BALANCE = 100n; + const pollId = 1; + const DLOG_BOUND = 1_000_000; + + const kc = 3141592653n; + const Kc = deriveCommitteeKey(kc); + + let circuit: WitnessTester; + + before(async () => { + circuit = await circomkitInstance.WitnessTester('ProcessHybridMessages_hybrid_2-1-5', { + file: 'hybrid/power/processHybridMessages', + template: 'ProcessHybridMessages', + params: [STATE_TREE_DEPTH, 1, B] + }); + }); + + it('tallies through VoterClient + OperatorClient and matches the circuit', async () => { + const operator = new OperatorClient({ network: 'testnet', secretKey: 778899n }); + const voterA = new VoterClient({ network: 'testnet', secretKey: 121212n }); + const voterB = new VoterClient({ network: 'testnet', secretKey: 343434n }); + + const coordPubKey = operator.getSigner().getPublicKey().toPoints() as [bigint, bigint]; + const pubA = voterA.getSigner().getPublicKey().toPoints() as [bigint, bigint]; + const pubB = voterB.getSigner().getPublicKey().toPoints() as [bigint, bigint]; + + const { tree, stateRoot } = buildHybridVoterTree( + [ + { pubKey: pubA, balance: UNIFIED_BALANCE }, + { pubKey: pubB, balance: UNIFIED_BALANCE } + ], + STATE_TREE_DEPTH + ); + const leafOf = (stateIdx: number) => ({ + voiceCreditBalance: UNIFIED_BALANCE, + voteOptionTreeRoot: 0n, + slNonce: 0n, + pathElements: tree.pathElementOf(stateIdx) as bigint[][] + }); + + const factoryA = voterA.genHybridMessageFactory(coordPubKey, Kc, pollId, M); + const factoryB = voterB.genHybridMessageFactory(coordPubKey, Kc, pollId, M); + + // A (stateIdx 0) votes opt0=2 then revotes opt2=9 (all nonce=1, reverse + // processing LWW: latest submission index wins). B (stateIdx 1) votes opt1=4. + const specs: { pub: [bigint, bigint]; stateIdx: number; nonce: number; msg: ReturnType }[] = [ + { pub: pubA, stateIdx: 0, nonce: 1, msg: factoryA(0, 1, 0, 2) }, + { pub: pubA, stateIdx: 0, nonce: 1, msg: factoryA(0, 1, 2, 9) }, + { pub: pubB, stateIdx: 1, nonce: 1, msg: factoryB(1, 1, 1, 4) } + ]; + + const messages = specs.map((s) => ({ + routing: s.msg.routing, + encPubKey: s.msg.encPubKey, + ciphertexts: s.msg.ciphertexts + })); + const voterPubKeys = specs.map((s) => s.pub); + + // ---- Off-chain coordinator processing (no vote content decrypted) ---- + const { routingView, aggregate } = operator.processHybridBatch(messages, voterPubKeys, M); + + // Coordinator sees routing only; LWW keeps A's nonce-2 revision and B's vote. + expect(routingView.every((r) => r.sigValid && r.commitmentValid)).to.equal(true); + expect(routingView[0].survivor).to.equal(false); // superseded revision + expect(routingView[1].survivor).to.equal(true); + expect(routingView[2].survivor).to.equal(true); + + // ---- Committee threshold decryption of the aggregate ---- + const sdkTotals = OperatorClient.decryptHybridAggregate(aggregate, kc, DLOG_BOUND); + expect(sdkTotals).to.deep.equal([0n, 4n, 9n, 0n, 0n]); + + // ---- Circuit must reproduce the identical aggregate (with the SAME + // stateRoot/Merkle authentication the SDK's processHybridBatch models + // implicitly by trusting the caller's voterPubKeys). ---- + const input = buildProcessHybridMessagesInput({ + coordPrivKey: operator.getSigner().getFormatedPrivKey(), + messages, + voterPubKeys, + leaves: specs.map((s) => leafOf(s.stateIdx)), + stateRoot, + messageNonces: specs.map((s) => ({ stateIdx: s.stateIdx, nonce: s.nonce })), + batchSize: B + }); + + const witness = await circuit.calculateWitness(input); + await circuit.expectConstraintPass(witness); + + for (let opt = 0; opt < M; opt++) { + const cAggC1x = await getSignal(circuit, witness, `aggC1[${opt}][0]`); + const cAggC1y = await getSignal(circuit, witness, `aggC1[${opt}][1]`); + const cAggC2x = await getSignal(circuit, witness, `aggC2[${opt}][0]`); + const cAggC2y = await getSignal(circuit, witness, `aggC2[${opt}][1]`); + expect([cAggC1x, cAggC1y]).to.deep.equal(aggregate[opt].c1, `aggC1 option ${opt}`); + expect([cAggC2x, cAggC2y]).to.deep.equal(aggregate[opt].c2, `aggC2 option ${opt}`); + } + }); +}); diff --git a/packages/circuits/ts/__tests__/utils/utils.ts b/packages/circuits/ts/__tests__/utils/utils.ts index e2bf317..d5b77d6 100644 --- a/packages/circuits/ts/__tests__/utils/utils.ts +++ b/packages/circuits/ts/__tests__/utils/utils.ts @@ -1,3 +1,4 @@ +import { Tree, poseidon, hashLeftRight } from '@dorafactory/maci-sdk'; import { Circomkit, type WitnessTester, type CircomkitConfig } from 'circomkit'; import fs from 'fs'; @@ -250,6 +251,183 @@ export function genStaticRandomKey(privKey: bigint, salt: bigint, index: bigint) return poseidon([privKey, salt, index]); } +// ============================================================================ +// Hybrid MACI + AHE test utilities +// ============================================================================ + +/** + * cw-amaci's `StateLeaf::hash_decativate_state_leaf` (called unconditionally by + * `execute_sign_up`) wraps every signed-up leaf as + * hash2([hash5(leaf), DEACTIVATE_CONSTANT]), DEACTIVATE_CONSTANT = + * hash5([0,0,0,0,0]). BallotValidity/ProcessHybridMessages both reproduce this + * exact wrap to Merkle-authenticate against a REAL on-chain-shaped state root, + * so any test building a state tree for those circuits must use it too. + */ +export const HYBRID_DEACTIVATE_CONSTANT = + 14655542659562014735865511769057053982292279840403315552050801315682099828156n; + +/** + * Build a quinary state tree of Hybrid MACI + AHE voters (mirrors + * `MaciContract::instantiate_hybrid_default` / `execute_sign_up`'s leaf + * layout: `[pubKey.x, pubKey.y, balance, voteOptionTreeRoot=0, nonce=0]`, + * wrapped with `HYBRID_DEACTIVATE_CONSTANT`), for feeding BallotValidity's / + * ProcessHybridMessages' `stateRoot`/`pathElements` witnesses in tests. + */ +export function buildHybridVoterTree( + voters: { pubKey: [bigint, bigint]; balance: bigint }[], + stateTreeDepth: number +): { tree: Tree; stateRoot: bigint } { + const leaves = voters.map(({ pubKey, balance }) => + hashLeftRight(poseidon([pubKey[0], pubKey[1], balance, 0n, 0n]), HYBRID_DEACTIVATE_CONSTANT) + ); + const tree = new Tree(5, stateTreeDepth, 0n); + tree.initLeaves(leaves); + return { tree, stateRoot: tree.root as bigint }; +} + +/** State-leaf witness for one real message slot (see `buildHybridVoterTree`). */ +export interface HybridStateLeafWitness { + voiceCreditBalance: bigint; + voteOptionTreeRoot: bigint; + slNonce: bigint; + pathElements: bigint[][]; +} + +/** + * All-zero quinary tree root at depth 2 (matches HYBRID_NONCE_ZERO_ROOT in + * contract.rs / server.ts): the starting value of the nonce tree before any + * batch is processed. + */ +export const NONCE_ZERO_ROOT = 19261153649140605024552417994922546473530072875902678653210025980873274131905n; + +/** + * Build nonce tree witnesses for a batch of real messages. + * + * Simulates the circuit's reverse nonce-tree walk (i = B-1 downto 0) to + * produce per-slot `currentNonce[i]` and `noncePathElements[i]` witnesses. + * The nonce tree has depth = stateTreeDepth (same tree shape as the + * registration state tree, but leaves are raw nonce values, not voter leaves). + * + * @param messageNonces - per real slot: { stateIdx, nonce } (length = real count) + * @param B - total circuit batch size (including padding) + * @param depth - nonce tree depth (= stateTreeDepth) + * @param initTree - optional pre-seeded nonce tree from a prior batch + */ +export function buildNonceTreeWitnesses( + messageNonces: { stateIdx: number; nonce: number }[], + B: number, + depth: number, + initTree?: Tree +): { + currentNonceRoot: bigint; + currentNonce: bigint[]; + noncePathElements: bigint[][][]; + newNonceRoot: bigint; + nonceTree: Tree; +} { + const nonceTree: Tree = initTree ?? new Tree(5, depth, 0n); + const currentNonceRoot = nonceTree.root as bigint; + const currentNonce: bigint[] = new Array(B).fill(0n); + const noncePathElements: bigint[][][] = Array.from({ length: B }, () => + Array.from({ length: depth }, () => new Array(4).fill(0n)) + ); + const actualCount = messageNonces.length; + for (let i = B - 1; i >= 0; i--) { + if (i >= actualCount) continue; + const { stateIdx, nonce } = messageNonces[i]; + currentNonce[i] = nonceTree.leaf(stateIdx) as bigint; + noncePathElements[i] = nonceTree.pathElementOf(stateIdx) as bigint[][]; + const cmd = BigInt(nonce); + if (cmd === currentNonce[i] + 1n) nonceTree.updateLeaf(stateIdx, cmd); + } + return { + currentNonceRoot, + currentNonce, + noncePathElements, + newNonceRoot: nonceTree.root as bigint, + nonceTree, + }; +} + +/** + * Build the `ProcessHybridMessages`/`ProcessHybridMessagesOnchain` witness + * input for a batch of REAL messages, zero-padding up to `batchSize` slots + * exactly like `OperatorClient.proveHybridBatch` does for the real proving + * path (see `packages/sdk/src/operator.ts`) -- kept in sync manually since + * that method bakes the padding logic into its own Groth16 `fullProve` call + * instead of exposing it standalone. + * + * Includes nonce-tree witnesses required by the cross-batch LWW circuit + * (added in the hybrid_lww_跨批次修复 plan). Pass `messageNonces` as an + * array of `{ stateIdx, nonce }` matching the REAL message slots (in the + * SAME order as `messages`); it must have length == real message count, not + * `batchSize`. Padding slots are filled with zero nonce witnesses automatically. + */ +export function buildProcessHybridMessagesInput({ + coordPrivKey, + messages, + voterPubKeys, + leaves, + stateRoot, + actualCount, + batchSize, + messageNonces, + currentNonceRoot: externalCurrentNonceRoot, + nonceTree: externalNonceTree, +}: { + coordPrivKey: bigint; + messages: { routing: bigint[]; encPubKey: [bigint, bigint]; ciphertexts: { c1: [bigint, bigint]; c2: [bigint, bigint] }[] }[]; + voterPubKeys: [bigint, bigint][]; + leaves: HybridStateLeafWitness[]; + stateRoot: bigint; + actualCount?: number; + batchSize: number; + messageNonces?: { stateIdx: number; nonce: number }[]; + currentNonceRoot?: bigint; + nonceTree?: Tree; +}) { + const B = batchSize; + const numOptions = messages[0]?.ciphertexts.length ?? 0; + const stateTreeDepth = leaves[0]?.pathElements.length ?? 0; + const leavesPerLevel = stateTreeDepth > 0 ? leaves[0].pathElements[0].length : 0; + + const pad = (arr: T[], fill: T): T[] => arr.concat(Array(B - arr.length).fill(fill)); + const zeroPath = () => Array.from({ length: stateTreeDepth }, () => Array(leavesPerLevel).fill(0n)); + + // Build nonce tree witnesses. + const nonces = messageNonces ?? messages.map((_, i) => ({ stateIdx: i, nonce: 1 })); + const { currentNonceRoot, currentNonce, noncePathElements } = buildNonceTreeWitnesses( + nonces, + B, + stateTreeDepth, + externalNonceTree, + ); + + return { + coordPrivKey, + message: pad(messages.map((m) => m.routing), Array(10).fill(0n)), + encPubKey: pad(messages.map((m) => m.encPubKey), [0n, 1n] as [bigint, bigint]), + voterPubKey: pad(voterPubKeys, [0n, 1n] as [bigint, bigint]), + voiceCreditBalance: pad(leaves.map((l) => l.voiceCreditBalance), 0n), + voteOptionTreeRoot: pad(leaves.map((l) => l.voteOptionTreeRoot), 0n), + slNonce: pad(leaves.map((l) => l.slNonce), 0n), + pathElements: pad(leaves.map((l) => l.pathElements), zeroPath()), + stateRoot, + currentNonceRoot: externalCurrentNonceRoot ?? currentNonceRoot, + noncePathElements, + currentNonce, + actualCount: BigInt(actualCount ?? messages.length), + encC1: pad( + messages.map((m) => m.ciphertexts.map((ct) => ct.c1)), + Array(numOptions).fill([0n, 1n]) + ), + encC2: pad( + messages.map((m) => m.ciphertexts.map((ct) => ct.c2)), + Array(numOptions).fill([0n, 1n]) + ) + }; +} + /** * Test scenario configuration for complex tests */ diff --git a/packages/sdk/src/libs/crypto/ahe.ts b/packages/sdk/src/libs/crypto/ahe.ts new file mode 100644 index 0000000..74e68be --- /dev/null +++ b/packages/sdk/src/libs/crypto/ahe.ts @@ -0,0 +1,316 @@ +import { Base8, addPoint, mulPointEscalar, Point } from '@zk-kit/baby-jubjub'; +import { signMessage } from '@zk-kit/eddsa-poseidon'; +import { poseidonEncrypt } from '@zk-kit/poseidon-cipher'; + +import { bigInt2Buffer } from './bigintUtils'; +import { poseidon, hashLeftRight, hash13 } from './hashing'; +import { genEcdhSharedKey, genKeypair, formatPrivKeyForBabyJub } from './keys'; +import { genRandomBabyJubValue } from './babyjub'; +import { PrivKey, PubKey } from './types'; + +/** + * Additively-homomorphic (exponential) ElGamal on BabyJubjub — the vote-content + * layer of the Hybrid MACI + AHE scheme. + * + * A weight `v` is encoded as `v*G`, so ciphertexts add homomorphically: + * Enc(a) + Enc(b) = Enc(a+b) + * The coordinator can therefore aggregate every voter's encrypted per-option + * weight WITHOUT decrypting any single ballot; only the threshold committee that + * holds shares of Kc decrypts the final per-option aggregate. + * + * All scalar/point math here must match the circuits in + * `packages/circuits/circom/hybrid/` exactly (G = Base8, raw 253-bit scalars). + */ + +export type AhePoint = [bigint, bigint]; + +export interface AheCiphertext { + c1: AhePoint; // r*G + c2: AhePoint; // v*G + r*Kc +} + +// Generator (same Base8 the circuit's BabyPbk uses). +export const AHE_G: AhePoint = [BigInt(Base8[0]), BigInt(Base8[1])]; + +// Twisted-Edwards identity / neutral element. +export const AHE_IDENTITY: AhePoint = [0n, 1n]; + +const toPoint = (p: AhePoint): Point => [p[0], p[1]] as Point; +const fromPoint = (p: Point): AhePoint => [BigInt(p[0]), BigInt(p[1])]; + +/** Scalar multiply a point: scalar * P (raw scalar, matches the circuit). */ +export const ahePointMul = (p: AhePoint, scalar: bigint): AhePoint => + fromPoint(mulPointEscalar(toPoint(p), scalar)); + +/** + * Derive a committee joint public key from a raw secret scalar: Kc = kc * G. + * In production kc is never reconstructed; each committee member holds a share + * and only partial products kc_i * c1 are combined. This helper is for the demo + * / tests where a single-party stand-in key is convenient. + */ +export const deriveCommitteeKey = (kc: bigint): AhePoint => ahePointMul(AHE_G, kc); + +/** Committee partial decryption factor for an aggregate: kc * c1. */ +export const committeePartial = (c1: AhePoint, kc: bigint): AhePoint => ahePointMul(c1, kc); + +/** + * Encrypt a single weight to the committee key Kc. + * @param v plaintext weight + * @param Kc committee joint public key + * @param r optional randomness (raw scalar < 2^253); random if omitted + */ +export const encryptAhe = (v: bigint, Kc: AhePoint, r: bigint = genRandomBabyJubValue()): AheCiphertext => { + const c1 = fromPoint(mulPointEscalar(toPoint(AHE_G), r)); + const vG = mulPointEscalar(toPoint(AHE_G), v); + const rKc = mulPointEscalar(toPoint(Kc), r); + const c2 = fromPoint(addPoint(vG, rKc)); + return { c1, c2 }; +}; + +/** Homomorphic addition of two ciphertexts. */ +export const addAhe = (a: AheCiphertext, b: AheCiphertext): AheCiphertext => ({ + c1: fromPoint(addPoint(toPoint(a.c1), toPoint(b.c1))), + c2: fromPoint(addPoint(toPoint(a.c2), toPoint(b.c2))) +}); + +/** Aggregate a list of ciphertexts (identity for empty list). */ +export const aggregateAhe = (cts: AheCiphertext[]): AheCiphertext => + cts.reduce((acc, ct) => addAhe(acc, ct), { + c1: [...AHE_IDENTITY], + c2: [...AHE_IDENTITY] + }); + +/** + * Commit to a ballot's ciphertext vector. MUST match `AheCommit` in + * `circom/hybrid/lib/aheCommit.circom`: fold hashLeftRight over the flattened + * coordinates in order [c1.x, c1.y, c2.x, c2.y] per option. + */ +export const aheCommit = (cts: AheCiphertext[]): bigint => { + let acc = 0n; + for (const ct of cts) { + acc = hashLeftRight(acc, ct.c1[0]); + acc = hashLeftRight(acc, ct.c1[1]); + acc = hashLeftRight(acc, ct.c2[0]); + acc = hashLeftRight(acc, ct.c2[1]); + } + return acc; +}; + +/** + * Build a one-hot weighted vote vector: `weight` on `voIdx`, 0 elsewhere. + */ +export const buildBallotVector = (voIdx: number, weight: number, numOptions: number): bigint[] => { + const vec = new Array(numOptions).fill(0n); + vec[voIdx] = BigInt(weight); + return vec; +}; + +/** + * Encrypt a full ballot vector, returning per-option ciphertexts and the + * randomness used (needed to build the ballotValidity ZK witness). + */ +export const encryptBallot = ( + weights: bigint[], + Kc: AhePoint +): { ciphertexts: AheCiphertext[]; randomness: bigint[] } => { + const randomness = weights.map(() => genRandomBabyJubValue()); + const ciphertexts = weights.map((v, i) => encryptAhe(v, Kc, randomness[i])); + return { ciphertexts, randomness }; +}; + +// BabyJubjub coordinate field modulus (= BN254 scalar field / SNARK field size). +// Point negation of (x, y) on this twisted-Edwards curve is (-x mod P, y). +const AHE_FIELD_P = 21888242871839275222246405745257275088548364400416034343698204186575808495617n; + +/** + * Recover `v*G` from an aggregate ciphertext given the committee-combined point + * `sharedC1 = k_c * c1`. v*G = c2 - k_c*c1. + */ +export const recoverAhe = (ct: AheCiphertext, sharedC1: AhePoint): AhePoint => { + const negShared: Point = [ + (-sharedC1[0] + AHE_FIELD_P) % AHE_FIELD_P, + sharedC1[1] + ] as Point; + return fromPoint(addPoint(toPoint(ct.c2), negShared)); +}; + +/** + * Baby-step-giant-step discrete log: find v in [0, maxBound] with v*G == P. + * Vote totals are bounded, so this is cheap. + */ +export const solveDLog = (P: AhePoint, maxBound: number): bigint | null => { + if (P[0] === AHE_IDENTITY[0] && P[1] === AHE_IDENTITY[1]) return 0n; + + const m = BigInt(Math.floor(Math.sqrt(maxBound)) + 1); + const key = (p: AhePoint) => `${p[0]},${p[1]}`; + + const table = new Map(); + let cur: AhePoint = [...AHE_IDENTITY]; + for (let j = 0n; j < m; j++) { + table.set(key(cur), j); + cur = fromPoint(addPoint(toPoint(cur), toPoint(AHE_G))); + } + + const mG = fromPoint(mulPointEscalar(toPoint(AHE_G), m)); + const negMG: AhePoint = [(-mG[0] + AHE_FIELD_P) % AHE_FIELD_P, mG[1]]; + let gamma: AhePoint = [...P]; + for (let i = 0n; i <= m; i++) { + const hit = table.get(key(gamma)); + if (hit !== undefined) return i * m + hit; + gamma = fromPoint(addPoint(toPoint(gamma), toPoint(negMG))); + } + return null; +}; + +/** + * Pack the Hybrid routing scalars into one field element. + * MUST match UnpackElement(3) in hybridMessageToCommand.circom: + * nonce (bits 0-31) | stateIdx (bits 32-63) | pollId (bits 64-95) + */ +export const packHybrid = ({ + nonce, + stateIdx, + pollId +}: { + nonce: number | bigint; + stateIdx: number | bigint; + pollId: number | bigint; +}): bigint => BigInt(nonce) + (BigInt(stateIdx) << 32n) + (BigInt(pollId) << 64n); + +export interface HybridMessage { + // Routing envelope (Poseidon-encrypted to the coordinator). + routing: bigint[]; // 10-element ciphertext + encPubKey: PubKey; // ephemeral key for ECDH with coordinator + // The ephemeral PRIVATE key matching `encPubKey` above, ALREADY formatted/ + // pruned for BabyJubjub (i.e. `genKeypair().formatedPrivKey`, matching what + // `genEcdhSharedKey` uses internally) — the exact scalar `Ecdh()` expects as + // `privKey` in circom (see that template's own note: "the private key needs + // to be hashed and pruned first"). Kept around (never published) so the + // voter's own `BallotValidityOnchain` proof can re-derive the same ECDH + // shared key and decrypt-check `routing` in-circuit (see + // `ballotValidity.circom`'s routing-binding step) — this is what lets the + // ballot proof assert "this stateIdx/aheCommitment is the SAME one signed + // into the routing envelope" without trusting SDK convention alone. + ephemeralPrivKey: bigint; + // Published AHE ballot (committee-encrypted; coordinator cannot decrypt). + ciphertexts: AheCiphertext[]; + aheCommitment: bigint; + // Cleartext binding info (for the demo / witness building; not secret). + stateIdx: number; + nonce: number; + randomness: bigint[]; + weights: bigint[]; +} + +/** + * Hybrid ballot nullifier: replaces plaintext `stateIdx`/`pubKey` as the + * PublishHybridMessage public identifier. MUST match `Hasher4` over + * [stateIdx, pubKey.x, pubKey.y, pollId] in `ballotValidity.circom` — salted + * with pollId so nullifiers don't correlate across rounds. + */ +export const hybridNullifier = ({ + stateIdx, + pubKey, + pollId +}: { + stateIdx: number | bigint; + pubKey: PubKey; + pollId: number | bigint; +}): bigint => poseidon([BigInt(stateIdx), pubKey[0], pubKey[1], BigInt(pollId)]); + +/** + * Fold a routing envelope + its ephemeral pubkey into ONE commitment, using + * the SAME Hasher13 algorithm (prevHash = 0) as cw-amaci's + * `hash_message_and_enc_pub_key` / `MessageHasher` in + * `ballotValidity.circom`. This is what `BallotValidityOnchain` exposes as + * `routingCommitment`, letting the contract recompute it cheaply from the + * `routing`/`enc_pub_key` it already received in `PublishHybridMessage`, + * instead of re-hashing 10 raw field elements through SHA256 again. + */ +export const hybridRoutingCommitment = ({ + routing, + encPubKey +}: { + routing: bigint[]; + encPubKey: PubKey; +}): bigint => hash13([...routing, encPubKey[0], encPubKey[1], 0n]); + +/** An EdDSA signature over a single field element. */ +export type SignFn = (msgHash: bigint) => { R8: [bigint, bigint] | bigint[]; S: bigint }; + +export interface BuildHybridMessageArgs { + voterPubKey: PubKey; + newPubKey?: PubKey; + coordPubKey: PubKey; + Kc: AhePoint; + stateIdx: number; + nonce: number; + pollId: number; + voIdx: number; + weight: number; + numOptions: number; +} + +/** + * Core Hybrid message builder that delegates signing to a caller-provided + * function (so callers can sign with a raw key or with an EdDSA keypair object). + */ +export const buildHybridMessageWithSigner = ( + args: BuildHybridMessageArgs, + sign: SignFn +): HybridMessage => { + const { voterPubKey, newPubKey, coordPubKey, Kc, stateIdx, nonce, pollId, voIdx, weight, numOptions } = + args; + + const weights = buildBallotVector(voIdx, weight, numOptions); + const { ciphertexts, randomness } = encryptBallot(weights, Kc); + const aheCommitment = aheCommit(ciphertexts); + + const npk: PubKey = newPubKey ?? [...voterPubKey]; + const packed = packHybrid({ nonce, stateIdx, pollId }); + + // Signature preimage: [packed, npk_x, npk_y, aheCommitment] (matches Hasher4). + const msgHash = poseidon([packed, npk[0], npk[1], aheCommitment]); + const signature = sign(msgHash); + + // Routing command: [packed, npk_x, npk_y, aheCommitment, R8_x, R8_y, S]. + const command = [ + packed, + npk[0], + npk[1], + aheCommitment, + BigInt(signature.R8[0]), + BigInt(signature.R8[1]), + BigInt(signature.S) + ]; + + const ephemeral = genKeypair(); + const sharedKey = genEcdhSharedKey(ephemeral.privKey, coordPubKey); + const routing = poseidonEncrypt(command, sharedKey, 0n).map((x) => BigInt(x)); + + return { + routing, + encPubKey: ephemeral.pubKey, + ephemeralPrivKey: ephemeral.formatedPrivKey, + ciphertexts, + aheCommitment, + stateIdx, + nonce, + randomness, + weights + }; +}; + +/** + * Build a full Hybrid message from a raw MACI private key: encrypts the ballot + * to Kc, signs the routing command (binding the aheCommitment), and encrypts the + * routing envelope to the coordinator. + */ +export const buildHybridMessage = ( + args: BuildHybridMessageArgs & { voterPrivKey: PrivKey } +): HybridMessage => + buildHybridMessageWithSigner(args, (msgHash) => signMessage(bigInt2Buffer(args.voterPrivKey), msgHash)); + +/** Format a MACI private key into the raw scalar the ECDH circuit consumes. */ +export const coordScalarForCircuit = (coordPrivKey: PrivKey): bigint => + formatPrivKeyForBabyJub(coordPrivKey); diff --git a/packages/sdk/src/libs/crypto/index.ts b/packages/sdk/src/libs/crypto/index.ts index bdc2b5d..512768b 100644 --- a/packages/sdk/src/libs/crypto/index.ts +++ b/packages/sdk/src/libs/crypto/index.ts @@ -10,4 +10,5 @@ export * from './curve'; export * from './adapter'; export * from './rerandomize'; export * from './pack'; +export * from './ahe'; export type { Keypair, PubKey, PrivKey } from './types'; diff --git a/packages/sdk/src/operator.ts b/packages/sdk/src/operator.ts index 5a39ee3..91e8800 100644 --- a/packages/sdk/src/operator.ts +++ b/packages/sdk/src/operator.ts @@ -14,7 +14,13 @@ import { adaptToUncompressed, unpackElement, packElement, - computeInputHash + computeInputHash, + aggregateAhe, + aheCommit, + recoverAhe, + solveDLog, + committeePartial, + type AheCiphertext } from './libs/crypto'; import { encryptOdevity, decrypt } from './libs/crypto/rerandomize'; import { poseidon } from './libs/crypto/hashing'; @@ -1888,4 +1894,269 @@ export class OperatorClient { getLogs(): LogEntry[] { return this.logs; } + + // ==================== Hybrid MACI + AHE Processing ==================== + + /** + * Process a batch of Hybrid MACI + AHE messages. + * + * The coordinator holds `coordPrivKey` and, for each message: + * 1. ECDH-decrypts ONLY the routing envelope (stateIdx / nonce / pollId / + * newPubKey / signature / aheCommitment) — never the vote content, + * 2. verifies the voter's signature over the ballot commitment, + * 3. checks the published AHE ciphertext hashes to the signed commitment, + * 4. applies plaintext last-write-wins (latest nonce per stateIdx wins), and + * 5. homomorphically aggregates surviving ballots per option. + * + * The result is one aggregate ciphertext per option, still encrypted. Only the + * threshold committee (holding shares of Kc) can decrypt the aggregate, and it + * only ever sees per-option totals — never a single vote. This is the + * off-circuit reference the `processHybridMessages` ZK proof attests to. + * + * @returns per-message routing view, survivor flags, and the aggregate ciphertexts + */ + processHybridBatch( + messages: { + routing: bigint[]; + encPubKey: PubKey; + ciphertexts: AheCiphertext[]; + }[], + voterPubKeys: PubKey[], + numOptions: number, + derivePathParams?: DerivePathParams + ): { + routingView: { + stateIdx: number; + nonce: number; + pollId: number; + aheCommitment: bigint; + sigValid: boolean; + commitmentValid: boolean; + survivor: boolean; + }[]; + aggregate: AheCiphertext[]; + } { + const signer = this.getSigner(derivePathParams); + + // Step 1-3: decrypt routing, verify signature, check commitment binding. + const decoded = messages.map((m, i) => { + const sharedKey = signer.genEcdhSharedKey(m.encPubKey); + const plain = poseidonDecrypt(m.routing, sharedKey, 0n, 7).map((x) => BigInt(x)); + const packed = plain[0]; + const nonce = Number(packed & (UINT32 - 1n)); + const stateIdx = Number((packed >> 32n) & (UINT32 - 1n)); + const pollId = Number((packed >> 64n) & (UINT32 - 1n)); + const newPubKey: PubKey = [plain[1], plain[2]]; + const aheCommitment = plain[3]; + const signature = { R8: [plain[4], plain[5]] as PubKey, S: plain[6] }; + + const msgHash = poseidon([packed, newPubKey[0], newPubKey[1], aheCommitment]); + const sigValid = verifySignature(msgHash, signature, voterPubKeys[i]); + const commitmentValid = aheCommit(m.ciphertexts) === aheCommitment; + + return { stateIdx, nonce, pollId, aheCommitment, sigValid, commitmentValid }; + }); + + // Step 4: last-write-wins in SUBMISSION order (matches the circuit and MACI's + // reverse-order processing): for each stateIdx the LATEST-index message wins. + // A message is beaten iff a later-index message shares its stateIdx; the + // survivor must additionally have a valid signature. nonce is NOT used to + // order — "higher nonce wins" is not MACI semantics. + const survivorSet = new Set(); + decoded.forEach((d, i) => { + if (!d.sigValid || !d.commitmentValid) return; + const beaten = decoded.some((e, j) => j > i && e.stateIdx === d.stateIdx); + if (!beaten) survivorSet.add(i); + }); + + // Step 5: homomorphic aggregation of surviving ballots per option. + const aggregate: AheCiphertext[] = []; + for (let opt = 0; opt < numOptions; opt++) { + const cts = [...survivorSet].map((i) => messages[i].ciphertexts[opt]); + aggregate.push(aggregateAhe(cts)); + } + + const routingView = decoded.map((d, i) => ({ ...d, survivor: survivorSet.has(i) })); + + return { routingView, aggregate }; + } + + /** + * Decode the ROUTING envelope of a single Hybrid message, exactly as the + * coordinator does inside `processHybridBatch` (and as the circuit does). + * + * The coordinator ECDH-decrypts only the routing scalars (stateIdx / nonce / + * pollId / newPubKey / signature / aheCommitment) and verifies the signature + * and the commitment binding. It never touches optionIdx or weight — those + * live in the AHE ciphertext addressed to the committee key Kc. + * + * This exists so a demo/UI can show "what the coordinator can actually read + * from one message" without running the whole batch. + */ + decodeHybridRouting( + message: { + routing: bigint[]; + encPubKey: PubKey; + ciphertexts: AheCiphertext[]; + }, + voterPubKey: PubKey, + derivePathParams?: DerivePathParams + ): { + packed: bigint; + stateIdx: number; + nonce: number; + pollId: number; + newPubKey: PubKey; + aheCommitment: bigint; + signature: { R8: PubKey; S: bigint }; + sigValid: boolean; + commitmentValid: boolean; + } { + const signer = this.getSigner(derivePathParams); + const sharedKey = signer.genEcdhSharedKey(message.encPubKey); + const plain = poseidonDecrypt(message.routing, sharedKey, 0n, 7).map((x) => BigInt(x)); + const packed = plain[0]; + const nonce = Number(packed & (UINT32 - 1n)); + const stateIdx = Number((packed >> 32n) & (UINT32 - 1n)); + const pollId = Number((packed >> 64n) & (UINT32 - 1n)); + const newPubKey: PubKey = [plain[1], plain[2]]; + const aheCommitment = plain[3]; + const signature = { R8: [plain[4], plain[5]] as PubKey, S: plain[6] }; + + const msgHash = poseidon([packed, newPubKey[0], newPubKey[1], aheCommitment]); + const sigValid = verifySignature(msgHash, signature, voterPubKey); + const commitmentValid = aheCommit(message.ciphertexts) === aheCommitment; + + return { packed, stateIdx, nonce, pollId, newPubKey, aheCommitment, signature, sigValid, commitmentValid }; + } + + /** + * Generate the `processHybridMessages` Groth16 proof attesting that + * `processHybridBatch` was executed faithfully (routing decryption, signature + * checks, voterPubKey Merkle authentication against `stateRoot`, LWW, and + * homomorphic aggregation) without revealing vote content. + * + * `messages`/`voterPubKeys`/`leaves` describe only the REAL messages in this + * batch (`messages.length` may be less than `batchSize` — the circuit's + * fixed-size witness arrays); the remaining `[messages.length, batchSize)` + * slots are zero-padded automatically and gated off by the circuit's + * `actualCount` input, so padding content never affects the proof's + * hash-chain position, survivor selection, or aggregate (see + * `processHybridMessages.circom`'s partial-batch note). + * + * `leaves[i]` is the state-leaf witness (voiceCreditBalance/voteOptionTreeRoot + * /slNonce/pathElements) needed to Merkle-authenticate `voterPubKeys[i]` + * against `stateRoot` at the message's decrypted `stateIdx` — the same leaf + * layout `BallotValidity` uses, so callers can reuse whatever they already + * pass there. + */ + async proveHybridBatch({ + messages, + voterPubKeys, + leaves, + stateRoot, + batchSize, + wasmFile, + zkeyFile, + derivePathParams + }: { + messages: { + routing: bigint[]; + encPubKey: PubKey; + ciphertexts: AheCiphertext[]; + }[]; + voterPubKeys: PubKey[]; + leaves: { + voiceCreditBalance: bigint; + voteOptionTreeRoot: bigint; + slNonce: bigint; + pathElements: bigint[][]; + }[]; + stateRoot: bigint; + /** Fixed circuit batch size (slot count); defaults to `messages.length` (no padding). */ + batchSize?: number; + wasmFile: ZKArtifact; + zkeyFile: ZKArtifact; + derivePathParams?: DerivePathParams; + }): Promise<{ proof: { a: string; b: string; c: string }; aggC1: bigint[][]; aggC2: bigint[][] }> { + const signer = this.getSigner(derivePathParams); + + const numOptions = messages[0]?.ciphertexts.length ?? 0; + const actualCount = messages.length; + const B = batchSize ?? actualCount; + if (actualCount > B) throw new Error('proveHybridBatch: messages.length exceeds batchSize'); + const stateTreeDepth = leaves[0]?.pathElements.length ?? 0; + const leavesPerLevel = stateTreeDepth > 0 ? leaves[0].pathElements[0].length : 0; + + const pad = (arr: T[], fill: T): T[] => arr.concat(Array(B - arr.length).fill(fill)); + const zeroPath = () => + Array.from({ length: stateTreeDepth }, () => Array(leavesPerLevel).fill(0n)); + + const input = { + // getFormatedPrivKey() already returns formatPrivKeyForBabyJub(secretKey), + // i.e. the exact scalar the circuit expects; do NOT format it again. + coordPrivKey: signer.getFormatedPrivKey(), + message: pad( + messages.map((m) => m.routing), + Array(10).fill(0n) + ), + encPubKey: pad( + messages.map((m) => m.encPubKey), + [0n, 1n] as PubKey + ), + voterPubKey: pad(voterPubKeys, [0n, 1n] as PubKey), + voiceCreditBalance: pad(leaves.map((l) => l.voiceCreditBalance), 0n), + voteOptionTreeRoot: pad(leaves.map((l) => l.voteOptionTreeRoot), 0n), + slNonce: pad(leaves.map((l) => l.slNonce), 0n), + pathElements: pad(leaves.map((l) => l.pathElements), zeroPath()), + stateRoot, + actualCount: BigInt(actualCount), + encC1: pad( + messages.map((m) => m.ciphertexts.map((ct) => ct.c1)), + Array(numOptions).fill([0n, 1n]) + ), + encC2: pad( + messages.map((m) => m.ciphertexts.map((ct) => ct.c2)), + Array(numOptions).fill([0n, 1n]) + ) + }; + + const { proof, publicSignals } = await groth16.fullProve(input, wasmFile, zkeyFile); + const proofHex = await adaptToUncompressed(proof); + + // The circuit's public outputs are [aggC1(2M), aggC2(2M), isReal(B), ...]; + // publicSignals lists outputs before public inputs. + const aggC1: bigint[][] = []; + const aggC2: bigint[][] = []; + for (let opt = 0; opt < numOptions; opt++) { + aggC1.push([BigInt(publicSignals[opt * 2]), BigInt(publicSignals[opt * 2 + 1])]); + } + const offset = numOptions * 2; + for (let opt = 0; opt < numOptions; opt++) { + aggC2.push([BigInt(publicSignals[offset + opt * 2]), BigInt(publicSignals[offset + opt * 2 + 1])]); + } + + return { proof: proofHex, aggC1, aggC2 }; + } + + /** + * Committee-side threshold decryption of a per-option aggregate ciphertext. + * + * In production each committee member contributes a partial `kc_i * c1` and the + * partials are combined; here a single combined scalar `kc` stands in for the + * demo. Returns the plaintext per-option total via bounded discrete-log. + */ + static decryptHybridAggregate( + aggregate: AheCiphertext[], + kc: bigint, + maxTotal: number + ): bigint[] { + return aggregate.map((ct) => { + const shared = committeePartial(ct.c1, kc); + const vG = recoverAhe(ct, shared); + const total = solveDLog(vG, maxTotal); + if (total === null) throw new Error('decryptHybridAggregate: discrete log not found in bound'); + return total; + }); + } } diff --git a/packages/sdk/src/voter.ts b/packages/sdk/src/voter.ts index 4baef21..8ef8c50 100644 --- a/packages/sdk/src/voter.ts +++ b/packages/sdk/src/voter.ts @@ -17,7 +17,10 @@ import { SNARK_FIELD_SIZE, adaptToUncompressed, packElement, - computeInputHash + computeInputHash, + buildHybridMessageWithSigner, + type HybridMessage, + type AhePoint } from './libs/crypto'; import { poseidon } from './libs/crypto/hashing'; import { poseidonEncrypt } from '@zk-kit/poseidon-cipher'; @@ -939,6 +942,184 @@ export class VoterClient { }; } + // ==================== Hybrid MACI + AHE Methods ==================== + + /** + * Create a factory for Hybrid MACI + AHE messages. + * + * A Hybrid message splits the classic MACI command in two: + * - a routing envelope (stateIdx / nonce / pollId / newPubKey / signature / + * aheCommitment) Poseidon-encrypted to the coordinator, exactly like MACI, and + * - the vote content (per-option weights) encrypted with additively-homomorphic + * ElGamal to the decryption committee key `Kc`, which the coordinator can + * aggregate but never decrypt. + * + * The voter signs `[packed, newPubKey_x, newPubKey_y, aheCommitment]`, binding + * the exact ballot to this authorization. This is what solves MACI's B-line + * problem: the coordinator processes and tallies without seeing any single vote. + * + * @param coordPubkey - coordinator public key (decrypts routing only) + * @param committeeKey - AHE committee joint public key Kc (decrypts aggregate only) + * @param pollId - poll ID for replay protection + * @param numOptions - number of vote options + */ + genHybridMessageFactory( + coordPubkey: bigint | string | PubKey, + committeeKey: AhePoint, + pollId: number, + numOptions: number, + derivePathParams?: DerivePathParams + ) { + const coordPubKey = this.unpackMaciPubkey(coordPubkey); + const signer = this.getSigner(derivePathParams); + const voterPubKey = [...signer.getPublicKey().toPoints()] as PubKey; + + return ( + stateIdx: number, + nonce: number, + voIdx: number, + weight: number, + newPubKey?: PubKey + ): HybridMessage => + buildHybridMessageWithSigner( + { + voterPubKey, + newPubKey, + coordPubKey, + Kc: committeeKey, + stateIdx, + nonce, + pollId, + voIdx, + weight, + numOptions + }, + (msgHash) => { + const sig = signer.sign(msgHash); + return { R8: sig.R8 as [bigint, bigint], S: BigInt(sig.S) }; + } + ); + } + + /** + * Build a batch of Hybrid vote messages. Each option in `plan` becomes one + * revision of the ballot; nonces increase so the coordinator's last-write-wins + * keeps only the final ballot per voter (demonstrating revote). + */ + buildHybridVotePayload({ + stateIdx, + operatorPubkey, + committeeKey, + selectedOptions, + pollId, + numOptions, + derivePathParams + }: { + stateIdx: number; + operatorPubkey: bigint | string | PubKey; + committeeKey: AhePoint; + selectedOptions: { idx: number; vc: number }[]; + pollId: number; + numOptions: number; + derivePathParams?: DerivePathParams; + }): HybridMessage[] { + const plan = this.normalizeVoteOptions(selectedOptions); + const genMessage = this.genHybridMessageFactory( + operatorPubkey, + committeeKey, + pollId, + numOptions, + derivePathParams + ); + + // All messages use nonce=1 and are submitted in FORWARD order (original + // first, revote last). The nonce tree in ProcessHybridMessages uses a + // "first-seen wins" check (cmdNonce==currentNonce+1 with tree starting at 0, + // i.e. effectively currentNonce==0). Combined with REVERSE processing in + // the circuit (newest chain index first), only the LATEST submission for + // each voter passes the nonce check — the revote wins because it is at a + // HIGHER chain index and therefore processed FIRST. Sequential nonces + // (1, 2, ...) would cause all messages to pass the check sequentially, + // leading to double-counting in the additive aggregate. + return plan.map(([voIdx, vc]) => genMessage(stateIdx, 1, voIdx, vc)); + } + + /** + * Generate the client-side ballotValidity Groth16 proof for a Hybrid message, + * proving the published ciphertext is a valid, in-budget, one-hot ballot that + * hashes to the signed commitment — without revealing the vote. + * + * The voice-credit budget is NOT a free input: it comes from the voter's real + * state leaf `[pubKey.x, pubKey.y, voiceCreditBalance, voteOptionTreeRoot, + * nonce]`, authenticated inside the circuit via a quinary state-tree Merkle + * inclusion against the public `stateRoot`. Callers pass the leaf fields plus + * the Merkle path (e.g. from the SDK `Tree` helper's `pathElementOf`). + * + * Anonymity: `stateIdx`/`pubKey` are private witnesses only — the circuit's + * public outputs are `nullifier` (an unlinkable-per-round identifier) and + * `routingCommitment` instead. The proof also re-derives the ECDH shared key + * from `message.ephemeralPrivKey`/`coordPubKey` and decrypts `message.routing` + * in-circuit, asserting its embedded stateIdx/aheCommitment match this + * ballot's own — see `ballotValidity.circom`'s routing-binding step. + */ + async genBallotValidityProof({ + message, + committeeKey, + pubKey, + stateIdx, + stateRoot, + coordPubKey, + pollId, + voiceCreditBalance, + voteOptionTreeRoot = 0n, + slNonce = 0n, + pathElements, + wasmFile, + zkeyFile + }: { + message: HybridMessage; + committeeKey: AhePoint; + pubKey: PubKey; + stateIdx: number | bigint; + stateRoot: bigint; + // The coordinator key `message.routing` was encrypted to, and the pollId + // packed into it — needed so the proof can decrypt-check `routing` + // in-circuit and bind to it (see `ballotValidity.circom`'s routing + // binding). Both are PUBLIC (folded into inputHash by the on-chain + // wrapper), unlike stateIdx/pubKey which stay private. + coordPubKey: PubKey; + pollId: number | bigint; + voiceCreditBalance: number | bigint; + voteOptionTreeRoot?: bigint; + slNonce?: number | bigint; + pathElements: bigint[][]; + wasmFile: ZKArtifact; + zkeyFile: ZKArtifact; + }): Promise<{ proof: { a: string; b: string; c: string }; publicSignals: string[] }> { + const input = { + voteWeights: message.weights, + r: message.randomness, + voiceCreditBalance: BigInt(voiceCreditBalance), + voteOptionTreeRoot: BigInt(voteOptionTreeRoot), + slNonce: BigInt(slNonce), + pathElements, + stateIdx: BigInt(stateIdx), + pubKey, + ephemeralPrivKey: message.ephemeralPrivKey, + routing: message.routing, + routingEncPubKey: message.encPubKey, + Kc: committeeKey, + stateRoot: BigInt(stateRoot), + coordPubKey, + pollId: BigInt(pollId) + }; + + const { proof, publicSignals } = await groth16.fullProve(input, wasmFile, zkeyFile); + const proofHex = await adaptToUncompressed(proof); + + return { proof: proofHex, publicSignals }; + } + // ==================== Legacy Methods (backward-compat, no pollId) ==================== private legacyGenMessageFactory( From f42d8f8fcb961ded01b8e06f07eb356f048b31ec Mon Sep 17 00:00:00 2001 From: 99Kies <1290017556@qq.com> Date: Mon, 13 Jul 2026 14:32:23 +0800 Subject: [PATCH 2/2] chore: update artifacts checksums Co-authored-by: Cursor --- artifacts/checksums.txt | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/artifacts/checksums.txt b/artifacts/checksums.txt index 7f5a795..f592f7a 100644 --- a/artifacts/checksums.txt +++ b/artifacts/checksums.txt @@ -1,5 +1 @@ -bf095310e0fd04c48eff6d9cb3cefc9063af9f19289e2c133891f7cfefe580a3 cw_amaci-aarch64.wasm -faa7d0f2e1fbc9878223d8c5b048bc5d41237360de840c7ca1c730aa9840213a cw_amaci_registry-aarch64.wasm -86583bd5db998cff1bd9b45c62e9d207875fa3ae63929d69afeacf4cb3938fab cw_api_saas-aarch64.wasm -9b20be65d366f0c05a678448c3cf90a9717ed394e0e77df5aec71cfed7df80e4 cw_maci-aarch64.wasm -904c160324e3f943f5841e4acfdb61402332c228d3fad4345f9c1f9c0b23f237 cw_test-aarch64.wasm +8f4809e83856f8b5f21def9e28953bfcd90281797e90987b595f325756e7c1cf cw_amaci-test-vkeys-aarch64.wasm