From 46c6049f11a191df6a2aed2a47e84abf815dd71c Mon Sep 17 00:00:00 2001 From: rymnc <43716372+rymnc@users.noreply.github.com> Date: Tue, 19 May 2026 23:19:58 +0200 Subject: [PATCH 1/5] feat(resilient_civic_participation): poc --- .../resilient-civic-participation/.gitignore | 4 + .../.rustfmt.toml | 8 + .../CHANGELOG.md | 10 + .../resilient-civic-participation/Cargo.toml | 61 + .../resilient-civic-participation/README.md | 131 + .../resilient-civic-participation/SPEC.md | 70 +- .../circuits/Nargo.toml | 8 + .../circuits/batch/Nargo.toml | 9 + .../circuits/batch/src/main.nr | 213 ++ .../circuits/lib/Nargo.toml | 8 + .../circuits/lib/src/blob.nr | 77 + .../circuits/lib/src/cmp.nr | 67 + .../circuits/lib/src/domain.nr | 11 + .../circuits/lib/src/fsrt.nr | 51 + .../circuits/lib/src/hasher.nr | 82 + .../circuits/lib/src/imt.nr | 167 ++ .../circuits/lib/src/lib.nr | 24 + .../circuits/lib/src/merkle.nr | 81 + .../circuits/lib/src/predicate.nr | 221 ++ .../circuits/lib/src/sponge.nr | 33 + .../circuits/resolution/Nargo.toml | 8 + .../circuits/resolution/src/main.nr | 128 + .../circuits/signer/Nargo.toml | 8 + .../circuits/signer/src/main.nr | 283 ++ .../contracts/script/Deploy.s.sol | 76 + .../contracts/src/PetitionRegistry.sol | 1004 +++++++ .../src/interfaces/IPetitionRegistry.sol | 106 + .../contracts/src/interfaces/IVerifier.sol | 9 + .../contracts/src/mocks/MockBatchVerifier.sol | 22 + .../contracts/src/mocks/MockERC20.sol | 57 + .../src/mocks/MockResolutionVerifier.sol | 22 + .../contracts/src/verifiers/BatchVerifier.sol | 2461 +++++++++++++++++ .../src/verifiers/ResolutionVerifier.sol | 2461 +++++++++++++++++ .../contracts/test/PetitionRegistry.t.sol | 592 ++++ .../deployments.toml | 12 + .../foundry.toml | 29 + .../rust-toolchain.toml | 4 + .../scripts/generate-verifiers.sh | 54 + .../soldeer.lock | 26 + .../src/adapters/bb_prover.rs | 786 ++++++ .../src/adapters/blob_4844.rs | 386 +++ .../src/adapters/chain_registry.rs | 398 +++ .../src/adapters/direct_relay_submission.rs | 143 + .../src/adapters/in_memory_blob.rs | 221 ++ .../src/adapters/in_memory_ri.rs | 157 ++ .../src/adapters/mock_proof.rs | 414 +++ .../src/adapters/mod.rs | 22 + .../resilient-civic-participation/src/blob.rs | 342 +++ .../src/clock.rs | 60 + .../src/disputant/core.rs | 276 ++ .../src/disputant/error.rs | 11 + .../src/disputant/mod.rs | 6 + .../src/disputant/types.rs | 20 + .../src/error.rs | 79 + .../resilient-civic-participation/src/fsrt.rs | 502 ++++ .../resilient-civic-participation/src/imt.rs | 463 ++++ .../resilient-civic-participation/src/lib.rs | 66 + .../src/organizer/core.rs | 235 ++ .../src/organizer/error.rs | 21 + .../src/organizer/mod.rs | 5 + .../src/organizer/types.rs | 30 + .../src/ports/blob.rs | 35 + .../src/ports/imt.rs | 84 + .../src/ports/mod.rs | 7 + .../src/ports/proof.rs | 65 + .../src/ports/ri.rs | 31 + .../src/ports/submission.rs | 22 + .../src/poseidon.rs | 542 ++++ .../src/predicate.rs | 740 +++++ .../src/registry/core.rs | 950 +++++++ .../src/registry/error.rs | 76 + .../src/registry/mod.rs | 5 + .../src/registry/types.rs | 117 + .../src/relayer/core.rs | 437 +++ .../src/relayer/error.rs | 35 + .../src/relayer/mod.rs | 5 + .../src/relayer/types.rs | 41 + .../src/resolver/core.rs | 337 +++ .../src/resolver/error.rs | 27 + .../src/resolver/mod.rs | 5 + .../src/resolver/types.rs | 27 + .../src/signer/core.rs | 407 +++ .../src/signer/error.rs | 21 + .../src/signer/mod.rs | 5 + .../src/signer/types.rs | 34 + .../src/types.rs | 475 ++++ .../tests/anvil_harness.rs | 159 ++ .../tests/common/mod.rs | 364 +++ .../tests/dispute_won_class_tag_out_of_set.rs | 467 ++++ ...icate_nullifier_across_batches_rejected.rs | 74 + .../tests/golden_path.rs | 549 ++++ 91 files changed, 19451 insertions(+), 33 deletions(-) create mode 100644 pocs/civic-participation/resilient-civic-participation/.gitignore create mode 100644 pocs/civic-participation/resilient-civic-participation/.rustfmt.toml create mode 100644 pocs/civic-participation/resilient-civic-participation/CHANGELOG.md create mode 100644 pocs/civic-participation/resilient-civic-participation/Cargo.toml create mode 100644 pocs/civic-participation/resilient-civic-participation/README.md create mode 100644 pocs/civic-participation/resilient-civic-participation/circuits/Nargo.toml create mode 100644 pocs/civic-participation/resilient-civic-participation/circuits/batch/Nargo.toml create mode 100644 pocs/civic-participation/resilient-civic-participation/circuits/batch/src/main.nr create mode 100644 pocs/civic-participation/resilient-civic-participation/circuits/lib/Nargo.toml create mode 100644 pocs/civic-participation/resilient-civic-participation/circuits/lib/src/blob.nr create mode 100644 pocs/civic-participation/resilient-civic-participation/circuits/lib/src/cmp.nr create mode 100644 pocs/civic-participation/resilient-civic-participation/circuits/lib/src/domain.nr create mode 100644 pocs/civic-participation/resilient-civic-participation/circuits/lib/src/fsrt.nr create mode 100644 pocs/civic-participation/resilient-civic-participation/circuits/lib/src/hasher.nr create mode 100644 pocs/civic-participation/resilient-civic-participation/circuits/lib/src/imt.nr create mode 100644 pocs/civic-participation/resilient-civic-participation/circuits/lib/src/lib.nr create mode 100644 pocs/civic-participation/resilient-civic-participation/circuits/lib/src/merkle.nr create mode 100644 pocs/civic-participation/resilient-civic-participation/circuits/lib/src/predicate.nr create mode 100644 pocs/civic-participation/resilient-civic-participation/circuits/lib/src/sponge.nr create mode 100644 pocs/civic-participation/resilient-civic-participation/circuits/resolution/Nargo.toml create mode 100644 pocs/civic-participation/resilient-civic-participation/circuits/resolution/src/main.nr create mode 100644 pocs/civic-participation/resilient-civic-participation/circuits/signer/Nargo.toml create mode 100644 pocs/civic-participation/resilient-civic-participation/circuits/signer/src/main.nr create mode 100644 pocs/civic-participation/resilient-civic-participation/contracts/script/Deploy.s.sol create mode 100644 pocs/civic-participation/resilient-civic-participation/contracts/src/PetitionRegistry.sol create mode 100644 pocs/civic-participation/resilient-civic-participation/contracts/src/interfaces/IPetitionRegistry.sol create mode 100644 pocs/civic-participation/resilient-civic-participation/contracts/src/interfaces/IVerifier.sol create mode 100644 pocs/civic-participation/resilient-civic-participation/contracts/src/mocks/MockBatchVerifier.sol create mode 100644 pocs/civic-participation/resilient-civic-participation/contracts/src/mocks/MockERC20.sol create mode 100644 pocs/civic-participation/resilient-civic-participation/contracts/src/mocks/MockResolutionVerifier.sol create mode 100644 pocs/civic-participation/resilient-civic-participation/contracts/src/verifiers/BatchVerifier.sol create mode 100644 pocs/civic-participation/resilient-civic-participation/contracts/src/verifiers/ResolutionVerifier.sol create mode 100644 pocs/civic-participation/resilient-civic-participation/contracts/test/PetitionRegistry.t.sol create mode 100644 pocs/civic-participation/resilient-civic-participation/deployments.toml create mode 100644 pocs/civic-participation/resilient-civic-participation/foundry.toml create mode 100644 pocs/civic-participation/resilient-civic-participation/rust-toolchain.toml create mode 100755 pocs/civic-participation/resilient-civic-participation/scripts/generate-verifiers.sh create mode 100644 pocs/civic-participation/resilient-civic-participation/soldeer.lock create mode 100644 pocs/civic-participation/resilient-civic-participation/src/adapters/bb_prover.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/adapters/blob_4844.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/adapters/chain_registry.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/adapters/direct_relay_submission.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/adapters/in_memory_blob.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/adapters/in_memory_ri.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/adapters/mock_proof.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/adapters/mod.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/blob.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/clock.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/disputant/core.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/disputant/error.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/disputant/mod.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/disputant/types.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/error.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/fsrt.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/imt.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/lib.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/organizer/core.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/organizer/error.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/organizer/mod.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/organizer/types.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/ports/blob.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/ports/imt.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/ports/mod.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/ports/proof.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/ports/ri.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/ports/submission.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/poseidon.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/predicate.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/registry/core.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/registry/error.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/registry/mod.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/registry/types.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/relayer/core.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/relayer/error.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/relayer/mod.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/relayer/types.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/resolver/core.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/resolver/error.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/resolver/mod.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/resolver/types.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/signer/core.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/signer/error.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/signer/mod.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/signer/types.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/src/types.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/tests/anvil_harness.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/tests/common/mod.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/tests/dispute_won_class_tag_out_of_set.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/tests/duplicate_nullifier_across_batches_rejected.rs create mode 100644 pocs/civic-participation/resilient-civic-participation/tests/golden_path.rs diff --git a/pocs/civic-participation/resilient-civic-participation/.gitignore b/pocs/civic-participation/resilient-civic-participation/.gitignore new file mode 100644 index 0000000..fa2ccf6 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/.gitignore @@ -0,0 +1,4 @@ +/target +Cargo.lock +**/broadcast/Deploy.s.sol/31337 +dependencies/** diff --git a/pocs/civic-participation/resilient-civic-participation/.rustfmt.toml b/pocs/civic-participation/resilient-civic-participation/.rustfmt.toml new file mode 100644 index 0000000..b42b6d8 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/.rustfmt.toml @@ -0,0 +1,8 @@ +max_width = 90 +normalize_comments = true +imports_layout = "Vertical" +imports_granularity = "Crate" +trailing_semicolon = false +edition = "2024" +use_try_shorthand = true +use_field_init_shorthand = true diff --git a/pocs/civic-participation/resilient-civic-participation/CHANGELOG.md b/pocs/civic-participation/resilient-civic-participation/CHANGELOG.md new file mode 100644 index 0000000..f51ed13 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +All notable changes to this PoC will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +## [Unreleased] + +### Added +- Initial implementation diff --git a/pocs/civic-participation/resilient-civic-participation/Cargo.toml b/pocs/civic-participation/resilient-civic-participation/Cargo.toml new file mode 100644 index 0000000..895c942 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/Cargo.toml @@ -0,0 +1,61 @@ +[package] +name = "resilient_civic_participation" +version = "0.1.0" +authors = ["rymnc <43716372+rymnc@users.noreply.github.com>"] +edition = "2024" + +[lib] +path = "src/lib.rs" + +[dependencies] +# Crypto +ark-bn254 = "0.5" +ark-bls12-381 = "0.5" +ark-ec = { version = "0.5", features = ["parallel"] } +ark-ff = { version = "0.5", features = ["parallel"] } +ark-serialize = "0.5" +ark-std = "0.5" +light-poseidon = "0.4" +lean-imt = { package = "zk-kit-lean-imt", version = "0.1.1" } +sha2 = "0.10" +sha3 = "0.10" +zeroize = { version = "1", features = ["zeroize_derive"] } +num-bigint = "0.4" + +c-kzg = "2.1" + +# Serialization +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "0.8" +hex = "0.4" + +# Util +thiserror = "2" +rand = "0.8" +getrandom = "0.2" +chrono = { version = "0.4", features = ["serde"] } +tokio = { version = "1", features = ["full"] } +tracing = "0.1" +tempfile = "3" + +alloy = { version = "1", features = [ + "providers", + "signers", + "contract", + "sol-types", + "node-bindings", + "network", + "transports", + "transport-http", + "rpc-types", + "consensus", + "eips", +] } + +[features] +default = ["test-mocks"] +test-mocks = [] + +[dev-dependencies] +tokio-test = "0.4" diff --git a/pocs/civic-participation/resilient-civic-participation/README.md b/pocs/civic-participation/resilient-civic-participation/README.md new file mode 100644 index 0000000..f514938 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/README.md @@ -0,0 +1,131 @@ +# Resilient Civic Participation + +> **Status:** Draft (proof of concept) +> **Privacy Primitive:** Credentialed petition signing with forward-secure ratchet, blob-anchored batches, on-chain resolution SNARK. + +## What this PoC demonstrates + +A petition system where signers prove eligibility against an external [ResilientIdentity (RI)](../../private-identity/resilient-private-identity/) credential root, sign once per petition with cross-petition unlinkability, and produce an outcome verifiable from durable chain state after the dispute window closes. Key properties: + +- **Per-petition forward secrecy.** Each signer maintains a Forward-Secure Ratchet Tree (FSRT) chain over `N = 2^24` slot values. After each finalised signing the slot's seed is overwritten in place; a post-signing device compromise reveals nothing about past signed slots beyond what the prior seed material had already produced. +- **Cross-petition unlinkability.** The signer SNARK exposes only `(petition_id, slot, class_tag, nullifier, identity_tag)`. The same RI leaf signs different petitions under distinct slot indices and distinct per-slot ratchet values; no on-chain linkability between two signings of the same signer beyond the predicate-match intersection bound. +- **Operator-free outcome verifiability.** After `close_at_block + dispute window`, anyone can reconstruct the leaf set `L` from blob bytes, recompute `(b, b_per_class)`, and re-verify the resolution SNARK. The organiser, RI issuer, relayers, and resolver can all go offline; the chain holds enough state to settle the outcome. + +The protocol spec is in [SPEC.md](./SPEC.md). The repo-wide umbrella requirements live in [../REQUIREMENTS.md](../REQUIREMENTS.md). + +## Layout + +``` +circuits/ -- Noir workspace (nargo 1.0-beta.21, bb 5.0-nightly.20260324) +├── lib/ -- shared: poseidon, IMT, FSRT, predicate, blob field-element binding, binary_merkle_root (vendored) +├── signer/ -- inner SNARK; compiled with `noir-recursive` +├── batch/ -- outer SNARK; recursively verifies BATCH_SIZE_MAX signer proofs via `bb_proof_verification` v5.0.0-nightly.20260324 +└── resolution/ -- outer SNARK; tallies leaves under `running_root` + +contracts/ -- foundry project +├── src/PetitionRegistry.sol -- on-chain state machine: register / publishBatch / dispute / resolve +├── src/interfaces/{IPetitionRegistry,IVerifier}.sol +├── src/mocks/{MockBatchVerifier,MockResolutionVerifier,MockERC20}.sol +├── src/verifiers/{Batch,Resolution}Verifier.sol -- emitted by `scripts/generate-verifiers.sh` +└── script/Deploy.s.sol -- forge deploy script + +scripts/generate-verifiers.sh -- nargo compile + bb write_vk + bb write_solidity_verifier + +src/ +├── lib.rs -- crate root, domain constants +├── types.rs -- shared domain types +├── poseidon.rs -- Poseidon1 helpers + sponge + domain separators +├── imt/ -- depth-24 sorted-linked-list Indexed Merkle Tree +├── fsrt.rs -- FSRT chain expansion + caterpillar frontier +├── predicate.rs -- postfix predicate grammar + canonical scalar encoding +├── blob.rs -- EIP-4844 blob payload encoder + batch_versioned_hash +├── ports/ -- proof, imt, ri, blob, submission traits +├── adapters/ +│ ├── bb_prover.rs -- real BB shell-out (signer = noir-recursive, outer = evm) +│ ├── blob_4844.rs -- real EIP-4844 blob carrier (c-kzg + alloy BlobTransactionSidecar) +│ ├── chain_registry.rs -- alloy sol! bindings for on-chain PetitionRegistry +│ ├── in_memory_ri.rs -- mock RI credential layer (lean_imt-backed) +│ ├── mock_proof.rs -- sentinel-bytes proof backend for unit + off-chain integration tests +│ └── in_memory_blob.rs -- mock blob carrier for unit tests +├── signer/ -- enrollment + per-petition signing + state journaling +├── organizer/ -- petition draft assembly + structural validation +├── relayer/ -- batch aggregation + leaf ordering + IMT insertion witness +├── disputant/ -- builders for the three SPEC dispute predicates +├── resolver/ -- leaf-set reconstruction + outcome computation +└── registry/ -- off-chain shadow state machine for unit tests + +tests/ +├── common/mod.rs -- in-process harness (mock proofs, in-memory RI / blob) +├── duplicate_nullifier_across_batches_rejected.rs +├── anvil_harness.rs -- spawns anvil + forge script + parses addresses +└── golden_path.rs -- real anvil + real EIP-4844 blob tx + on-chain PetitionRegistry +``` + +## Build & run + +Prerequisites: `nargo 1.0-beta.21+`, `bb 5.0-nightly.20260324+`, `foundry` (forge + anvil), `rust 2024 edition`. + +```bash +# Compile circuits + emit Solidity verifiers from bb-generated VKs. +(cd circuits && nargo compile) +scripts/generate-verifiers.sh + +# Compile contracts. +forge build + +# Off-chain unit + integration tests (mock proofs, in-memory blob / RI). +cargo test + +# Real anvil + EIP-4844 + on-chain PetitionRegistry. Uses MockProofBackend +# + Mock*Verifier on chain by default so a developer can iterate without +# paying real bb proving time. Set `USE_MOCK_VERIFIER=false` to opt in. +cargo test --test golden_path +``` + +The mock golden-path runs the full lifecycle in-process. The anvil golden-path spawns anvil with the cancun hardfork, deploys `PetitionRegistry` + the mock verifiers via `forge script`, publishes a real EIP-4844 blob transaction, and asserts `blobhash(0)` binding succeeds on chain. + +## Cryptographic assumptions + +- **UltraHonk SNARK** (Aztec Barretenberg) over BN254 KZG; recursive verification. The signer circuit is compiled with `bb --verifier_target noir-recursive` so the batch circuit can `verify_honk_proof_zk` it in-circuit (`bb_proof_verification` v5.0.0-nightly.20260324). Outer batch + resolution circuits emit EVM Honk verifiers via `bb write_solidity_verifier -t evm`. +- **BLS12-381 KZG (EIP-4844)** for blob payload commitments. `EIP4844BlobCarrier` calls `c-kzg::ethereum_kzg_settings()` for the canonical mainnet trusted setup, builds an alloy `BlobTransactionSidecar`, and the relayer submits a real EIP-4844 blob transaction; on-chain `PetitionRegistry.publishBatch` reads `blobhash(0)` and binds it into the batch SNARK's public inputs. +- **Forward-Secure Ratchet (Bellare-Yee 2003)** instantiated by the Poseidon1 sponge as a length-doubling PRG. +- **Caterpillar Merkle frontier (Szydlo)** for log-space Merkle traversal at depth 24. +- **Keccak-256** for `petition_id` derivation. The top byte is masked to keep the identifier under the BN254 scalar modulus (no silent reduction between the on-chain `bytes32` and the SNARK's `Fr` public input). + +## Threat model summary + +Adversary: +- Indefinite passive observation of L1 + the blob retention window + voluntary blob archives. +- Compelled key disclosure for Organiser / Relayer / Resolver / Disputant / archiver. +- Compelled key disclosure or device compromise for a signer (yielding `(identity_secret, attr_vector, RI Merkle path, s_curr, t, caterpillar, chain_root)`). +- Cross-petition correlation across every observable, including predicate-match intersections. +- Sybil enrolment of multiple RI identities. + +Honest-party assumptions: +- Poseidon1 sponge security and UltraHonk soundness. +- EIP-4844 blob commitment binding. +- L1 censorship-resistant inclusion and finality. +- Permissionless Relayer entry such that signers can resubmit on Relayer-side censorship. +- Sybil resistance from RI. + +Out of scope: +- Network-transport anonymity beyond what Tor or an equivalent provides. +- Real-time device compromise before the chain advances past `s_slot`. +- Forensic recovery of overwritten storage on commodity media without `TRIM`. + +## Known limitations and shortcuts (PoC scope) + +| Concern | Mitigation / Production Path | +|---------|------------------------------| +| `BATCH_SIZE_MAX = 6` in the PoC batch circuit (SPEC value: 100). The recursive verifier inflates the constraint count linearly in `BATCH_SIZE_MAX`; the PoC cap keeps `nargo compile` + `bb prove` tractable on a developer laptop. | Production rebuilds the batch circuit with `BATCH_SIZE_MAX = 100`. The recursion API and the Solidity verifier ABI stay unchanged. | +| `InMemoryRi` is an in-process `lean_imt`-backed Poseidon1 Merkle tree; root age is tracked by block number only. | The RI port maps directly onto the [resilient-private-identity](../../private-identity/resilient-private-identity/) PoC's on-chain `IdentityTree`. | +| `petition_id` is masked to fit in the BN254 scalar field (top byte zeroed) so the on-chain `bytes32` and the SNARK's `Fr` public input agree bit-for-bit. SPEC defines it as a full `keccak256` output. | Either keep the mask convention in production (loses 8 bits of identifier entropy: still 248 bits, far above any collision concern) or split the storage into a separate `Fr`-typed `snark_petition_id` derived from the full `keccak256`. | +| FSRT depth at runtime defaults to a SPEC-mandated `2^24`; the runtime API accepts an override (`chain_len`) so tests use a shallow tree. | The spec-mandated depth must be used in production. The eager-expansion design materializes all `v_i` in RAM during enrollment; production deployments retain only the caterpillar frontier and recompute `v_slot` on demand. | +| The `anvil` golden-path test deploys mock Honk verifiers (`Mock{Batch,Resolution}Verifier`) by default so iteration time stays in seconds. The real Honk verifiers emitted by `scripts/generate-verifiers.sh` accept the bb-generated proofs but require multi-minute prover time per batch on commodity hardware. | Real BB proving is wired through `BBProver` (see `src/adapters/bb_prover.rs`) and is opt-in via `USE_MOCK_VERIFIER=false` in the deploy script. | + +## References + +- [SPEC.md](./SPEC.md): full protocol specification. +- [Resilient Civic Participation use case](https://github.com/ethereum/iptf-map/blob/master/use-cases/resilient-civic-participation.md) (iptf-map). +- [Civic Participation approach](https://github.com/ethereum/iptf-map/blob/master/approaches/approach-civic-participation.md) (iptf-map). +- [ResilientIdentity SPEC](../../private-identity/resilient-private-identity/SPEC.md): the credential layer this PoC composes with. diff --git a/pocs/civic-participation/resilient-civic-participation/SPEC.md b/pocs/civic-participation/resilient-civic-participation/SPEC.md index d8d96ed..d2c9ed1 100644 --- a/pocs/civic-participation/resilient-civic-participation/SPEC.md +++ b/pocs/civic-participation/resilient-civic-participation/SPEC.md @@ -38,7 +38,7 @@ Rejected alternatives: |-------------|-----------------| | Operator-stored signed lists with KYC | Operator state becomes a compelled-disclosure surface; outcome guarantees are lost when the operator goes offline. | | ZK with issuer-online revocation | Outcome verifiability depends on continued issuer cooperation; an adversarial issuer blocks participation. | -| Static identity commitment under one long-lived secret | Device compromise at `T_audit` reveals every past signing identifier under one static-commitment opening. | +| Static identity commitment under one long-lived secret | Device compromise at `T_compromise` reveals every past signing identifier under one static-commitment opening. | | Per-signature on-chain transactions | Per-signature gas cost exceeds the budget for petitions at the scale of national or supranational civic instruments. | ## Protocol Design @@ -75,7 +75,7 @@ The Registry MUST enforce the current `state` at every entry point. 1. Signer MUST sample `s_0` from a CSPRNG with at least 254 bits of entropy. 2. Signer MUST derive `(v_i, s_{i+1})` for `i in [0, 2^24)` by inductive sponge expansion. 3. Signer MUST build a depth-24 Poseidon1 Merkle tree over `{v_i}`; the root is `chain_root`. -4. Signer MUST compute `attr_hash = Poseidon1(DOMAIN_ATTR, attr_0, ..., attr_{n-1}, chain_root, attr_version)` and submit it with the RI enrollment proof; RI appends a leaf under `attr_hash`. +4. Signer MUST compute `attr_hash = Poseidon1(DOMAIN_ATTR, attr_0, ..., attr_{n-1}, chain_root, attr_version, identity_secret)` and submit it with the RI enrollment proof; RI appends a leaf under `attr_hash`. `identity_secret` is an independent CSPRNG-sampled per-signer secret. 5. Signer MUST discard `s_1, ..., s_{2^24 - 1}` and intermediate Merkle nodes, and MUST retain `(s_curr = s_0, t = 0, caterpillar, chain_root, attr_version)` per [Off-Chain Signer State](#off-chain-signer-state). #### Petition Registration @@ -84,13 +84,13 @@ The Registry MUST enforce the current `state` at every entry point. 2. Registry MUST structurally validate the predicate and class-binding clause, MUST recompute and assert `predicate_hash`, MUST derive `petition_id`, MUST assign `slot = S` then increment `S`, MUST snapshot `alpha_at_registration`, and MUST assert `B >= alpha_at_registration * N_expected * predicate_op_count`. 3. Registry MUST initialise `running_root`, `identity_tag_set_root`, `leaf_count`, `next_batch_index` and MUST emit `PetitionRegistered`. -The signing window MUST satisfy `close_at_block - registration_block <= 11.5 days * BLOCKS_PER_DAY`. Organizer MUST select an `R` that has been published on RI for at least 30 days. +The signing window MUST satisfy `close_at_block - registration_block <= 11.5 days * BLOCKS_PER_DAY`. Organizer MUST select an `R` that has been published on RI for at least 30 days. Each `class_thresholds[i]` MUST be at least 1; a zero threshold would collapse the resolution SNARK's per-class predicate to `true` regardless of participation and would zero the bounty floor. #### Per-Signature Generation 1. Signer MUST read `(slot, R, predicate_def, salt, class_index)` for petition `X` and MUST advance the chain locally from `t` to `slot(X)`, setting `s_curr <- s_{slot(X)}`. 2. `(v_slot, _) = Poseidon1Sponge.absorb(DOMAIN_FSRT_PRG, s_slot).squeeze(2)`; `class_tag = attr[class_index]`. -3. `nullifier = Poseidon1(DOMAIN_NULLIFIER, v_slot, petition_id, class_index, class_tag)`; `identity_tag = Poseidon1(DOMAIN_IDTAG, v_slot, petition_id)`. +3. `nullifier = Poseidon1(DOMAIN_NULLIFIER, v_slot, petition_id, class_index, class_tag, identity_secret)`; `identity_tag = Poseidon1(DOMAIN_IDTAG, v_slot, petition_id)`. 4. Signer MUST build the signer SNARK and MUST submit it with `(nullifier, identity_tag, class_tag)` to a relayer. 5. After L1 finality of the carrying batch, signer MUST overwrite `s_curr` past `s_slot`, advance the caterpillar frontier, and set `t <- slot + 1`. The signer MUST journal this transition to fsync'd storage before the signing counts as complete. @@ -103,8 +103,8 @@ The signing window MUST satisfy `close_at_block - registration_block <= 11.5 day #### Dispute -1. Disputant submits `dispute(petition_id, batch_index, position_i, violation_type, opening_proofs, evidence)`. -2. Registry MUST validate openings via the `0x0A` precompile against `batch_versioned_hash`, MUST apply the violation predicate, MUST set the BatchRecord state to `Repudiated`, MUST advance `next_batch_index` to `batch_index`, MUST roll back `running_root`, `identity_tag_set_root`, and `leaf_count` to the values held by the immediately preceding active batch (or the initial empty-IMT state if no such predecessor exists), and MUST emit `BatchRepudiated`. +1. Disputant submits `dispute(petition_id, batch_index, position_i, position_j, violation_type, opening_proofs)`. `position_j` is `None` for violation `0x01` and `Some(j)` for `0x02` and `0x03`. The record content at each position MUST be derived by the Registry from `opening_proofs` rather than submitted separately. +2. Registry MUST validate openings via the `0x0A` precompile against `batch_versioned_hash`, MUST derive the record content for `position_i` (and `position_j` where applicable) from the openings, MUST apply the violation predicate against that derived content, MUST set the BatchRecord state at `batch_index` to `Repudiated` together with every BatchRecord at index `> batch_index` whose state is still `Active` (those records' `prior_running_root` is no longer canonical), MUST advance `next_batch_index` to `batch_index`, MUST roll back `running_root`, `identity_tag_set_root`, and `leaf_count` to the values held by the immediately preceding active batch (or the initial empty-IMT state if no such predecessor exists), and MUST emit `BatchRepudiated`. Violation types: @@ -120,7 +120,7 @@ Violation types: 2. For each `c in class_set`, `count[c] = |{leaf in L : class_tag(leaf) = c}|`; `b_per_class[i] = (count[class_set[i]] >= class_thresholds[i])`; `b = AND_i b_per_class[i]`. 3. Resolver MUST build and submit the resolution SNARK; Registry MUST validate and emit `PetitionResolved` and `BountyPaid`. First valid submission claims the bounty. -After `close_at_block + 14 days` with no valid resolution, any party MAY call `markUnresolved(petition_id)`. The Registry MUST refund the bounty (less a gas rebate to the caller) to the Organizer, MUST replace `running_root` with the tombstone marker, MUST transition the petition state to `Unresolved`, and MUST emit `PetitionUnresolved`. +After `close_at_block + 14 days` with no valid resolution, any party MAY call `markUnresolved(petition_id)`. The Registry MUST refund the bounty (less a gas rebate to the caller, capped at 1% of the total bounty so a caller cannot starve the Organizer of their refund) to the Organizer, MUST replace `running_root` with the tombstone marker, MUST transition the petition state to `Unresolved`, and MUST emit `PetitionUnresolved`. This call is only permitted when the petition's state is `DisputeWindow`; the 14-day timer makes any earlier state unreachable. ### Data Structures @@ -130,7 +130,7 @@ Predicates are postfix expressions over `attr_vector`, evaluated inside the sign Per-attribute type tags: `INT64` (unsigned 64-bit comparators), `HASH` (equality-only), `BOOL` (equality-only). Bounds: `1 <= predicate_tuple_count <= 20`, `1 <= predicate_op_count <= 20`; the signer SNARK pads to `L_max = 20` operations for constant-time evaluation. -Every petition's predicate MUST include `attr[class_index] == class_tag` as a top-level AND-clause outside any OR sub-expression. The Registry MUST validate this structurally at registration; the signer SNARK enforces it at proving. +Every petition's predicate MUST be *class-bound*: every minimal satisfying assignment MUST force `attr[class_index]` into `class_set`. The Registry MUST validate this structurally at registration via a taint analysis where `PUSH_TUPLE(t)` is *Bound* iff `tuples[t].claim_index == class_index`, `tuples[t].comparator == EQ`, and `tuples[t].operand in class_set`; `AND(a, b)` is Bound iff at least one of `a`, `b` is Bound; `OR(a, b)` is Bound iff both `a` and `b` are Bound; `NOT(a)` re-tags Bound as Tainted; any other combination of Bound and non-Bound operands yields Tainted. The top-level evaluation MUST be Bound. The signer SNARK enforces the per-signer specialisation: `attrs[class_index] == class_tag` is checked directly, and at least one binding tuple MUST have `operand == class_tag`, so every accepted signature carries a `class_tag in class_set`. `predicate_hash = Poseidon1(DOMAIN_PRED, canonical_predicate_def, petition_id, salt)`; `salt` is a 32-byte nonce registered with the petition definition. @@ -151,7 +151,7 @@ op := op_code u8 // 0x20=PUSH_TUPLE, 0x21=AND, // zero otherwise ``` -Serialised length `1 + tuple_count * 35 + 1 + op_count * 2`, capped at 1024 bytes. In the signer SNARK, `canonical_predicate_def` is absorbed as 34 BN254 scalars: 31-byte big-endian segments, final segment zero-padded, a one-byte length marker as the first segment's high byte, each segment reduced modulo the BN254 scalar field order. +Serialised length `1 + tuple_count * 35 + 1 + op_count * 2`, capped at 1024 bytes. In the signer SNARK, `canonical_predicate_def` is absorbed as 34 BN254 scalars: 31-byte big-endian segments, final segment zero-padded, a two-byte big-endian length marker occupying segment 0's first two content bytes (leaving 29 content bytes for predicate bytes in segment 0 and 31 in segments 1..34), each segment reduced modulo the BN254 scalar field order. The two-byte length marker eliminates the modular-256 collision space a one-byte marker would admit at the 1024-byte upper bound. #### Petition Record @@ -274,9 +274,23 @@ Events (`petition_id` indexed in every event except `AlphaUpdated`, which has no ### Domain Separators -For each tag `X` in `{nullifier, identity_tag, leaf, fsrt_prg, predicate, attr_hash, batch_snark, petition_id, resolution_snark}`, `DOMAIN_X = Poseidon1(encode("RCP/" || X || "/v1"), 0, 0, 0)`. +The protocol uses small distinct BN254 scalar constants for Poseidon1-based domain separation: -`encode: ASCII string -> BN254 scalar` takes strings of length `<= 31`, left-pads with `0x00` to 31 bytes, interprets big-endian, reduces modulo the BN254 scalar field order. Implementations MUST embed the pinned constants at compile time. +| Tag | Value | +|---|---| +| `DOMAIN_NULLIFIER` | `1` | +| `DOMAIN_IDTAG` | `2` | +| `DOMAIN_LEAF` | `3` | +| `DOMAIN_FSRT_PRG` | `4` | +| `DOMAIN_PRED` | `5` | +| `DOMAIN_ATTR` | `6` | +| `DOMAIN_BATCH_SNARK` | `7` | +| `DOMAIN_PETITION` | `8` | +| `DOMAIN_RESOLUTION_SNARK` | `9` | + +The `keccak256`-based `petition_id` derivation (see [Global Registry State](#global-registry-state)) uses a distinct 32-byte tag `DOMAIN_PETITION_ID = keccak256("RCP/petition_id/v1")` to provide cryptographic separation in the Keccak hash function context where small-integer prefixes would otherwise collide with naturally-occurring input bytes. + +Implementations MUST embed these constants at compile time. The Noir circuits (`circuits/lib/src/domain.nr`), the Rust crate (`src/poseidon.rs`), and the on-chain registry (`contracts/src/PetitionRegistry.sol`) MUST agree byte-for-byte on every constant. ### FSRT Chain @@ -288,24 +302,24 @@ UltraHonk; zero-knowledge. **Public inputs (ordered):** `R`, `petition_id`, `predicate_hash`, `class_index`, `class_tag`, `slot`, `nullifier`, `identity_tag`. -**Private inputs:** `identity_secret`, `attr_vector`, `attr_version`, `chain_root`, RI Merkle path to the `attr_hash` leaf in `R`, `s_slot`, Merkle path from `v_slot` to `chain_root`, predicate-evaluation stack trace, `canonical_predicate_def`, `salt`. +**Private inputs:** `identity_secret`, `attr_vector`, `attr_version`, `chain_root`, RI Merkle path to the `attr_hash` leaf in `R`, `s_slot`, Merkle path from `v_slot` to `chain_root`, predicate-evaluation stack trace (`op_codes`, `op_operands`, `op_count`, `tuple_*`, `tuple_count`), `salt`. The signer SNARK reconstructs `canonical_predicate_def` from the witnessed predicate program and hashes it, so it is not supplied as a separate input. **Circuit constraints:** -1. `attr_hash = Poseidon1(DOMAIN_ATTR, attr_0, ..., attr_{n-1}, chain_root, attr_version)` opens the leaf at the provided RI Merkle path to `R`. +1. `attr_hash = Poseidon1(DOMAIN_ATTR, attr_0, ..., attr_{n-1}, chain_root, attr_version, identity_secret)` opens the leaf at the provided RI Merkle path to `R`. `identity_secret` is bound into the RI leaf so an attacker who learns `s_0` alone cannot enroll under the same RI leaf as the victim. 2. `predicate_hash = Poseidon1(DOMAIN_PRED, canonical_predicate_def, petition_id, salt)`. 3. `attr_vector` satisfies `canonical_predicate_def` under postfix evaluation. -4. `attr[class_index] == class_tag` holds at the top level outside any OR sub-expression. +4. `attrs[class_index] == class_tag` holds directly, and at least one tuple in `predicate_def` matches `(claim_index = class_index, comparator = EQ, operand = class_tag)`. Combined with the Registry's structural class-binding check at registration (every minimal satisfying assignment of the predicate forces `attr[class_index]` into `class_set`), this guarantees `class_tag in class_set` for every accepted signature. 5. `(v_slot, _) = Poseidon1Sponge.absorb(DOMAIN_FSRT_PRG, s_slot).squeeze(2)`. 6. `v_slot` opens at index `slot` in `chain_root` under the provided Merkle path; `chain_root` equals the value bound through `attr_hash`. -7. `nullifier = Poseidon1(DOMAIN_NULLIFIER, v_slot, petition_id, class_index, class_tag)`. +7. `nullifier = Poseidon1(DOMAIN_NULLIFIER, v_slot, petition_id, class_index, class_tag, identity_secret)`. Combined with `identity_secret`'s presence in `attr_hash`, this enforces "one signature per RI leaf per petition" even when `s_0` is compromised in isolation. 8. `identity_tag = Poseidon1(DOMAIN_IDTAG, v_slot, petition_id)`. ### Batch SNARK UltraHonk; recursive. `BATCH_SIZE_MAX = 100`. -**Public inputs (ordered):** `petition_id`, `R`, `predicate_hash`, `class_index`, `slot`, `batch_size`, `prior_running_root`, `new_running_root`, `prior_identity_tag_set_root`, `new_identity_tag_set_root`, `prior_leaf_count`, `new_leaf_count`, `batch_versioned_hash`. +**Public inputs (ordered):** `petition_id`, `R`, `predicate_hash`, `class_index`, `slot`, `batch_size`, `prior_running_root`, `new_running_root`, `prior_identity_tag_set_root`, `new_identity_tag_set_root`, `prior_leaf_count`, `new_leaf_count`, `batch_versioned_hash`, `bls_fields[BATCH_SIZE_MAX * 4]`, `signer_vk_hash`. The `bls_fields` array carries the per-position BLS12-381 field-element decompositions used by constraint 8; positions in `[batch_size, BATCH_SIZE_MAX)` decode to `(0, 0, 0)`. `signer_vk_hash` binds the recursion to a deploy-pinned signer verification key: the Registry MUST assert equality against a constructor-supplied `pinned_signer_vk_hash` immutable. **Private inputs:** per position `i in [0, batch_size)`, the signer SNARK proof and tuple `(nullifier_i, identity_tag_i, class_tag_i)`; IMT insertion proofs against `prior_running_root` and `prior_identity_tag_set_root`; cross-field decomposition witnesses per [Blob Payload](#blob-payload). @@ -324,7 +338,7 @@ UltraHonk; recursive. `BATCH_SIZE_MAX = 100`. UltraHonk; zero-knowledge. -**Public inputs (ordered):** `predicate_hash`, `R`, `running_root`, `leaf_count`, `class_set`, `class_thresholds`, `b`, `b_per_class`. +**Public inputs (ordered):** `predicate_hash`, `R`, `running_root`, `leaf_count`, `class_set[CLASS_MAX]`, `class_set_len`, `class_thresholds[CLASS_MAX]`, `b`, `b_per_class[CLASS_MAX]`, `class_index`. `class_set` and `class_thresholds` are zero-padded to a fixed-size `CLASS_MAX` array; `class_set_len <= CLASS_MAX` selects the active prefix. `class_index` is bound to the petition's stored value at registration via the Registry's resolution-prior-state check; positions in `[class_set_len, CLASS_MAX)` of `b_per_class` MUST decode to `0`. **Private inputs:** leaf set `L = {leaf_1, ..., leaf_{leaf_count}}` underlying `running_root`; IMT membership proof per leaf; the witness pair `(nullifier_j, class_tag_j)` with `leaf_j = Poseidon1(DOMAIN_LEAF, nullifier_j, class_tag_j)`. @@ -334,9 +348,10 @@ UltraHonk; zero-knowledge. 2. `leaf_j` are pairwise distinct. 3. Each `leaf_j` opens `running_root` under the IMT membership proof. 4. `leaf_j = Poseidon1(DOMAIN_LEAF, nullifier_j, class_tag_j)` with the witnessed pair. -5. `class_set` is strictly increasing. -6. For `i in [0, |class_set|)`: `count_i = card{ j in [1, leaf_count] : class_tag_j = class_set[i] }`; `b_per_class[i] = 1` iff `count_i >= class_thresholds[i]`. -7. `b = AND_i b_per_class[i]`. +5. `class_set[0 .. class_set_len]` is strictly increasing. +6. For every `j in [1, leaf_count]`, `class_tag_j` equals at least one `class_set[k]` for `k in [0, class_set_len)`. An off-class leaf admitted into `running_root` (e.g. via a predicate the Registry should have rejected) cannot be discarded by the Resolution SNARK; if such a leaf exists, the petition can only exit via `markUnresolved` after the 14-day timer. +7. For `i in [0, class_set_len)`: `count_i = card{ j in [1, leaf_count] : class_tag_j = class_set[i] }`; `b_per_class[i] = 1` iff `count_i >= class_thresholds[i]`. +8. `b = AND_{i in [0, class_set_len)} b_per_class[i]`. ## Security Model @@ -357,12 +372,12 @@ Out of scope: network transport anonymity beyond what Tor or an equivalent provi ### Guarantees -- **Per-petition forward secrecy.** An adversary holding audit-time runtime state, `identity_secret`, `attr_vector`, the RI Merkle path, and the full blob and L1 archives recovers `v_{k'}`, `s_{k'}`, or any value computationally non-trivial in `v_{k'}` (including the slot-`k'` nullifier and identity tag) for any slot `k' < t` with advantage at most `(t - k') * eps_sponge`, under the Poseidon1-sponge PRG assumption. +- **Per-petition forward secrecy.** An adversary holding post-signing runtime state, `identity_secret`, `attr_vector`, the RI Merkle path, and the full blob and L1 archives recovers `v_{k'}`, `s_{k'}`, or any value computationally non-trivial in `v_{k'}` (including the slot-`k'` nullifier and identity tag) for any slot `k' < t` with advantage at most `(t - k') * eps_sponge`, under the Poseidon1-sponge PRG assumption. - **Signer-level unlinkability.** For petitions `X1`, `X2` and records `r_1 in batch_{X1}`, `r_2 in batch_{X2}`, the adversary's advantage in deciding "same signer" exceeds `1/k - negl` only when it holds at least one of `v_{slot(X1)}` or `v_{slot(X2)}`, where `k` is the cardinality of Signers in `R` whose `attr_vector` satisfies both predicates and matches each petition's `class_tag`. - **One signature per RI leaf per petition.** For petition `X` and RI leaf `L_RI`, at most one record in `running_root` derives from `L_RI` after batching and dispute resolution. - **Outcome verifiability.** A verifier holding L1 chain state, the SRS identified by `srs_hash`, and the Poseidon1 parameter set re-verifies the resolution SNARK, confirming `b` and `b_per_class`. - **In-window dispute soundness.** A batch's contribution to `running_root` is removed only when the disputant produces valid KZG openings against `batch_versioned_hash` and evidence satisfying one of the enumerated violation predicates. -- **Domain separation.** Reuse across petitions or deployments is rejected: `petition_id` derivation binds `chain_id` and `registry_address`, and each signer SNARK exposes `(petition_id, slot, class_tag, nullifier, identity_tag)` as public inputs. +- **Domain separation.** Reuse across petitions is rejected because `petition_id` derivation binds `chain_id` and `registry_address` (so two deployments produce distinct `petition_id`s for the same organizer inputs), and each signer SNARK exposes `(petition_id, slot, class_tag, nullifier, identity_tag)` as public inputs. The Poseidon1 domain constants ([Domain Separators](#domain-separators)) are small integers chosen to be pairwise distinct within this protocol; they do not provide cross-protocol separation on their own, and applications combining RCP with other Poseidon1-based protocols on the same field MUST rely on the `petition_id` binding for separation. ### Observability @@ -376,17 +391,6 @@ Out of scope: network transport anonymity beyond what Tor or an equivalent provi | Disputant, Resolver | Public blob contents (within retention window or from voluntary archive) | | Ethereum observer | Batch SNARK public inputs; resolution SNARK public inputs; petition record fields | -### Limitations and Shortcuts (PoC Scope) - -| Concern | Mitigation / Production Path | -|---------|------------------------------| -| Forward-secrecy bound assumes ideal storage erasure | Hardware-backed secure storage (Secure Enclave, TPM, HSM) for `s_curr`, `t`, `attr_version`, and the caterpillar frontier. | -| Network-layer correlation at the relayer | Signer transport over Tor; submission paths SHOULD NOT use a transport-layer identifier (Tor circuit, VPN session) stable across petitions. | -| Multi-device signing under one RI identity | Each device runs a fresh enrollment with a distinct `chain_root` and a new `attr_hash` leaf under an incremented `attr_version`. Signer wallets MUST refuse to export `s_curr`, `caterpillar`, or `t`. | -| State-level builder censorship | FOCIL (EIP-7805); builder-pool diversity and direct-L1 submission. | -| Predicate breadth and anonymity-set sizing | Deployment policy SHOULD enforce a minimum match count (e.g., `k >= 100`). | -| Blob retention beyond 4096 epochs | Resolvers SHOULD archive blob payloads for petitions they intend to resolve. | - ## Terminology | Term | Definition | diff --git a/pocs/civic-participation/resilient-civic-participation/circuits/Nargo.toml b/pocs/civic-participation/resilient-civic-participation/circuits/Nargo.toml new file mode 100644 index 0000000..2b25d91 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/circuits/Nargo.toml @@ -0,0 +1,8 @@ +[workspace] +members = [ + "lib", + "signer", + "batch", + "resolution", +] +default-member = "signer" diff --git a/pocs/civic-participation/resilient-civic-participation/circuits/batch/Nargo.toml b/pocs/civic-participation/resilient-civic-participation/circuits/batch/Nargo.toml new file mode 100644 index 0000000..6649ff9 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/circuits/batch/Nargo.toml @@ -0,0 +1,9 @@ +[package] +name = "rcp_batch" +type = "bin" +authors = ["rymnc <43716372+rymnc@users.noreply.github.com>"] +compiler_version = ">=1.0.0" + +[dependencies] +rcp_lib = { path = "../lib" } +bb_proof_verification = { git = "https://github.com/AztecProtocol/aztec-packages/", tag = "v5.0.0-nightly.20260324", directory = "barretenberg/noir/bb_proof_verification" } diff --git a/pocs/civic-participation/resilient-civic-participation/circuits/batch/src/main.nr b/pocs/civic-participation/resilient-civic-participation/circuits/batch/src/main.nr new file mode 100644 index 0000000..c0c08bf --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/circuits/batch/src/main.nr @@ -0,0 +1,213 @@ +use bb_proof_verification::{UltraHonkVerificationKey, UltraHonkZKProof, verify_honk_proof}; +use rcp_lib::blob; +use rcp_lib::cmp; +use rcp_lib::hasher; +use rcp_lib::imt; + +// Batch SNARK (SPEC section Batch SNARK). +// +// BATCH_SIZE_MAX = 6 in this PoC (the SPEC permits up to 100). Each +// position recursively verifies one signer UltraHonk proof against a +// pinned signer verification key, then constrains the IMT advance and +// the blob digest binding. +// +// `signer_public_inputs[i]` is `[r_root, petition_id, predicate_hash, +// class_index, class_tag_i, slot, nullifier_i, identity_tag_i]` per +// SPEC section Signer SNARK. The batch circuit re-uses these public +// inputs as the per-position witness for IMT inserts and blob digest +// recomputation. + +global N_SIGNER_PUB: u32 = 8; +global BATCH_SIZE: u32 = 6; +global IMT_DEPTH: u32 = 24; +global FE_PER_RECORD: u32 = 4; +// Per-record BLS12-381 field-element decompositions exposed as +// public inputs: `BLS_FIELDS_LEN = BATCH_SIZE * FE_PER_RECORD`. +global BLS_FIELDS_LEN: u32 = BATCH_SIZE * FE_PER_RECORD; + +fn main( + petition_id: pub Field, + r_root: pub Field, + predicate_hash: pub Field, + class_index: pub Field, + slot: pub Field, + // SPEC sec Batch SNARK constraint 1: `1 <= batch_size <= BATCH_SIZE_MAX`. + // The PoC circuit instantiates BATCH_SIZE = BATCH_SIZE_MAX = 6 and + // enforces equality (per-position constraints assume all positions + // are filled with real signer proofs). Production removes the + // equality constraint and gates per-position constraints on + // `(i as u32) < batch_size`. + batch_size: pub u32, + prior_running_root: pub Field, + new_running_root: pub Field, + prior_identity_tag_set_root: pub Field, + new_identity_tag_set_root: pub Field, + prior_leaf_count: pub u32, + new_leaf_count: pub u32, + batch_versioned_hash: pub Field, + // Constraint 8: per-position BLS12-381 field-element decomposition + // of `(nullifier_i, identity_tag_i, class_tag_i)` per SPEC sectionBlob + // Payload. The contract verifies each `bls_fields[k]` opens at + // canonical eval point `omega^k` against `batch_versioned_hash` + // via the `0x0a` KZG point-evaluation precompile. + bls_fields: pub [Field; BLS_FIELDS_LEN], + // Pin signer VK by exposing its hash as a public input. The + // contract asserts equality with the deploy-pinned + // `pinnedSignerVkHash` immutable. + signer_vk_hash: pub Field, + // Recursive verification of N=BATCH_SIZE signer proofs. + signer_vk: UltraHonkVerificationKey, + signer_proofs: [UltraHonkZKProof; BATCH_SIZE], + signer_public_inputs: [[Field; N_SIGNER_PUB]; BATCH_SIZE], + // Per-position IMT insert witnesses for the running tree. + running_imt_low_leaf_indices: [u32; BATCH_SIZE], + running_imt_low_indices: [[bool; IMT_DEPTH]; BATCH_SIZE], + running_imt_low_elements: [[Field; IMT_DEPTH]; BATCH_SIZE], + running_imt_low_values: [Field; BATCH_SIZE], + running_imt_low_next_indices: [Field; BATCH_SIZE], + running_imt_low_next_values: [Field; BATCH_SIZE], + running_imt_new_indices: [[bool; IMT_DEPTH]; BATCH_SIZE], + running_imt_new_elements: [[Field; IMT_DEPTH]; BATCH_SIZE], + running_imt_new_low_next_indices: [Field; BATCH_SIZE], + running_imt_intermediate_roots: [Field; BATCH_SIZE], + // Per-position IMT insert witnesses for the identity-tag tree. + idtag_imt_low_leaf_indices: [u32; BATCH_SIZE], + idtag_imt_low_indices: [[bool; IMT_DEPTH]; BATCH_SIZE], + idtag_imt_low_elements: [[Field; IMT_DEPTH]; BATCH_SIZE], + idtag_imt_low_values: [Field; BATCH_SIZE], + idtag_imt_low_next_indices: [Field; BATCH_SIZE], + idtag_imt_low_next_values: [Field; BATCH_SIZE], + idtag_imt_new_indices: [[bool; IMT_DEPTH]; BATCH_SIZE], + idtag_imt_new_elements: [[Field; IMT_DEPTH]; BATCH_SIZE], + idtag_imt_new_low_next_indices: [Field; BATCH_SIZE], + idtag_imt_intermediate_roots: [Field; BATCH_SIZE], +) { + // Constraint 1 (PoC variant): the circuit only accepts full batches. + assert(batch_size == BATCH_SIZE, "batch: batch_size != BATCH_SIZE (PoC enforces equality)"); + + // Constraint 7: leaf-count advance, expressed in terms of batch_size. + assert(new_leaf_count == prior_leaf_count + batch_size, "batch: leaf_count mismatch"); + + let mut prev_running = prior_running_root; + let mut prev_idtag = prior_identity_tag_set_root; + let mut prev_leaf: Field = 0; + + for i in 0..BATCH_SIZE { + // Constraint 2: recursively verify signer SNARK for position i. + // The public inputs must equal the pinned tuple + // (r_root, petition_id, predicate_hash, class_index, class_tag_i, + // slot, nullifier_i, identity_tag_i). + assert(signer_public_inputs[i][0] == r_root, "batch: signer pi[0] != r_root"); + assert(signer_public_inputs[i][1] == petition_id, "batch: signer pi[1] != petition_id"); + assert( + signer_public_inputs[i][2] == predicate_hash, + "batch: signer pi[2] != predicate_hash", + ); + assert(signer_public_inputs[i][3] == class_index, "batch: signer pi[3] != class_index"); + assert(signer_public_inputs[i][5] == slot, "batch: signer pi[5] != slot"); + verify_honk_proof( + signer_vk, + signer_proofs[i], + signer_public_inputs[i], + signer_vk_hash, + ); + + let class_tag_i: Field = signer_public_inputs[i][4]; + let nullifier_i: Field = signer_public_inputs[i][6]; + let identity_tag_i: Field = signer_public_inputs[i][7]; + + // class_tag is encoded as u16 BE in the blob (SPEC). Range-check + // in-circuit so blob decode cannot disambiguate duplicate + // class_tags via hidden high bits. + let class_tag_u: u32 = class_tag_i as u32; + assert(class_tag_u < 65536, "batch: class_tag exceeds u16"); + + // Constraint 3 (pairwise nullifier distinctness) is enforced + // by constraint 4's non-membership against the running IMT; + // identity-tag pairwise distinctness comes from the idtag IMT + // non-membership. We additionally enforce intra-batch + // strictly-increasing leaf ordering below (constraint 5), + // which subsumes pairwise nullifier distinctness within the + // batch. + + // Constraint 5: leaf_i = Poseidon1(DOMAIN_LEAF, nullifier_i, class_tag_i) + // strictly increasing in i. + let leaf_i = hasher::hash_leaf(nullifier_i, class_tag_i); + // Reject leaf_0 == 0 (Poseidon-improbable; defensive). + if i == 0 { + assert(leaf_i != 0, "batch: leaf_0 == 0"); + } + if i > 0 { + let lt = cmp::lt_field(prev_leaf, leaf_i); + assert(lt, "batch: leaves not strictly increasing"); + } + prev_leaf = leaf_i; + + // Constraint 4 + 6: running IMT non-membership of nullifier_i, + // and insertion to advance the root. + let running_witness = imt::ImtInsertWitness { + low: imt::ImtLowLeafWitness { + leaf_index: running_imt_low_leaf_indices[i], + value: running_imt_low_values[i], + next_index: running_imt_low_next_indices[i], + next_value: running_imt_low_next_values[i], + path_indices: running_imt_low_indices[i], + path_elements: running_imt_low_elements[i], + }, + new_low_next_index: running_imt_new_low_next_indices[i], + new_leaf_path_indices: running_imt_new_indices[i], + new_leaf_path_elements: running_imt_new_elements[i], + }; + imt::verify_insert( + leaf_i, + prev_running, + running_imt_intermediate_roots[i], + prior_leaf_count + (i as u32), + running_witness, + ); + prev_running = running_imt_intermediate_roots[i]; + + // Constraint 4 + 6: identity-tag IMT non-membership and insertion. + let idtag_witness = imt::ImtInsertWitness { + low: imt::ImtLowLeafWitness { + leaf_index: idtag_imt_low_leaf_indices[i], + value: idtag_imt_low_values[i], + next_index: idtag_imt_low_next_indices[i], + next_value: idtag_imt_low_next_values[i], + path_indices: idtag_imt_low_indices[i], + path_elements: idtag_imt_low_elements[i], + }, + new_low_next_index: idtag_imt_new_low_next_indices[i], + new_leaf_path_indices: idtag_imt_new_indices[i], + new_leaf_path_elements: idtag_imt_new_elements[i], + }; + imt::verify_insert( + identity_tag_i, + prev_idtag, + idtag_imt_intermediate_roots[i], + prior_leaf_count + (i as u32), + idtag_witness, + ); + prev_idtag = idtag_imt_intermediate_roots[i]; + + // Constraint 8 (cross-field binding): re-derive the 4 BLS + // field elements from `(nullifier_i, identity_tag_i, class_tag_i)` + // per SPEC sectionBlob Payload and constrain equality with the public + // input. The contract closes the loop with a KZG point + // evaluation against `batch_versioned_hash`. + let fields = blob::record_to_fields(nullifier_i, identity_tag_i, class_tag_i); + assert(bls_fields[i * FE_PER_RECORD + 0] == fields.fe0, "batch: fe0 mismatch"); + assert(bls_fields[i * FE_PER_RECORD + 1] == fields.fe1, "batch: fe1 mismatch"); + assert(bls_fields[i * FE_PER_RECORD + 2] == fields.fe2, "batch: fe2 mismatch"); + assert(bls_fields[i * FE_PER_RECORD + 3] == fields.fe3, "batch: fe3 mismatch"); + } + + assert(prev_running == new_running_root, "batch: new_running_root mismatch"); + assert(prev_idtag == new_identity_tag_set_root, "batch: new_idtag_root mismatch"); + // `batch_versioned_hash` is the EIP-4844 versioned hash. The + // contract enforces `blobhash(0) == batch_versioned_hash` and, per + // constraint 8, verifies KZG openings against it at canonical + // evaluation points `omega^k` whose evaluations equal + // `bls_fields[k]`. + let _ = batch_versioned_hash; +} diff --git a/pocs/civic-participation/resilient-civic-participation/circuits/lib/Nargo.toml b/pocs/civic-participation/resilient-civic-participation/circuits/lib/Nargo.toml new file mode 100644 index 0000000..29f3aae --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/circuits/lib/Nargo.toml @@ -0,0 +1,8 @@ +[package] +name = "rcp_lib" +type = "lib" +authors = ["rymnc <43716372+rymnc@users.noreply.github.com>"] +compiler_version = ">=1.0.0" + +[dependencies] +poseidon = { tag = "v0.3.0", git = "https://github.com/noir-lang/poseidon" } diff --git a/pocs/civic-participation/resilient-civic-participation/circuits/lib/src/blob.nr b/pocs/civic-participation/resilient-civic-participation/circuits/lib/src/blob.nr new file mode 100644 index 0000000..f460990 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/circuits/lib/src/blob.nr @@ -0,0 +1,77 @@ +// Cross-field decomposition between the BN254 in-circuit scalars and +// the BLS12-381 field elements that make up an EIP-4844 blob (SPEC +// section Blob Payload). +// +// Each `RecordEntry` occupies 4 BLS12-381 field elements with layout: +// fe(4i + 0) = 0x00 || nullifier_bytes[0..31] +// fe(4i + 1) = 0x00 || nullifier_bytes[31..32] || identity_tag_bytes[0..30] +// fe(4i + 2) = 0x00 || identity_tag_bytes[30..32] || class_tag_bytes[0..2] || padding[0..27] +// fe(4i + 3) = 0x00 || padding[27..30] || zeros[3..31] +// +// The on-chain registry derives the four-element opening of each record +// from the blob KZG commitments and binds them into `batch_versioned_hash` +// via constraint 8. Inside the batch circuit we recompute the four +// field-element values from the in-circuit `(nullifier_i, identity_tag_i, +// class_tag_i)` and constrain equality. + +pub struct RecordFields { + pub fe0: Field, + pub fe1: Field, + pub fe2: Field, + pub fe3: Field, +} + +// Decompose a 32-byte big-endian Field into 32 bytes (range-checked). +pub fn field_to_be_bytes_32(f: Field) -> [u8; 32] { + let bytes: [u8; 32] = f.to_be_bytes(); + bytes +} + +// Assemble a BLS12-381 field element from at most 31 content bytes +// prefixed with a leading 0x00 byte. The high byte clearing keeps the +// value within the BLS scalar field for any 31-byte payload. +pub fn assemble_bls_fe(bytes: [u8; 31]) -> Field { + let mut acc: Field = 0; + for i in 0..31 { + acc = acc * 256 + bytes[i] as Field; + } + acc +} + +// Recompute the four BLS12-381 field elements for the i-th record. +pub fn record_to_fields(nullifier: Field, identity_tag: Field, class_tag: Field) -> RecordFields { + let null_bytes = field_to_be_bytes_32(nullifier); + let idtag_bytes = field_to_be_bytes_32(identity_tag); + let class_bytes: [u8; 32] = class_tag.to_be_bytes(); + + // fe0: 31 content bytes = nullifier_bytes[0..31]. + let mut fe0_bytes: [u8; 31] = [0; 31]; + for i in 0..31 { + fe0_bytes[i] = null_bytes[i]; + } + + // fe1: 1 byte of nullifier + 30 bytes of identity_tag. + let mut fe1_bytes: [u8; 31] = [0; 31]; + fe1_bytes[0] = null_bytes[31]; + for i in 0..30 { + fe1_bytes[1 + i] = idtag_bytes[i]; + } + + // fe2: 2 bytes of identity_tag + 2 bytes of class_tag + 27 bytes + // of zero padding. + let mut fe2_bytes: [u8; 31] = [0; 31]; + fe2_bytes[0] = idtag_bytes[30]; + fe2_bytes[1] = idtag_bytes[31]; + fe2_bytes[2] = class_bytes[30]; + fe2_bytes[3] = class_bytes[31]; + + // fe3: 31 zero bytes (full record padding). + let fe3_bytes: [u8; 31] = [0; 31]; + + RecordFields { + fe0: assemble_bls_fe(fe0_bytes), + fe1: assemble_bls_fe(fe1_bytes), + fe2: assemble_bls_fe(fe2_bytes), + fe3: assemble_bls_fe(fe3_bytes), + } +} diff --git a/pocs/civic-participation/resilient-civic-participation/circuits/lib/src/cmp.nr b/pocs/civic-participation/resilient-civic-participation/circuits/lib/src/cmp.nr new file mode 100644 index 0000000..e3da621 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/circuits/lib/src/cmp.nr @@ -0,0 +1,67 @@ +// Field-level less-than under canonical BN254 ordering. +// +// Noir's `Ord for Field` ships in newer compilers, but pinning the +// helper here gives a stable signature across compiler versions and +// keeps the call sites readable. +// +// Both inputs are reduced canonical BN254 representatives (`< p`); the +// caller is responsible for that (Poseidon outputs satisfy this by +// construction, and witnessed Field inputs are range-checked by the +// witness contract). We compare lexicographically over the 32-byte +// little-endian representation starting from the most-significant byte. + +pub fn lt_field(a: Field, b: Field) -> bool { + let a_le: [u8; 32] = a.to_le_bytes(); + let b_le: [u8; 32] = b.to_le_bytes(); + let mut decided: bool = false; + let mut result: bool = false; + for i in 0..32 { + let ai = a_le[31 - i]; + let bi = b_le[31 - i]; + if !decided { + if ai < bi { + result = true; + decided = true; + } else if ai > bi { + result = false; + decided = true; + } + } + } + result +} + +// Assert `f` is a canonical BN254 representative. In Noir, `to_le_bytes(32)` +// is constrained to produce the unique canonical 32-byte decomposition of +// the field element, so calling it forces the witness to round-trip via +// the canonical form. The high-byte check is conservative (BN254 modulus +// high byte = 0x30; any canonical scalar's high byte is <= 0x30). +pub fn assert_canonical(f: Field) { + let bytes: [u8; 32] = f.to_le_bytes(); + assert(bytes[31] <= 0x30, "cmp: scalar exceeds BN254 modulus high byte"); +} + +pub fn le_field(a: Field, b: Field) -> bool { + !lt_field(b, a) +} + +pub fn ge_field(a: Field, b: Field) -> bool { + !lt_field(a, b) +} + +#[test] +fn test_lt_field_small() { + assert(lt_field(0, 1)); + assert(!lt_field(1, 0)); + assert(!lt_field(5, 5)); + assert(lt_field(100, 200)); +} + +#[test] +fn test_le_ge_edges() { + assert(le_field(5, 5)); + assert(ge_field(5, 5)); + assert(le_field(5, 6)); + assert(!le_field(6, 5)); + assert(ge_field(7, 6)); +} diff --git a/pocs/civic-participation/resilient-civic-participation/circuits/lib/src/domain.nr b/pocs/civic-participation/resilient-civic-participation/circuits/lib/src/domain.nr new file mode 100644 index 0000000..34e0366 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/circuits/lib/src/domain.nr @@ -0,0 +1,11 @@ +// Domain separation tags for the protocol. + +pub global DOMAIN_NULLIFIER: Field = 1; +pub global DOMAIN_IDTAG: Field = 2; +pub global DOMAIN_LEAF: Field = 3; +pub global DOMAIN_FSRT_PRG: Field = 4; +pub global DOMAIN_PRED: Field = 5; +pub global DOMAIN_ATTR: Field = 6; +pub global DOMAIN_BATCH_SNARK: Field = 7; +pub global DOMAIN_PETITION: Field = 8; +pub global DOMAIN_RESOLUTION_SNARK: Field = 9; diff --git a/pocs/civic-participation/resilient-civic-participation/circuits/lib/src/fsrt.nr b/pocs/civic-participation/resilient-civic-participation/circuits/lib/src/fsrt.nr new file mode 100644 index 0000000..a4e4775 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/circuits/lib/src/fsrt.nr @@ -0,0 +1,51 @@ +// Forward-Secure Ratchet Tree (FSRT) PRG step (SPEC FSRT Chain). +// +// `(v_i, s_{i+1}) = Poseidon1Sponge.absorb(DOMAIN_FSRT_PRG, s_i).squeeze(2)`. + +use crate::domain; +use crate::sponge::Poseidon1Sponge; + +pub fn fsrt_prg_step(s_i: Field) -> (Field, Field) { + let mut sponge = Poseidon1Sponge::with_domain(domain::DOMAIN_FSRT_PRG); + sponge.absorb_one(s_i); + let out = sponge.squeeze_two(); + (out[0], out[1]) +} + +// Derive v_slot given s_slot. The signer's witness threads s_slot in +// directly; this is the absorb-squeeze that the SPEC mandates. +pub fn derive_v_slot(s_slot: Field) -> Field { + let (v, _) = fsrt_prg_step(s_slot); + v +} + +// Cross-impl pinning. Vectors generated by `cargo test print_fsrt_test_vectors +// -- --ignored --nocapture` in the Rust crate; any drift between Rust's +// `Poseidon1Sponge` and Noir's `sponge::Poseidon1Sponge` trips one of these. +#[test] +fn fsrt_matches_rust_vector_seed_1() { + let (v, s) = fsrt_prg_step(0x01); + assert(v == 0x009bc50a6a06d81e6cc6536ce447821de52e271d42b023d07367e61b24cee0b0); + assert(s == 0x06bd9c4ae889f0fe6b132c7b054745e28c34b9932a6a3ca5e0ed8dcbf1fa1774); +} + +#[test] +fn fsrt_matches_rust_vector_seed_2() { + let (v, s) = fsrt_prg_step(0x02); + assert(v == 0x0afcc800986d0e08e5216f5b2828f97318d2383dd7aaa76ed6213abf296bdc53); + assert(s == 0x20d195c4ab1f4984f5bdc3cd97efdb0c3488251fabf1f519c6096f86f1a5a9fb); +} + +#[test] +fn fsrt_matches_rust_vector_seed_7() { + let (v, s) = fsrt_prg_step(0x07); + assert(v == 0x2c974b3d4d07c1274750242881243414e76bf98a6ceb3d88e827720bf6824cd9); + assert(s == 0x2e05f8b22dd04dbeff6a7cb7faf129ba05fe259a5f2838851f7413adaea202bc); +} + +#[test] +fn fsrt_matches_rust_vector_seed_large() { + let (v, s) = fsrt_prg_step(0x12345678); + assert(v == 0x2526048d8f53cd818e963eac54c9e209f3831804a6f1ec3a318cf6c8608f08f2); + assert(s == 0x2727e1f093d5d7a01d145a43cf2dedc8521afe3db52c7a6d7d2c9cfd4c405f33); +} diff --git a/pocs/civic-participation/resilient-civic-participation/circuits/lib/src/hasher.nr b/pocs/civic-participation/resilient-civic-participation/circuits/lib/src/hasher.nr new file mode 100644 index 0000000..f9b38f4 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/circuits/lib/src/hasher.nr @@ -0,0 +1,82 @@ +use crate::domain; +use poseidon::poseidon::bn254::{hash_2, hash_3, hash_4, hash_5, hash_6}; + +pub fn hash_merkle_node(left: Field, right: Field) -> Field { + hash_2([left, right]) +} + +// `attr_hash = Poseidon1(DOMAIN_ATTR, attr_0..attr_{n-1}, chain_root, +// attr_version, identity_secret)`. For `ATTR_COUNT = 4` the arity is 8. +// Implemented as a nested chain of width-5 hashes that produces a +// single field output. identity_secret pins the RI leaf to a specific +// signer-held secret, defending against s_0 compromise alone. +pub fn hash_attr( + attrs: [Field; 4], + chain_root: Field, + attr_version: Field, + identity_secret: Field, +) -> Field { + let h1 = hash_5( + [domain::DOMAIN_ATTR, attrs[0], attrs[1], attrs[2], attrs[3]], + ); + hash_4([h1, chain_root, attr_version, identity_secret]) +} + +// `nullifier = Poseidon1(DOMAIN_NULLIFIER, v_slot, petition_id, +// class_index, class_tag, identity_secret)`. identity_secret binds the +// nullifier to a specific signer secret; combined with hash_attr's +// identity_secret binding (which forces a unique RI leaf per secret), +// this enforces "one signature per RI leaf per petition" even under +// partial key compromise. +pub fn hash_nullifier( + v_slot: Field, + petition_id: Field, + class_index: Field, + class_tag: Field, + identity_secret: Field, +) -> Field { + hash_6( + [domain::DOMAIN_NULLIFIER, v_slot, petition_id, class_index, class_tag, identity_secret], + ) +} + +// `identity_tag = Poseidon1(DOMAIN_IDTAG, v_slot, petition_id)`. +pub fn hash_identity_tag(v_slot: Field, petition_id: Field) -> Field { + hash_3([domain::DOMAIN_IDTAG, v_slot, petition_id]) +} + +// `leaf = Poseidon1(DOMAIN_LEAF, nullifier, class_tag)`. +pub fn hash_leaf(nullifier: Field, class_tag: Field) -> Field { + hash_3([domain::DOMAIN_LEAF, nullifier, class_tag]) +} + +// `predicate_hash = Poseidon1(DOMAIN_PRED, canonical_predicate_def[..], +// petition_id, salt)`. SPEC section Predicate fixes the canonical +// decomposition at 34 scalars. We absorb in a sponge-style chain: +// start from the domain tag, fold each canonical scalar in, then fold +// petition_id and salt. +pub fn hash_predicate(canonical: [Field; 34], petition_id: Field, salt: Field) -> Field { + let mut acc = domain::DOMAIN_PRED; + for i in 0..34 { + acc = hash_2([acc, canonical[i]]); + } + acc = hash_2([acc, petition_id]); + hash_2([acc, salt]) +} + +// IMT leaf hash: `H(value || next_index || next_value)` with a trailing +// zero to fix the arity at 4. Domain separation against external 4-input +// Poseidon calls relies on input-space disjointness: `value` is itself a +// Poseidon output (`Poseidon1(DOMAIN_LEAF, nullifier, class_tag)`) for +// running-tree leaves or `identity_tag` (a Poseidon output) for the +// idtag tree, so the input tuple cannot collide with an external call. +pub fn hash_imt_leaf(value: Field, next_index: Field, next_value: Field) -> Field { + hash_4([value, next_index, next_value, 0]) +} + +// The hash of the empty IMT leaf `(0, 0, 0)`. Used by `imt::verify_insert` +// to constrain that the new-leaf position was empty in the intermediate +// (post-low-update) root before the new leaf was placed. +pub fn imt_empty_leaf_hash() -> Field { + hash_imt_leaf(0, 0, 0) +} diff --git a/pocs/civic-participation/resilient-civic-participation/circuits/lib/src/imt.nr b/pocs/civic-participation/resilient-civic-participation/circuits/lib/src/imt.nr new file mode 100644 index 0000000..c64e9b9 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/circuits/lib/src/imt.nr @@ -0,0 +1,167 @@ +// Sorted-linked-list Indexed Merkle Tree (Aztec-style). +// +// Each leaf is `H(value, next_index, next_value)`. Membership proves a +// value is in the tree; non-membership proves a value lies strictly +// between `low_leaf.value` and `low_leaf.next_value`. Insertion replaces +// the low leaf and adds a new leaf at the next free position (absolute +// index `prior_leaf_count + 1`, since index 0 holds the sentinel empty +// leaf), mutating two Merkle paths. + +use crate::cmp; +use crate::hasher; +use crate::hasher::hash_imt_leaf; +use crate::merkle; + +pub struct ImtLowLeafWitness { + pub leaf_index: u32, + pub value: Field, + pub next_index: Field, + pub next_value: Field, + pub path_indices: [bool; 24], + pub path_elements: [Field; 24], +} + +pub struct ImtInsertWitness { + pub low: ImtLowLeafWitness, + // Post-update low leaf points at the newly inserted leaf and at the + // new value. The Aztec-style scheme places the new leaf at the next + // free absolute index = `prior_leaf_count + 1`. + pub new_low_next_index: Field, + // Merkle siblings of the new-leaf position in the post-low-update + // tree. Bound by `verify_insert` step 3 against the intermediate + // root. + pub new_leaf_path_indices: [bool; 24], + pub new_leaf_path_elements: [Field; 24], +} + +// Non-membership of `value` against root `r`: prove `low.value < value` +// AND `(low.next_value > value || low.next_index == 0)`, and that `low` +// hashes to a leaf opening to `r` under its Merkle path. +pub fn verify_non_membership(value: Field, root: Field, low: ImtLowLeafWitness) { + // Canonicity invariants: lt_field's byte comparison is sound only on + // canonical BN254 representatives. Poseidon outputs are canonical by + // construction; we defensively enforce the same for the witnessed + // low.value / low.next_value (which the prover supplies). + cmp::assert_canonical(low.value); + cmp::assert_canonical(low.next_value); + cmp::assert_canonical(value); + + // 1. low.value < value (strict, integer ordering). + let lt_low = cmp::lt_field(low.value, value); + assert(lt_low, "imt: low.value >= value (would be membership or out of order)"); + + // 2. Either low is the tail (next_index == 0) or value < low.next_value. + if low.next_index != 0 { + let lt_next = cmp::lt_field(value, low.next_value); + assert(lt_next, "imt: value >= low.next_value (would be membership or out of order)"); + } + + // 3. low's hash opens at low.leaf_index to root under path_elements. + // Binding leaf_index to path_indices via 24-bit decomposition is + // deferred to verify_insert (where it matters for the linked-list + // invariant); membership-only callers don't need it. + let low_hash = hash_imt_leaf(low.value, low.next_index, low.next_value); + merkle::verify_membership_depth_24(low_hash, root, 24, low.path_indices, low.path_elements); +} + +// Insertion: prove that inserting `value` into `prior_root` yields +// `new_root`. The new low leaf points at the new leaf (`next_index = +// prior_leaf_count + 1`, since index 0 is the sentinel), and the new +// leaf inherits low's prior `next_index` / `next_value`. +// +// `prior_leaf_count` is the count of REAL leaves (excluding sentinel) +// before this insertion; the new leaf goes to absolute index +// `prior_leaf_count + 1`. +pub fn verify_insert( + value: Field, + prior_root: Field, + new_root: Field, + prior_leaf_count: u32, + insert: ImtInsertWitness, +) { + // Bound the absolute new-leaf index: must be < 2^24 (tree depth). + assert(prior_leaf_count < 16777215, "imt: tree full"); + + // 1. Non-membership of `value` against `prior_root` via `low`. + verify_non_membership(value, prior_root, insert.low); + + // 1a. Bind low.leaf_index to low.path_indices via 24-bit + // decomposition. Without this the prover could supply + // path_indices for a different leaf position than low.leaf_index, + // breaking the linked-list invariant on later inserts. + let expected_low_bits = merkle::u32_to_bits_24(insert.low.leaf_index); + merkle::assert_bits_eq_24(insert.low.path_indices, expected_low_bits); + + // 1b. Reject the degenerate case where low and the new insertion + // collide at the same position; insertion requires the new slot + // to be previously empty AND distinct from low's slot. + let new_absolute: u32 = prior_leaf_count + 1; + assert( + insert.low.leaf_index != new_absolute, + "imt: low.leaf_index collides with new insertion position", + ); + + // 1c. Bind new_low_next_index to the new leaf's absolute index. + // SPEC's "Aztec-style 1-indexed scheme" maps to: new leaf at + // index `prior_leaf_count + 1` (since index 0 is the sentinel). + let expected_new_low_next: Field = new_absolute as Field; + assert( + insert.new_low_next_index == expected_new_low_next, + "imt: new_low_next_index != prior_leaf_count + 1", + ); + + // 2. Compute the post-update low-leaf hash and the intermediate + // root by re-running the low-leaf path with the updated leaf + // (siblings unchanged). + let updated_low_hash = hash_imt_leaf(insert.low.value, insert.new_low_next_index, value); + let intermediate_root = merkle::compute_root_depth_24( + updated_low_hash, + insert.low.path_indices, + insert.low.path_elements, + ); + + // 3. Bind new_leaf_path_indices to the bit decomposition of the new + // leaf's absolute index. Without this the prover picks the + // insertion position freely. + let expected_new_bits = merkle::u32_to_bits_24(new_absolute); + merkle::assert_bits_eq_24(insert.new_leaf_path_indices, expected_new_bits); + + // 4. Bind the new-leaf path to the intermediate tree: the new-leaf + // position must have been empty (hash = imt_empty_leaf_hash) in + // the post-low-update root. This closes the Aztec-style two-path + // insertion proof. + let empty_leaf = hasher::imt_empty_leaf_hash(); + let intermediate_via_new = merkle::compute_root_depth_24( + empty_leaf, + insert.new_leaf_path_indices, + insert.new_leaf_path_elements, + ); + assert( + intermediate_via_new == intermediate_root, + "imt: new-leaf position was not empty in intermediate root", + ); + + // 5. Compute new_root from the new leaf with the same siblings. + // The new leaf's `next_index` and `next_value` inherit from low. + let new_leaf_hash = hash_imt_leaf(value, insert.low.next_index, insert.low.next_value); + let recomputed_root = merkle::compute_root_depth_24( + new_leaf_hash, + insert.new_leaf_path_indices, + insert.new_leaf_path_elements, + ); + assert(recomputed_root == new_root, "imt: post-insert root mismatch"); +} + +// Membership check: `value` is in the tree at `leaf_index` (the leaf at +// `leaf_index` has `.value == value`). +pub fn verify_membership( + value: Field, + next_index: Field, + next_value: Field, + root: Field, + path_indices: [bool; 24], + path_elements: [Field; 24], +) { + let leaf_hash = hash_imt_leaf(value, next_index, next_value); + merkle::verify_membership_depth_24(leaf_hash, root, 24, path_indices, path_elements); +} diff --git a/pocs/civic-participation/resilient-civic-participation/circuits/lib/src/lib.nr b/pocs/civic-participation/resilient-civic-participation/circuits/lib/src/lib.nr new file mode 100644 index 0000000..19d5fc5 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/circuits/lib/src/lib.nr @@ -0,0 +1,24 @@ +pub mod domain; +pub mod hasher; +pub mod sponge; +pub mod merkle; +pub mod imt; +pub mod fsrt; +pub mod predicate; +pub mod blob; +pub mod cmp; + +// Capacity constants matching `src/lib.rs`. Single source of truth across +// Rust + Noir; if these change here, the Rust crate's analogues must too. +pub global FSRT_DEPTH: u32 = 24; +pub global IMT_DEPTH: u32 = 24; +pub global ATTR_COUNT: u32 = 4; +// SPEC value is 100 but the PoC's recursive-verification cap shrinks +// this to 6 to keep the batch circuit compile time tractable. Listed +// in SPEC.md section Limitations. Matches Rust `BATCH_SIZE_MAX`. +pub global BATCH_SIZE_MAX: u32 = 6; +pub global PREDICATE_TUPLE_MAX: u32 = 20; +pub global PREDICATE_OP_MAX: u32 = 20; +pub global PREDICATE_SCALAR_COUNT: u32 = 34; +pub global RECORDS_PER_BLOB: u32 = 1000; +pub global RI_DEPTH: u32 = 32; diff --git a/pocs/civic-participation/resilient-civic-participation/circuits/lib/src/merkle.nr b/pocs/civic-participation/resilient-civic-participation/circuits/lib/src/merkle.nr new file mode 100644 index 0000000..e823b91 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/circuits/lib/src/merkle.nr @@ -0,0 +1,81 @@ +use crate::hasher::hash_merkle_node; + +fn merkle_hash(pair: [Field; 2]) -> Field { + hash_merkle_node(pair[0], pair[1]) +} + +// Generic binary Merkle root with a runtime depth bounded by the +// compile-time MAX_DEPTH. Path bits are bool (false = left, true = +// right). +pub fn binary_merkle_root( + hasher: fn([Field; 2]) -> Field, + leaf: Field, + depth: u32, + indices: [bool; MAX_DEPTH], + siblings: [Field; MAX_DEPTH], +) -> Field { + let mut node = leaf; + for i in 0..MAX_DEPTH { + if i < depth { + let sibling = siblings[i]; + let (left, right) = if indices[i] { + (sibling, node) + } else { + (node, sibling) + }; + node = hasher([left, right]); + } + } + node +} + +pub fn verify_membership_depth_24( + leaf: Field, + root: Field, + proof_length: u32, + path_indices: [bool; 24], + path_elements: [Field; 24], +) { + let computed = binary_merkle_root(merkle_hash, leaf, proof_length, path_indices, path_elements); + assert(computed == root, "merkle proof failed (depth 24)"); +} + +pub fn verify_membership_depth_32( + leaf: Field, + root: Field, + proof_length: u32, + path_indices: [bool; 32], + path_elements: [Field; 32], +) { + let computed = binary_merkle_root(merkle_hash, leaf, proof_length, path_indices, path_elements); + assert(computed == root, "merkle proof failed (depth 32)"); +} + +pub fn compute_root_depth_24( + leaf: Field, + path_indices: [bool; 24], + path_elements: [Field; 24], +) -> Field { + binary_merkle_root(merkle_hash, leaf, 24, path_indices, path_elements) +} + +// Decompose `x: u32` into 24 LSB-first bool bits and assert `x < 2^24`. +// Used to bind a leaf_index witness to a `[bool; 24]` path_indices array. +pub fn u32_to_bits_24(x: u32) -> [bool; 24] { + let limit: u32 = 16777216; // 2^24 + assert(x < limit, "merkle: index >= 2^24"); + let mut bits: [bool; 24] = [false; 24]; + let mut acc: u32 = x; + for i in 0..24 { + bits[i] = (acc & 1) == 1; + acc = acc >> 1; + } + bits +} + +// Equality check between two [bool; 24] arrays. +pub fn assert_bits_eq_24(a: [bool; 24], b: [bool; 24]) { + for i in 0..24 { + assert(a[i] == b[i], "merkle: path-index bit mismatch"); + } +} diff --git a/pocs/civic-participation/resilient-civic-participation/circuits/lib/src/predicate.nr b/pocs/civic-participation/resilient-civic-participation/circuits/lib/src/predicate.nr new file mode 100644 index 0000000..d9c3ac6 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/circuits/lib/src/predicate.nr @@ -0,0 +1,221 @@ +// Postfix predicate evaluator (SPEC section Predicate). +// +// Each operation is one of: +// 0x20 PushTuple operand: tuple index (in [0, tuple_count)) +// 0x21 And +// 0x22 Or +// 0x23 Not +// 0xff Nop +// +// Tuples are pre-evaluated outside this module: the signer SNARK +// computes `tuple_bool[t]` from `(claim_index, operand, type_tag, +// comparator, attrs[claim_index])` and feeds an array of N=20 booleans +// in. This keeps the postfix loop branch-free. +// +// The evaluator runs over a padded fixed-size op array of length 20 and +// a runtime `op_count` parameter; ops past `op_count` are required to +// be NOP. The class-binding clause is checked structurally by the +// caller before this function is invoked. + +use crate::cmp; + +pub global STACK_CAPACITY: u32 = 20; + +pub fn evaluate( + op_codes: [u8; 20], + op_operands: [u8; 20], + tuple_bool: [bool; 20], + op_count: u32, +) -> bool { + let mut stack: [bool; 20] = [false; 20]; + let mut sp: u32 = 0; + + for i in 0..20 { + let active = (i as u32) < op_count; + let code = op_codes[i]; + let operand = op_operands[i]; + + // PUSH_TUPLE: push tuple_bool[operand]. + let push_v = tuple_bool[operand as u32]; + + let mut new_stack = stack; + let mut new_sp = sp; + + if active { + if code == 0x20 { + new_stack[sp] = push_v; + new_sp = sp + 1; + } else if code == 0x21 { + // AND: stack[sp-2] = stack[sp-2] && stack[sp-1]; sp -= 1. + assert(sp >= 2, "predicate: AND on short stack"); + let a = stack[sp - 1]; + let b = stack[sp - 2]; + new_stack[sp - 2] = a & b; + new_sp = sp - 1; + } else if code == 0x22 { + assert(sp >= 2, "predicate: OR on short stack"); + let a = stack[sp - 1]; + let b = stack[sp - 2]; + new_stack[sp - 2] = a | b; + new_sp = sp - 1; + } else if code == 0x23 { + assert(sp >= 1, "predicate: NOT on empty stack"); + let a = stack[sp - 1]; + new_stack[sp - 1] = !a; + new_sp = sp; + } else { + assert(code == 0xff, "predicate: unknown opcode"); + } + } else { + // Inactive positions must be NOP. + assert(code == 0xff, "predicate: non-NOP past op_count"); + } + + stack = new_stack; + sp = new_sp; + } + + assert(sp == 1, "predicate: final stack depth != 1"); + stack[0] +} + +// In-circuit version of `src/predicate.rs::canonical_scalars`. Encodes +// the (tuples + ops + counts) witness into a 742-byte serialized buffer +// matching SPEC section Predicate, then packs into 34 BN254 scalars. +// +// Used by the signer SNARK to bind the EVALUATED predicate program to +// the HASHED `predicate_hash`. +pub fn encode_canonical_def( + op_codes: [u8; 20], + op_operands: [u8; 20], + op_count: u32, + tuple_claim_index: [u8; 20], + tuple_operand: [Field; 20], + tuple_type_tag: [u8; 20], + tuple_comparator: [u8; 20], + tuple_count: u32, +) -> [Field; 34] { + // Step 0: precompute tuple operand bytes with R3 canonicity checks. + let mut tuple_operand_bytes: [[u8; 32]; 20] = [[0; 32]; 20]; + for t in 0..20 { + let bytes: [u8; 32] = tuple_operand[t].to_be_bytes(); + tuple_operand_bytes[t] = bytes; + let active = (t as u32) < tuple_count; + if active { + // R3: operand high byte must be < 0x30 (canonical BN254). + assert(bytes[0] < 0x30, "predicate: tuple operand exceeds BN254 modulus"); + // INT64 operands must fit in low 8 bytes. + if tuple_type_tag[t] == 0x01 { + for k in 0..24 { + assert(bytes[k] == 0, "predicate: INT64 operand exceeds 64 bits"); + } + } + } + } + + // Step 1: build the 742-byte serialized buffer (max-sized). + let mut buf: [u8; 742] = [0; 742]; + buf[0] = tuple_count as u8; + + // Tuple section (35 bytes per tuple). + for t in 0..20 { + let active = (t as u32) < tuple_count; + let t_off: u32 = 1 + (t as u32) * 35; + if active { + buf[t_off] = tuple_claim_index[t]; + for k in 0..32 { + buf[t_off + 1 + (k as u32)] = tuple_operand_bytes[t][k]; + } + buf[t_off + 33] = tuple_type_tag[t]; + buf[t_off + 34] = tuple_comparator[t]; + } + } + + // Op-count + op section: variable offset (depends on tuple_count). + for tc in 0..21 { + if (tc as u32) == tuple_count { + let oc_pos: u32 = 1 + (tc as u32) * 35; + buf[oc_pos] = op_count as u8; + for o in 0..20 { + let o_active = (o as u32) < op_count; + if o_active { + let o_off: u32 = oc_pos + 1 + (o as u32) * 2; + buf[o_off] = op_codes[o]; + buf[o_off + 1] = op_operands[o]; + } + } + } + } + + // Step 2: compute actual serialized length (1 + tc*35 + 1 + oc*2). + let serialized_len: u32 = 2 + tuple_count * 35 + op_count * 2; + let len_hi: u8 = (serialized_len / 256) as u8; + let len_lo: u8 = (serialized_len % 256) as u8; + + // Step 3: pack buf into 34 canonical scalars. + let mut canonical: [Field; 34] = [0; 34]; + + // Segment 0: 0x00 (top byte) | length_hi | length_lo | buf[0..29]. + let mut seg0: [u8; 32] = [0; 32]; + seg0[1] = len_hi; + seg0[2] = len_lo; + for k in 0..29 { + seg0[3 + k] = buf[k]; + } + canonical[0] = field_from_be_bytes_32(seg0); + + // Segments 1..34: 0x00 (top byte) | buf[29 + 31*(i-1) .. 29 + 31*i]. + for i in 1..34 { + let mut seg: [u8; 32] = [0; 32]; + let src_off: u32 = 29 + 31 * (i as u32 - 1); + for k in 0..31 { + let src_idx: u32 = src_off + (k as u32); + if src_idx < 742 { + seg[1 + k] = buf[src_idx]; + } + } + canonical[i] = field_from_be_bytes_32(seg); + } + + canonical +} + +// Construct a Field from 32 BE bytes; asserts canonicity (top byte <= 0x30). +fn field_from_be_bytes_32(bytes: [u8; 32]) -> Field { + assert(bytes[0] <= 0x30, "predicate: scalar top byte exceeds BN254 modulus"); + let mut acc: Field = 0; + for i in 0..32 { + acc = acc * 256 + (bytes[i] as Field); + } + acc +} + +// Per-tuple evaluation: `attrs[claim_index] OP operand`. SPEC section +// Predicate lists OP in {0x10 ==, 0x11 <=, 0x12 >=}. type_tag selects +// how the operand is interpreted; for the PoC we lift INT64 to a +// Field comparison via canonical BN254 ordering, and HASH / BOOL use +// equality only. +pub fn evaluate_tuple(attr_value: Field, operand: Field, type_tag: u8, comparator: u8) -> bool { + if (type_tag == 0x02) | (type_tag == 0x03) { + // HASH and BOOL: equality only. + assert(comparator == 0x10, "predicate: HASH/BOOL must use =="); + attr_value == operand + } else if type_tag == 0x01 { + // INT64: ==, <=, >=. Use canonical BN254 ordering; INT64 + // attribute values fit in 64 bits so the ordering coincides + // with integer ordering. + if comparator == 0x10 { + attr_value == operand + } else if comparator == 0x11 { + cmp::le_field(attr_value, operand) + } else if comparator == 0x12 { + cmp::ge_field(attr_value, operand) + } else { + assert(false, "predicate: unknown comparator"); + false + } + } else { + assert(false, "predicate: unknown type_tag"); + false + } +} diff --git a/pocs/civic-participation/resilient-civic-participation/circuits/lib/src/sponge.nr b/pocs/civic-participation/resilient-civic-participation/circuits/lib/src/sponge.nr new file mode 100644 index 0000000..1104b54 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/circuits/lib/src/sponge.nr @@ -0,0 +1,33 @@ +// Poseidon1 sponge (SPEC Primitives): rate r = 4, capacity c = 1, +// `t = 5`, 0x80-prefixed length-padding to a multiple of r scalars. +// Built on `poseidon::poseidon::permute` over the bn254 x5_5 instance; +// produces outputs identical to the Rust `Poseidon1Sponge` in +// `src/poseidon.rs` (pinned by `tests::sponge_fsrt_matches_rust`). + +use poseidon::poseidon::bn254::consts::x5_5_config; +use poseidon::poseidon::permute; + +pub struct Poseidon1Sponge { + state: [Field; 5], +} + +impl Poseidon1Sponge { + pub fn with_domain(domain: Field) -> Self { + Self { state: [domain, 0, 0, 0, 0] } + } + + // Absorb a single field element with 0x80-prefix padding to one + // rate-block. SPEC `absorb(DOMAIN, x)` for the FSRT PRG step. + pub fn absorb_one(&mut self, m: Field) { + self.state[1] += m; + self.state[2] += 0x80; + self.state = permute(x5_5_config(), self.state); + } + + // Squeeze the first two rate positions. rate = 4 means two outputs + // are available from a single permutation; SPEC `squeeze(2)` does + // not invoke a second permute. + pub fn squeeze_two(self) -> [Field; 2] { + [self.state[1], self.state[2]] + } +} diff --git a/pocs/civic-participation/resilient-civic-participation/circuits/resolution/Nargo.toml b/pocs/civic-participation/resilient-civic-participation/circuits/resolution/Nargo.toml new file mode 100644 index 0000000..f40c30c --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/circuits/resolution/Nargo.toml @@ -0,0 +1,8 @@ +[package] +name = "rcp_resolution" +type = "bin" +authors = ["rymnc <43716372+rymnc@users.noreply.github.com>"] +compiler_version = ">=1.0.0" + +[dependencies] +rcp_lib = { path = "../lib" } diff --git a/pocs/civic-participation/resilient-civic-participation/circuits/resolution/src/main.nr b/pocs/civic-participation/resilient-civic-participation/circuits/resolution/src/main.nr new file mode 100644 index 0000000..9a23132 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/circuits/resolution/src/main.nr @@ -0,0 +1,128 @@ +use rcp_lib::cmp; +use rcp_lib::hasher; +use rcp_lib::merkle; + +// Resolution SNARK (SPEC section Resolution SNARK). +// +// Iterates over all `leaf_count` leaves in `running_root`, recomputes +// `leaf_j = Poseidon1(DOMAIN_LEAF, nullifier_j, class_tag_j)` from the +// witnessed pair, opens each leaf at the IMT membership path, and +// tallies per-class counts. Outputs `b` and `b_per_class[i]`. +// +// We bound `leaf_count` at compile time to a max of `100_000` records +// (the SPEC permits up to `10 * sum(class_thresholds)` with no global +// cap; deployments choose a circuit size per expected participation). +// For the PoC integration test we instantiate a smaller variant via +// `LEAF_MAX = 200`. Tweak the constant below if larger fixtures are +// required. + +global LEAF_MAX: u32 = 200; +global CLASS_MAX: u32 = 16; +global IMT_DEPTH: u32 = 24; + +fn main( + predicate_hash: pub Field, + r_root: pub Field, + running_root: pub Field, + leaf_count: pub u32, + class_set: pub [Field; CLASS_MAX], + class_set_len: pub u32, + class_thresholds: pub [u32; CLASS_MAX], + b: pub Field, + b_per_class: pub [Field; CLASS_MAX], + // class_index is bound to the petition's stored value via the + // Registry's _checkResolutionPriorState. The proof system commits + // to this value; the resolution leaves carry their own class_tag, + // so the circuit does not need to use class_index internally + // beyond exposing it as a public input. + class_index: pub Field, + // Private + nullifier: [Field; LEAF_MAX], + class_tag: [Field; LEAF_MAX], + imt_next_index: [Field; LEAF_MAX], + imt_next_value: [Field; LEAF_MAX], + imt_path_indices: [[bool; IMT_DEPTH]; LEAF_MAX], + imt_path_elements: [[Field; IMT_DEPTH]; LEAF_MAX], +) { + // Constraint 1: bound leaf_count. + assert(leaf_count <= LEAF_MAX, "resolution: leaf_count > LEAF_MAX"); + assert(class_set_len <= CLASS_MAX, "resolution: class_set_len > CLASS_MAX"); + + // Constraint 5: class_set strictly increasing. + for i in 0..CLASS_MAX { + if (i as u32) + 1 < class_set_len { + let lt = cmp::lt_field(class_set[i], class_set[i + 1]); + assert(lt, "resolution: class_set not strictly increasing"); + } + } + + // Per-class counts. + let mut counts: [u32; CLASS_MAX] = [0; CLASS_MAX]; + + // Constraint 2-4: per-leaf membership in running_root with the + // witnessed (nullifier_j, class_tag_j) pair. Constraint 6: each + // active class_tag_j must lie in class_set; off-class leaves that + // would otherwise be silently dropped from every count[k] are + // rejected at proving time. + for j in 0..LEAF_MAX { + let active = (j as u32) < leaf_count; + if active { + let leaf_j = hasher::hash_leaf(nullifier[j], class_tag[j]); + let imt_leaf = hasher::hash_imt_leaf(leaf_j, imt_next_index[j], imt_next_value[j]); + merkle::verify_membership_depth_24( + imt_leaf, + running_root, + IMT_DEPTH, + imt_path_indices[j], + imt_path_elements[j], + ); + + let mut found_in_class_set: bool = false; + for k in 0..CLASS_MAX { + let in_range = (k as u32) < class_set_len; + let matches = class_tag[j] == class_set[k]; + if in_range & matches { + counts[k] = counts[k] + 1; + found_in_class_set = true; + } + } + assert(found_in_class_set, "resolution: class_tag not in class_set"); + } + } + + // Constraint 6: per-class boolean. + let mut all_satisfied: bool = true; + for k in 0..CLASS_MAX { + let in_range = (k as u32) < class_set_len; + let satisfies = counts[k] >= class_thresholds[k]; + let expected = if in_range { + (if satisfies { 1 } else { 0 }) + } else { + 0 + }; + assert(b_per_class[k] == expected as Field, "resolution: b_per_class[k] mismatch"); + if in_range { + all_satisfied = all_satisfied & satisfies; + } + } + + // Constraint 7: b = AND_i b_per_class[i]. + let expected_b: Field = if all_satisfied { 1 } else { 0 }; + assert(b == expected_b, "resolution: b mismatch"); + + // Constraint 2 (pairwise distinct leaves) cross-check: leaves form + // a strictly increasing sequence under canonical BN254 ordering. + for j in 0..LEAF_MAX { + let in_range = (j as u32) + 1 < leaf_count; + if in_range { + let lj = hasher::hash_leaf(nullifier[j], class_tag[j]); + let lj1 = hasher::hash_leaf(nullifier[j + 1], class_tag[j + 1]); + let lt = cmp::lt_field(lj, lj1); + assert(lt, "resolution: leaves not strictly increasing"); + } + } + + let _ = predicate_hash; + let _ = r_root; + let _ = class_index; +} diff --git a/pocs/civic-participation/resilient-civic-participation/circuits/signer/Nargo.toml b/pocs/civic-participation/resilient-civic-participation/circuits/signer/Nargo.toml new file mode 100644 index 0000000..2221135 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/circuits/signer/Nargo.toml @@ -0,0 +1,8 @@ +[package] +name = "rcp_signer" +type = "bin" +authors = ["rymnc <43716372+rymnc@users.noreply.github.com>"] +compiler_version = ">=1.0.0" + +[dependencies] +rcp_lib = { path = "../lib" } diff --git a/pocs/civic-participation/resilient-civic-participation/circuits/signer/src/main.nr b/pocs/civic-participation/resilient-civic-participation/circuits/signer/src/main.nr new file mode 100644 index 0000000..88792f6 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/circuits/signer/src/main.nr @@ -0,0 +1,283 @@ +use rcp_lib::fsrt; +use rcp_lib::hasher; +use rcp_lib::merkle; +use rcp_lib::predicate; + +// Signer SNARK (SPEC section Signer SNARK). +// +// Public inputs (ordered per spec): R, petition_id, predicate_hash, +// class_index, class_tag, slot, nullifier, identity_tag. +fn main( + r_root: pub Field, + petition_id: pub Field, + predicate_hash: pub Field, + class_index: pub Field, + class_tag: pub Field, + slot: pub Field, + nullifier: pub Field, + identity_tag: pub Field, + // Private + identity_secret: Field, + attrs: [Field; 4], + attr_version: Field, + chain_root: Field, + ri_path_indices: [bool; 32], + ri_path_elements: [Field; 32], + // Actual proof depth for the RI Merkle inclusion. The RI tree is a + // variable-depth LeanIMT off chain; the witness pads to depth 32 + // and `binary_merkle_root` only walks `ri_proof_length` levels. + ri_proof_length: u32, + s_slot: Field, + chain_path_indices: [bool; 24], + chain_path_elements: [Field; 24], + salt: Field, + // Predicate program + op_codes: [u8; 20], + op_operands: [u8; 20], + op_count: u32, + tuple_claim_index: [u8; 20], + tuple_operand: [Field; 20], + tuple_type_tag: [u8; 20], + tuple_comparator: [u8; 20], + tuple_count: u32, +) { + // Constraint 1: attr_hash inclusion in R. identity_secret is mixed + // into attr_hash so each RI leaf binds to a specific signer secret. + let attr_hash = hasher::hash_attr(attrs, chain_root, attr_version, identity_secret); + merkle::verify_membership_depth_32( + attr_hash, + r_root, + ri_proof_length, + ri_path_indices, + ri_path_elements, + ); + + // Constraint 2: predicate_hash binding from IN-CIRCUIT encoding. + // The signer SNARK computes canonical_predicate_def from the + // witnessed (tuples + ops + counts) and hashes it, forcing the + // EVALUATED predicate to equal the HASHED predicate. + let canonical = predicate::encode_canonical_def( + op_codes, + op_operands, + op_count, + tuple_claim_index, + tuple_operand, + tuple_type_tag, + tuple_comparator, + tuple_count, + ); + let recomputed_pred_hash = hasher::hash_predicate(canonical, petition_id, salt); + assert(recomputed_pred_hash == predicate_hash, "signer: predicate_hash mismatch"); + + // Constraint 2a: non-empty predicate. + assert(tuple_count >= 1, "signer: tuple_count must be >= 1"); + assert(tuple_count <= 20, "signer: tuple_count > PREDICATE_TUPLE_MAX"); + assert(op_count >= 1, "signer: op_count must be >= 1"); + assert(op_count <= 20, "signer: op_count > PREDICATE_OP_MAX"); + + // Constraint 4a: identify tuples that are class-binding for the + // SIGNER's specific class_tag, i.e., tuples whose claim_index == + // class_index AND operand == class_tag AND comparator == EQ. At + // least one such tuple must exist; pushing any of them marks the + // stack as Bound. Multi-class predicates (e.g. `attr == A OR attr + // == B`) are accepted via OR(Bound, Bound) -> Bound. + let mut is_binding_tuple: [bool; 20] = [false; 20]; + let mut any_binding: bool = false; + for t in 0..20 { + let active = (t as u32) < tuple_count; + let claim_match = tuple_claim_index[t] as Field == class_index; + let operand_match = tuple_operand[t] == class_tag; + let comparator_match = tuple_comparator[t] == 0x10; + let bind_for_signer = active & claim_match & operand_match & comparator_match; + is_binding_tuple[t] = bind_for_signer; + any_binding = any_binding | bind_for_signer; + } + assert(any_binding, "signer: no class-binding tuple matches (class_index, class_tag)"); + + // Constraint 4b: data-oblivious 3-state taint propagation. + // States: 0 = Free, 1 = Bound (carries class-binding), 2 = Tainted. + // PUSH_TUPLE(k): Bound iff is_binding_tuple[k]; else Free. + // AND: combined = Bound if any operand Bound, else Tainted if any Tainted, else Free. + // OR : Bound if BOTH operands Bound, else Tainted if any Bound or Tainted, else Free. + // NOT: Tainted if operand Bound, else operand. + // NOP: no-op. + // After all ops, top of taint stack MUST be Bound and stack depth == 1. + let mut taint_stack: [u32; 20] = [0; 20]; + let mut taint_sp: u32 = 0; + for i in 0..20 { + let active = (i as u32) < op_count; + let code = op_codes[i]; + let operand = op_operands[i]; + if active { + if code == 0x20 { + // PUSH_TUPLE: Bound iff is_binding_tuple[operand]. + let v: u32 = if is_binding_tuple[operand as u32] { + 1 + } else { + 0 + }; + taint_stack[taint_sp] = v; + taint_sp = taint_sp + 1; + } else if code == 0x21 { + // AND + assert(operand == 0, "signer: AND op operand must be 0"); + assert(taint_sp >= 2, "signer: AND underflow"); + let a = taint_stack[taint_sp - 1]; + let b = taint_stack[taint_sp - 2]; + let combined: u32 = if (a == 1) | (b == 1) { + 1 + } else if (a == 2) | (b == 2) { + 2 + } else { + 0 + }; + taint_stack[taint_sp - 2] = combined; + taint_sp = taint_sp - 1; + } else if code == 0x22 { + // OR: from the SIGNER's perspective, a Bound branch + // means the signer commits to their class via that + // branch. The Registry's structural validator rejects + // predicates with non-binding alternatives. So at + // proving time we only need any branch Bound -> Bound. + assert(operand == 0, "signer: OR op operand must be 0"); + assert(taint_sp >= 2, "signer: OR underflow"); + let a = taint_stack[taint_sp - 1]; + let b = taint_stack[taint_sp - 2]; + let combined: u32 = if (a == 1) | (b == 1) { + 1 + } else if (a == 2) | (b == 2) { + 2 + } else { + 0 + }; + taint_stack[taint_sp - 2] = combined; + taint_sp = taint_sp - 1; + } else if code == 0x23 { + // NOT + assert(operand == 0, "signer: NOT op operand must be 0"); + assert(taint_sp >= 1, "signer: NOT underflow"); + let a = taint_stack[taint_sp - 1]; + let combined: u32 = if a == 1 { 2 } else { a }; + taint_stack[taint_sp - 1] = combined; + } else { + assert(code == 0xff, "signer: unknown opcode"); + assert(operand == 0, "signer: NOP op operand must be 0"); + } + } + } + assert(taint_sp == 1, "signer: predicate stack must terminate with exactly 1 element"); + assert(taint_stack[0] == 1, "signer: class binding not bound at top level (outside OR/NOT)"); + + // Constraint 3: predicate evaluation. + // First, per-tuple evaluation. We assert claim_index < ATTR_COUNT + // for all (active and inactive) tuples since inactive tuples still + // have witness values. + let mut tuple_bool: [bool; 20] = [false; 20]; + for t in 0..20 { + let ci = tuple_claim_index[t] as u32; + assert(ci < 4, "signer: tuple claim_index >= ATTR_COUNT"); + let attr_v = attrs[ci]; + let v = predicate::evaluate_tuple( + attr_v, + tuple_operand[t], + tuple_type_tag[t], + tuple_comparator[t], + ); + let active = (t as u32) < tuple_count; + tuple_bool[t] = if active { v } else { false }; + } + let result = predicate::evaluate(op_codes, op_operands, tuple_bool, op_count); + assert(result, "signer: predicate evaluates to false"); + + // Constraint 4c: `attrs[class_index] == class_tag` defense in depth. + let ci_u32 = class_index as u32; + assert(ci_u32 < 4, "signer: class_index out of range"); + let class_attr = attrs[ci_u32]; + assert(class_attr == class_tag, "signer: class binding violated"); + + // Constraint 5: v_slot derivation. + let v_slot = fsrt::derive_v_slot(s_slot); + + // Constraint 6: v_slot inclusion in chain_root at index `slot`. + // Bind public `slot` to chain_path_indices via 24-bit + // decomposition, then verify the Merkle path. + let slot_u32 = slot as u32; + let slot_limit: u32 = 16777216; // 2^24 + assert(slot_u32 < slot_limit, "signer: slot out of range"); + let expected_chain_bits = merkle::u32_to_bits_24(slot_u32); + merkle::assert_bits_eq_24(chain_path_indices, expected_chain_bits); + merkle::verify_membership_depth_24( + v_slot, + chain_root, + 24, + chain_path_indices, + chain_path_elements, + ); + + // Constraint 7: nullifier derivation. identity_secret is mixed in + // so a malicious signer with the same s_0 but different RI leaf + // (different identity_secret) cannot produce the same nullifier. + let recomputed_null = + hasher::hash_nullifier(v_slot, petition_id, class_index, class_tag, identity_secret); + assert(recomputed_null == nullifier, "signer: nullifier mismatch"); + + // Constraint 8: identity_tag derivation. + let recomputed_idtag = hasher::hash_identity_tag(v_slot, petition_id); + assert(recomputed_idtag == identity_tag, "signer: identity_tag mismatch"); +} + +#[test] +fn test_signer_circuit_structure_compiles() { + let attrs: [Field; 4] = [10, 20, 826, 40]; + let attr_version: Field = 0; + let chain_root: Field = 0; + let identity_secret: Field = 0xdeadbeef; + let r_root: Field = hasher::hash_attr(attrs, chain_root, attr_version, identity_secret); + let ri_path_indices: [bool; 32] = [false; 32]; + let ri_path_elements: [Field; 32] = [0; 32]; + let ri_proof_length: u32 = 0; + let s_slot: Field = 1; + let chain_path_indices: [bool; 24] = [false; 24]; + let chain_path_elements: [Field; 24] = [0; 24]; + let salt: Field = 7; + let petition_id: Field = 42; + // Predicate: tuples[0] = (class_index=2, operand=class_tag=826, HASH, EQ). + let mut tuple_claim_index: [u8; 20] = [0; 20]; + tuple_claim_index[0] = 2; + let mut tuple_operand: [Field; 20] = [0; 20]; + tuple_operand[0] = 826; + let mut tuple_type_tag: [u8; 20] = [0; 20]; + tuple_type_tag[0] = 0x02; // HASH + let mut tuple_comparator: [u8; 20] = [0; 20]; + tuple_comparator[0] = 0x10; // EQ + let tuple_count: u32 = 1; + let mut op_codes: [u8; 20] = [0xff; 20]; + op_codes[0] = 0x20; // PUSH_TUPLE + let mut op_operands: [u8; 20] = [0; 20]; + op_operands[0] = 0; + let op_count: u32 = 1; + + let canonical = predicate::encode_canonical_def( + op_codes, + op_operands, + op_count, + tuple_claim_index, + tuple_operand, + tuple_type_tag, + tuple_comparator, + tuple_count, + ); + let predicate_hash = hasher::hash_predicate(canonical, petition_id, salt); + let v_slot = fsrt::derive_v_slot(s_slot); + let class_index: Field = 2; + let class_tag: Field = 826; + let nullifier = + hasher::hash_nullifier(v_slot, petition_id, class_index, class_tag, identity_secret); + let identity_tag = hasher::hash_identity_tag(v_slot, petition_id); + + let _ = ( + r_root, petition_id, predicate_hash, class_index, class_tag, nullifier, identity_tag, + ri_path_indices, ri_path_elements, s_slot, chain_path_indices, chain_path_elements, salt, + ri_proof_length, identity_secret, + ); +} diff --git a/pocs/civic-participation/resilient-civic-participation/contracts/script/Deploy.s.sol b/pocs/civic-participation/resilient-civic-participation/contracts/script/Deploy.s.sol new file mode 100644 index 0000000..ea45772 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/contracts/script/Deploy.s.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.21; + +import {Script, console} from "forge-std/Script.sol"; +import {Config} from "forge-std/Config.sol"; +import {PetitionRegistry} from "../src/PetitionRegistry.sol"; +import {IVerifier} from "../src/interfaces/IVerifier.sol"; +import {MockBatchVerifier} from "../src/mocks/MockBatchVerifier.sol"; +import {MockResolutionVerifier} from "../src/mocks/MockResolutionVerifier.sol"; +import {MockERC20} from "../src/mocks/MockERC20.sol"; +import {HonkVerifier as BatchVerifier} from "../src/verifiers/BatchVerifier.sol"; +import {HonkVerifier as ResolutionVerifier} from "../src/verifiers/ResolutionVerifier.sol"; + +/// @title Deploy +/// @notice With `USE_MOCK_VERIFIER=true` deploys mock SNARK verifiers +/// (accept any bytes); with `false` deploys the Honk verifiers +/// emitted by `scripts/generate-verifiers.sh`. +contract Deploy is Script, Config { + function run() public { + _loadConfig("./deployments.toml", true); + + bool useMockVerifier = config.get("use_mock_verifier").toBool(); + address governance = config.get("governance").toAddress(); + bytes32 emptyImtRoot = vm.envBytes32("EMPTY_IMT_ROOT"); + bytes32 pinnedSignerVkHash = vm.envBytes32("PINNED_SIGNER_VK_HASH"); + + vm.startBroadcast(); + + MockERC20 mockToken = new MockERC20("Mock USDC", "mUSDC", 6); + console.log("MockERC20:", address(mockToken)); + + address batchVerifierAddr; + address resolutionVerifierAddr; + if (useMockVerifier) { + batchVerifierAddr = address(new MockBatchVerifier()); + resolutionVerifierAddr = address(new MockResolutionVerifier()); + console.log("MockBatchVerifier:", batchVerifierAddr); + console.log("MockResolutionVerifier:", resolutionVerifierAddr); + } else { + batchVerifierAddr = address(new BatchVerifier()); + resolutionVerifierAddr = address(new ResolutionVerifier()); + console.log("BatchVerifier:", batchVerifierAddr); + console.log("ResolutionVerifier:", resolutionVerifierAddr); + } + + PetitionRegistry registry = new PetitionRegistry( + PetitionRegistry.InitArgs({ + batchVerifier: IVerifier(batchVerifierAddr), + resolutionVerifier: IVerifier(resolutionVerifierAddr), + bountyToken: address(mockToken), + governance: governance, + alpha: uint64(1), + alphaMin: uint64(1), + alphaMax: uint64(1_000), + srsHash: bytes32(0), + attrCount: uint8(4), + emptyImtRoot: emptyImtRoot, + pinnedSignerVkHash: pinnedSignerVkHash + }) + ); + console.log("PetitionRegistry:", address(registry)); + + vm.stopBroadcast(); + + config.set("bounty_token_address", address(mockToken)); + config.set("petition_registry_address", address(registry)); + config.set("batch_verifier_address", batchVerifierAddr); + config.set("resolution_verifier_address", resolutionVerifierAddr); + + console.log(""); + console.log("=== Deployment Summary ==="); + console.log("Chain ID:", block.chainid); + console.log("MockERC20:", address(mockToken)); + console.log("PetitionRegistry:", address(registry)); + } +} diff --git a/pocs/civic-participation/resilient-civic-participation/contracts/src/PetitionRegistry.sol b/pocs/civic-participation/resilient-civic-participation/contracts/src/PetitionRegistry.sol new file mode 100644 index 0000000..f9d5f3e --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/contracts/src/PetitionRegistry.sol @@ -0,0 +1,1004 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.21; + +import {IVerifier} from "./interfaces/IVerifier.sol"; +import {IPetitionRegistry} from "./interfaces/IPetitionRegistry.sol"; +import {PoseidonT3} from "poseidon-solidity/PoseidonT3.sol"; +import {PoseidonT4} from "poseidon-solidity/PoseidonT4.sol"; + +/// @title PetitionRegistry +/// @notice On-chain state machine for the Resilient Civic Participation +/// protocol. Holds petition records, batch records, IMT roots, +/// and resolution outputs. The batch SNARK recursively verifies +/// all per-signer SNARKs in-circuit (SPEC Batch SNARK +/// constraint 2), so the registry only verifies one outer proof +/// per batch and one resolution proof per outcome. Records live +/// in the EIP-4844 blob attached to the publishBatch +/// transaction; the contract binds `blobhash(0)` into the batch +/// public inputs and never reads record contents from calldata. +/// Disputes verify KZG point-evaluation openings via the +/// precompile at address 0x0a. +contract PetitionRegistry is IPetitionRegistry { + // ---------- Storage ---------- + + struct PetitionRecord { + bytes32 rRoot; + bytes32 predicateHash; + bytes32 predicateHashPreId; + bytes32 salt; + uint16[] classSet; + uint64[] classThresholds; + uint8 classIndex; + uint64 closeAtBlock; + uint64 registrationBlock; + uint32 slot; + uint256 bounty; + uint64 alphaAtRegistration; + address organizer; + bytes32 runningRoot; + bytes32 identityTagSetRoot; + uint64 leafCount; + uint32 nextBatchIndex; + bool b; + bool[] bPerClass; + PetitionState state; + } + + struct BatchRecord { + bytes32 batchVersionedHash; + bytes32 priorRunningRoot; + bytes32 newRunningRoot; + bytes32 priorIdentityTagSetRoot; + bytes32 newIdentityTagSetRoot; + uint64 priorLeafCount; + uint64 newLeafCount; + address relayer; + uint64 submittedAtBlock; + BatchState state; + } + + mapping(bytes32 => PetitionRecord) internal petitions; + mapping(bytes32 => BatchRecord[]) internal batches; + + IVerifier public immutable batchVerifier; + IVerifier public immutable resolutionVerifier; + address public immutable bountyToken; + address public governance; + + uint64 public alpha; + uint64 public alphaMin; + uint64 public alphaMax; + bytes32 public srsHash; + uint8 public attrCount; + uint32 public s; + + /// 12-second block assumption (post-Merge Ethereum mainnet). 2 hours. + /// Deployments on chains with different block cadence MUST recompute. + uint64 public constant COOLDOWN_BLOCKS = 600; + /// 12-second block assumption. 14 days. + uint64 public constant RESOLUTION_DEADLINE_BLOCKS = 100_800; + /// 12-second block assumption. 11.5 days. + uint64 public constant MAX_SIGNING_WINDOW_BLOCKS = 82_800; + uint32 public constant BATCH_SIZE_MAX = 6; + bytes32 public constant DOMAIN_PETITION_ID = keccak256("RCP/petition_id/v1"); + bytes32 public constant TOMBSTONE_RUNNING_ROOT = bytes32(uint256(1)); + uint16 public constant GAS_REBATE_BPS = 100; + /// Gas estimate for `markUnresolved` reimbursement (per call). + /// Used by the gas-based rebate cap; conservative upper bound. + uint256 public constant MARK_UNRESOLVED_GAS_ESTIMATE = 100_000; + /// Poseidon1 domain separator constants (mirrors + /// circuits/lib/src/domain.nr and src/poseidon.rs). + uint256 internal constant DOMAIN_NULLIFIER = 1; + uint256 internal constant DOMAIN_IDTAG = 2; + uint256 internal constant DOMAIN_LEAF = 3; + uint256 internal constant DOMAIN_FSRT_PRG = 4; + uint256 internal constant DOMAIN_PRED = 5; + uint256 internal constant DOMAIN_ATTR = 6; + uint256 internal constant DOMAIN_BATCH_SNARK = 7; + uint256 internal constant DOMAIN_PETITION = 8; + uint256 internal constant DOMAIN_RESOLUTION_SNARK = 9; + + // ---------- Errors ---------- + error NotGovernance(); + error InvalidPetition(); + error InvalidState(); + error InvalidBatch(); + error InvalidDispute(); + error BountyFloor(); + error ClassThresholdsInvalid(); + error ClassSetInvalid(); + error SigningWindowTooLong(); + error BatchSizeOutOfRange(); + error PriorStateMismatch(); + error ProofRejected(); + error BlobHashMismatch(); + error AlreadyResolved(); + error TooEarly(); + error AlphaOutOfBounds(); + error PaymentFailed(); + error ViolationFalse(); + error PredicateHashMismatch(); + error PredicateMalformed(); + error ClassBindingMissing(); + error VerifierAddressInvalid(); + error SignerVkHashMismatch(); + error SlotOverflow(); + + // ---------- Constructor ---------- + + struct InitArgs { + IVerifier batchVerifier; + IVerifier resolutionVerifier; + address bountyToken; + address governance; + uint64 alpha; + uint64 alphaMin; + uint64 alphaMax; + bytes32 srsHash; + uint8 attrCount; + bytes32 emptyImtRoot; + /// Deploy-pinned hash of the signer SNARK's verification key. + /// Every batch proof must commit to this value as a public input. + bytes32 pinnedSignerVkHash; + } + + /// Poseidon1 hash of the empty depth-24 indexed Merkle tree (with + /// the implicit (0, 0, 0) leaf at index 0). Off-chain code computes + /// this with `IndexedMerkleTree::new().root_fr()`; the deployer + /// passes it in so a fresh `PetitionRecord` matches the empty-IMT + /// state the relayer's running and identity-tag IMTs maintain. + bytes32 public immutable emptyImtRoot; + + /// Deploy-pinned signer VK hash. + bytes32 public immutable pinnedSignerVkHash; + + constructor(InitArgs memory args) { + // Reject zero-address and EOA verifiers/token. + if (address(args.batchVerifier) == address(0)) revert VerifierAddressInvalid(); + if (address(args.resolutionVerifier) == address(0)) revert VerifierAddressInvalid(); + if (args.bountyToken == address(0)) revert VerifierAddressInvalid(); + if (address(args.batchVerifier).code.length == 0) revert VerifierAddressInvalid(); + if (address(args.resolutionVerifier).code.length == 0) revert VerifierAddressInvalid(); + if (args.bountyToken.code.length == 0) revert VerifierAddressInvalid(); + + batchVerifier = args.batchVerifier; + resolutionVerifier = args.resolutionVerifier; + bountyToken = args.bountyToken; + governance = args.governance; + alpha = args.alpha; + alphaMin = args.alphaMin; + alphaMax = args.alphaMax; + srsHash = args.srsHash; + attrCount = args.attrCount; + emptyImtRoot = args.emptyImtRoot; + pinnedSignerVkHash = args.pinnedSignerVkHash; + } + + modifier onlyGovernance() { + if (msg.sender != governance) revert NotGovernance(); + _; + } + + // ---------- Register ---------- + + /// register does not accept caller-supplied predicateHash / + /// predicateHashPreId. Both are recomputed on-chain from + /// `params.predicateDef` via Poseidon1 (PoseidonT3 library). + function register(PetitionParams calldata params) external returns (bytes32 petitionId) { + _validateParams(params); + _validateClassBinding(params.predicateDef, params.classIndex, params.classSet); + + uint32 sAtReg = s; + // SPEC sec FSRT Chain: `S` is bounded by `N - 1 = 2^24 - 1`. + if (sAtReg >= (1 << 24)) revert SlotOverflow(); + bytes32 predicateHash; + bytes32 predicateHashPreId; + (petitionId, predicateHash, predicateHashPreId) = _deriveIds(params, sAtReg); + + _writePetition(petitionId, params, predicateHash, predicateHashPreId, sAtReg); + s = sAtReg + 1; + _safeTransferFrom(bountyToken, msg.sender, address(this), params.bounty); + _emitRegistered(petitionId, sAtReg, params, predicateHash); + } + + function _deriveIds(PetitionParams calldata params, uint32 sAtReg) + internal + view + returns (bytes32 petitionId, bytes32 predicateHash, bytes32 predicateHashPreId) + { + bytes32[34] memory canonical = _canonicalScalars(params.predicateDef); + predicateHashPreId = _recomputePredicateHash(canonical, bytes32(0), params.salt); + petitionId = _derivePetitionId(params, predicateHashPreId, sAtReg); + predicateHash = _recomputePredicateHash(canonical, petitionId, params.salt); + } + + function _emitRegistered(bytes32 petitionId, uint32 sAtReg, PetitionParams calldata params, bytes32 predicateHash) + internal + { + emit PetitionRegistered( + petitionId, + sAtReg, + params.rRoot, + predicateHash, + params.classSet, + params.classThresholds, + params.classIndex, + params.closeAtBlock, + params.bounty + ); + } + + function _validateParams(PetitionParams calldata params) internal view { + if (params.closeAtBlock <= block.number) revert InvalidPetition(); + if (params.closeAtBlock - block.number > MAX_SIGNING_WINDOW_BLOCKS) revert SigningWindowTooLong(); + if (params.classIndex >= attrCount) revert InvalidPetition(); + + uint256 n = params.classSet.length; + if (n == 0 || n != params.classThresholds.length) revert ClassSetInvalid(); + // Bound class_set size at the resolution circuit's CLASS_MAX. + if (n > 16) revert ClassSetInvalid(); + for (uint256 i = 1; i < n; i++) { + if (params.classSet[i] <= params.classSet[i - 1]) revert ClassSetInvalid(); + } + uint256 sumThresholds = 0; + for (uint256 i = 0; i < n; i++) { + if (params.classThresholds[i] < 1) revert ClassThresholdsInvalid(); + sumThresholds += params.classThresholds[i]; + } + + // Bound predicateDef length per SPEC. + if (params.predicateDef.length > 1024) revert PredicateMalformed(); + if (params.predicateDef.length < 2) revert PredicateMalformed(); + uint8 tupleCount = uint8(params.predicateDef[0]); + if (tupleCount < 1 || tupleCount > 20) revert PredicateMalformed(); + uint256 opOff = 1 + uint256(tupleCount) * 35; + if (opOff >= params.predicateDef.length) revert PredicateMalformed(); + uint8 opCount = uint8(params.predicateDef[opOff]); + if (opCount < 1 || opCount > 20) revert PredicateMalformed(); + // Total serialized length check. + if (params.predicateDef.length != opOff + 1 + uint256(opCount) * 2) { + revert PredicateMalformed(); + } + uint256 floor_ = uint256(alpha) * 10 * sumThresholds * uint256(opCount); + if (params.bounty < floor_) revert BountyFloor(); + } + + // ---------- Poseidon helpers ---------- + + function _poseidon2(uint256 a, uint256 b) internal pure returns (uint256) { + uint256[2] memory inp = [a, b]; + return PoseidonT3.hash(inp); + } + + function _poseidon3(uint256 a, uint256 b, uint256 c) internal pure returns (uint256) { + uint256[3] memory inp = [a, b, c]; + return PoseidonT4.hash(inp); + } + + /// Mirrors `src/predicate.rs::canonical_scalars` and + /// `circuits/lib/src/predicate.nr::encode_canonical_def` scalar pack. + function _canonicalScalars(bytes calldata predicateDef) internal pure returns (bytes32[34] memory out) { + uint256 len = predicateDef.length; + // Segment 0: 0x00 (top) | length_hi | length_lo | def[0..29] + bytes memory seg0 = new bytes(32); + seg0[1] = bytes1(uint8(len >> 8)); + seg0[2] = bytes1(uint8(len & 0xff)); + uint256 n0 = len > 29 ? 29 : len; + for (uint256 k = 0; k < n0; k++) { + seg0[3 + k] = predicateDef[k]; + } + out[0] = _bytes32From(seg0); + // Segments 1..33: 0x00 (top) | def[29 + 31*(i-1) .. 29 + 31*i] + for (uint256 i = 1; i < 34; i++) { + bytes memory seg = new bytes(32); + uint256 srcOff = 29 + 31 * (i - 1); + for (uint256 k = 0; k < 31 && srcOff + k < len; k++) { + seg[1 + k] = predicateDef[srcOff + k]; + } + out[i] = _bytes32From(seg); + } + } + + function _bytes32From(bytes memory b) internal pure returns (bytes32 r) { + require(b.length == 32, "bytes32From: bad length"); + assembly { + r := mload(add(b, 32)) + } + } + + /// Mirrors `src/poseidon.rs::hash_predicate` and Noir + /// `circuits/lib/src/hasher.nr::hash_predicate`: iterated Poseidon2 + /// chain starting from DOMAIN_PRED, folding canonical[0..34], then + /// petition_id, then salt. + function _recomputePredicateHash(bytes32[34] memory canonical, bytes32 petitionId, bytes32 salt) + internal + pure + returns (bytes32) + { + uint256 acc = DOMAIN_PRED; + for (uint256 i = 0; i < 34; i++) { + acc = _poseidon2(acc, uint256(canonical[i])); + } + acc = _poseidon2(acc, uint256(petitionId)); + acc = _poseidon2(acc, uint256(salt)); + return bytes32(acc); + } + + /// Structural class-binding validation. Mirrors + /// `src/predicate.rs::validate_class_binding` but TIGHTENS to require + /// the class-binding tuple to be `tuples[0]` (matching the Noir + /// signer SNARK constraint that ties tuples[0] to (class_index, + /// class_tag, EQ)). + function _validateClassBinding(bytes calldata predicateDef, uint8 classIndex, uint16[] calldata classSet) + internal + pure + { + uint8 tupleCount = uint8(predicateDef[0]); + uint256 opOff = 1 + uint256(tupleCount) * 35; + uint8 opCount = uint8(predicateDef[opOff]); + + // Identify class-binding tuples: claim_index == classIndex, + // operand < 2^16 with value in classSet, comparator == EQ. + // Any PUSH of such a tuple is "Bound"; non-binding pushes are + // "Free". The OR rule then requires BOTH branches to be Bound + // for the result to remain Bound, so the multi-class predicate + // (e.g. "attr == A OR attr == B" with A, B in classSet) is + // accepted while OR(Bound, Free) is tainted. + bool[20] memory isBindingTuple; + for (uint256 t = 0; t < tupleCount; t++) { + uint256 tOff = 1 + t * 35; + if (uint8(predicateDef[tOff]) != classIndex) continue; + uint8 typeTag = uint8(predicateDef[tOff + 33]); + if (typeTag != 0x01 && typeTag != 0x02 && typeTag != 0x03) { + revert PredicateMalformed(); + } + if (uint8(predicateDef[tOff + 34]) != 0x10) continue; // not EQ + bool highZero = true; + for (uint256 b = 0; b < 30; b++) { + if (uint8(predicateDef[tOff + 1 + b]) != 0) { + highZero = false; + break; + } + } + if (!highZero) continue; + uint16 ct = (uint16(uint8(predicateDef[tOff + 1 + 30])) << 8) | uint16(uint8(predicateDef[tOff + 1 + 31])); + if (_classInSetMemory(ct, classSet)) { + isBindingTuple[t] = true; + } + } + // At least one tuple must be class-binding. + bool anyBinding; + for (uint256 t = 0; t < tupleCount; t++) { + if (isBindingTuple[t]) { + anyBinding = true; + break; + } + } + if (!anyBinding) revert ClassBindingMissing(); + + uint8[20] memory taint; + uint256 sp = 0; + for (uint256 i = 0; i < opCount; i++) { + uint8 code = uint8(predicateDef[opOff + 1 + i * 2]); + uint8 operand = uint8(predicateDef[opOff + 1 + i * 2 + 1]); + if (code == 0x20) { + // PUSH_TUPLE + if (sp >= 20) revert PredicateMalformed(); + if (operand >= tupleCount) revert PredicateMalformed(); + taint[sp] = isBindingTuple[operand] ? 1 : 0; + sp++; + } else if (code == 0x21) { + // AND + if (operand != 0) revert PredicateMalformed(); + if (sp < 2) revert PredicateMalformed(); + uint8 a = taint[sp - 1]; + uint8 b = taint[sp - 2]; + uint8 combined = (a == 1 || b == 1) ? 1 : ((a == 2 || b == 2) ? 2 : 0); + taint[sp - 2] = combined; + sp--; + } else if (code == 0x22) { + // OR: both branches must commit to the class binding + // for the result to remain Bound. If only one side is + // Bound, the other side could let an off-class signer + // through, so we taint. Both Bound -> Bound covers the + // canonical multi-class predicate (e.g. "attr == A OR + // attr == B" when A, B both in class_set). + if (operand != 0) revert PredicateMalformed(); + if (sp < 2) revert PredicateMalformed(); + uint8 a = taint[sp - 1]; + uint8 b = taint[sp - 2]; + uint8 combined; + if (a == 1 && b == 1) { + combined = 1; + } else if (a == 1 || b == 1 || a == 2 || b == 2) { + combined = 2; + } else { + combined = 0; + } + taint[sp - 2] = combined; + sp--; + } else if (code == 0x23) { + // NOT (taints Bound) + if (operand != 0) revert PredicateMalformed(); + if (sp < 1) revert PredicateMalformed(); + uint8 a = taint[sp - 1]; + taint[sp - 1] = a == 1 ? 2 : a; + } else if (code == 0xff) { + // NOP + if (operand != 0) revert PredicateMalformed(); + } else { + revert PredicateMalformed(); + } + } + if (sp != 1) revert PredicateMalformed(); + if (taint[0] != 1) revert ClassBindingMissing(); + } + + function _classInSetMemory(uint16 c, uint16[] calldata set) internal pure returns (bool) { + uint256 lo = 0; + uint256 hi = set.length; + while (lo < hi) { + uint256 mid = (lo + hi) >> 1; + uint16 v = set[mid]; + if (v == c) return true; + if (v < c) lo = mid + 1; + else hi = mid; + } + return false; + } + + function _derivePetitionId(PetitionParams calldata params, bytes32 predicateHashPreId, uint32 sAtReg) + internal + view + returns (bytes32) + { + bytes32 h = keccak256( + abi.encodePacked( + DOMAIN_PETITION_ID, + uint64(block.chainid), + address(this), + msg.sender, + sAtReg, + predicateHashPreId, + params.closeAtBlock + ) + ); + // Mask the top byte so the resulting bytes32 is always less + // than the BN254 scalar field modulus. This keeps the on-chain + // bytes32 identifier and the signer SNARK's `petition_id` Fr + // public input bit-identical (no silent modular reduction). + return h & bytes32(uint256(0x00ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff)); + } + + function _writePetition( + bytes32 petitionId, + PetitionParams calldata params, + bytes32 predicateHash, + bytes32 predicateHashPreId, + uint32 sAtReg + ) internal { + PetitionRecord storage rec = petitions[petitionId]; + if (rec.state != PetitionState.Unset) revert InvalidPetition(); + _writeScalars(rec, params, predicateHash, predicateHashPreId, sAtReg); + rec.classSet = params.classSet; + rec.classThresholds = params.classThresholds; + // SPEC: Registered -> SigningOpen is atomic with the registration + // call. The Registered enum value documents the lifecycle but + // is never externally observed because the promotion happens + // synchronously here. + rec.state = PetitionState.SigningOpen; + } + + function _writeScalars( + PetitionRecord storage rec, + PetitionParams calldata params, + bytes32 predicateHash, + bytes32 predicateHashPreId, + uint32 sAtReg + ) internal { + rec.rRoot = params.rRoot; + rec.predicateHash = predicateHash; + rec.predicateHashPreId = predicateHashPreId; + rec.salt = params.salt; + rec.classIndex = params.classIndex; + rec.closeAtBlock = params.closeAtBlock; + rec.registrationBlock = uint64(block.number); + rec.slot = sAtReg; + rec.bounty = params.bounty; + rec.alphaAtRegistration = alpha; + rec.organizer = msg.sender; + rec.runningRoot = emptyImtRoot; + rec.identityTagSetRoot = emptyImtRoot; + } + + // ---------- Publish batch ---------- + + /// @notice Publish a batch. The transaction MUST be an EIP-4844 blob + /// transaction; the registry reads `blobhash(0)` and binds + /// it into `pi.batchVersionedHash`. The batch SNARK + /// encapsulates verification of all signer SNARKs (SPEC + /// constraint 2) and the IMT advance; the cross-field + /// binding to the blob (constraint 8) is closed by per- + /// position KZG point-evaluation against `batchVersionedHash`. + /// @param pi Batch SNARK public inputs (including blsFields). + /// @param batchProof bb-emitted UltraHonk proof bytes. + /// @param kzgCommitment 48-byte KZG commitment to the published blob. + /// @param kzgProofs Concatenated 48-byte KZG proofs, one per + /// canonical evaluation point `omega^k` for + /// k in [0, 24). Length MUST equal `24 * 48`. + function publishBatch( + BatchPublicInputs calldata pi, + bytes calldata batchProof, + bytes calldata kzgCommitment, + bytes calldata kzgProofs + ) external { + _preflightBatch(pi); + if (!batchVerifier.verify(batchProof, _batchPublicInputs(pi))) revert ProofRejected(); + _verifyConstraint8Openings(pi, kzgCommitment, kzgProofs); + _commitBatch(pi); + } + + // EIP-4844 stores blobs in bit-reversal-permuted evaluation form, + // so the canonical eval point at index `k` is `omega^{br(k, 12)}` + // where `omega` is the primitive 4096th root of unity in BLS12-381 + function _kzgEvalPoint(uint256 k) internal pure returns (bytes32) { + if (k == 0) return bytes32(uint256(0x0000000000000000000000000000000000000000000000000000000000000001)); + if (k == 1) return bytes32(uint256(0x73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000000)); + if (k == 2) return bytes32(uint256(0x00000000000000008d51ccce760304d0ec030002760300000001000000000000)); + if (k == 3) return bytes32(uint256(0x73eda753299d7d47a5e80b39939ed33467baa40089fb5bfefffeffff00000001)); + if (k == 4) return bytes32(uint256(0x345766f603fa66e78c0625cd70d77ce2b38b21c28713b7007228fd3397743f7a)); + if (k == 5) return bytes32(uint256(0x3f96405d25a31660a733b23a98ca5b22a032824078eaa4fe8dd702cb688bc087)); + if (k == 6) return bytes32(uint256(0x1333b22e5ce11044babc5affca86bf658e74903694b04fd86037fe81ae99502e)); + if (k == 7) return bytes32(uint256(0x60b9f524ccbc6d03787d7d083f1b189fc54913cc6b4e0c269fc8017d5166afd3)); + if (k == 8) return bytes32(uint256(0x20b1ce9140267af9dd1c0af834cec32c17beb312f20b6f7653ea61d87742bcce)); + if (k == 9) return bytes32(uint256(0x533bd8c1e977024e561dcd0fd4d314d93bfef0f00df2ec88ac159e2688bd4333)); + if (k == 10) return bytes32(uint256(0x4f2c596e753e4fcc6e92a9c460afca4a1ef4e672ebc1e1bb95df4b360411fe73)); + if (k == 11) return bytes32(uint256(0x24c14de4b45f2d7bc4a72e43a8f20dbb34c8bd90143c7a436a20b4c8fbee018e)); + if (k == 12) return bytes32(uint256(0x1edc919ec91f38ac5ccd4631f16edba4967a6b6cfb0faca4807b811a823f728d)); + if (k == 13) return bytes32(uint256(0x551115b4607e449bd66c91d61832fc60bd43389604eeaf5a7f847ee47dc08d74)); + if (k == 14) return bytes32(uint256(0x38c7f2dd7e0c63fccabf643eda8951f257bc96af334c36bca1abb31fb37786b9)); + if (k == 15) return bytes32(uint256(0x3b25b475ab91194b687a73c92f188612fc010d53ccb225425e544cdf4c887948)); + if (k == 16) return bytes32(uint256(0x50e0903a157988bab4bcd40e22f55448bf6e88fb4c38fb8a360c60997369df4e)); + if (k == 17) return bytes32(uint256(0x230d17191423f48d7e7d03f9e6ac83bc944f1b07b3c56074c9f39f658c9620b3)); + if (k == 18) return bytes32(uint256(0x65f6c5837cb5fca206050b5832d1099726bc7f62d13a6e1c3ec50c9031a36ca3)); + if (k == 19) return bytes32(uint256(0x0df6e1cface780a62d34ccafd6d0ce6e2d0124a02ec3ede2c13af36ece5c935e)); + if (k == 20) return bytes32(uint256(0x2c7e0457c83a7d9c5aea51f540eb0c04963dc46688b5e11768cc0c58459f155b)); + if (k == 21) return bytes32(uint256(0x476fa2fb6162ffabd84f8612c8b6cc00bd7fdf9c77487ae79733f3a6ba60eaa6)); + if (k == 22) return bytes32(uint256(0x5303da18a9d30564a8f0cfd2438f018c01e943612401899720d4ed194fccfeb9)); + return bytes32(uint256(0x20e9cd3a7fca77e38a490835c612d67951d460a1dbfcd267df2b12e5b0330148)); + } + + function _verifyConstraint8Openings( + BatchPublicInputs calldata pi, + bytes calldata kzgCommitment, + bytes calldata kzgProofs + ) internal view { + if (kzgCommitment.length != 48) revert InvalidBatch(); + if (kzgProofs.length != 48 * 24) revert InvalidBatch(); + for (uint256 k = 0; k < 24; k++) { + _verifyKzgOpening( + pi.batchVersionedHash, _kzgEvalPoint(k), pi.blsFields[k], kzgCommitment, kzgProofs[k * 48:(k + 1) * 48] + ); + } + } + + function _preflightBatch(BatchPublicInputs calldata pi) internal { + PetitionRecord storage rec = petitions[pi.petitionId]; + _advanceStateOnRead(rec); + if (rec.state != PetitionState.SigningOpen) revert InvalidState(); + // SPEC sec Batch Publication step 4: `[1, BATCH_SIZE_MAX]`. The PoC + // batch circuit additionally enforces `batch_size == BATCH_SIZE_MAX` + // (see circuits/batch/src/main.nr); production removes the circuit-side + // equality and accepts the full SPEC range here. + if (pi.batchSize == 0 || pi.batchSize > BATCH_SIZE_MAX) revert BatchSizeOutOfRange(); + if (pi.newLeafCount != pi.priorLeafCount + pi.batchSize) revert InvalidBatch(); + if (pi.signerVkHash != pinnedSignerVkHash) revert SignerVkHashMismatch(); + _checkPriorState(rec, pi); + if (blobhash(0) != pi.batchVersionedHash) revert BlobHashMismatch(); + } + + function _checkPriorState(PetitionRecord storage rec, BatchPublicInputs calldata pi) internal view { + if (rec.runningRoot != pi.priorRunningRoot) revert PriorStateMismatch(); + if (rec.identityTagSetRoot != pi.priorIdentityTagSetRoot) revert PriorStateMismatch(); + if (rec.leafCount != pi.priorLeafCount) revert PriorStateMismatch(); + if (rec.rRoot != pi.rRoot) revert PriorStateMismatch(); + if (rec.predicateHash != pi.predicateHash) revert PriorStateMismatch(); + if (rec.classIndex != pi.classIndex) revert PriorStateMismatch(); + if (pi.slot != rec.slot) revert PriorStateMismatch(); + } + + function _commitBatch(BatchPublicInputs calldata pi) internal { + PetitionRecord storage rec = petitions[pi.petitionId]; + uint32 batchIndex = rec.nextBatchIndex; + batches[pi.petitionId].push( + BatchRecord({ + batchVersionedHash: pi.batchVersionedHash, + priorRunningRoot: pi.priorRunningRoot, + newRunningRoot: pi.newRunningRoot, + priorIdentityTagSetRoot: pi.priorIdentityTagSetRoot, + newIdentityTagSetRoot: pi.newIdentityTagSetRoot, + priorLeafCount: pi.priorLeafCount, + newLeafCount: pi.newLeafCount, + relayer: msg.sender, + submittedAtBlock: uint64(block.number), + state: BatchState.Active + }) + ); + rec.runningRoot = pi.newRunningRoot; + rec.identityTagSetRoot = pi.newIdentityTagSetRoot; + rec.leafCount = pi.newLeafCount; + rec.nextBatchIndex = batchIndex + 1; + emit BatchPublished( + pi.petitionId, + batchIndex, + pi.batchVersionedHash, + pi.newRunningRoot, + pi.newIdentityTagSetRoot, + pi.newLeafCount + ); + } + + // ---------- Resolve ---------- + + function resolve(bytes32 petitionId, ResolutionPublicInputs calldata pi, bytes calldata resolutionProof) external { + PetitionRecord storage rec = petitions[petitionId]; + _advanceStateOnRead(rec); + if (rec.state != PetitionState.DisputeWindow) revert InvalidState(); + if (rec.bPerClass.length != 0) revert AlreadyResolved(); + + if (!resolutionVerifier.verify(resolutionProof, _resolutionPublicInputs(rec, pi))) revert ProofRejected(); + + rec.b = pi.b; + rec.bPerClass = pi.bPerClass; + rec.state = PetitionState.Resolved; + emit PetitionResolved(petitionId, pi.b, pi.bPerClass); + + _safeTransfer(bountyToken, msg.sender, rec.bounty); + emit BountyPaid(petitionId, msg.sender, rec.bounty); + } + + // ---------- Mark Unresolved ---------- + + /// Rebate to caller is `min(MARK_UNRESOLVED_GAS_ESTIMATE * tx.gasprice, + /// bounty * GAS_REBATE_BPS / 10_000)`. The cap is the 1% bound from + /// SPEC line 123 ("capped at 1%"). The gas-based bound prevents a + /// griefer from claiming 1% of a large bounty for negligible work + /// when bountyToken is denominated near 1:1 with ETH (e.g., WETH). + /// For tokens with different decimals the wei-vs-token-unit + /// comparison treats wei numerically; the cap still bounds payout. + function markUnresolved(bytes32 petitionId) external { + PetitionRecord storage rec = petitions[petitionId]; + _advanceStateOnRead(rec); + if (rec.state != PetitionState.DisputeWindow) revert InvalidState(); + if (block.number < uint256(rec.closeAtBlock) + RESOLUTION_DEADLINE_BLOCKS) revert TooEarly(); + + uint256 rebateCap = (rec.bounty * uint256(GAS_REBATE_BPS)) / 10_000; + uint256 estimatedGasCost = MARK_UNRESOLVED_GAS_ESTIMATE * tx.gasprice; + uint256 rebate = estimatedGasCost < rebateCap ? estimatedGasCost : rebateCap; + uint256 refund = rec.bounty - rebate; + rec.runningRoot = TOMBSTONE_RUNNING_ROOT; + rec.state = PetitionState.Unresolved; + _safeTransfer(bountyToken, rec.organizer, refund); + if (rebate > 0) _safeTransfer(bountyToken, msg.sender, rebate); + emit PetitionUnresolved(petitionId); + emit BountyRefunded(petitionId, rec.organizer, refund); + } + + // ---------- Dispute ---------- + + struct DisputeParams { + bytes32 petitionId; + uint32 batchIndex; + uint32 positionI; + uint32 positionJ; + uint8 violationType; + } + + /// Dispute a published batch using KZG openings against + /// `batchVersionedHash`. Positions identify which record(s) inside + /// the batch the dispute targets; the contract derives canonical + /// evaluation points internally, eliminating the previous attack + /// surface where a caller could supply arbitrary `z` values. + /// `openingsBlob` carries y-values only (32 bytes each); `proofsBlob` + /// carries 48-byte KZG proofs. + function dispute( + bytes32 petitionId, + uint32 batchIndex, + uint32 positionI, + uint32 positionJ, + uint8 violationType, + bytes calldata kzgCommitment, + bytes calldata openingsBlob, + bytes calldata proofsBlob + ) external { + DisputeParams memory dp = DisputeParams(petitionId, batchIndex, positionI, positionJ, violationType); + _preflightDispute(dp, kzgCommitment); + _verifyOpenings(dp, kzgCommitment, openingsBlob, proofsBlob); + if (!_applyViolationPredicate(dp, openingsBlob)) revert ViolationFalse(); + _cascadeRepudiation(dp); + } + + function _preflightDispute(DisputeParams memory dp, bytes calldata kzgCommitment) internal { + PetitionRecord storage rec = petitions[dp.petitionId]; + _advanceStateOnRead(rec); + // SPEC line 107: dispute only during DisputeWindow. + if (rec.state != PetitionState.DisputeWindow) revert InvalidState(); + if (dp.batchIndex >= batches[dp.petitionId].length) revert InvalidBatch(); + BatchRecord storage bat = batches[dp.petitionId][dp.batchIndex]; + if (bat.state != BatchState.Active) revert InvalidBatch(); + if (kzgCommitment.length != 48) revert InvalidBatch(); + + // Position bound: must lie in [0, BATCH_SIZE_MAX). + if (dp.positionI >= BATCH_SIZE_MAX) revert InvalidDispute(); + if (dp.violationType != 0x01) { + if (dp.positionJ >= BATCH_SIZE_MAX) revert InvalidDispute(); + if (dp.positionI == dp.positionJ) revert InvalidDispute(); + } + if (dp.violationType == 0x03) { + // SPEC: ordering violation compares records i and i+1. + if (dp.positionJ != dp.positionI + 1) revert InvalidDispute(); + } + + bytes32 sha = sha256(kzgCommitment); + bytes32 reconstructedVh = (sha & ~bytes32(uint256(0xff) << 248)) | bytes32(uint256(0x01) << 248); + if (reconstructedVh != bat.batchVersionedHash) revert BlobHashMismatch(); + } + + /// y-only openings; z is derived from positions inside the contract. + /// Layout: `openingsBlob[k*32 .. k*32+32]` for opening index k. + /// For violation 0x01: 4 openings (one record's 4 field elements). + /// For violation 0x02, 0x03: 8 openings (two records' 8 field elements). + function _verifyOpenings( + DisputeParams memory dp, + bytes calldata kzgCommitment, + bytes calldata openingsBlob, + bytes calldata proofsBlob + ) internal view { + uint256 needed = dp.violationType == 0x01 ? 4 : 8; + if (openingsBlob.length != needed * 32) revert InvalidBatch(); + if (proofsBlob.length != needed * 48) revert InvalidBatch(); + bytes32 vh = batches[dp.petitionId][dp.batchIndex].batchVersionedHash; + + for (uint256 k = 0; k < needed; k++) { + uint32 pos = k < 4 ? dp.positionI : dp.positionJ; + uint256 evalIdx = uint256(pos) * 4 + (k % 4); + _verifyKzgOpening( + vh, + _kzgEvalPoint(evalIdx), + _read32(openingsBlob, k * 32), + kzgCommitment, + proofsBlob[k * 48:(k + 1) * 48] + ); + } + } + + function _applyViolationPredicate(DisputeParams memory dp, bytes calldata openingsBlob) + internal + view + returns (bool) + { + if (dp.violationType == 0x01) { + return !_classInSet(_decodeRecordClassTag(openingsBlob, 0), petitions[dp.petitionId].classSet); + } else if (dp.violationType == 0x02) { + bytes32 idTagI = _reconstructIdentityTag(openingsBlob, 0); + bytes32 idTagJ = _reconstructIdentityTag(openingsBlob, 4 * 32); + return idTagI == idTagJ; + } else if (dp.violationType == 0x03) { + // SPEC: leaf_k = Poseidon1(DOMAIN_LEAF, nullifier_k, class_tag_k) + bytes32 nullI = _reconstructNullifier(openingsBlob, 0); + bytes32 nullJ = _reconstructNullifier(openingsBlob, 4 * 32); + uint16 ctI = _decodeRecordClassTag(openingsBlob, 0); + uint16 ctJ = _decodeRecordClassTag(openingsBlob, 4 * 32); + uint256 leafI = _poseidon3(DOMAIN_LEAF, uint256(nullI), uint256(ctI)); + uint256 leafJ = _poseidon3(DOMAIN_LEAF, uint256(nullJ), uint256(ctJ)); + return leafI >= leafJ; + } + revert InvalidBatch(); + } + + function _cascadeRepudiation(DisputeParams memory dp) internal { + PetitionRecord storage rec = petitions[dp.petitionId]; + uint256 cnt = batches[dp.petitionId].length; + bytes32 newRunning; + bytes32 newIdtag; + uint64 newLeafCount; + if (dp.batchIndex > 0) { + BatchRecord storage prev = batches[dp.petitionId][dp.batchIndex - 1]; + newRunning = prev.newRunningRoot; + newIdtag = prev.newIdentityTagSetRoot; + newLeafCount = prev.newLeafCount; + } else { + // SPEC line 107: rollback to initial empty-IMT state if no + // active predecessor exists. zero would brick the petition. + newRunning = emptyImtRoot; + newIdtag = emptyImtRoot; + newLeafCount = 0; + } + for (uint256 b = dp.batchIndex; b < cnt; b++) { + if (batches[dp.petitionId][b].state == BatchState.Active) { + batches[dp.petitionId][b].state = BatchState.Repudiated; + } + } + rec.runningRoot = newRunning; + rec.identityTagSetRoot = newIdtag; + rec.leafCount = newLeafCount; + rec.nextBatchIndex = dp.batchIndex; + emit BatchRepudiated(dp.petitionId, dp.batchIndex, newRunning, newIdtag, newLeafCount); + } + + // ---------- Governance ---------- + + function updateAlpha(uint64 newAlpha) external onlyGovernance { + if (newAlpha < alphaMin || newAlpha > alphaMax) revert AlphaOutOfBounds(); + emit AlphaUpdated(alpha, newAlpha); + alpha = newAlpha; + } + + // ---------- View ---------- + + function getPetition(bytes32 petitionId) external view returns (PetitionRecord memory) { + return petitions[petitionId]; + } + + function getBatch(bytes32 petitionId, uint32 idx) external view returns (BatchRecord memory) { + return batches[petitionId][idx]; + } + + function getBatchCount(bytes32 petitionId) external view returns (uint256) { + return batches[petitionId].length; + } + + // ---------- Internal ---------- + + function _advanceStateOnRead(PetitionRecord storage rec) internal { + if (rec.state == PetitionState.Unset) revert InvalidPetition(); + // Registered -> SigningOpen atomic with first read after registration. + if (rec.state == PetitionState.Registered) { + rec.state = PetitionState.SigningOpen; + } + if (rec.state == PetitionState.SigningOpen && block.number >= rec.closeAtBlock) { + rec.state = PetitionState.SigningClosed; + } + // SigningClosed -> Cooldown atomic with SigningClosed entry. + if (rec.state == PetitionState.SigningClosed) { + rec.state = PetitionState.Cooldown; + } + if (rec.state == PetitionState.Cooldown && block.number >= rec.closeAtBlock + COOLDOWN_BLOCKS) { + rec.state = PetitionState.DisputeWindow; + } + } + + function _classInSet(uint16 c, uint16[] storage set) internal view returns (bool) { + uint256 lo = 0; + uint256 hi = set.length; + while (lo < hi) { + uint256 mid = (lo + hi) >> 1; + uint16 v = set[mid]; + if (v == c) return true; + if (v < c) lo = mid + 1; + else hi = mid; + } + return false; + } + + function _batchPublicInputs(BatchPublicInputs calldata pi) internal pure returns (bytes32[] memory out) { + // 13 named PIs + 24 blsFields + 1 signerVkHash = 38 total. PI ordering + // matches SPEC sec Batch SNARK "Public inputs (ordered)" exactly. + out = new bytes32[](13 + 24 + 1); + out[0] = pi.petitionId; + out[1] = pi.rRoot; + out[2] = pi.predicateHash; + out[3] = bytes32(uint256(pi.classIndex)); + out[4] = bytes32(uint256(pi.slot)); + out[5] = bytes32(uint256(pi.batchSize)); + out[6] = pi.priorRunningRoot; + out[7] = pi.newRunningRoot; + out[8] = pi.priorIdentityTagSetRoot; + out[9] = pi.newIdentityTagSetRoot; + out[10] = bytes32(uint256(pi.priorLeafCount)); + out[11] = bytes32(uint256(pi.newLeafCount)); + out[12] = pi.batchVersionedHash; + for (uint256 k = 0; k < 24; k++) { + out[13 + k] = pi.blsFields[k]; + } + out[37] = pi.signerVkHash; + } + + // Resolution circuit (circuits/resolution/src/main.nr) public input + // layout, declaration-order: + // predicate_hash, r_root, running_root, leaf_count, + // class_set[CLASS_MAX], class_set_len, + // class_thresholds[CLASS_MAX], b, b_per_class[CLASS_MAX]. + // Total: 4 + 16 + 1 + 16 + 1 + 16 = 54. Unused slots in the + // fixed-size arrays are padded with 0. + uint256 internal constant RESOLUTION_CLASS_MAX = 16; + + function _resolutionPublicInputs(PetitionRecord storage rec, ResolutionPublicInputs calldata pi) + internal + view + returns (bytes32[] memory out) + { + uint256 csLen = rec.classSet.length; + if (pi.bPerClass.length != csLen) revert PriorStateMismatch(); + // 4 named + 16 class_set + 1 class_set_len + 16 thresholds + 1 b + 16 bPerClass + 1 classIndex = 55 + out = new bytes32[](4 + RESOLUTION_CLASS_MAX + 1 + RESOLUTION_CLASS_MAX + 1 + RESOLUTION_CLASS_MAX + 1); + uint256 j = 0; + out[j++] = rec.predicateHash; + out[j++] = rec.rRoot; + out[j++] = rec.runningRoot; + out[j++] = bytes32(uint256(rec.leafCount)); + for (uint256 i = 0; i < RESOLUTION_CLASS_MAX; i++) { + out[j++] = i < csLen ? bytes32(uint256(rec.classSet[i])) : bytes32(0); + } + out[j++] = bytes32(csLen); + for (uint256 i = 0; i < RESOLUTION_CLASS_MAX; i++) { + out[j++] = i < csLen ? bytes32(uint256(rec.classThresholds[i])) : bytes32(0); + } + out[j++] = bytes32(uint256(pi.b ? 1 : 0)); + for (uint256 i = 0; i < RESOLUTION_CLASS_MAX; i++) { + out[j++] = i < csLen ? bytes32(uint256(pi.bPerClass[i] ? 1 : 0)) : bytes32(0); + } + out[j++] = bytes32(uint256(rec.classIndex)); + } + + function _verifyKzgOpening( + bytes32 versionedHash, + bytes32 z, + bytes32 y, + bytes calldata commitment, + bytes calldata proof_ + ) internal view { + bytes memory input = bytes.concat(versionedHash, z, y, commitment, proof_); + (bool ok, bytes memory ret) = address(0x0a).staticcall(input); + if (!ok || ret.length == 0) revert ProofRejected(); + } + + function _read32(bytes calldata data, uint256 off) internal pure returns (bytes32 v) { + assembly { + v := calldataload(add(data.offset, off)) + } + } + + /// Decode `class_tag` (uint16 BE) from the y-value at `base + 2*32` + /// (FE2 of a record at the given base offset). `base = 0` is the + /// first record; `base = 4*32` is the second record. + function _decodeRecordClassTag(bytes calldata openingsBlob, uint256 base) internal pure returns (uint16 classTag) { + bytes32 fe2y = _read32(openingsBlob, base + 2 * 32); + uint256 contentRaw = uint256(fe2y) & ((uint256(1) << 248) - 1); + // class_tag occupies content bytes [2..4] of FE2. + uint256 hi4 = contentRaw >> (27 * 8); + classTag = uint16(hi4 & 0xffff); + } + + function _reconstructNullifier(bytes calldata openingsBlob, uint256 base) internal pure returns (bytes32) { + bytes32 fe0y = _read32(openingsBlob, base); + bytes32 fe1y = _read32(openingsBlob, base + 32); + uint256 fe0 = uint256(fe0y) & ((uint256(1) << 248) - 1); + uint256 fe1 = uint256(fe1y) & ((uint256(1) << 248) - 1); + uint256 fe1Byte0 = (fe1 >> (30 * 8)) & 0xff; + uint256 nul = (fe0 << 8) | fe1Byte0; + return bytes32(nul); + } + + function _reconstructIdentityTag(bytes calldata openingsBlob, uint256 base) internal pure returns (bytes32) { + bytes32 fe1y = _read32(openingsBlob, base + 32); + bytes32 fe2y = _read32(openingsBlob, base + 2 * 32); + uint256 fe1 = uint256(fe1y) & ((uint256(1) << 248) - 1); + uint256 fe2 = uint256(fe2y) & ((uint256(1) << 248) - 1); + uint256 fe1Body = fe1 & ((uint256(1) << (30 * 8)) - 1); + uint256 fe2Hi = (fe2 >> (29 * 8)) & 0xffff; + uint256 idt = (fe1Body << 16) | fe2Hi; + return bytes32(idt); + } + + function _safeTransfer(address token, address to, uint256 amount) internal { + (bool ok, bytes memory ret) = token.call(abi.encodeWithSignature("transfer(address,uint256)", to, amount)); + if (!ok || (ret.length != 0 && !abi.decode(ret, (bool)))) revert PaymentFailed(); + } + + function _safeTransferFrom(address token, address from, address to, uint256 amount) internal { + (bool ok, bytes memory ret) = + token.call(abi.encodeWithSignature("transferFrom(address,address,uint256)", from, to, amount)); + if (!ok || (ret.length != 0 && !abi.decode(ret, (bool)))) revert PaymentFailed(); + } +} diff --git a/pocs/civic-participation/resilient-civic-participation/contracts/src/interfaces/IPetitionRegistry.sol b/pocs/civic-participation/resilient-civic-participation/contracts/src/interfaces/IPetitionRegistry.sol new file mode 100644 index 0000000..6db518f --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/contracts/src/interfaces/IPetitionRegistry.sol @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.21; + +/// @title IPetitionRegistry +/// @notice Public entry points of the on-chain petition registry. +interface IPetitionRegistry { + enum PetitionState { + Unset, + Registered, + SigningOpen, + SigningClosed, + Cooldown, + DisputeWindow, + Resolved, + Unresolved + } + + enum BatchState { + Unset, + Active, + Repudiated + } + + struct PetitionParams { + bytes32 rRoot; + bytes predicateDef; + bytes32 salt; + uint16[] classSet; + uint64[] classThresholds; + uint8 classIndex; + uint64 closeAtBlock; + uint256 bounty; + } + + struct BatchPublicInputs { + bytes32 petitionId; + bytes32 rRoot; + bytes32 predicateHash; + uint8 classIndex; + uint32 slot; + uint32 batchSize; + bytes32 priorRunningRoot; + bytes32 newRunningRoot; + bytes32 priorIdentityTagSetRoot; + bytes32 newIdentityTagSetRoot; + uint64 priorLeafCount; + uint64 newLeafCount; + // Bound to `blobhash(0)` of the publishing transaction. + bytes32 batchVersionedHash; + // Per-position BLS12-381 field-element decompositions of the + // batch records (SPEC constraint 8). Length is + // `BATCH_SIZE_MAX * FE_PER_RECORD = 24`; the contract verifies + // each value via a KZG point-evaluation at `omega^k` against + // `batchVersionedHash`. + bytes32[24] blsFields; + // Signer VK hash pinned by the deploy-time constant + // `pinnedSignerVkHash`. Without this, a malicious relayer could + // supply their own signer VK and prove anything. + bytes32 signerVkHash; + } + + // Resolver supplies only the outcome bits; the contract reads every + // other public input (predicateHash, rRoot, runningRoot, leafCount, + // classSet, classThresholds, classIndex) from PetitionRecord at + // resolve-time. This removes the substitution attack surface for + // classSet / classThresholds (CRIT-1 in AUDIT.md). + struct ResolutionPublicInputs { + bool b; + bool[] bPerClass; + } + + event PetitionRegistered( + bytes32 indexed petitionId, + uint32 slot, + bytes32 rRoot, + bytes32 predicateHash, + uint16[] classSet, + uint64[] classThresholds, + uint8 classIndex, + uint64 closeAtBlock, + uint256 bounty + ); + + event BatchPublished( + bytes32 indexed petitionId, + uint32 indexed batchIndex, + bytes32 batchVersionedHash, + bytes32 newRunningRoot, + bytes32 newIdentityTagSetRoot, + uint64 newLeafCount + ); + + event BatchRepudiated( + bytes32 indexed petitionId, + uint32 indexed batchIndex, + bytes32 newRunningRoot, + bytes32 newIdentityTagSetRoot, + uint64 newLeafCount + ); + + event PetitionResolved(bytes32 indexed petitionId, bool b, bool[] bPerClass); + event PetitionUnresolved(bytes32 indexed petitionId); + event BountyPaid(bytes32 indexed petitionId, address recipient, uint256 amount); + event BountyRefunded(bytes32 indexed petitionId, address recipient, uint256 amount); + event AlphaUpdated(uint64 oldAlpha, uint64 newAlpha); +} diff --git a/pocs/civic-participation/resilient-civic-participation/contracts/src/interfaces/IVerifier.sol b/pocs/civic-participation/resilient-civic-participation/contracts/src/interfaces/IVerifier.sol new file mode 100644 index 0000000..40bae0e --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/contracts/src/interfaces/IVerifier.sol @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.21; + +/// @title IVerifier +/// @notice Aztec Honk verifier ABI. Each circuit-specific verifier contract +/// exposes this single `verify` entry point. +interface IVerifier { + function verify(bytes calldata proof, bytes32[] calldata publicInputs) external view returns (bool); +} diff --git a/pocs/civic-participation/resilient-civic-participation/contracts/src/mocks/MockBatchVerifier.sol b/pocs/civic-participation/resilient-civic-participation/contracts/src/mocks/MockBatchVerifier.sol new file mode 100644 index 0000000..5627d9b --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/contracts/src/mocks/MockBatchVerifier.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.21; + +import {IVerifier} from "../interfaces/IVerifier.sol"; + +/// Test-only mock. NOT for production deployment. Deploy scripts are +/// gated by `use_mock_verifier`. +contract MockBatchVerifier is IVerifier { + bool public result; + + constructor() { + result = true; + } + + function setResult(bool r) external { + result = r; + } + + function verify(bytes calldata, bytes32[] calldata) external view returns (bool) { + return result; + } +} diff --git a/pocs/civic-participation/resilient-civic-participation/contracts/src/mocks/MockERC20.sol b/pocs/civic-participation/resilient-civic-participation/contracts/src/mocks/MockERC20.sol new file mode 100644 index 0000000..b2a4612 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/contracts/src/mocks/MockERC20.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.21; + +/// @title MockERC20 +/// @notice Minimal ERC-20 used to back the bounty escrow in integration +/// tests. NOT production-grade; no permits, no flash protection. +contract MockERC20 { + string public name; + string public symbol; + uint8 public decimals; + uint256 public totalSupply; + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + constructor(string memory _name, string memory _symbol, uint8 _decimals) { + name = _name; + symbol = _symbol; + decimals = _decimals; + } + + function mint(address to, uint256 amount) external { + balanceOf[to] += amount; + totalSupply += amount; + emit Transfer(address(0), to, amount); + } + + function approve(address spender, uint256 amount) external returns (bool) { + allowance[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } + + function transfer(address to, uint256 amount) external returns (bool) { + _transfer(msg.sender, to, amount); + return true; + } + + function transferFrom(address from, address to, uint256 amount) external returns (bool) { + uint256 a = allowance[from][msg.sender]; + require(a >= amount, "allowance"); + if (a != type(uint256).max) { + allowance[from][msg.sender] = a - amount; + } + _transfer(from, to, amount); + return true; + } + + function _transfer(address from, address to, uint256 amount) internal { + require(balanceOf[from] >= amount, "balance"); + balanceOf[from] -= amount; + balanceOf[to] += amount; + emit Transfer(from, to, amount); + } +} diff --git a/pocs/civic-participation/resilient-civic-participation/contracts/src/mocks/MockResolutionVerifier.sol b/pocs/civic-participation/resilient-civic-participation/contracts/src/mocks/MockResolutionVerifier.sol new file mode 100644 index 0000000..b98429d --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/contracts/src/mocks/MockResolutionVerifier.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.21; + +import {IVerifier} from "../interfaces/IVerifier.sol"; + +/// Test-only mock. NOT for production deployment. Deploy scripts are +/// gated by `use_mock_verifier`. +contract MockResolutionVerifier is IVerifier { + bool public result; + + constructor() { + result = true; + } + + function setResult(bool r) external { + result = r; + } + + function verify(bytes calldata, bytes32[] calldata) external view returns (bool) { + return result; + } +} diff --git a/pocs/civic-participation/resilient-civic-participation/contracts/src/verifiers/BatchVerifier.sol b/pocs/civic-participation/resilient-civic-participation/contracts/src/verifiers/BatchVerifier.sol new file mode 100644 index 0000000..797b35c --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/contracts/src/verifiers/BatchVerifier.sol @@ -0,0 +1,2461 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2022 Aztec +pragma solidity >=0.8.21; + +uint256 constant N = 8388608; +uint256 constant LOG_N = 23; +uint256 constant NUMBER_OF_PUBLIC_INPUTS = 46; +uint256 constant VK_HASH = 0x1ba7301a10d3350ff8153681e0585cf2c222c4742055fc6d075d974bb1caa6aa; + +library HonkVerificationKey { + function loadVerificationKey() internal pure returns (Honk.VerificationKey memory) { + Honk.VerificationKey memory vk = Honk.VerificationKey({ + circuitSize: uint256(8388608), + logCircuitSize: uint256(23), + publicInputsSize: uint256(46), + ql: Honk.G1Point({ + x: uint256(0x28afdf223c1e37dd1ed0ea36a501d452e8303ec0d7b0f0c1190643cb378d05aa), + y: uint256(0x26669a3cee22f5934b03890e7d378f1b0af7bf4da66315b406b7fe73a45c28f5) + }), + qr: Honk.G1Point({ + x: uint256(0x1c235aaeabbd5ebf8e88bad2e8ed2c75b110c8b7619a35755efec7f90a40bc20), + y: uint256(0x10a20bd4bf35c7f78881156d1719a60fd4778c000e8692f2540e270ba1d7e869) + }), + qo: Honk.G1Point({ + x: uint256(0x2bb693d9b1d319385db43a757c4874dd5bafcdf345c269204396cbbfb053176b), + y: uint256(0x2ca5a6648c50f25a2209130d241bc2f2b7c03d9cbd521a45cfdc9034cf001810) + }), + q4: Honk.G1Point({ + x: uint256(0x125a42517869f9f0e1b2ce3e3b889d65cbb685ec5a92a0a768db018ac8f2c02e), + y: uint256(0x1a6a93fd45dedb36b976f71209ad91b2517b9015607f00f77fca827cece0b60e) + }), + qm: Honk.G1Point({ + x: uint256(0x11e1e9a1795eae9daee17132acfce3fc847a7bb4727288358fa9bad75e2a3b3e), + y: uint256(0x0a9e0e3028d0cccf9391152272822677ebecc599f142ed853bae5261c6e9a013) + }), + qc: Honk.G1Point({ + x: uint256(0x10809ba4d4d7c6a23c34add6286d5962031e34ec36942f4fca7949b65cff8f36), + y: uint256(0x0c09477b2256c3070719a0510c07834f179acce197dfac67fae19431c93ac498) + }), + qLookup: Honk.G1Point({ + x: uint256(0x00cdd2dad6e4eee82b2f1715bca87d0173f54d2f37b58a0a655a71db2ab41145), + y: uint256(0x042d0b5efe9443070262f3a07fd827795cc2ca27794c3d9deb0d21b24a40bba4) + }), + qArith: Honk.G1Point({ + x: uint256(0x03adcc37155120d265a0f14b02030de34a0e633db4d2dd2d43f38b9305eb67ed), + y: uint256(0x1150d770c6f4922e8a8f8b0e07f3518f9d92cc82310b9709d38295b7e3955464) + }), + qDeltaRange: Honk.G1Point({ + x: uint256(0x00bd5eba84f074705f50e1e8ec3dd5be96779d8226a2b01616196e5175bf06ef), + y: uint256(0x06312884bbaf6181a426081a9e23ca8b50704cca7b8612724c10eb22e242120e) + }), + qElliptic: Honk.G1Point({ + x: uint256(0x12f0032053ad637ce38294a37ce96a0d9ae6ad62f76f152832c804421c88a4f0), + y: uint256(0x086d14220db54d42ec16669ba32d5d478cc441955917920edb179ac4c783064a) + }), + qMemory: Honk.G1Point({ + x: uint256(0x10bd3137142e01027fb6fbb858fbf2b7baa9fc57a687b2465d30d7b44f4c07e6), + y: uint256(0x2324a88edea963bd4b91f99a7ff55f9735efa6a8c71bc9568f0544c54e1eeeef) + }), + qNnf: Honk.G1Point({ + x: uint256(0x09ab3382283fa45ed128236adbe15650f09a384b20c2618781bf591b1f606072), + y: uint256(0x0a503a7720d5fa9d4bd03e0b0e59634d224f4ebbd302ce4f9dbaa37bcddb2e98) + }), + qPoseidon2External: Honk.G1Point({ + x: uint256(0x1626bc7323110f8013cbc09baf43d1f0d2f41df1f4dee6307695f3e3a4a2b160), + y: uint256(0x0304d20611986de3dcbe90c3538dbfabf7959902e2e71817ca7ebfc14503c96d) + }), + qPoseidon2Internal: Honk.G1Point({ + x: uint256(0x1f9aa8cf2e44c9e72f7f9b1245e349a5730b8f3e3f4c6ca99160fa9dc4223b39), + y: uint256(0x2caae1503305f0fe0beaf2ed743761cd8a8c07461759d30c6e2ebf49d1b4a51a) + }), + s1: Honk.G1Point({ + x: uint256(0x301f29bbb513f597dcb0f5b19fea88a65a620530524c7c5c940707dab40aede8), + y: uint256(0x27f6ee6fab90c14c5eb4d204993109d01095949b5cb7ee06a3190c7a29ce31c2) + }), + s2: Honk.G1Point({ + x: uint256(0x27f86bad7034cc304abd1110bd983d55b916b868c4f227c7747badacba680cca), + y: uint256(0x0cf6abaf8e21d67dbad9ba01213a55ddbacc19fe8274422fddb3d0a7a95396ff) + }), + s3: Honk.G1Point({ + x: uint256(0x1b6a8ec4ec26f55e32b6032d9405c2ffdbc1f8f658251757565b15c925d7c22a), + y: uint256(0x0a407fc6e6af56c828bd72c79cf3e9bbe1cd15a3b41ed5cc38c1922c6165d259) + }), + s4: Honk.G1Point({ + x: uint256(0x2b05a0f5848523baba6407482ef736a61432fb01e533e52155688af32b5ec229), + y: uint256(0x0cf64fd6b2a126b8bd5dd4fcbd5a7aabb5511a70f56a8fe1ceb29a44bebfa1aa) + }), + t1: Honk.G1Point({ + x: uint256(0x099e3bd5a0a00ab7fe18040105b9b395b5d8b7b4a63b05df652b0d10ef146d26), + y: uint256(0x0015b8d2515d76e2ccec99dcd194592129af3a637f5a622a32440f860d1e2a7f) + }), + t2: Honk.G1Point({ + x: uint256(0x1b917517920bad3d8bc01c9595092a222b888108dc25d1aa450e0b4bc212c37e), + y: uint256(0x305e8992b148eedb22e6e992077a84482141c7ebe42000a1d58ccb74381f6d19) + }), + t3: Honk.G1Point({ + x: uint256(0x16465a5ccbb550cd2c63bd58116fe47c86847618681dc29d8a9363ab7c40e1c3), + y: uint256(0x2e24d420fbf9508ed31de692db477b439973ac12d7ca796d6fe98ca40e6ca6b7) + }), + t4: Honk.G1Point({ + x: uint256(0x043d063b130adfb37342af45d0155a28edd1a7e46c840d9c943fdf45521c64ce), + y: uint256(0x261522c4089330646aff96736194949330952ae74c573d1686d9cb4a00733854) + }), + id1: Honk.G1Point({ + x: uint256(0x108d850a36506b44e6c5f04f629c423a9300a392cca6e1c70e349b5d16acead4), + y: uint256(0x15edabfe773bb71c4c716653c5f73e281e0e80cb50e7d51258ce6ae356656361) + }), + id2: Honk.G1Point({ + x: uint256(0x2625e9573bceb1d8bed32c31852c9b3c0c3f50af0b7d532b862aa4232383b81b), + y: uint256(0x27c6c16208f11cc10bcf8f9fbbd9ab5c563db47c30e86e0b2ebe3aa6f32dc47b) + }), + id3: Honk.G1Point({ + x: uint256(0x203f9174b56da6ff5ae50d7c4681ef8ec8e13e590c69131f8543ac8f66fb5a4c), + y: uint256(0x2ecb8db214724583d59b6ad8400a51acdb1dc0d59588a4db2590faa333215af3) + }), + id4: Honk.G1Point({ + x: uint256(0x0e8b36904e49cc1509dacf8e7b50cf6c8d6507ecd47d4abea97a6b3e38f72ddf), + y: uint256(0x2bfa4d76942dde9d7c38d2dd3b58c50d00d177e69478fdf18c211580ea87c573) + }), + lagrangeFirst: Honk.G1Point({ + x: uint256(0x0000000000000000000000000000000000000000000000000000000000000001), + y: uint256(0x0000000000000000000000000000000000000000000000000000000000000002) + }), + lagrangeLast: Honk.G1Point({ + x: uint256(0x07eb157486941d0d2edb360b06d7be7901256cfba9d356c6d7c418afb30c8ca2), + y: uint256(0x2450ddb61bd9d1c1c68c5eef523b62e2f4b505b9e729b3e856b7d20f737f9dca) + }) + }); + return vk; + } +} + +pragma solidity ^0.8.27; + +interface IVerifier { + function verify(bytes calldata _proof, bytes32[] calldata _publicInputs) external view returns (bool); +} + +/** + * @notice Library of error codes + * @dev You can run `forge inspect Errors errors` to get the selectors for the optimised verifier + */ +library Errors { + error ValueGeLimbMax(); + error ValueGeGroupOrder(); + error ValueGeFieldOrder(); + + error InvertOfZero(); + error NotPowerOfTwo(); + error ModExpFailed(); + + error ProofLengthWrong(); + error ProofLengthWrongWithLogN(uint256 logN, uint256 actualLength, uint256 expectedLength); + error PublicInputsLengthWrong(); + error SumcheckFailed(); + error ShpleminiFailed(); + + error PointAtInfinity(); + + error ConsistencyCheckFailed(); + error GeminiChallengeInSubgroup(); +} + +type Fr is uint256; + +using {add as +} for Fr global; +using {sub as -} for Fr global; +using {mul as *} for Fr global; + +using {notEqual as !=} for Fr global; +using {equal as ==} for Fr global; + +uint256 constant SUBGROUP_SIZE = 256; +uint256 constant MODULUS = 21888242871839275222246405745257275088548364400416034343698204186575808495617; // Prime field order +uint256 constant P = MODULUS; +Fr constant SUBGROUP_GENERATOR = Fr.wrap(0x07b0c561a6148404f086204a9f36ffb0617942546750f230c893619174a57a76); +Fr constant SUBGROUP_GENERATOR_INVERSE = Fr.wrap(0x204bd3277422fad364751ad938e2b5e6a54cf8c68712848a692c553d0329f5d6); +Fr constant MINUS_ONE = Fr.wrap(MODULUS - 1); +Fr constant ONE = Fr.wrap(1); +Fr constant ZERO = Fr.wrap(0); +// Instantiation + +library FrLib { + bytes4 internal constant FRLIB_MODEXP_FAILED_SELECTOR = 0xf8d61709; + + function invert(Fr value) internal view returns (Fr) { + uint256 v = Fr.unwrap(value); + require(v != 0, Errors.InvertOfZero()); + + uint256 result; + + // Call the modexp precompile to invert in the field + assembly { + let free := mload(0x40) + mstore(free, 0x20) + mstore(add(free, 0x20), 0x20) + mstore(add(free, 0x40), 0x20) + mstore(add(free, 0x60), v) + mstore(add(free, 0x80), sub(MODULUS, 2)) + mstore(add(free, 0xa0), MODULUS) + let success := staticcall(gas(), 0x05, free, 0xc0, 0x00, 0x20) + if iszero(success) { + mstore(0x00, FRLIB_MODEXP_FAILED_SELECTOR) + revert(0, 0x04) + } + result := mload(0x00) + mstore(0x40, add(free, 0xc0)) + } + + return Fr.wrap(result); + } + + function pow(Fr base, uint256 v) internal view returns (Fr) { + uint256 b = Fr.unwrap(base); + // Only works for power of 2 + require(v > 0 && (v & (v - 1)) == 0, Errors.NotPowerOfTwo()); + uint256 result; + + // Call the modexp precompile to invert in the field + assembly { + let free := mload(0x40) + mstore(free, 0x20) + mstore(add(free, 0x20), 0x20) + mstore(add(free, 0x40), 0x20) + mstore(add(free, 0x60), b) + mstore(add(free, 0x80), v) + mstore(add(free, 0xa0), MODULUS) + let success := staticcall(gas(), 0x05, free, 0xc0, 0x00, 0x20) + if iszero(success) { + mstore(0x00, FRLIB_MODEXP_FAILED_SELECTOR) + revert(0, 0x04) + } + result := mload(0x00) + mstore(0x40, add(free, 0xc0)) + } + + return Fr.wrap(result); + } + + function div(Fr numerator, Fr denominator) internal view returns (Fr) { + unchecked { + return numerator * invert(denominator); + } + } + + function sqr(Fr value) internal pure returns (Fr) { + unchecked { + return value * value; + } + } + + function unwrap(Fr value) internal pure returns (uint256) { + unchecked { + return Fr.unwrap(value); + } + } + + function neg(Fr value) internal pure returns (Fr) { + unchecked { + return Fr.wrap(MODULUS - Fr.unwrap(value)); + } + } + + function from(uint256 value) internal pure returns (Fr) { + unchecked { + require(value < MODULUS, Errors.ValueGeFieldOrder()); + return Fr.wrap(value); + } + } + + function fromBytes32(bytes32 value) internal pure returns (Fr) { + unchecked { + uint256 v = uint256(value); + require(v < MODULUS, Errors.ValueGeFieldOrder()); + return Fr.wrap(v); + } + } + + function toBytes32(Fr value) internal pure returns (bytes32) { + unchecked { + return bytes32(Fr.unwrap(value)); + } + } +} + +// Free functions +function add(Fr a, Fr b) pure returns (Fr) { + unchecked { + return Fr.wrap(addmod(Fr.unwrap(a), Fr.unwrap(b), MODULUS)); + } +} + +function mul(Fr a, Fr b) pure returns (Fr) { + unchecked { + return Fr.wrap(mulmod(Fr.unwrap(a), Fr.unwrap(b), MODULUS)); + } +} + +function sub(Fr a, Fr b) pure returns (Fr) { + unchecked { + return Fr.wrap(addmod(Fr.unwrap(a), MODULUS - Fr.unwrap(b), MODULUS)); + } +} + +function notEqual(Fr a, Fr b) pure returns (bool) { + unchecked { + return Fr.unwrap(a) != Fr.unwrap(b); + } +} + +function equal(Fr a, Fr b) pure returns (bool) { + unchecked { + return Fr.unwrap(a) == Fr.unwrap(b); + } +} + +uint256 constant CONST_PROOF_SIZE_LOG_N = 25; + +uint256 constant NUMBER_OF_SUBRELATIONS = 28; +uint256 constant BATCHED_RELATION_PARTIAL_LENGTH = 8; +uint256 constant ZK_BATCHED_RELATION_PARTIAL_LENGTH = 9; +uint256 constant NUMBER_OF_ENTITIES = 41; +// The number of entities added for ZK (gemini_masking_poly) +uint256 constant NUM_MASKING_POLYNOMIALS = 1; +uint256 constant NUMBER_OF_ENTITIES_ZK = NUMBER_OF_ENTITIES + NUM_MASKING_POLYNOMIALS; +uint256 constant NUMBER_UNSHIFTED = 36; +uint256 constant NUMBER_UNSHIFTED_ZK = NUMBER_UNSHIFTED + NUM_MASKING_POLYNOMIALS; +uint256 constant NUMBER_TO_BE_SHIFTED = 5; +uint256 constant PAIRING_POINTS_SIZE = 8; + +uint256 constant FIELD_ELEMENT_SIZE = 0x20; +uint256 constant GROUP_ELEMENT_SIZE = 0x40; + +// Powers of alpha used to batch subrelations (alpha, alpha^2, ..., alpha^(NUM_SUBRELATIONS-1)) +uint256 constant NUMBER_OF_ALPHAS = NUMBER_OF_SUBRELATIONS - 1; + +// ENUM FOR WIRES +enum WIRE { + Q_M, + Q_C, + Q_L, + Q_R, + Q_O, + Q_4, + Q_LOOKUP, + Q_ARITH, + Q_RANGE, + Q_ELLIPTIC, + Q_MEMORY, + Q_NNF, + Q_POSEIDON2_EXTERNAL, + Q_POSEIDON2_INTERNAL, + SIGMA_1, + SIGMA_2, + SIGMA_3, + SIGMA_4, + ID_1, + ID_2, + ID_3, + ID_4, + TABLE_1, + TABLE_2, + TABLE_3, + TABLE_4, + LAGRANGE_FIRST, + LAGRANGE_LAST, + W_L, + W_R, + W_O, + W_4, + Z_PERM, + LOOKUP_INVERSES, + LOOKUP_READ_COUNTS, + LOOKUP_READ_TAGS, + W_L_SHIFT, + W_R_SHIFT, + W_O_SHIFT, + W_4_SHIFT, + Z_PERM_SHIFT +} + +library Honk { + struct G1Point { + uint256 x; + uint256 y; + } + + struct VerificationKey { + // Misc Params + uint256 circuitSize; + uint256 logCircuitSize; + uint256 publicInputsSize; + // Selectors + G1Point qm; + G1Point qc; + G1Point ql; + G1Point qr; + G1Point qo; + G1Point q4; + G1Point qLookup; // Lookup + G1Point qArith; // Arithmetic widget + G1Point qDeltaRange; // Delta Range sort + G1Point qMemory; // Memory + G1Point qNnf; // Non-native Field + G1Point qElliptic; // Auxillary + G1Point qPoseidon2External; + G1Point qPoseidon2Internal; + // Copy constraints + G1Point s1; + G1Point s2; + G1Point s3; + G1Point s4; + // Copy identity + G1Point id1; + G1Point id2; + G1Point id3; + G1Point id4; + // Precomputed lookup table + G1Point t1; + G1Point t2; + G1Point t3; + G1Point t4; + // Fixed first and last + G1Point lagrangeFirst; + G1Point lagrangeLast; + } + + struct RelationParameters { + // challenges + Fr eta; + Fr beta; + Fr gamma; + // derived + Fr publicInputsDelta; + } + + struct Proof { + // Pairing point object + Fr[PAIRING_POINTS_SIZE] pairingPointObject; + // Free wires + G1Point w1; + G1Point w2; + G1Point w3; + G1Point w4; + // Lookup helpers - Permutations + G1Point zPerm; + // Lookup helpers - logup + G1Point lookupReadCounts; + G1Point lookupReadTags; + G1Point lookupInverses; + // Sumcheck + Fr[BATCHED_RELATION_PARTIAL_LENGTH][CONST_PROOF_SIZE_LOG_N] sumcheckUnivariates; + Fr[NUMBER_OF_ENTITIES] sumcheckEvaluations; + // Shplemini + G1Point[CONST_PROOF_SIZE_LOG_N - 1] geminiFoldComms; + Fr[CONST_PROOF_SIZE_LOG_N] geminiAEvaluations; + G1Point shplonkQ; + G1Point kzgQuotient; + } + + /// forge-lint: disable-next-item(pascal-case-struct) + struct ZKProof { + // Pairing point object + Fr[PAIRING_POINTS_SIZE] pairingPointObject; + // ZK: Gemini masking polynomial commitment (sent first, right after public inputs) + G1Point geminiMaskingPoly; + // Commitments to wire polynomials + G1Point w1; + G1Point w2; + G1Point w3; + G1Point w4; + // Commitments to logup witness polynomials + G1Point lookupReadCounts; + G1Point lookupReadTags; + G1Point lookupInverses; + // Commitment to grand permutation polynomial + G1Point zPerm; + G1Point[3] libraCommitments; + // Sumcheck + Fr libraSum; + Fr[ZK_BATCHED_RELATION_PARTIAL_LENGTH][CONST_PROOF_SIZE_LOG_N] sumcheckUnivariates; + Fr libraEvaluation; + Fr[NUMBER_OF_ENTITIES_ZK] sumcheckEvaluations; // Includes gemini_masking_poly eval at index 0 (first position) + // Shplemini + G1Point[CONST_PROOF_SIZE_LOG_N - 1] geminiFoldComms; + Fr[CONST_PROOF_SIZE_LOG_N] geminiAEvaluations; + Fr[4] libraPolyEvals; + G1Point shplonkQ; + G1Point kzgQuotient; + } +} + +// ZKTranscript library to generate fiat shamir challenges, the ZK transcript only differest +/// forge-lint: disable-next-item(pascal-case-struct) +struct ZKTranscript { + // Oink + Honk.RelationParameters relationParameters; + Fr[NUMBER_OF_ALPHAS] alphas; // Powers of alpha: [alpha, alpha^2, ..., alpha^(NUM_SUBRELATIONS-1)] + Fr[CONST_PROOF_SIZE_LOG_N] gateChallenges; + // Sumcheck + Fr libraChallenge; + Fr[CONST_PROOF_SIZE_LOG_N] sumCheckUChallenges; + // Shplemini + Fr rho; + Fr geminiR; + Fr shplonkNu; + Fr shplonkZ; + // Derived + Fr publicInputsDelta; +} + +library ZKTranscriptLib { + function generateTranscript( + Honk.ZKProof memory proof, + bytes32[] calldata publicInputs, + uint256 vkHash, + uint256 publicInputsSize, + uint256 logN + ) external pure returns (ZKTranscript memory t) { + Fr previousChallenge; + (t.relationParameters, previousChallenge) = + generateRelationParametersChallenges(proof, publicInputs, vkHash, publicInputsSize, previousChallenge); + + (t.alphas, previousChallenge) = generateAlphaChallenges(previousChallenge, proof); + + (t.gateChallenges, previousChallenge) = generateGateChallenges(previousChallenge, logN); + (t.libraChallenge, previousChallenge) = generateLibraChallenge(previousChallenge, proof); + (t.sumCheckUChallenges, previousChallenge) = generateSumcheckChallenges(proof, previousChallenge, logN); + + (t.rho, previousChallenge) = generateRhoChallenge(proof, previousChallenge); + + (t.geminiR, previousChallenge) = generateGeminiRChallenge(proof, previousChallenge, logN); + + (t.shplonkNu, previousChallenge) = generateShplonkNuChallenge(proof, previousChallenge, logN); + + (t.shplonkZ, previousChallenge) = generateShplonkZChallenge(proof, previousChallenge); + return t; + } + + function splitChallenge(Fr challenge) internal pure returns (Fr first, Fr second) { + uint256 challengeU256 = uint256(Fr.unwrap(challenge)); + // Split into two equal 127-bit chunks (254/2) + uint256 lo = challengeU256 & 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF; // 127 bits + uint256 hi = challengeU256 >> 127; + first = FrLib.from(lo); + second = FrLib.from(hi); + } + + function generateRelationParametersChallenges( + Honk.ZKProof memory proof, + bytes32[] calldata publicInputs, + uint256 vkHash, + uint256 publicInputsSize, + Fr previousChallenge + ) internal pure returns (Honk.RelationParameters memory rp, Fr nextPreviousChallenge) { + (rp.eta, previousChallenge) = generateEtaChallenge(proof, publicInputs, vkHash, publicInputsSize); + + (rp.beta, rp.gamma, nextPreviousChallenge) = generateBetaGammaChallenges(previousChallenge, proof); + } + + function generateEtaChallenge( + Honk.ZKProof memory proof, + bytes32[] calldata publicInputs, + uint256 vkHash, + uint256 publicInputsSize + ) internal pure returns (Fr eta, Fr previousChallenge) { + // Size: 1 (vkHash) + publicInputsSize + 8 (geminiMask(2) + 3 wires(6)) + bytes32[] memory round0 = new bytes32[](1 + publicInputsSize + 8); + round0[0] = bytes32(vkHash); + + for (uint256 i = 0; i < publicInputsSize - PAIRING_POINTS_SIZE; i++) { + require(uint256(publicInputs[i]) < P, Errors.ValueGeFieldOrder()); + round0[1 + i] = publicInputs[i]; + } + for (uint256 i = 0; i < PAIRING_POINTS_SIZE; i++) { + round0[1 + publicInputsSize - PAIRING_POINTS_SIZE + i] = FrLib.toBytes32(proof.pairingPointObject[i]); + } + + // For ZK flavors: hash the gemini masking poly commitment (sent right after public inputs) + round0[1 + publicInputsSize] = bytes32(proof.geminiMaskingPoly.x); + round0[1 + publicInputsSize + 1] = bytes32(proof.geminiMaskingPoly.y); + + // Create the first challenge + // Note: w4 is added to the challenge later on + round0[1 + publicInputsSize + 2] = bytes32(proof.w1.x); + round0[1 + publicInputsSize + 3] = bytes32(proof.w1.y); + round0[1 + publicInputsSize + 4] = bytes32(proof.w2.x); + round0[1 + publicInputsSize + 5] = bytes32(proof.w2.y); + round0[1 + publicInputsSize + 6] = bytes32(proof.w3.x); + round0[1 + publicInputsSize + 7] = bytes32(proof.w3.y); + + previousChallenge = FrLib.from(uint256(keccak256(abi.encodePacked(round0))) % P); + (eta,) = splitChallenge(previousChallenge); + } + + function generateBetaGammaChallenges(Fr previousChallenge, Honk.ZKProof memory proof) + internal + pure + returns (Fr beta, Fr gamma, Fr nextPreviousChallenge) + { + bytes32[7] memory round1; + round1[0] = FrLib.toBytes32(previousChallenge); + round1[1] = bytes32(proof.lookupReadCounts.x); + round1[2] = bytes32(proof.lookupReadCounts.y); + round1[3] = bytes32(proof.lookupReadTags.x); + round1[4] = bytes32(proof.lookupReadTags.y); + round1[5] = bytes32(proof.w4.x); + round1[6] = bytes32(proof.w4.y); + + nextPreviousChallenge = FrLib.from(uint256(keccak256(abi.encodePacked(round1))) % P); + (beta, gamma) = splitChallenge(nextPreviousChallenge); + } + + // Alpha challenges non-linearise the gate contributions + function generateAlphaChallenges(Fr previousChallenge, Honk.ZKProof memory proof) + internal + pure + returns (Fr[NUMBER_OF_ALPHAS] memory alphas, Fr nextPreviousChallenge) + { + // Generate the original sumcheck alpha 0 by hashing zPerm and zLookup + uint256[5] memory alpha0; + alpha0[0] = Fr.unwrap(previousChallenge); + alpha0[1] = proof.lookupInverses.x; + alpha0[2] = proof.lookupInverses.y; + alpha0[3] = proof.zPerm.x; + alpha0[4] = proof.zPerm.y; + + nextPreviousChallenge = FrLib.from(uint256(keccak256(abi.encodePacked(alpha0))) % P); + Fr alpha; + (alpha,) = splitChallenge(nextPreviousChallenge); + + // Compute powers of alpha for batching subrelations + alphas[0] = alpha; + for (uint256 i = 1; i < NUMBER_OF_ALPHAS; i++) { + alphas[i] = alphas[i - 1] * alpha; + } + } + + function generateGateChallenges(Fr previousChallenge, uint256 logN) + internal + pure + returns (Fr[CONST_PROOF_SIZE_LOG_N] memory gateChallenges, Fr nextPreviousChallenge) + { + previousChallenge = FrLib.from(uint256(keccak256(abi.encodePacked(Fr.unwrap(previousChallenge)))) % P); + (gateChallenges[0],) = splitChallenge(previousChallenge); + for (uint256 i = 1; i < logN; i++) { + gateChallenges[i] = gateChallenges[i - 1] * gateChallenges[i - 1]; + } + nextPreviousChallenge = previousChallenge; + } + + function generateLibraChallenge(Fr previousChallenge, Honk.ZKProof memory proof) + internal + pure + returns (Fr libraChallenge, Fr nextPreviousChallenge) + { + // 2 comm, 1 sum, 1 challenge + uint256[4] memory challengeData; + challengeData[0] = Fr.unwrap(previousChallenge); + challengeData[1] = proof.libraCommitments[0].x; + challengeData[2] = proof.libraCommitments[0].y; + challengeData[3] = Fr.unwrap(proof.libraSum); + nextPreviousChallenge = FrLib.from(uint256(keccak256(abi.encodePacked(challengeData))) % P); + (libraChallenge,) = splitChallenge(nextPreviousChallenge); + } + + function generateSumcheckChallenges(Honk.ZKProof memory proof, Fr prevChallenge, uint256 logN) + internal + pure + returns (Fr[CONST_PROOF_SIZE_LOG_N] memory sumcheckChallenges, Fr nextPreviousChallenge) + { + for (uint256 i = 0; i < logN; i++) { + Fr[ZK_BATCHED_RELATION_PARTIAL_LENGTH + 1] memory univariateChal; + univariateChal[0] = prevChallenge; + + for (uint256 j = 0; j < ZK_BATCHED_RELATION_PARTIAL_LENGTH; j++) { + univariateChal[j + 1] = proof.sumcheckUnivariates[i][j]; + } + prevChallenge = FrLib.from(uint256(keccak256(abi.encodePacked(univariateChal))) % P); + + (sumcheckChallenges[i],) = splitChallenge(prevChallenge); + } + nextPreviousChallenge = prevChallenge; + } + + // We add Libra claimed eval + 2 libra commitments (grand_sum, quotient) + function generateRhoChallenge(Honk.ZKProof memory proof, Fr prevChallenge) + internal + pure + returns (Fr rho, Fr nextPreviousChallenge) + { + uint256[NUMBER_OF_ENTITIES_ZK + 6] memory rhoChallengeElements; + rhoChallengeElements[0] = Fr.unwrap(prevChallenge); + uint256 i; + for (i = 1; i <= NUMBER_OF_ENTITIES_ZK; i++) { + rhoChallengeElements[i] = Fr.unwrap(proof.sumcheckEvaluations[i - 1]); + } + rhoChallengeElements[i] = Fr.unwrap(proof.libraEvaluation); + i += 1; + rhoChallengeElements[i] = proof.libraCommitments[1].x; + rhoChallengeElements[i + 1] = proof.libraCommitments[1].y; + i += 2; + rhoChallengeElements[i] = proof.libraCommitments[2].x; + rhoChallengeElements[i + 1] = proof.libraCommitments[2].y; + + nextPreviousChallenge = FrLib.from(uint256(keccak256(abi.encodePacked(rhoChallengeElements))) % P); + (rho,) = splitChallenge(nextPreviousChallenge); + } + + function generateGeminiRChallenge(Honk.ZKProof memory proof, Fr prevChallenge, uint256 logN) + internal + pure + returns (Fr geminiR, Fr nextPreviousChallenge) + { + uint256[] memory gR = new uint256[]((logN - 1) * 2 + 1); + gR[0] = Fr.unwrap(prevChallenge); + + for (uint256 i = 0; i < logN - 1; i++) { + gR[1 + i * 2] = proof.geminiFoldComms[i].x; + gR[2 + i * 2] = proof.geminiFoldComms[i].y; + } + + nextPreviousChallenge = FrLib.from(uint256(keccak256(abi.encodePacked(gR))) % P); + + (geminiR,) = splitChallenge(nextPreviousChallenge); + } + + function generateShplonkNuChallenge(Honk.ZKProof memory proof, Fr prevChallenge, uint256 logN) + internal + pure + returns (Fr shplonkNu, Fr nextPreviousChallenge) + { + uint256[] memory shplonkNuChallengeElements = new uint256[](logN + 1 + 4); + shplonkNuChallengeElements[0] = Fr.unwrap(prevChallenge); + + for (uint256 i = 1; i <= logN; i++) { + shplonkNuChallengeElements[i] = Fr.unwrap(proof.geminiAEvaluations[i - 1]); + } + + uint256 libraIdx = 0; + for (uint256 i = logN + 1; i <= logN + 4; i++) { + shplonkNuChallengeElements[i] = Fr.unwrap(proof.libraPolyEvals[libraIdx]); + libraIdx++; + } + + nextPreviousChallenge = FrLib.from(uint256(keccak256(abi.encodePacked(shplonkNuChallengeElements))) % P); + (shplonkNu,) = splitChallenge(nextPreviousChallenge); + } + + function generateShplonkZChallenge(Honk.ZKProof memory proof, Fr prevChallenge) + internal + pure + returns (Fr shplonkZ, Fr nextPreviousChallenge) + { + uint256[3] memory shplonkZChallengeElements; + shplonkZChallengeElements[0] = Fr.unwrap(prevChallenge); + + shplonkZChallengeElements[1] = proof.shplonkQ.x; + shplonkZChallengeElements[2] = proof.shplonkQ.y; + + nextPreviousChallenge = FrLib.from(uint256(keccak256(abi.encodePacked(shplonkZChallengeElements))) % P); + (shplonkZ,) = splitChallenge(nextPreviousChallenge); + } + + function loadProof(bytes calldata proof, uint256 logN) internal pure returns (Honk.ZKProof memory p) { + uint256 boundary = 0x0; + + // Pairing point object + for (uint256 i = 0; i < PAIRING_POINTS_SIZE; i++) { + uint256 limb = uint256(bytes32(proof[boundary:boundary + FIELD_ELEMENT_SIZE])); + // lo limbs (even index) < 2^136, hi limbs (odd index) < 2^120 + require(limb < 2 ** (i % 2 == 0 ? 136 : 120), Errors.ValueGeLimbMax()); + p.pairingPointObject[i] = FrLib.from(limb); + boundary += FIELD_ELEMENT_SIZE; + } + + // Gemini masking polynomial commitment (sent first in ZK flavors, right after pairing points) + p.geminiMaskingPoly = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); + boundary += GROUP_ELEMENT_SIZE; + + // Commitments + p.w1 = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); + boundary += GROUP_ELEMENT_SIZE; + p.w2 = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); + boundary += GROUP_ELEMENT_SIZE; + p.w3 = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); + boundary += GROUP_ELEMENT_SIZE; + + // Lookup / Permutation Helper Commitments + p.lookupReadCounts = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); + boundary += GROUP_ELEMENT_SIZE; + p.lookupReadTags = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); + boundary += GROUP_ELEMENT_SIZE; + p.w4 = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); + boundary += GROUP_ELEMENT_SIZE; + p.lookupInverses = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); + boundary += GROUP_ELEMENT_SIZE; + p.zPerm = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); + boundary += GROUP_ELEMENT_SIZE; + p.libraCommitments[0] = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); + boundary += GROUP_ELEMENT_SIZE; + + p.libraSum = bytesToFr(proof[boundary:boundary + FIELD_ELEMENT_SIZE]); + boundary += FIELD_ELEMENT_SIZE; + // Sumcheck univariates + for (uint256 i = 0; i < logN; i++) { + for (uint256 j = 0; j < ZK_BATCHED_RELATION_PARTIAL_LENGTH; j++) { + p.sumcheckUnivariates[i][j] = bytesToFr(proof[boundary:boundary + FIELD_ELEMENT_SIZE]); + boundary += FIELD_ELEMENT_SIZE; + } + } + + // Sumcheck evaluations (includes gemini_masking_poly eval at index 0 for ZK flavors) + for (uint256 i = 0; i < NUMBER_OF_ENTITIES_ZK; i++) { + p.sumcheckEvaluations[i] = bytesToFr(proof[boundary:boundary + FIELD_ELEMENT_SIZE]); + boundary += FIELD_ELEMENT_SIZE; + } + + p.libraEvaluation = bytesToFr(proof[boundary:boundary + FIELD_ELEMENT_SIZE]); + boundary += FIELD_ELEMENT_SIZE; + + p.libraCommitments[1] = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); + boundary += GROUP_ELEMENT_SIZE; + p.libraCommitments[2] = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); + boundary += GROUP_ELEMENT_SIZE; + + // Gemini + // Read gemini fold univariates + for (uint256 i = 0; i < logN - 1; i++) { + p.geminiFoldComms[i] = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); + boundary += GROUP_ELEMENT_SIZE; + } + + // Read gemini a evaluations + for (uint256 i = 0; i < logN; i++) { + p.geminiAEvaluations[i] = bytesToFr(proof[boundary:boundary + FIELD_ELEMENT_SIZE]); + boundary += FIELD_ELEMENT_SIZE; + } + + for (uint256 i = 0; i < 4; i++) { + p.libraPolyEvals[i] = bytesToFr(proof[boundary:boundary + FIELD_ELEMENT_SIZE]); + boundary += FIELD_ELEMENT_SIZE; + } + + // Shplonk + p.shplonkQ = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); + boundary += GROUP_ELEMENT_SIZE; + // KZG + p.kzgQuotient = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); + } +} + +library RelationsLib { + struct EllipticParams { + // Points + Fr x_1; + Fr y_1; + Fr x_2; + Fr y_2; + Fr y_3; + Fr x_3; + // push accumulators into memory + Fr x_double_identity; + } + + // Parameters used within the Memory Relation + // A struct is used to work around stack too deep. This relation has alot of variables + struct MemParams { + Fr memory_record_check; + Fr partial_record_check; + Fr next_gate_access_type; + Fr record_delta; + Fr index_delta; + Fr adjacent_values_match_if_adjacent_indices_match; + Fr adjacent_values_match_if_adjacent_indices_match_and_next_access_is_a_read_operation; + Fr access_check; + Fr next_gate_access_type_is_boolean; + Fr ROM_consistency_check_identity; + Fr RAM_consistency_check_identity; + Fr timestamp_delta; + Fr RAM_timestamp_check_identity; + Fr memory_identity; + Fr index_is_monotonically_increasing; + } + + // Parameters used within the Non-Native Field Relation + // A struct is used to work around stack too deep. This relation has alot of variables + struct NnfParams { + Fr limb_subproduct; + Fr non_native_field_gate_1; + Fr non_native_field_gate_2; + Fr non_native_field_gate_3; + Fr limb_accumulator_1; + Fr limb_accumulator_2; + Fr nnf_identity; + } + + struct PoseidonExternalParams { + Fr s1; + Fr s2; + Fr s3; + Fr s4; + Fr u1; + Fr u2; + Fr u3; + Fr u4; + Fr t0; + Fr t1; + Fr t2; + Fr t3; + Fr v1; + Fr v2; + Fr v3; + Fr v4; + Fr q_pos_by_scaling; + } + + struct PoseidonInternalParams { + Fr u1; + Fr u2; + Fr u3; + Fr u4; + Fr u_sum; + Fr v1; + Fr v2; + Fr v3; + Fr v4; + Fr s1; + Fr q_pos_by_scaling; + } + + Fr internal constant GRUMPKIN_CURVE_B_PARAMETER_NEGATED = Fr.wrap(17); // -(-17) + uint256 internal constant NEG_HALF_MODULO_P = 0x183227397098d014dc2822db40c0ac2e9419f4243cdcb848a1f0fac9f8000000; + + // Constants for the Non-native Field relation + Fr internal constant LIMB_SIZE = Fr.wrap(uint256(1) << 68); + Fr internal constant SUBLIMB_SHIFT = Fr.wrap(uint256(1) << 14); + + function accumulateRelationEvaluations( + Fr[NUMBER_OF_ENTITIES] memory purportedEvaluations, + Honk.RelationParameters memory rp, + Fr[NUMBER_OF_ALPHAS] memory subrelationChallenges, + Fr powPartialEval + ) internal pure returns (Fr accumulator) { + Fr[NUMBER_OF_SUBRELATIONS] memory evaluations; + + // Accumulate all relations in Ultra Honk - each with varying number of subrelations + accumulateArithmeticRelation(purportedEvaluations, evaluations, powPartialEval); + accumulatePermutationRelation(purportedEvaluations, rp, evaluations, powPartialEval); + accumulateLogDerivativeLookupRelation(purportedEvaluations, rp, evaluations, powPartialEval); + accumulateDeltaRangeRelation(purportedEvaluations, evaluations, powPartialEval); + accumulateEllipticRelation(purportedEvaluations, evaluations, powPartialEval); + accumulateMemoryRelation(purportedEvaluations, rp, evaluations, powPartialEval); + accumulateNnfRelation(purportedEvaluations, evaluations, powPartialEval); + accumulatePoseidonExternalRelation(purportedEvaluations, evaluations, powPartialEval); + accumulatePoseidonInternalRelation(purportedEvaluations, evaluations, powPartialEval); + + // batch the subrelations with the precomputed alpha powers to obtain the full honk relation + accumulator = scaleAndBatchSubrelations(evaluations, subrelationChallenges); + } + + /** + * Aesthetic helper function that is used to index by enum into proof.sumcheckEvaluations, it avoids + * the relation checking code being cluttered with uint256 type casting, which is often a different colour in code + * editors, and thus is noisy. + */ + function wire(Fr[NUMBER_OF_ENTITIES] memory p, WIRE _wire) internal pure returns (Fr) { + return p[uint256(_wire)]; + } + + /** + * Ultra Arithmetic Relation + * + */ + function accumulateArithmeticRelation( + Fr[NUMBER_OF_ENTITIES] memory p, + Fr[NUMBER_OF_SUBRELATIONS] memory evals, + Fr domainSep + ) internal pure { + // Relation 0 + Fr q_arith = wire(p, WIRE.Q_ARITH); + { + Fr neg_half = Fr.wrap(NEG_HALF_MODULO_P); + + Fr accum = (q_arith - Fr.wrap(3)) * (wire(p, WIRE.Q_M) * wire(p, WIRE.W_R) * wire(p, WIRE.W_L)) * neg_half; + accum = accum + (wire(p, WIRE.Q_L) * wire(p, WIRE.W_L)) + (wire(p, WIRE.Q_R) * wire(p, WIRE.W_R)) + + (wire(p, WIRE.Q_O) * wire(p, WIRE.W_O)) + (wire(p, WIRE.Q_4) * wire(p, WIRE.W_4)) + wire(p, WIRE.Q_C); + accum = accum + (q_arith - ONE) * wire(p, WIRE.W_4_SHIFT); + accum = accum * q_arith; + accum = accum * domainSep; + evals[0] = accum; + } + + // Relation 1 + { + Fr accum = wire(p, WIRE.W_L) + wire(p, WIRE.W_4) - wire(p, WIRE.W_L_SHIFT) + wire(p, WIRE.Q_M); + accum = accum * (q_arith - Fr.wrap(2)); + accum = accum * (q_arith - ONE); + accum = accum * q_arith; + accum = accum * domainSep; + evals[1] = accum; + } + } + + function accumulatePermutationRelation( + Fr[NUMBER_OF_ENTITIES] memory p, + Honk.RelationParameters memory rp, + Fr[NUMBER_OF_SUBRELATIONS] memory evals, + Fr domainSep + ) internal pure { + Fr grand_product_numerator; + Fr grand_product_denominator; + + { + Fr num = wire(p, WIRE.W_L) + wire(p, WIRE.ID_1) * rp.beta + rp.gamma; + num = num * (wire(p, WIRE.W_R) + wire(p, WIRE.ID_2) * rp.beta + rp.gamma); + num = num * (wire(p, WIRE.W_O) + wire(p, WIRE.ID_3) * rp.beta + rp.gamma); + num = num * (wire(p, WIRE.W_4) + wire(p, WIRE.ID_4) * rp.beta + rp.gamma); + + grand_product_numerator = num; + } + { + Fr den = wire(p, WIRE.W_L) + wire(p, WIRE.SIGMA_1) * rp.beta + rp.gamma; + den = den * (wire(p, WIRE.W_R) + wire(p, WIRE.SIGMA_2) * rp.beta + rp.gamma); + den = den * (wire(p, WIRE.W_O) + wire(p, WIRE.SIGMA_3) * rp.beta + rp.gamma); + den = den * (wire(p, WIRE.W_4) + wire(p, WIRE.SIGMA_4) * rp.beta + rp.gamma); + + grand_product_denominator = den; + } + + // Contribution 2 + { + Fr acc = (wire(p, WIRE.Z_PERM) + wire(p, WIRE.LAGRANGE_FIRST)) * grand_product_numerator; + + acc = acc + - ((wire(p, WIRE.Z_PERM_SHIFT) + (wire(p, WIRE.LAGRANGE_LAST) * rp.publicInputsDelta)) + * grand_product_denominator); + acc = acc * domainSep; + evals[2] = acc; + } + + // Contribution 3 + { + Fr acc = (wire(p, WIRE.LAGRANGE_LAST) * wire(p, WIRE.Z_PERM_SHIFT)) * domainSep; + evals[3] = acc; + } + } + + function accumulateLogDerivativeLookupRelation( + Fr[NUMBER_OF_ENTITIES] memory p, + Honk.RelationParameters memory rp, + Fr[NUMBER_OF_SUBRELATIONS] memory evals, + Fr domainSep + ) internal pure { + Fr table_term; + Fr lookup_term; + + // Calculate the write term (the table accumulation) + // table_term = table_1 + γ + table_2 * β + table_3 * β² + table_4 * β³ + { + Fr beta_sqr = rp.beta * rp.beta; + table_term = wire(p, WIRE.TABLE_1) + rp.gamma + (wire(p, WIRE.TABLE_2) * rp.beta) + + (wire(p, WIRE.TABLE_3) * beta_sqr) + (wire(p, WIRE.TABLE_4) * beta_sqr * rp.beta); + } + + // Calculate the read term + // lookup_term = derived_entry_1 + γ + derived_entry_2 * β + derived_entry_3 * β² + q_index * β³ + { + Fr beta_sqr = rp.beta * rp.beta; + Fr derived_entry_1 = wire(p, WIRE.W_L) + rp.gamma + (wire(p, WIRE.Q_R) * wire(p, WIRE.W_L_SHIFT)); + Fr derived_entry_2 = wire(p, WIRE.W_R) + wire(p, WIRE.Q_M) * wire(p, WIRE.W_R_SHIFT); + Fr derived_entry_3 = wire(p, WIRE.W_O) + wire(p, WIRE.Q_C) * wire(p, WIRE.W_O_SHIFT); + + lookup_term = derived_entry_1 + (derived_entry_2 * rp.beta) + (derived_entry_3 * beta_sqr) + + (wire(p, WIRE.Q_O) * beta_sqr * rp.beta); + } + + Fr lookup_inverse = wire(p, WIRE.LOOKUP_INVERSES) * table_term; + Fr table_inverse = wire(p, WIRE.LOOKUP_INVERSES) * lookup_term; + + Fr inverse_exists_xor = wire(p, WIRE.LOOKUP_READ_TAGS) + wire(p, WIRE.Q_LOOKUP) + - (wire(p, WIRE.LOOKUP_READ_TAGS) * wire(p, WIRE.Q_LOOKUP)); + + // Inverse calculated correctly relation + Fr accumulatorNone = lookup_term * table_term * wire(p, WIRE.LOOKUP_INVERSES) - inverse_exists_xor; + accumulatorNone = accumulatorNone * domainSep; + + // Inverse + Fr accumulatorOne = wire(p, WIRE.Q_LOOKUP) * lookup_inverse - wire(p, WIRE.LOOKUP_READ_COUNTS) * table_inverse; + + Fr read_tag = wire(p, WIRE.LOOKUP_READ_TAGS); + + Fr read_tag_boolean_relation = read_tag * read_tag - read_tag; + + evals[4] = accumulatorNone; + evals[5] = accumulatorOne; + evals[6] = read_tag_boolean_relation * domainSep; + } + + function accumulateDeltaRangeRelation( + Fr[NUMBER_OF_ENTITIES] memory p, + Fr[NUMBER_OF_SUBRELATIONS] memory evals, + Fr domainSep + ) internal pure { + Fr minus_one = ZERO - ONE; + Fr minus_two = ZERO - Fr.wrap(2); + Fr minus_three = ZERO - Fr.wrap(3); + + // Compute wire differences + Fr delta_1 = wire(p, WIRE.W_R) - wire(p, WIRE.W_L); + Fr delta_2 = wire(p, WIRE.W_O) - wire(p, WIRE.W_R); + Fr delta_3 = wire(p, WIRE.W_4) - wire(p, WIRE.W_O); + Fr delta_4 = wire(p, WIRE.W_L_SHIFT) - wire(p, WIRE.W_4); + + // Contribution 6 + { + Fr acc = delta_1; + acc = acc * (delta_1 + minus_one); + acc = acc * (delta_1 + minus_two); + acc = acc * (delta_1 + minus_three); + acc = acc * wire(p, WIRE.Q_RANGE); + acc = acc * domainSep; + evals[7] = acc; + } + + // Contribution 7 + { + Fr acc = delta_2; + acc = acc * (delta_2 + minus_one); + acc = acc * (delta_2 + minus_two); + acc = acc * (delta_2 + minus_three); + acc = acc * wire(p, WIRE.Q_RANGE); + acc = acc * domainSep; + evals[8] = acc; + } + + // Contribution 8 + { + Fr acc = delta_3; + acc = acc * (delta_3 + minus_one); + acc = acc * (delta_3 + minus_two); + acc = acc * (delta_3 + minus_three); + acc = acc * wire(p, WIRE.Q_RANGE); + acc = acc * domainSep; + evals[9] = acc; + } + + // Contribution 9 + { + Fr acc = delta_4; + acc = acc * (delta_4 + minus_one); + acc = acc * (delta_4 + minus_two); + acc = acc * (delta_4 + minus_three); + acc = acc * wire(p, WIRE.Q_RANGE); + acc = acc * domainSep; + evals[10] = acc; + } + } + + function accumulateEllipticRelation( + Fr[NUMBER_OF_ENTITIES] memory p, + Fr[NUMBER_OF_SUBRELATIONS] memory evals, + Fr domainSep + ) internal pure { + EllipticParams memory ep; + ep.x_1 = wire(p, WIRE.W_R); + ep.y_1 = wire(p, WIRE.W_O); + + ep.x_2 = wire(p, WIRE.W_L_SHIFT); + ep.y_2 = wire(p, WIRE.W_4_SHIFT); + ep.y_3 = wire(p, WIRE.W_O_SHIFT); + ep.x_3 = wire(p, WIRE.W_R_SHIFT); + + Fr q_sign = wire(p, WIRE.Q_L); + Fr q_is_double = wire(p, WIRE.Q_M); + + // Contribution 10 point addition, x-coordinate check + // q_elliptic * (x3 + x2 + x1)(x2 - x1)(x2 - x1) - y2^2 - y1^2 + 2(y2y1)*q_sign = 0 + Fr x_diff = (ep.x_2 - ep.x_1); + Fr y1_sqr = (ep.y_1 * ep.y_1); + { + // Move to top + Fr partialEval = domainSep; + + Fr y2_sqr = (ep.y_2 * ep.y_2); + Fr y1y2 = ep.y_1 * ep.y_2 * q_sign; + Fr x_add_identity = (ep.x_3 + ep.x_2 + ep.x_1); + x_add_identity = x_add_identity * x_diff * x_diff; + x_add_identity = x_add_identity - y2_sqr - y1_sqr + y1y2 + y1y2; + + evals[11] = x_add_identity * partialEval * wire(p, WIRE.Q_ELLIPTIC) * (ONE - q_is_double); + } + + // Contribution 11 point addition, x-coordinate check + // q_elliptic * (q_sign * y1 + y3)(x2 - x1) + (x3 - x1)(y2 - q_sign * y1) = 0 + { + Fr y1_plus_y3 = ep.y_1 + ep.y_3; + Fr y_diff = ep.y_2 * q_sign - ep.y_1; + Fr y_add_identity = y1_plus_y3 * x_diff + (ep.x_3 - ep.x_1) * y_diff; + evals[12] = y_add_identity * domainSep * wire(p, WIRE.Q_ELLIPTIC) * (ONE - q_is_double); + } + + // Contribution 10 point doubling, x-coordinate check + // (x3 + x1 + x1) (4y1*y1) - 9 * x1 * x1 * x1 * x1 = 0 + // N.B. we're using the equivalence x1*x1*x1 === y1*y1 - curve_b to reduce degree by 1 + { + Fr x_pow_4 = (y1_sqr + GRUMPKIN_CURVE_B_PARAMETER_NEGATED) * ep.x_1; + Fr y1_sqr_mul_4 = y1_sqr + y1_sqr; + y1_sqr_mul_4 = y1_sqr_mul_4 + y1_sqr_mul_4; + Fr x1_pow_4_mul_9 = x_pow_4 * Fr.wrap(9); + + // NOTE: pushed into memory (stack >:'( ) + ep.x_double_identity = (ep.x_3 + ep.x_1 + ep.x_1) * y1_sqr_mul_4 - x1_pow_4_mul_9; + + Fr acc = ep.x_double_identity * domainSep * wire(p, WIRE.Q_ELLIPTIC) * q_is_double; + evals[11] = evals[11] + acc; + } + + // Contribution 11 point doubling, y-coordinate check + // (y1 + y1) (2y1) - (3 * x1 * x1)(x1 - x3) = 0 + { + Fr x1_sqr_mul_3 = (ep.x_1 + ep.x_1 + ep.x_1) * ep.x_1; + Fr y_double_identity = x1_sqr_mul_3 * (ep.x_1 - ep.x_3) - (ep.y_1 + ep.y_1) * (ep.y_1 + ep.y_3); + evals[12] = evals[12] + y_double_identity * domainSep * wire(p, WIRE.Q_ELLIPTIC) * q_is_double; + } + } + + function accumulateMemoryRelation( + Fr[NUMBER_OF_ENTITIES] memory p, + Honk.RelationParameters memory rp, + Fr[NUMBER_OF_SUBRELATIONS] memory evals, + Fr domainSep + ) internal pure { + MemParams memory ap; + + // Compute eta powers locally + Fr eta_two = rp.eta * rp.eta; + Fr eta_three = eta_two * rp.eta; + + /** + * MEMORY + * + * A RAM memory record contains a tuple of the following fields: + * * i: `index` of memory cell being accessed + * * t: `timestamp` of memory cell being accessed (used for RAM, set to 0 for ROM) + * * v: `value` of memory cell being accessed + * * a: `access` type of record. read: 0 = read, 1 = write + * * r: `record` of memory cell. record = access + index * eta + timestamp * eta_two + value * eta_three + * + * A ROM memory record contains a tuple of the following fields: + * * i: `index` of memory cell being accessed + * * v: `value1` of memory cell being accessed (ROM tables can store up to 2 values per index) + * * v2:`value2` of memory cell being accessed (ROM tables can store up to 2 values per index) + * * r: `record` of memory cell. record = index * eta + value2 * eta_two + value1 * eta_three + * + * When performing a read/write access, the values of i, t, v, v2, a, r are stored in the following wires + + * selectors, depending on whether the gate is a RAM read/write or a ROM read + * + * | gate type | i | v2/t | v | a | r | + * | --------- | -- | ----- | -- | -- | -- | + * | ROM | w1 | w2 | w3 | -- | w4 | + * | RAM | w1 | w2 | w3 | qc | w4 | + * + * (for accesses where `index` is a circuit constant, it is assumed the circuit will apply a copy constraint on + * `w2` to fix its value) + * + * + */ + + /** + * Memory Record Check + * Partial degree: 1 + * Total degree: 4 + * + * A ROM/ROM access gate can be evaluated with the identity: + * + * qc + w1 \eta + w2 \eta_two + w3 \eta_three - w4 = 0 + * + * For ROM gates, qc = 0 + */ + ap.memory_record_check = wire(p, WIRE.W_O) * eta_three; + ap.memory_record_check = ap.memory_record_check + (wire(p, WIRE.W_R) * eta_two); + ap.memory_record_check = ap.memory_record_check + (wire(p, WIRE.W_L) * rp.eta); + ap.memory_record_check = ap.memory_record_check + wire(p, WIRE.Q_C); + ap.partial_record_check = ap.memory_record_check; // used in RAM consistency check; deg 1 or 4 + ap.memory_record_check = ap.memory_record_check - wire(p, WIRE.W_4); + + /** + * Contribution 13 & 14 + * ROM Consistency Check + * Partial degree: 1 + * Total degree: 4 + * + * For every ROM read, a set equivalence check is applied between the record witnesses, and a second set of + * records that are sorted. + * + * We apply the following checks for the sorted records: + * + * 1. w1, w2, w3 correctly map to 'index', 'v1, 'v2' for a given record value at w4 + * 2. index values for adjacent records are monotonically increasing + * 3. if, at gate i, index_i == index_{i + 1}, then value1_i == value1_{i + 1} and value2_i == value2_{i + 1} + * + */ + ap.index_delta = wire(p, WIRE.W_L_SHIFT) - wire(p, WIRE.W_L); + ap.record_delta = wire(p, WIRE.W_4_SHIFT) - wire(p, WIRE.W_4); + + ap.index_is_monotonically_increasing = ap.index_delta * (ap.index_delta - Fr.wrap(1)); // deg 2 + + ap.adjacent_values_match_if_adjacent_indices_match = (ap.index_delta * MINUS_ONE + ONE) * ap.record_delta; // deg 2 + + evals[14] = ap.adjacent_values_match_if_adjacent_indices_match * (wire(p, WIRE.Q_L) * wire(p, WIRE.Q_R)) + * (wire(p, WIRE.Q_MEMORY) * domainSep); // deg 5 + evals[15] = ap.index_is_monotonically_increasing * (wire(p, WIRE.Q_L) * wire(p, WIRE.Q_R)) + * (wire(p, WIRE.Q_MEMORY) * domainSep); // deg 5 + + ap.ROM_consistency_check_identity = ap.memory_record_check * (wire(p, WIRE.Q_L) * wire(p, WIRE.Q_R)); // deg 3 or 7 + + /** + * Contributions 15,16,17 + * RAM Consistency Check + * + * The 'access' type of the record is extracted with the expression `w_4 - ap.partial_record_check` + * (i.e. for an honest Prover `w1 * eta + w2 * eta^2 + w3 * eta^3 - w4 = access`. + * This is validated by requiring `access` to be boolean + * + * For two adjacent entries in the sorted list if _both_ + * A) index values match + * B) adjacent access value is 0 (i.e. next gate is a READ) + * then + * C) both values must match. + * The gate boolean check is + * (A && B) => C === !(A && B) || C === !A || !B || C + * + * N.B. it is the responsibility of the circuit writer to ensure that every RAM cell is initialized + * with a WRITE operation. + */ + Fr access_type = (wire(p, WIRE.W_4) - ap.partial_record_check); // will be 0 or 1 for honest Prover; deg 1 or 4 + ap.access_check = access_type * (access_type - Fr.wrap(1)); // check value is 0 or 1; deg 2 or 8 + + // reverse order we could re-use `ap.partial_record_check` 1 - ((w3' * eta + w2') * eta + w1') * eta + // deg 1 or 4 + ap.next_gate_access_type = wire(p, WIRE.W_O_SHIFT) * eta_three; + ap.next_gate_access_type = ap.next_gate_access_type + (wire(p, WIRE.W_R_SHIFT) * eta_two); + ap.next_gate_access_type = ap.next_gate_access_type + (wire(p, WIRE.W_L_SHIFT) * rp.eta); + ap.next_gate_access_type = wire(p, WIRE.W_4_SHIFT) - ap.next_gate_access_type; + + Fr value_delta = wire(p, WIRE.W_O_SHIFT) - wire(p, WIRE.W_O); + ap.adjacent_values_match_if_adjacent_indices_match_and_next_access_is_a_read_operation = + (ap.index_delta * MINUS_ONE + ONE) * value_delta * (ap.next_gate_access_type * MINUS_ONE + ONE); // deg 3 or 6 + + // We can't apply the RAM consistency check identity on the final entry in the sorted list (the wires in the + // next gate would make the identity fail). We need to validate that its 'access type' bool is correct. Can't + // do with an arithmetic gate because of the `eta` factors. We need to check that the *next* gate's access + // type is correct, to cover this edge case + // deg 2 or 4 + ap.next_gate_access_type_is_boolean = + ap.next_gate_access_type * ap.next_gate_access_type - ap.next_gate_access_type; + + // Putting it all together... + evals[16] = ap.adjacent_values_match_if_adjacent_indices_match_and_next_access_is_a_read_operation + * (wire(p, WIRE.Q_O)) * (wire(p, WIRE.Q_MEMORY) * domainSep); // deg 5 or 8 + evals[17] = ap.index_is_monotonically_increasing * (wire(p, WIRE.Q_O)) * (wire(p, WIRE.Q_MEMORY) * domainSep); // deg 4 + evals[18] = ap.next_gate_access_type_is_boolean * (wire(p, WIRE.Q_O)) * (wire(p, WIRE.Q_MEMORY) * domainSep); // deg 4 or 6 + + ap.RAM_consistency_check_identity = ap.access_check * (wire(p, WIRE.Q_O)); // deg 3 or 9 + + /** + * RAM Timestamp Consistency Check + * + * | w1 | w2 | w3 | w4 | + * | index | timestamp | timestamp_check | -- | + * + * Let delta_index = index_{i + 1} - index_{i} + * + * Iff delta_index == 0, timestamp_check = timestamp_{i + 1} - timestamp_i + * Else timestamp_check = 0 + */ + ap.timestamp_delta = wire(p, WIRE.W_R_SHIFT) - wire(p, WIRE.W_R); + ap.RAM_timestamp_check_identity = (ap.index_delta * MINUS_ONE + ONE) * ap.timestamp_delta - wire(p, WIRE.W_O); // deg 3 + + /** + * Complete Contribution 12 + * The complete RAM/ROM memory identity + * Partial degree: + */ + ap.memory_identity = ap.ROM_consistency_check_identity; // deg 3 or 6 + ap.memory_identity = + ap.memory_identity + ap.RAM_timestamp_check_identity * (wire(p, WIRE.Q_4) * wire(p, WIRE.Q_L)); // deg 4 + ap.memory_identity = ap.memory_identity + ap.memory_record_check * (wire(p, WIRE.Q_M) * wire(p, WIRE.Q_L)); // deg 3 or 6 + ap.memory_identity = ap.memory_identity + ap.RAM_consistency_check_identity; // deg 3 or 9 + + // (deg 3 or 9) + (deg 4) + (deg 3) + ap.memory_identity = ap.memory_identity * (wire(p, WIRE.Q_MEMORY) * domainSep); // deg 4 or 10 + evals[13] = ap.memory_identity; + } + + function accumulateNnfRelation( + Fr[NUMBER_OF_ENTITIES] memory p, + Fr[NUMBER_OF_SUBRELATIONS] memory evals, + Fr domainSep + ) internal pure { + NnfParams memory ap; + + /** + * Contribution 12 + * Non native field arithmetic gate 2 + * deg 4 + * + * _ _ + * / _ _ _ 14 \ + * q_2 . q_4 | (w_1 . w_2) + (w_1 . w_2) + (w_1 . w_4 + w_2 . w_3 - w_3) . 2 - w_3 - w_4 | + * \_ _/ + * + * + */ + ap.limb_subproduct = wire(p, WIRE.W_L) * wire(p, WIRE.W_R_SHIFT) + wire(p, WIRE.W_L_SHIFT) * wire(p, WIRE.W_R); + ap.non_native_field_gate_2 = + (wire(p, WIRE.W_L) * wire(p, WIRE.W_4) + wire(p, WIRE.W_R) * wire(p, WIRE.W_O) - wire(p, WIRE.W_O_SHIFT)); + ap.non_native_field_gate_2 = ap.non_native_field_gate_2 * LIMB_SIZE; + ap.non_native_field_gate_2 = ap.non_native_field_gate_2 - wire(p, WIRE.W_4_SHIFT); + ap.non_native_field_gate_2 = ap.non_native_field_gate_2 + ap.limb_subproduct; + ap.non_native_field_gate_2 = ap.non_native_field_gate_2 * wire(p, WIRE.Q_4); + + ap.limb_subproduct = ap.limb_subproduct * LIMB_SIZE; + ap.limb_subproduct = ap.limb_subproduct + (wire(p, WIRE.W_L_SHIFT) * wire(p, WIRE.W_R_SHIFT)); + ap.non_native_field_gate_1 = ap.limb_subproduct; + ap.non_native_field_gate_1 = ap.non_native_field_gate_1 - (wire(p, WIRE.W_O) + wire(p, WIRE.W_4)); + ap.non_native_field_gate_1 = ap.non_native_field_gate_1 * wire(p, WIRE.Q_O); + + ap.non_native_field_gate_3 = ap.limb_subproduct; + ap.non_native_field_gate_3 = ap.non_native_field_gate_3 + wire(p, WIRE.W_4); + ap.non_native_field_gate_3 = ap.non_native_field_gate_3 - (wire(p, WIRE.W_O_SHIFT) + wire(p, WIRE.W_4_SHIFT)); + ap.non_native_field_gate_3 = ap.non_native_field_gate_3 * wire(p, WIRE.Q_M); + + Fr non_native_field_identity = + ap.non_native_field_gate_1 + ap.non_native_field_gate_2 + ap.non_native_field_gate_3; + non_native_field_identity = non_native_field_identity * wire(p, WIRE.Q_R); + + // ((((w2' * 2^14 + w1') * 2^14 + w3) * 2^14 + w2) * 2^14 + w1 - w4) * qm + // deg 2 + ap.limb_accumulator_1 = wire(p, WIRE.W_R_SHIFT) * SUBLIMB_SHIFT; + ap.limb_accumulator_1 = ap.limb_accumulator_1 + wire(p, WIRE.W_L_SHIFT); + ap.limb_accumulator_1 = ap.limb_accumulator_1 * SUBLIMB_SHIFT; + ap.limb_accumulator_1 = ap.limb_accumulator_1 + wire(p, WIRE.W_O); + ap.limb_accumulator_1 = ap.limb_accumulator_1 * SUBLIMB_SHIFT; + ap.limb_accumulator_1 = ap.limb_accumulator_1 + wire(p, WIRE.W_R); + ap.limb_accumulator_1 = ap.limb_accumulator_1 * SUBLIMB_SHIFT; + ap.limb_accumulator_1 = ap.limb_accumulator_1 + wire(p, WIRE.W_L); + ap.limb_accumulator_1 = ap.limb_accumulator_1 - wire(p, WIRE.W_4); + ap.limb_accumulator_1 = ap.limb_accumulator_1 * wire(p, WIRE.Q_4); + + // ((((w3' * 2^14 + w2') * 2^14 + w1') * 2^14 + w4) * 2^14 + w3 - w4') * qm + // deg 2 + ap.limb_accumulator_2 = wire(p, WIRE.W_O_SHIFT) * SUBLIMB_SHIFT; + ap.limb_accumulator_2 = ap.limb_accumulator_2 + wire(p, WIRE.W_R_SHIFT); + ap.limb_accumulator_2 = ap.limb_accumulator_2 * SUBLIMB_SHIFT; + ap.limb_accumulator_2 = ap.limb_accumulator_2 + wire(p, WIRE.W_L_SHIFT); + ap.limb_accumulator_2 = ap.limb_accumulator_2 * SUBLIMB_SHIFT; + ap.limb_accumulator_2 = ap.limb_accumulator_2 + wire(p, WIRE.W_4); + ap.limb_accumulator_2 = ap.limb_accumulator_2 * SUBLIMB_SHIFT; + ap.limb_accumulator_2 = ap.limb_accumulator_2 + wire(p, WIRE.W_O); + ap.limb_accumulator_2 = ap.limb_accumulator_2 - wire(p, WIRE.W_4_SHIFT); + ap.limb_accumulator_2 = ap.limb_accumulator_2 * wire(p, WIRE.Q_M); + + Fr limb_accumulator_identity = ap.limb_accumulator_1 + ap.limb_accumulator_2; + limb_accumulator_identity = limb_accumulator_identity * wire(p, WIRE.Q_O); // deg 3 + + ap.nnf_identity = non_native_field_identity + limb_accumulator_identity; + ap.nnf_identity = ap.nnf_identity * (wire(p, WIRE.Q_NNF) * domainSep); + evals[19] = ap.nnf_identity; + } + + function accumulatePoseidonExternalRelation( + Fr[NUMBER_OF_ENTITIES] memory p, + Fr[NUMBER_OF_SUBRELATIONS] memory evals, + Fr domainSep + ) internal pure { + PoseidonExternalParams memory ep; + + ep.s1 = wire(p, WIRE.W_L) + wire(p, WIRE.Q_L); + ep.s2 = wire(p, WIRE.W_R) + wire(p, WIRE.Q_R); + ep.s3 = wire(p, WIRE.W_O) + wire(p, WIRE.Q_O); + ep.s4 = wire(p, WIRE.W_4) + wire(p, WIRE.Q_4); + + ep.u1 = ep.s1 * ep.s1 * ep.s1 * ep.s1 * ep.s1; + ep.u2 = ep.s2 * ep.s2 * ep.s2 * ep.s2 * ep.s2; + ep.u3 = ep.s3 * ep.s3 * ep.s3 * ep.s3 * ep.s3; + ep.u4 = ep.s4 * ep.s4 * ep.s4 * ep.s4 * ep.s4; + // matrix mul v = M_E * u with 14 additions + ep.t0 = ep.u1 + ep.u2; // u_1 + u_2 + ep.t1 = ep.u3 + ep.u4; // u_3 + u_4 + ep.t2 = ep.u2 + ep.u2 + ep.t1; // 2u_2 + // ep.t2 += ep.t1; // 2u_2 + u_3 + u_4 + ep.t3 = ep.u4 + ep.u4 + ep.t0; // 2u_4 + // ep.t3 += ep.t0; // u_1 + u_2 + 2u_4 + ep.v4 = ep.t1 + ep.t1; + ep.v4 = ep.v4 + ep.v4 + ep.t3; + // ep.v4 += ep.t3; // u_1 + u_2 + 4u_3 + 6u_4 + ep.v2 = ep.t0 + ep.t0; + ep.v2 = ep.v2 + ep.v2 + ep.t2; + // ep.v2 += ep.t2; // 4u_1 + 6u_2 + u_3 + u_4 + ep.v1 = ep.t3 + ep.v2; // 5u_1 + 7u_2 + u_3 + 3u_4 + ep.v3 = ep.t2 + ep.v4; // u_1 + 3u_2 + 5u_3 + 7u_4 + + ep.q_pos_by_scaling = wire(p, WIRE.Q_POSEIDON2_EXTERNAL) * domainSep; + evals[20] = evals[20] + ep.q_pos_by_scaling * (ep.v1 - wire(p, WIRE.W_L_SHIFT)); + + evals[21] = evals[21] + ep.q_pos_by_scaling * (ep.v2 - wire(p, WIRE.W_R_SHIFT)); + + evals[22] = evals[22] + ep.q_pos_by_scaling * (ep.v3 - wire(p, WIRE.W_O_SHIFT)); + + evals[23] = evals[23] + ep.q_pos_by_scaling * (ep.v4 - wire(p, WIRE.W_4_SHIFT)); + } + + function accumulatePoseidonInternalRelation( + Fr[NUMBER_OF_ENTITIES] memory p, + Fr[NUMBER_OF_SUBRELATIONS] memory evals, + Fr domainSep + ) internal pure { + PoseidonInternalParams memory ip; + + Fr[4] memory INTERNAL_MATRIX_DIAGONAL = [ + FrLib.from(0x10dc6e9c006ea38b04b1e03b4bd9490c0d03f98929ca1d7fb56821fd19d3b6e7), + FrLib.from(0x0c28145b6a44df3e0149b3d0a30b3bb599df9756d4dd9b84a86b38cfb45a740b), + FrLib.from(0x00544b8338791518b2c7645a50392798b21f75bb60e3596170067d00141cac15), + FrLib.from(0x222c01175718386f2e2e82eb122789e352e105a3b8fa852613bc534433ee428b) + ]; + + // add round constants + ip.s1 = wire(p, WIRE.W_L) + wire(p, WIRE.Q_L); + + // apply s-box round + ip.u1 = ip.s1 * ip.s1 * ip.s1 * ip.s1 * ip.s1; + ip.u2 = wire(p, WIRE.W_R); + ip.u3 = wire(p, WIRE.W_O); + ip.u4 = wire(p, WIRE.W_4); + + // matrix mul with v = M_I * u 4 muls and 7 additions + ip.u_sum = ip.u1 + ip.u2 + ip.u3 + ip.u4; + + ip.q_pos_by_scaling = wire(p, WIRE.Q_POSEIDON2_INTERNAL) * domainSep; + + ip.v1 = ip.u1 * INTERNAL_MATRIX_DIAGONAL[0] + ip.u_sum; + evals[24] = evals[24] + ip.q_pos_by_scaling * (ip.v1 - wire(p, WIRE.W_L_SHIFT)); + + ip.v2 = ip.u2 * INTERNAL_MATRIX_DIAGONAL[1] + ip.u_sum; + evals[25] = evals[25] + ip.q_pos_by_scaling * (ip.v2 - wire(p, WIRE.W_R_SHIFT)); + + ip.v3 = ip.u3 * INTERNAL_MATRIX_DIAGONAL[2] + ip.u_sum; + evals[26] = evals[26] + ip.q_pos_by_scaling * (ip.v3 - wire(p, WIRE.W_O_SHIFT)); + + ip.v4 = ip.u4 * INTERNAL_MATRIX_DIAGONAL[3] + ip.u_sum; + evals[27] = evals[27] + ip.q_pos_by_scaling * (ip.v4 - wire(p, WIRE.W_4_SHIFT)); + } + + // Batch subrelation evaluations using precomputed powers of alpha + // First subrelation is implicitly scaled by 1, subsequent ones use powers from the subrelationChallenges array + function scaleAndBatchSubrelations( + Fr[NUMBER_OF_SUBRELATIONS] memory evaluations, + Fr[NUMBER_OF_ALPHAS] memory subrelationChallenges + ) internal pure returns (Fr accumulator) { + accumulator = evaluations[0]; + + for (uint256 i = 1; i < NUMBER_OF_SUBRELATIONS; ++i) { + accumulator = accumulator + evaluations[i] * subrelationChallenges[i - 1]; + } + } +} + +library CommitmentSchemeLib { + using FrLib for Fr; + + // Avoid stack too deep + struct ShpleminiIntermediates { + Fr unshiftedScalar; + Fr shiftedScalar; + Fr unshiftedScalarNeg; + Fr shiftedScalarNeg; + // Scalar to be multiplied by [1]₁ + Fr constantTermAccumulator; + // Accumulator for powers of rho + Fr batchingChallenge; + // Linear combination of multilinear (sumcheck) evaluations and powers of rho + Fr batchedEvaluation; + Fr[4] denominators; + Fr[4] batchingScalars; + // 1/(z - r^{2^i}) for i = 0, ..., logSize, dynamically updated + Fr posInvertedDenominator; + // 1/(z + r^{2^i}) for i = 0, ..., logSize, dynamically updated + Fr negInvertedDenominator; + // ν^{2i} * 1/(z - r^{2^i}) + Fr scalingFactorPos; + // ν^{2i+1} * 1/(z + r^{2^i}) + Fr scalingFactorNeg; + // Fold_i(r^{2^i}) reconstructed by Verifier + Fr[] foldPosEvaluations; + } + + // Compute the evaluations Aₗ(r^{2ˡ}) for l = 0, ..., m-1 + function computeFoldPosEvaluations( + Fr[CONST_PROOF_SIZE_LOG_N] memory sumcheckUChallenges, + Fr batchedEvalAccumulator, + Fr[CONST_PROOF_SIZE_LOG_N] memory geminiEvaluations, + Fr[] memory geminiEvalChallengePowers, + uint256 logSize + ) internal view returns (Fr[] memory) { + Fr[] memory foldPosEvaluations = new Fr[](logSize); + for (uint256 i = logSize; i > 0; --i) { + Fr challengePower = geminiEvalChallengePowers[i - 1]; + Fr u = sumcheckUChallenges[i - 1]; + + Fr batchedEvalRoundAcc = + ((challengePower * batchedEvalAccumulator * Fr.wrap(2)) - geminiEvaluations[i - 1] + * (challengePower * (ONE - u) - u)); + // Divide by the denominator + batchedEvalRoundAcc = batchedEvalRoundAcc * (challengePower * (ONE - u) + u).invert(); + + batchedEvalAccumulator = batchedEvalRoundAcc; + foldPosEvaluations[i - 1] = batchedEvalRoundAcc; + } + return foldPosEvaluations; + } + + function computeSquares(Fr r, uint256 logN) internal pure returns (Fr[] memory) { + Fr[] memory squares = new Fr[](logN); + squares[0] = r; + for (uint256 i = 1; i < logN; ++i) { + squares[i] = squares[i - 1].sqr(); + } + return squares; + } +} + +uint256 constant Q = 21888242871839275222246405745257275088696311157297823662689037894645226208583; // EC group order. F_q + +// Fr utility + +function bytesToFr(bytes calldata proofSection) pure returns (Fr scalar) { + scalar = FrLib.fromBytes32(bytes32(proofSection)); +} + +// EC Point utilities +function bytesToG1Point(bytes calldata proofSection) pure returns (Honk.G1Point memory point) { + uint256 x = uint256(bytes32(proofSection[0x00:0x20])); + uint256 y = uint256(bytes32(proofSection[0x20:0x40])); + require(x < Q && y < Q, Errors.ValueGeGroupOrder()); + + // Reject the point at infinity (0,0). EVM precompiles silently treat (0,0) + // as the identity element, which could zero out commitments. + // On-curve validation (y² = x³ + 3) is handled by the ecAdd/ecMul precompiles + // per EIP-196, so we only need to catch this special case here. + require((x | y) != 0, Errors.PointAtInfinity()); + + point = Honk.G1Point({x: x, y: y}); +} + +function negateInplace(Honk.G1Point memory point) pure returns (Honk.G1Point memory) { + // When y == 0 (order-2 point), negation is the same point. Q - 0 = Q which is >= Q. + if (point.y != 0) { + point.y = Q - point.y; + } + return point; +} + +/** + * Convert the pairing points to G1 points. + * + * The pairing points are serialised as an array of 2 limbs representing two points + * (P0 and P1, used for lhs and rhs of pairing operation). + * + * There are 2 limbs (lo, hi) for each coordinate, so 4 limbs per point, 8 total. + * Layout: [P0.x_lo, P0.x_hi, P0.y_lo, P0.y_hi, P1.x_lo, P1.x_hi, P1.y_lo, P1.y_hi] + * + * @param pairingPoints The pairing points to convert. + * @return lhs P0 point + * @return rhs P1 point + */ +function convertPairingPointsToG1(Fr[PAIRING_POINTS_SIZE] memory pairingPoints) + pure + returns (Honk.G1Point memory lhs, Honk.G1Point memory rhs) +{ + // P0 (lhs): x = lo | (hi << 136) + uint256 lhsX = Fr.unwrap(pairingPoints[0]); + lhsX |= Fr.unwrap(pairingPoints[1]) << 136; + + uint256 lhsY = Fr.unwrap(pairingPoints[2]); + lhsY |= Fr.unwrap(pairingPoints[3]) << 136; + + // P1 (rhs): x = lo | (hi << 136) + uint256 rhsX = Fr.unwrap(pairingPoints[4]); + rhsX |= Fr.unwrap(pairingPoints[5]) << 136; + + uint256 rhsY = Fr.unwrap(pairingPoints[6]); + rhsY |= Fr.unwrap(pairingPoints[7]) << 136; + + // Reconstructed coordinates must be < Q to prevent malleability. + // Without this, two different limb encodings could map to the same curve point + // (via mulmod reduction in on-curve checks) but produce different transcript hashes. + require(lhsX < Q && lhsY < Q && rhsX < Q && rhsY < Q, Errors.ValueGeGroupOrder()); + + lhs.x = lhsX; + lhs.y = lhsY; + rhs.x = rhsX; + rhs.y = rhsY; +} + +/** + * Hash the pairing inputs from the present verification context with those extracted from the public inputs. + * + * @param proofPairingPoints Pairing points from the proof - (public inputs). + * @param accLhs Accumulator point for the left side - result of shplemini. + * @param accRhs Accumulator point for the right side - result of shplemini. + * @return recursionSeparator The recursion separator - generated from hashing the above. + */ +function generateRecursionSeparator( + Fr[PAIRING_POINTS_SIZE] memory proofPairingPoints, + Honk.G1Point memory accLhs, + Honk.G1Point memory accRhs +) pure returns (Fr recursionSeparator) { + // hash the proof aggregated X + // hash the proof aggregated Y + // hash the accum X + // hash the accum Y + + (Honk.G1Point memory proofLhs, Honk.G1Point memory proofRhs) = convertPairingPointsToG1(proofPairingPoints); + + uint256[8] memory recursionSeparatorElements; + + // Proof points + recursionSeparatorElements[0] = proofLhs.x; + recursionSeparatorElements[1] = proofLhs.y; + recursionSeparatorElements[2] = proofRhs.x; + recursionSeparatorElements[3] = proofRhs.y; + + // Accumulator points + recursionSeparatorElements[4] = accLhs.x; + recursionSeparatorElements[5] = accLhs.y; + recursionSeparatorElements[6] = accRhs.x; + recursionSeparatorElements[7] = accRhs.y; + + recursionSeparator = FrLib.from(uint256(keccak256(abi.encodePacked(recursionSeparatorElements))) % P); +} + +/** + * G1 Mul with Separator + * Using the ecAdd and ecMul precompiles + * + * @param basePoint The point to multiply. + * @param other The other point to add. + * @param recursionSeperator The separator to use for the multiplication. + * @return `(recursionSeperator * basePoint) + other`. + */ +function mulWithSeperator(Honk.G1Point memory basePoint, Honk.G1Point memory other, Fr recursionSeperator) + view + returns (Honk.G1Point memory) +{ + Honk.G1Point memory result; + + result = ecMul(recursionSeperator, basePoint); + result = ecAdd(result, other); + + return result; +} + +/** + * G1 Mul + * Takes a Fr value and a G1 point and uses the ecMul precompile to return the result. + * + * @param value The value to multiply the point by. + * @param point The point to multiply. + * @return result The result of the multiplication. + */ +function ecMul(Fr value, Honk.G1Point memory point) view returns (Honk.G1Point memory) { + Honk.G1Point memory result; + + assembly { + let free := mload(0x40) + // Write the point into memory (two 32 byte words) + // Memory layout: + // Address | value + // free | point.x + // free + 0x20| point.y + mstore(free, mload(point)) + mstore(add(free, 0x20), mload(add(point, 0x20))) + // Write the scalar into memory (one 32 byte word) + // Memory layout: + // Address | value + // free + 0x40| value + mstore(add(free, 0x40), value) + + // Call the ecMul precompile, it takes in the following + // [point.x, point.y, scalar], and returns the result back into the free memory location. + let success := staticcall(gas(), 0x07, free, 0x60, free, 0x40) + if iszero(success) { + revert(0, 0) + } + // Copy the result of the multiplication back into the result memory location. + // Memory layout: + // Address | value + // result | result.x + // result + 0x20| result.y + mstore(result, mload(free)) + mstore(add(result, 0x20), mload(add(free, 0x20))) + + mstore(0x40, add(free, 0x60)) + } + + return result; +} + +/** + * G1 Add + * Takes two G1 points and uses the ecAdd precompile to return the result. + * + * @param lhs The left hand side of the addition. + * @param rhs The right hand side of the addition. + * @return result The result of the addition. + */ +function ecAdd(Honk.G1Point memory lhs, Honk.G1Point memory rhs) view returns (Honk.G1Point memory) { + Honk.G1Point memory result; + + assembly { + let free := mload(0x40) + // Write lhs into memory (two 32 byte words) + // Memory layout: + // Address | value + // free | lhs.x + // free + 0x20| lhs.y + mstore(free, mload(lhs)) + mstore(add(free, 0x20), mload(add(lhs, 0x20))) + + // Write rhs into memory (two 32 byte words) + // Memory layout: + // Address | value + // free + 0x40| rhs.x + // free + 0x60| rhs.y + mstore(add(free, 0x40), mload(rhs)) + mstore(add(free, 0x60), mload(add(rhs, 0x20))) + + // Call the ecAdd precompile, it takes in the following + // [lhs.x, lhs.y, rhs.x, rhs.y], and returns their addition back into the free memory location. + let success := staticcall(gas(), 0x06, free, 0x80, free, 0x40) + if iszero(success) { revert(0, 0) } + + // Copy the result of the addition back into the result memory location. + // Memory layout: + // Address | value + // result | result.x + // result + 0x20| result.y + mstore(result, mload(free)) + mstore(add(result, 0x20), mload(add(free, 0x20))) + + mstore(0x40, add(free, 0x80)) + } + + return result; +} + +function rejectPointAtInfinity(Honk.G1Point memory point) pure { + require((point.x | point.y) != 0, Errors.PointAtInfinity()); +} + +/** + * Check if pairing point limbs are all zero (default/infinity). + * Default pairing points indicate no recursive verification occurred. + */ +function arePairingPointsDefault(Fr[PAIRING_POINTS_SIZE] memory pairingPoints) pure returns (bool) { + uint256 acc = 0; + for (uint256 i = 0; i < PAIRING_POINTS_SIZE; i++) { + acc |= Fr.unwrap(pairingPoints[i]); + } + return acc == 0; +} + +function pairing(Honk.G1Point memory rhs, Honk.G1Point memory lhs) view returns (bool decodedResult) { + bytes memory input = abi.encodePacked( + rhs.x, + rhs.y, + // Fixed G2 point + uint256(0x198e9393920d483a7260bfb731fb5d25f1aa493335a9e71297e485b7aef312c2), + uint256(0x1800deef121f1e76426a00665e5c4479674322d4f75edadd46debd5cd992f6ed), + uint256(0x090689d0585ff075ec9e99ad690c3395bc4b313370b38ef355acdadcd122975b), + uint256(0x12c85ea5db8c6deb4aab71808dcb408fe3d1e7690c43d37b4ce6cc0166fa7daa), + lhs.x, + lhs.y, + // G2 point from VK + uint256(0x260e01b251f6f1c7e7ff4e580791dee8ea51d87a358e038b4efe30fac09383c1), + uint256(0x0118c4d5b837bcc2bc89b5b398b5974e9f5944073b32078b7e231fec938883b0), + uint256(0x04fc6369f7110fe3d25156c1bb9a72859cf2a04641f99ba4ee413c80da6a5fe4), + uint256(0x22febda3c0c0632a56475b4214e5615e11e6dd3f96e6cea2854a87d4dacc5e55) + ); + + (bool success, bytes memory result) = address(0x08).staticcall(input); + decodedResult = success && abi.decode(result, (bool)); +} + +abstract contract BaseZKHonkVerifier is IVerifier { + using FrLib for Fr; + + struct PairingInputs { + Honk.G1Point P_0; + Honk.G1Point P_1; + } + + struct SmallSubgroupIpaIntermediates { + Fr[SUBGROUP_SIZE] challengePolyLagrange; + Fr challengePolyEval; + Fr lagrangeFirst; + Fr lagrangeLast; + Fr rootPower; + Fr[SUBGROUP_SIZE] denominators; // this has to disappear + Fr diff; + } + + // Constants for proof length calculation (matching UltraKeccakZKFlavor) + uint256 internal constant NUM_WITNESS_ENTITIES = 8 + NUM_MASKING_POLYNOMIALS; + uint256 internal constant NUM_ELEMENTS_COMM = 2; // uint256 elements for curve points + uint256 internal constant NUM_ELEMENTS_FR = 1; // uint256 elements for field elements + uint256 internal constant NUM_LIBRA_EVALUATIONS = 4; // libra evaluations + + uint256 internal constant LIBRA_COMMITMENTS = 3; + uint256 internal constant LIBRA_EVALUATIONS = 4; + uint256 internal constant LIBRA_UNIVARIATES_LENGTH = 9; + + uint256 internal constant SHIFTED_COMMITMENTS_START = 30; + uint256 internal constant PERMUTATION_ARGUMENT_VALUE_SEPARATOR = 1 << 28; + + uint256 internal immutable $N; + uint256 internal immutable $LOG_N; + uint256 internal immutable $VK_HASH; + uint256 internal immutable $NUM_PUBLIC_INPUTS; + uint256 internal immutable $MSMSize; + + constructor(uint256 _N, uint256 _logN, uint256 _vkHash, uint256 _numPublicInputs) { + $N = _N; + $LOG_N = _logN; + $VK_HASH = _vkHash; + $NUM_PUBLIC_INPUTS = _numPublicInputs; + $MSMSize = NUMBER_UNSHIFTED_ZK + _logN + LIBRA_COMMITMENTS + 2; + } + + function verify(bytes calldata proof, bytes32[] calldata publicInputs) + public + view + override + returns (bool verified) + { + // Calculate expected proof size based on $LOG_N + uint256 expectedProofSize = calculateProofSize($LOG_N); + + // Check the received proof is the expected size where each field element is 32 bytes + require( + proof.length == expectedProofSize, Errors.ProofLengthWrongWithLogN($LOG_N, proof.length, expectedProofSize) + ); + + Honk.VerificationKey memory vk = loadVerificationKey(); + Honk.ZKProof memory p = ZKTranscriptLib.loadProof(proof, $LOG_N); + + require(publicInputs.length == vk.publicInputsSize - PAIRING_POINTS_SIZE, Errors.PublicInputsLengthWrong()); + + // Generate the fiat shamir challenges for the whole protocol + ZKTranscript memory t = + ZKTranscriptLib.generateTranscript(p, publicInputs, $VK_HASH, $NUM_PUBLIC_INPUTS, $LOG_N); + + // Derive public input delta + t.relationParameters.publicInputsDelta = computePublicInputDelta( + publicInputs, + p.pairingPointObject, + t.relationParameters.beta, + t.relationParameters.gamma, /*pubInputsOffset=*/ + 1 + ); + + // Sumcheck + require(verifySumcheck(p, t), Errors.SumcheckFailed()); + require(verifyShplemini(p, vk, t), Errors.ShpleminiFailed()); + + verified = true; + } + + function computePublicInputDelta( + bytes32[] memory publicInputs, + Fr[PAIRING_POINTS_SIZE] memory pairingPointObject, + Fr beta, + Fr gamma, + uint256 offset + ) internal view returns (Fr publicInputDelta) { + Fr numerator = Fr.wrap(1); + Fr denominator = Fr.wrap(1); + + Fr numeratorAcc = gamma + (beta * FrLib.from(PERMUTATION_ARGUMENT_VALUE_SEPARATOR + offset)); + Fr denominatorAcc = gamma - (beta * FrLib.from(offset + 1)); + + { + for (uint256 i = 0; i < $NUM_PUBLIC_INPUTS - PAIRING_POINTS_SIZE; i++) { + Fr pubInput = FrLib.fromBytes32(publicInputs[i]); + + numerator = numerator * (numeratorAcc + pubInput); + denominator = denominator * (denominatorAcc + pubInput); + + numeratorAcc = numeratorAcc + beta; + denominatorAcc = denominatorAcc - beta; + } + + for (uint256 i = 0; i < PAIRING_POINTS_SIZE; i++) { + Fr pubInput = pairingPointObject[i]; + + numerator = numerator * (numeratorAcc + pubInput); + denominator = denominator * (denominatorAcc + pubInput); + + numeratorAcc = numeratorAcc + beta; + denominatorAcc = denominatorAcc - beta; + } + } + + // Fr delta = numerator / denominator; // TOOO: batch invert later? + publicInputDelta = FrLib.div(numerator, denominator); + } + + function verifySumcheck(Honk.ZKProof memory proof, ZKTranscript memory tp) internal view returns (bool verified) { + Fr roundTargetSum = tp.libraChallenge * proof.libraSum; // default 0 + Fr powPartialEvaluation = Fr.wrap(1); + + // We perform sumcheck reductions over log n rounds ( the multivariate degree ) + for (uint256 round; round < $LOG_N; ++round) { + Fr[ZK_BATCHED_RELATION_PARTIAL_LENGTH] memory roundUnivariate = proof.sumcheckUnivariates[round]; + Fr totalSum = roundUnivariate[0] + roundUnivariate[1]; + require(totalSum == roundTargetSum, Errors.SumcheckFailed()); + + Fr roundChallenge = tp.sumCheckUChallenges[round]; + + // Update the round target for the next rounf + roundTargetSum = computeNextTargetSum(roundUnivariate, roundChallenge); + powPartialEvaluation = + powPartialEvaluation * (Fr.wrap(1) + roundChallenge * (tp.gateChallenges[round] - Fr.wrap(1))); + } + + // Last round + // For ZK flavors: sumcheckEvaluations has 42 elements + // Index 0 is gemini_masking_poly, indices 1-41 are the regular entities used in relations + Fr[NUMBER_OF_ENTITIES] memory relationsEvaluations; + for (uint256 i = 0; i < NUMBER_OF_ENTITIES; i++) { + relationsEvaluations[i] = proof.sumcheckEvaluations[i + NUM_MASKING_POLYNOMIALS]; // Skip gemini_masking_poly at index 0 + } + Fr grandHonkRelationSum = RelationsLib.accumulateRelationEvaluations( + relationsEvaluations, tp.relationParameters, tp.alphas, powPartialEvaluation + ); + + Fr evaluation = Fr.wrap(1); + for (uint256 i = 2; i < $LOG_N; i++) { + evaluation = evaluation * tp.sumCheckUChallenges[i]; + } + + grandHonkRelationSum = + grandHonkRelationSum * (Fr.wrap(1) - evaluation) + proof.libraEvaluation * tp.libraChallenge; + verified = (grandHonkRelationSum == roundTargetSum); + } + + // Return the new target sum for the next sumcheck round + function computeNextTargetSum(Fr[ZK_BATCHED_RELATION_PARTIAL_LENGTH] memory roundUnivariates, Fr roundChallenge) + internal + view + returns (Fr targetSum) + { + Fr[ZK_BATCHED_RELATION_PARTIAL_LENGTH] memory BARYCENTRIC_LAGRANGE_DENOMINATORS = [ + Fr.wrap(0x0000000000000000000000000000000000000000000000000000000000009d80), + Fr.wrap(0x30644e72e131a029b85045b68181585d2833e84879b9709143e1f593efffec51), + Fr.wrap(0x00000000000000000000000000000000000000000000000000000000000005a0), + Fr.wrap(0x30644e72e131a029b85045b68181585d2833e84879b9709143e1f593effffd31), + Fr.wrap(0x0000000000000000000000000000000000000000000000000000000000000240), + Fr.wrap(0x30644e72e131a029b85045b68181585d2833e84879b9709143e1f593effffd31), + Fr.wrap(0x00000000000000000000000000000000000000000000000000000000000005a0), + Fr.wrap(0x30644e72e131a029b85045b68181585d2833e84879b9709143e1f593efffec51), + Fr.wrap(0x0000000000000000000000000000000000000000000000000000000000009d80) + ]; + + // To compute the next target sum, we evaluate the given univariate at a point u (challenge). + + // Performing Barycentric evaluations + // Compute B(x) + Fr numeratorValue = Fr.wrap(1); + for (uint256 i = 0; i < ZK_BATCHED_RELATION_PARTIAL_LENGTH; ++i) { + numeratorValue = numeratorValue * (roundChallenge - Fr.wrap(i)); + } + + Fr[ZK_BATCHED_RELATION_PARTIAL_LENGTH] memory denominatorInverses; + for (uint256 i = 0; i < ZK_BATCHED_RELATION_PARTIAL_LENGTH; ++i) { + denominatorInverses[i] = FrLib.invert(BARYCENTRIC_LAGRANGE_DENOMINATORS[i] * (roundChallenge - Fr.wrap(i))); + } + + for (uint256 i = 0; i < ZK_BATCHED_RELATION_PARTIAL_LENGTH; ++i) { + targetSum = targetSum + roundUnivariates[i] * denominatorInverses[i]; + } + + // Scale the sum by the value of B(x) + targetSum = targetSum * numeratorValue; + } + + function verifyShplemini(Honk.ZKProof memory proof, Honk.VerificationKey memory vk, ZKTranscript memory tp) + internal + view + returns (bool verified) + { + CommitmentSchemeLib.ShpleminiIntermediates memory mem; // stack + + // - Compute vector (r, r², ... , r²⁽ⁿ⁻¹⁾), where n = log_circuit_size + Fr[] memory powers_of_evaluation_challenge = CommitmentSchemeLib.computeSquares(tp.geminiR, $LOG_N); + // Arrays hold values that will be linearly combined for the gemini and shplonk batch openings + Fr[] memory scalars = new Fr[]($MSMSize); + Honk.G1Point[] memory commitments = new Honk.G1Point[]($MSMSize); + + mem.posInvertedDenominator = (tp.shplonkZ - powers_of_evaluation_challenge[0]).invert(); + mem.negInvertedDenominator = (tp.shplonkZ + powers_of_evaluation_challenge[0]).invert(); + + mem.unshiftedScalar = mem.posInvertedDenominator + (tp.shplonkNu * mem.negInvertedDenominator); + mem.shiftedScalar = + tp.geminiR.invert() * (mem.posInvertedDenominator - (tp.shplonkNu * mem.negInvertedDenominator)); + + scalars[0] = Fr.wrap(1); + commitments[0] = proof.shplonkQ; + + /* Batch multivariate opening claims, shifted and unshifted + * The vector of scalars is populated as follows: + * \f[ + * \left( + * - \left(\frac{1}{z-r} + \nu \times \frac{1}{z+r}\right), + * \ldots, + * - \rho^{i+k-1} \times \left(\frac{1}{z-r} + \nu \times \frac{1}{z+r}\right), + * - \rho^{i+k} \times \frac{1}{r} \times \left(\frac{1}{z-r} - \nu \times \frac{1}{z+r}\right), + * \ldots, + * - \rho^{k+m-1} \times \frac{1}{r} \times \left(\frac{1}{z-r} - \nu \times \frac{1}{z+r}\right) + * \right) + * \f] + * + * The following vector is concatenated to the vector of commitments: + * \f[ + * f_0, \ldots, f_{m-1}, f_{\text{shift}, 0}, \ldots, f_{\text{shift}, k-1} + * \f] + * + * Simultaneously, the evaluation of the multilinear polynomial + * \f[ + * \sum \rho^i \cdot f_i + \sum \rho^{i+k} \cdot f_{\text{shift}, i} + * \f] + * at the challenge point \f$ (u_0,\ldots, u_{n-1}) \f$ is computed. + * + * This approach minimizes the number of iterations over the commitments to multilinear polynomials + * and eliminates the need to store the powers of \f$ \rho \f$. + */ + // For ZK flavors: evaluations array is [gemini_masking_poly, qm, qc, ql, qr, ...] + // Start batching challenge at 1, not rho, to match non-ZK pattern + mem.batchingChallenge = Fr.wrap(1); + mem.batchedEvaluation = Fr.wrap(0); + + mem.unshiftedScalarNeg = mem.unshiftedScalar.neg(); + mem.shiftedScalarNeg = mem.shiftedScalar.neg(); + + // Process all NUMBER_UNSHIFTED_ZK evaluations (includes gemini_masking_poly at index 0) + for (uint256 i = 1; i <= NUMBER_UNSHIFTED_ZK; ++i) { + scalars[i] = mem.unshiftedScalarNeg * mem.batchingChallenge; + mem.batchedEvaluation = mem.batchedEvaluation + + (proof.sumcheckEvaluations[i - NUM_MASKING_POLYNOMIALS] * mem.batchingChallenge); + mem.batchingChallenge = mem.batchingChallenge * tp.rho; + } + // g commitments are accumulated at r + // For each of the to be shifted commitments perform the shift in place by + // adding to the unshifted value. + // We do so, as the values are to be used in batchMul later, and as + // `a * c + b * c = (a + b) * c` this will allow us to reduce memory and compute. + // Applied to w1, w2, w3, w4 and zPerm + for (uint256 i = 0; i < NUMBER_TO_BE_SHIFTED; ++i) { + uint256 scalarOff = i + SHIFTED_COMMITMENTS_START; + uint256 evaluationOff = i + NUMBER_UNSHIFTED_ZK; + + scalars[scalarOff] = scalars[scalarOff] + (mem.shiftedScalarNeg * mem.batchingChallenge); + mem.batchedEvaluation = + mem.batchedEvaluation + (proof.sumcheckEvaluations[evaluationOff] * mem.batchingChallenge); + mem.batchingChallenge = mem.batchingChallenge * tp.rho; + } + + commitments[1] = proof.geminiMaskingPoly; + + commitments[2] = vk.qm; + commitments[3] = vk.qc; + commitments[4] = vk.ql; + commitments[5] = vk.qr; + commitments[6] = vk.qo; + commitments[7] = vk.q4; + commitments[8] = vk.qLookup; + commitments[9] = vk.qArith; + commitments[10] = vk.qDeltaRange; + commitments[11] = vk.qElliptic; + commitments[12] = vk.qMemory; + commitments[13] = vk.qNnf; + commitments[14] = vk.qPoseidon2External; + commitments[15] = vk.qPoseidon2Internal; + commitments[16] = vk.s1; + commitments[17] = vk.s2; + commitments[18] = vk.s3; + commitments[19] = vk.s4; + commitments[20] = vk.id1; + commitments[21] = vk.id2; + commitments[22] = vk.id3; + commitments[23] = vk.id4; + commitments[24] = vk.t1; + commitments[25] = vk.t2; + commitments[26] = vk.t3; + commitments[27] = vk.t4; + commitments[28] = vk.lagrangeFirst; + commitments[29] = vk.lagrangeLast; + + // Accumulate proof points + commitments[30] = proof.w1; + commitments[31] = proof.w2; + commitments[32] = proof.w3; + commitments[33] = proof.w4; + commitments[34] = proof.zPerm; + commitments[35] = proof.lookupInverses; + commitments[36] = proof.lookupReadCounts; + commitments[37] = proof.lookupReadTags; + + /* Batch gemini claims from the prover + * place the commitments to gemini aᵢ to the vector of commitments, compute the contributions from + * aᵢ(−r²ⁱ) for i=1, … , n−1 to the constant term accumulator, add corresponding scalars + * + * 1. Moves the vector + * \f[ + * \left( \text{com}(A_1), \text{com}(A_2), \ldots, \text{com}(A_{n-1}) \right) + * \f] + * to the 'commitments' vector. + * + * 2. Computes the scalars: + * \f[ + * \frac{\nu^{2}}{z + r^2}, \frac{\nu^3}{z + r^4}, \ldots, \frac{\nu^{n-1}}{z + r^{2^{n-1}}} + * \f] + * and places them into the 'scalars' vector. + * + * 3. Accumulates the summands of the constant term: + * \f[ + * \sum_{i=2}^{n-1} \frac{\nu^{i} \cdot A_i(-r^{2^i})}{z + r^{2^i}} + * \f] + * and adds them to the 'constant_term_accumulator'. + */ + + // Add contributions from A₀(r) and A₀(-r) to constant_term_accumulator: + // Compute the evaluations Aₗ(r^{2ˡ}) for l = 0, ..., $LOG_N - 1 + Fr[] memory foldPosEvaluations = CommitmentSchemeLib.computeFoldPosEvaluations( + tp.sumCheckUChallenges, + mem.batchedEvaluation, + proof.geminiAEvaluations, + powers_of_evaluation_challenge, + $LOG_N + ); + + mem.constantTermAccumulator = foldPosEvaluations[0] * mem.posInvertedDenominator; + mem.constantTermAccumulator = + mem.constantTermAccumulator + (proof.geminiAEvaluations[0] * tp.shplonkNu * mem.negInvertedDenominator); + + mem.batchingChallenge = tp.shplonkNu.sqr(); + uint256 boundary = NUMBER_UNSHIFTED_ZK + 1; + + // Compute Shplonk constant term contributions from Aₗ(± r^{2ˡ}) for l = 1, ..., m-1; + // Compute scalar multipliers for each fold commitment + for (uint256 i = 0; i < $LOG_N - 1; ++i) { + bool dummy_round = i >= ($LOG_N - 1); + + if (!dummy_round) { + // Update inverted denominators + mem.posInvertedDenominator = (tp.shplonkZ - powers_of_evaluation_challenge[i + 1]).invert(); + mem.negInvertedDenominator = (tp.shplonkZ + powers_of_evaluation_challenge[i + 1]).invert(); + + // Compute the scalar multipliers for Aₗ(± r^{2ˡ}) and [Aₗ] + mem.scalingFactorPos = mem.batchingChallenge * mem.posInvertedDenominator; + mem.scalingFactorNeg = mem.batchingChallenge * tp.shplonkNu * mem.negInvertedDenominator; + scalars[boundary + i] = mem.scalingFactorNeg.neg() + mem.scalingFactorPos.neg(); + + // Accumulate the const term contribution given by + // v^{2l} * Aₗ(r^{2ˡ}) /(z-r^{2^l}) + v^{2l+1} * Aₗ(-r^{2ˡ}) /(z+ r^{2^l}) + Fr accumContribution = mem.scalingFactorNeg * proof.geminiAEvaluations[i + 1]; + accumContribution = accumContribution + mem.scalingFactorPos * foldPosEvaluations[i + 1]; + mem.constantTermAccumulator = mem.constantTermAccumulator + accumContribution; + } + // Update the running power of v + mem.batchingChallenge = mem.batchingChallenge * tp.shplonkNu * tp.shplonkNu; + + commitments[boundary + i] = proof.geminiFoldComms[i]; + } + + boundary += $LOG_N - 1; + + // Finalize the batch opening claim + mem.denominators[0] = Fr.wrap(1).div(tp.shplonkZ - tp.geminiR); + mem.denominators[1] = Fr.wrap(1).div(tp.shplonkZ - SUBGROUP_GENERATOR * tp.geminiR); + mem.denominators[2] = mem.denominators[0]; + mem.denominators[3] = mem.denominators[0]; + + for (uint256 i = 0; i < LIBRA_EVALUATIONS; i++) { + Fr scalingFactor = mem.denominators[i] * mem.batchingChallenge; + mem.batchingScalars[i] = scalingFactor.neg(); + mem.batchingChallenge = mem.batchingChallenge * tp.shplonkNu; + mem.constantTermAccumulator = mem.constantTermAccumulator + scalingFactor * proof.libraPolyEvals[i]; + } + scalars[boundary] = mem.batchingScalars[0]; + scalars[boundary + 1] = mem.batchingScalars[1] + mem.batchingScalars[2]; + scalars[boundary + 2] = mem.batchingScalars[3]; + + for (uint256 i = 0; i < LIBRA_COMMITMENTS; i++) { + commitments[boundary++] = proof.libraCommitments[i]; + } + + commitments[boundary] = Honk.G1Point({x: 1, y: 2}); + scalars[boundary++] = mem.constantTermAccumulator; + + require( + checkEvalsConsistency(proof.libraPolyEvals, tp.geminiR, tp.sumCheckUChallenges, proof.libraEvaluation), + Errors.ConsistencyCheckFailed() + ); + + Honk.G1Point memory quotient_commitment = proof.kzgQuotient; + + commitments[boundary] = quotient_commitment; + scalars[boundary] = tp.shplonkZ; // evaluation challenge + + PairingInputs memory pair; + pair.P_0 = batchMul(commitments, scalars); + pair.P_1 = negateInplace(quotient_commitment); + + // Aggregate pairing points (skip if default/infinity — no recursive verification occurred) + if (!arePairingPointsDefault(proof.pairingPointObject)) { + Fr recursionSeparator = generateRecursionSeparator(proof.pairingPointObject, pair.P_0, pair.P_1); + (Honk.G1Point memory P_0_other, Honk.G1Point memory P_1_other) = + convertPairingPointsToG1(proof.pairingPointObject); + + // Validate the points from the proof are on the curve + rejectPointAtInfinity(P_0_other); + rejectPointAtInfinity(P_1_other); + + // accumulate with aggregate points in proof + pair.P_0 = mulWithSeperator(pair.P_0, P_0_other, recursionSeparator); + pair.P_1 = mulWithSeperator(pair.P_1, P_1_other, recursionSeparator); + } + + return pairing(pair.P_0, pair.P_1); + } + + function checkEvalsConsistency( + Fr[LIBRA_EVALUATIONS] memory libraPolyEvals, + Fr geminiR, + Fr[CONST_PROOF_SIZE_LOG_N] memory uChallenges, + Fr libraEval + ) internal view returns (bool check) { + Fr one = Fr.wrap(1); + Fr vanishingPolyEval = geminiR.pow(SUBGROUP_SIZE) - one; + require(vanishingPolyEval != Fr.wrap(0), Errors.GeminiChallengeInSubgroup()); + + SmallSubgroupIpaIntermediates memory mem; + mem.challengePolyLagrange[0] = one; + for (uint256 round = 0; round < $LOG_N; round++) { + uint256 currIdx = 1 + LIBRA_UNIVARIATES_LENGTH * round; + mem.challengePolyLagrange[currIdx] = one; + for (uint256 idx = currIdx + 1; idx < currIdx + LIBRA_UNIVARIATES_LENGTH; idx++) { + mem.challengePolyLagrange[idx] = mem.challengePolyLagrange[idx - 1] * uChallenges[round]; + } + } + + mem.rootPower = one; + mem.challengePolyEval = Fr.wrap(0); + for (uint256 idx = 0; idx < SUBGROUP_SIZE; idx++) { + mem.denominators[idx] = mem.rootPower * geminiR - one; + mem.denominators[idx] = mem.denominators[idx].invert(); + mem.challengePolyEval = mem.challengePolyEval + mem.challengePolyLagrange[idx] * mem.denominators[idx]; + mem.rootPower = mem.rootPower * SUBGROUP_GENERATOR_INVERSE; + } + + Fr numerator = vanishingPolyEval * Fr.wrap(SUBGROUP_SIZE).invert(); + mem.challengePolyEval = mem.challengePolyEval * numerator; + mem.lagrangeFirst = mem.denominators[0] * numerator; + mem.lagrangeLast = mem.denominators[SUBGROUP_SIZE - 1] * numerator; + + mem.diff = mem.lagrangeFirst * libraPolyEvals[2]; + + mem.diff = mem.diff + (geminiR - SUBGROUP_GENERATOR_INVERSE) + * (libraPolyEvals[1] - libraPolyEvals[2] - libraPolyEvals[0] * mem.challengePolyEval); + mem.diff = mem.diff + mem.lagrangeLast * (libraPolyEvals[2] - libraEval) - vanishingPolyEval * libraPolyEvals[3]; + + check = mem.diff == Fr.wrap(0); + } + + // This implementation is the same as above with different constants + function batchMul(Honk.G1Point[] memory base, Fr[] memory scalars) + internal + view + returns (Honk.G1Point memory result) + { + uint256 limit = $MSMSize; + + // Validate all points are on the curve + for (uint256 i = 0; i < limit; ++i) { + rejectPointAtInfinity(base[i]); + } + + bool success = true; + assembly { + let free := mload(0x40) + + let count := 0x01 + for {} lt(count, add(limit, 1)) { count := add(count, 1) } { + // Get loop offsets + let base_base := add(base, mul(count, 0x20)) + let scalar_base := add(scalars, mul(count, 0x20)) + + mstore(add(free, 0x40), mload(mload(base_base))) + mstore(add(free, 0x60), mload(add(0x20, mload(base_base)))) + // Add scalar + mstore(add(free, 0x80), mload(scalar_base)) + + success := and(success, staticcall(gas(), 7, add(free, 0x40), 0x60, add(free, 0x40), 0x40)) + // accumulator = accumulator + accumulator_2 + success := and(success, staticcall(gas(), 6, free, 0x80, free, 0x40)) + } + + // Return the result + mstore(result, mload(free)) + mstore(add(result, 0x20), mload(add(free, 0x20))) + } + + require(success, Errors.ShpleminiFailed()); + } + + // Calculate proof size based on log_n (matching UltraKeccakZKFlavor formula) + function calculateProofSize(uint256 logN) internal pure returns (uint256) { + // Witness and Libra commitments + uint256 proofLength = NUM_WITNESS_ENTITIES * NUM_ELEMENTS_COMM; // witness commitments + proofLength += NUM_ELEMENTS_COMM * 3; // Libra concat, grand sum, quotient comms + Gemini masking + + // Sumcheck + proofLength += logN * ZK_BATCHED_RELATION_PARTIAL_LENGTH * NUM_ELEMENTS_FR; // sumcheck univariates + proofLength += NUMBER_OF_ENTITIES_ZK * NUM_ELEMENTS_FR; // sumcheck evaluations + + // Libra and Gemini + proofLength += NUM_ELEMENTS_FR * 2; // Libra sum, claimed eval + proofLength += logN * NUM_ELEMENTS_FR; // Gemini a evaluations + proofLength += NUM_LIBRA_EVALUATIONS * NUM_ELEMENTS_FR; // libra evaluations + + // PCS commitments + proofLength += (logN - 1) * NUM_ELEMENTS_COMM; // Gemini Fold commitments + proofLength += NUM_ELEMENTS_COMM * 2; // Shplonk Q and KZG W commitments + + // Pairing points + proofLength += PAIRING_POINTS_SIZE; // pairing inputs carried on public inputs + + return proofLength * 32; + } + + function loadVerificationKey() internal pure virtual returns (Honk.VerificationKey memory); +} + +contract HonkVerifier is BaseZKHonkVerifier(N, LOG_N, VK_HASH, NUMBER_OF_PUBLIC_INPUTS) { + function loadVerificationKey() internal pure override returns (Honk.VerificationKey memory) { + return HonkVerificationKey.loadVerificationKey(); + } +} diff --git a/pocs/civic-participation/resilient-civic-participation/contracts/src/verifiers/ResolutionVerifier.sol b/pocs/civic-participation/resilient-civic-participation/contracts/src/verifiers/ResolutionVerifier.sol new file mode 100644 index 0000000..5e55488 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/contracts/src/verifiers/ResolutionVerifier.sol @@ -0,0 +1,2461 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2022 Aztec +pragma solidity >=0.8.21; + +uint256 constant N = 8388608; +uint256 constant LOG_N = 23; +uint256 constant NUMBER_OF_PUBLIC_INPUTS = 63; +uint256 constant VK_HASH = 0x26a630489b17d8d7a48d1dd273624d99e10553924b52d97159d3fc6dd8cd029f; + +library HonkVerificationKey { + function loadVerificationKey() internal pure returns (Honk.VerificationKey memory) { + Honk.VerificationKey memory vk = Honk.VerificationKey({ + circuitSize: uint256(8388608), + logCircuitSize: uint256(23), + publicInputsSize: uint256(63), + ql: Honk.G1Point({ + x: uint256(0x1cd5e2aa84099c3309e68c0c2ce3be3e2826c79fb181d0d106f4e1a6c2b586b6), + y: uint256(0x11e1a496390e72b2464d1aa3554fb4050019724a0994dd8fa8d32499bf9cfcbc) + }), + qr: Honk.G1Point({ + x: uint256(0x1d1e5a726529a372d116dbc9608b8bc67a3f27dd990645065635ecb42283ccef), + y: uint256(0x18d77a1e548cb7125d95ed393ac3e665be076c909ee065797b5ab0c2023e2f4f) + }), + qo: Honk.G1Point({ + x: uint256(0x178075e0c7ef28c0d5662887786a7d979e7109f30d1517e1a161bdca66c45ea1), + y: uint256(0x1dc4b1fc004b1439acd99d47f261e9dbd0936cd63dcd73d391c54778b20cba9f) + }), + q4: Honk.G1Point({ + x: uint256(0x1484287f6599570fb88b3adad8007b031386edb7ebe05e109a5a3169b3607a9e), + y: uint256(0x2a3edf55fdb4e2e89fb2dcf2034335c048cd49f4e842d73fd721a86535b2cdc0) + }), + qm: Honk.G1Point({ + x: uint256(0x24d1e2f4c681f0028d99a470d5255eaa05591a03e382991c5588a2274257b33e), + y: uint256(0x3011811bbe9f22016d8bd9e38f1bcf1726fdb08bd1ebbcb31e2f6a0e5ecf611c) + }), + qc: Honk.G1Point({ + x: uint256(0x29ea1153886db9dc28440ba999d5bc265cd697274a36663d2f9b98a024db6fde), + y: uint256(0x220513adb85fea331d3677009d599f6d41dd8ab5149722e95a0c65d39ea4d72f) + }), + qLookup: Honk.G1Point({ + x: uint256(0x285fd8127d70b2b0fbe7fe18e3d49d10ac112437c92f92c7f92a54c919e8d306), + y: uint256(0x188efb453aba600442bb164e60b744d2c89c806f31272611253707b506c2bde0) + }), + qArith: Honk.G1Point({ + x: uint256(0x1d2619431f712fa57f09124395fd79edf571985bc6a20ff4a9a8904a336817fd), + y: uint256(0x2e54130e592ab35ecda8414972cb41149f6852e3e03431316326f856bb13a416) + }), + qDeltaRange: Honk.G1Point({ + x: uint256(0x0ee0f2db50f7ab561d2332c2520f25ab7d04fdab8510a70a67fb01133241dfdf), + y: uint256(0x03cf5a1860352fd8584318c6f8617b7d4d413915c68ee6a4e827327cc4c275f1) + }), + qElliptic: Honk.G1Point({ + x: uint256(0x11e387e1219af95f3b3e4ac2347e39df535b4eb56e3170fe3794789f6ab767c6), + y: uint256(0x00493e8387669e89ac1c9b18a61e0eceea2c9b5d77d9ebee2fae336248b53750) + }), + qMemory: Honk.G1Point({ + x: uint256(0x005ce9a46595780f9e73cb377a5ff2c1b256c4f73a9ba796364d8ca15b61da28), + y: uint256(0x2ccf8867ffffa74e5f5268810ff435dcf628372a26fa121625bb2c3c1ac1fb30) + }), + qNnf: Honk.G1Point({ + x: uint256(0x2da97dea02ece8396ba49e31976be3e280793dd74597bab30dc85fe017fc7c9e), + y: uint256(0x1511d1bb2f98c392a6ad2f738cd7675918238911122cf806002b61f68159b4a1) + }), + qPoseidon2External: Honk.G1Point({ + x: uint256(0x18ee65d42f2bcef30fe5d47c6bf917609ef002ababb613345ec34daa4606c14b), + y: uint256(0x06ddbc9e392a88ffd1f14411a920541828e364aae2cb18d2fd53d930db010488) + }), + qPoseidon2Internal: Honk.G1Point({ + x: uint256(0x1c63ab29d3a25b9c8223c36729372cfb4a879c59bcfd09704e78d7dacec31143), + y: uint256(0x0e1ee54876c72c2c70ac89d696dc5b7d4194fb4d8d0a32b32bdb0828dd22f401) + }), + s1: Honk.G1Point({ + x: uint256(0x2391e3308c87c168ef192610faab7a4549c1c1e577bf7595b0e96846038e37d2), + y: uint256(0x2c266d4e7619172fcd4516b46df387e0e28e72de2b4abb05c2171d0f6b53c85f) + }), + s2: Honk.G1Point({ + x: uint256(0x25a7a4007c25a107bd82d2095e7a1d49aff1ec3a7aa1eab6a64f3d86f193b0ba), + y: uint256(0x2f16dc9e70a310aadbf70bf46376f1f70814ab03e15e4d2853bad87bdf7453f9) + }), + s3: Honk.G1Point({ + x: uint256(0x240ed26c8e2e61bf756e58c148b49599b5c406ba25cd8d06f398628b9133512e), + y: uint256(0x09cff5a99a5ebbaaf933ef3d1f9f35d34127bb50751cc66c36e87572012baab5) + }), + s4: Honk.G1Point({ + x: uint256(0x08ee63d1a021b86e32e6524ee0933ad715f6a6227b012f91bef7e607927d4979), + y: uint256(0x1557c3835216e16230115a87177439c1ef803b2b9f9257e772972dd2bcf4b1d7) + }), + t1: Honk.G1Point({ + x: uint256(0x099e3bd5a0a00ab7fe18040105b9b395b5d8b7b4a63b05df652b0d10ef146d26), + y: uint256(0x0015b8d2515d76e2ccec99dcd194592129af3a637f5a622a32440f860d1e2a7f) + }), + t2: Honk.G1Point({ + x: uint256(0x1b917517920bad3d8bc01c9595092a222b888108dc25d1aa450e0b4bc212c37e), + y: uint256(0x305e8992b148eedb22e6e992077a84482141c7ebe42000a1d58ccb74381f6d19) + }), + t3: Honk.G1Point({ + x: uint256(0x16465a5ccbb550cd2c63bd58116fe47c86847618681dc29d8a9363ab7c40e1c3), + y: uint256(0x2e24d420fbf9508ed31de692db477b439973ac12d7ca796d6fe98ca40e6ca6b7) + }), + t4: Honk.G1Point({ + x: uint256(0x043d063b130adfb37342af45d0155a28edd1a7e46c840d9c943fdf45521c64ce), + y: uint256(0x261522c4089330646aff96736194949330952ae74c573d1686d9cb4a00733854) + }), + id1: Honk.G1Point({ + x: uint256(0x28809a7a15aff59955dd76faff42dea8afd020fad1edcde49cd4caab4b8c3a53), + y: uint256(0x2d03db449085fe25953479c9e5b59888d8da8b0410b3d2c2d616d73cb9f00654) + }), + id2: Honk.G1Point({ + x: uint256(0x195275a73db12cc3b33d016f896ba96b4a714fe5fffc446d96511c882c46eb1e), + y: uint256(0x1e287d67649f8c4b323a082a2ed2c0187033a534cb0f5a5a6536c76390b4153c) + }), + id3: Honk.G1Point({ + x: uint256(0x0e0f483fdf0b20349341480f2bc1ccdbec542aabc314d62f1dfd2dd69c07e0f0), + y: uint256(0x008519c0b0befb21b3b551c01b1be408587dab68c6e32d5e1bcc0d919e95ac0f) + }), + id4: Honk.G1Point({ + x: uint256(0x16019af98e2ffff4cb0dfcc1f9c9d83a12096e57faf26466e1df12e1043ba6fb), + y: uint256(0x20fc36893bf6d9c7024fa777502cb64090391d0838b03581cf189a66a7c2de4d) + }), + lagrangeFirst: Honk.G1Point({ + x: uint256(0x0000000000000000000000000000000000000000000000000000000000000001), + y: uint256(0x0000000000000000000000000000000000000000000000000000000000000002) + }), + lagrangeLast: Honk.G1Point({ + x: uint256(0x2ea6a95b505d2756b81479a260e2706f29d32ae01b7d072d589640d3ab4e3c27), + y: uint256(0x059057fb15ad6732fbbf3abef8445f3b00f5a486c1f8b545bb7fd5deb9778f72) + }) + }); + return vk; + } +} + +pragma solidity ^0.8.27; + +interface IVerifier { + function verify(bytes calldata _proof, bytes32[] calldata _publicInputs) external view returns (bool); +} + +/** + * @notice Library of error codes + * @dev You can run `forge inspect Errors errors` to get the selectors for the optimised verifier + */ +library Errors { + error ValueGeLimbMax(); + error ValueGeGroupOrder(); + error ValueGeFieldOrder(); + + error InvertOfZero(); + error NotPowerOfTwo(); + error ModExpFailed(); + + error ProofLengthWrong(); + error ProofLengthWrongWithLogN(uint256 logN, uint256 actualLength, uint256 expectedLength); + error PublicInputsLengthWrong(); + error SumcheckFailed(); + error ShpleminiFailed(); + + error PointAtInfinity(); + + error ConsistencyCheckFailed(); + error GeminiChallengeInSubgroup(); +} + +type Fr is uint256; + +using {add as +} for Fr global; +using {sub as -} for Fr global; +using {mul as *} for Fr global; + +using {notEqual as !=} for Fr global; +using {equal as ==} for Fr global; + +uint256 constant SUBGROUP_SIZE = 256; +uint256 constant MODULUS = 21888242871839275222246405745257275088548364400416034343698204186575808495617; // Prime field order +uint256 constant P = MODULUS; +Fr constant SUBGROUP_GENERATOR = Fr.wrap(0x07b0c561a6148404f086204a9f36ffb0617942546750f230c893619174a57a76); +Fr constant SUBGROUP_GENERATOR_INVERSE = Fr.wrap(0x204bd3277422fad364751ad938e2b5e6a54cf8c68712848a692c553d0329f5d6); +Fr constant MINUS_ONE = Fr.wrap(MODULUS - 1); +Fr constant ONE = Fr.wrap(1); +Fr constant ZERO = Fr.wrap(0); +// Instantiation + +library FrLib { + bytes4 internal constant FRLIB_MODEXP_FAILED_SELECTOR = 0xf8d61709; + + function invert(Fr value) internal view returns (Fr) { + uint256 v = Fr.unwrap(value); + require(v != 0, Errors.InvertOfZero()); + + uint256 result; + + // Call the modexp precompile to invert in the field + assembly { + let free := mload(0x40) + mstore(free, 0x20) + mstore(add(free, 0x20), 0x20) + mstore(add(free, 0x40), 0x20) + mstore(add(free, 0x60), v) + mstore(add(free, 0x80), sub(MODULUS, 2)) + mstore(add(free, 0xa0), MODULUS) + let success := staticcall(gas(), 0x05, free, 0xc0, 0x00, 0x20) + if iszero(success) { + mstore(0x00, FRLIB_MODEXP_FAILED_SELECTOR) + revert(0, 0x04) + } + result := mload(0x00) + mstore(0x40, add(free, 0xc0)) + } + + return Fr.wrap(result); + } + + function pow(Fr base, uint256 v) internal view returns (Fr) { + uint256 b = Fr.unwrap(base); + // Only works for power of 2 + require(v > 0 && (v & (v - 1)) == 0, Errors.NotPowerOfTwo()); + uint256 result; + + // Call the modexp precompile to invert in the field + assembly { + let free := mload(0x40) + mstore(free, 0x20) + mstore(add(free, 0x20), 0x20) + mstore(add(free, 0x40), 0x20) + mstore(add(free, 0x60), b) + mstore(add(free, 0x80), v) + mstore(add(free, 0xa0), MODULUS) + let success := staticcall(gas(), 0x05, free, 0xc0, 0x00, 0x20) + if iszero(success) { + mstore(0x00, FRLIB_MODEXP_FAILED_SELECTOR) + revert(0, 0x04) + } + result := mload(0x00) + mstore(0x40, add(free, 0xc0)) + } + + return Fr.wrap(result); + } + + function div(Fr numerator, Fr denominator) internal view returns (Fr) { + unchecked { + return numerator * invert(denominator); + } + } + + function sqr(Fr value) internal pure returns (Fr) { + unchecked { + return value * value; + } + } + + function unwrap(Fr value) internal pure returns (uint256) { + unchecked { + return Fr.unwrap(value); + } + } + + function neg(Fr value) internal pure returns (Fr) { + unchecked { + return Fr.wrap(MODULUS - Fr.unwrap(value)); + } + } + + function from(uint256 value) internal pure returns (Fr) { + unchecked { + require(value < MODULUS, Errors.ValueGeFieldOrder()); + return Fr.wrap(value); + } + } + + function fromBytes32(bytes32 value) internal pure returns (Fr) { + unchecked { + uint256 v = uint256(value); + require(v < MODULUS, Errors.ValueGeFieldOrder()); + return Fr.wrap(v); + } + } + + function toBytes32(Fr value) internal pure returns (bytes32) { + unchecked { + return bytes32(Fr.unwrap(value)); + } + } +} + +// Free functions +function add(Fr a, Fr b) pure returns (Fr) { + unchecked { + return Fr.wrap(addmod(Fr.unwrap(a), Fr.unwrap(b), MODULUS)); + } +} + +function mul(Fr a, Fr b) pure returns (Fr) { + unchecked { + return Fr.wrap(mulmod(Fr.unwrap(a), Fr.unwrap(b), MODULUS)); + } +} + +function sub(Fr a, Fr b) pure returns (Fr) { + unchecked { + return Fr.wrap(addmod(Fr.unwrap(a), MODULUS - Fr.unwrap(b), MODULUS)); + } +} + +function notEqual(Fr a, Fr b) pure returns (bool) { + unchecked { + return Fr.unwrap(a) != Fr.unwrap(b); + } +} + +function equal(Fr a, Fr b) pure returns (bool) { + unchecked { + return Fr.unwrap(a) == Fr.unwrap(b); + } +} + +uint256 constant CONST_PROOF_SIZE_LOG_N = 25; + +uint256 constant NUMBER_OF_SUBRELATIONS = 28; +uint256 constant BATCHED_RELATION_PARTIAL_LENGTH = 8; +uint256 constant ZK_BATCHED_RELATION_PARTIAL_LENGTH = 9; +uint256 constant NUMBER_OF_ENTITIES = 41; +// The number of entities added for ZK (gemini_masking_poly) +uint256 constant NUM_MASKING_POLYNOMIALS = 1; +uint256 constant NUMBER_OF_ENTITIES_ZK = NUMBER_OF_ENTITIES + NUM_MASKING_POLYNOMIALS; +uint256 constant NUMBER_UNSHIFTED = 36; +uint256 constant NUMBER_UNSHIFTED_ZK = NUMBER_UNSHIFTED + NUM_MASKING_POLYNOMIALS; +uint256 constant NUMBER_TO_BE_SHIFTED = 5; +uint256 constant PAIRING_POINTS_SIZE = 8; + +uint256 constant FIELD_ELEMENT_SIZE = 0x20; +uint256 constant GROUP_ELEMENT_SIZE = 0x40; + +// Powers of alpha used to batch subrelations (alpha, alpha^2, ..., alpha^(NUM_SUBRELATIONS-1)) +uint256 constant NUMBER_OF_ALPHAS = NUMBER_OF_SUBRELATIONS - 1; + +// ENUM FOR WIRES +enum WIRE { + Q_M, + Q_C, + Q_L, + Q_R, + Q_O, + Q_4, + Q_LOOKUP, + Q_ARITH, + Q_RANGE, + Q_ELLIPTIC, + Q_MEMORY, + Q_NNF, + Q_POSEIDON2_EXTERNAL, + Q_POSEIDON2_INTERNAL, + SIGMA_1, + SIGMA_2, + SIGMA_3, + SIGMA_4, + ID_1, + ID_2, + ID_3, + ID_4, + TABLE_1, + TABLE_2, + TABLE_3, + TABLE_4, + LAGRANGE_FIRST, + LAGRANGE_LAST, + W_L, + W_R, + W_O, + W_4, + Z_PERM, + LOOKUP_INVERSES, + LOOKUP_READ_COUNTS, + LOOKUP_READ_TAGS, + W_L_SHIFT, + W_R_SHIFT, + W_O_SHIFT, + W_4_SHIFT, + Z_PERM_SHIFT +} + +library Honk { + struct G1Point { + uint256 x; + uint256 y; + } + + struct VerificationKey { + // Misc Params + uint256 circuitSize; + uint256 logCircuitSize; + uint256 publicInputsSize; + // Selectors + G1Point qm; + G1Point qc; + G1Point ql; + G1Point qr; + G1Point qo; + G1Point q4; + G1Point qLookup; // Lookup + G1Point qArith; // Arithmetic widget + G1Point qDeltaRange; // Delta Range sort + G1Point qMemory; // Memory + G1Point qNnf; // Non-native Field + G1Point qElliptic; // Auxillary + G1Point qPoseidon2External; + G1Point qPoseidon2Internal; + // Copy constraints + G1Point s1; + G1Point s2; + G1Point s3; + G1Point s4; + // Copy identity + G1Point id1; + G1Point id2; + G1Point id3; + G1Point id4; + // Precomputed lookup table + G1Point t1; + G1Point t2; + G1Point t3; + G1Point t4; + // Fixed first and last + G1Point lagrangeFirst; + G1Point lagrangeLast; + } + + struct RelationParameters { + // challenges + Fr eta; + Fr beta; + Fr gamma; + // derived + Fr publicInputsDelta; + } + + struct Proof { + // Pairing point object + Fr[PAIRING_POINTS_SIZE] pairingPointObject; + // Free wires + G1Point w1; + G1Point w2; + G1Point w3; + G1Point w4; + // Lookup helpers - Permutations + G1Point zPerm; + // Lookup helpers - logup + G1Point lookupReadCounts; + G1Point lookupReadTags; + G1Point lookupInverses; + // Sumcheck + Fr[BATCHED_RELATION_PARTIAL_LENGTH][CONST_PROOF_SIZE_LOG_N] sumcheckUnivariates; + Fr[NUMBER_OF_ENTITIES] sumcheckEvaluations; + // Shplemini + G1Point[CONST_PROOF_SIZE_LOG_N - 1] geminiFoldComms; + Fr[CONST_PROOF_SIZE_LOG_N] geminiAEvaluations; + G1Point shplonkQ; + G1Point kzgQuotient; + } + + /// forge-lint: disable-next-item(pascal-case-struct) + struct ZKProof { + // Pairing point object + Fr[PAIRING_POINTS_SIZE] pairingPointObject; + // ZK: Gemini masking polynomial commitment (sent first, right after public inputs) + G1Point geminiMaskingPoly; + // Commitments to wire polynomials + G1Point w1; + G1Point w2; + G1Point w3; + G1Point w4; + // Commitments to logup witness polynomials + G1Point lookupReadCounts; + G1Point lookupReadTags; + G1Point lookupInverses; + // Commitment to grand permutation polynomial + G1Point zPerm; + G1Point[3] libraCommitments; + // Sumcheck + Fr libraSum; + Fr[ZK_BATCHED_RELATION_PARTIAL_LENGTH][CONST_PROOF_SIZE_LOG_N] sumcheckUnivariates; + Fr libraEvaluation; + Fr[NUMBER_OF_ENTITIES_ZK] sumcheckEvaluations; // Includes gemini_masking_poly eval at index 0 (first position) + // Shplemini + G1Point[CONST_PROOF_SIZE_LOG_N - 1] geminiFoldComms; + Fr[CONST_PROOF_SIZE_LOG_N] geminiAEvaluations; + Fr[4] libraPolyEvals; + G1Point shplonkQ; + G1Point kzgQuotient; + } +} + +// ZKTranscript library to generate fiat shamir challenges, the ZK transcript only differest +/// forge-lint: disable-next-item(pascal-case-struct) +struct ZKTranscript { + // Oink + Honk.RelationParameters relationParameters; + Fr[NUMBER_OF_ALPHAS] alphas; // Powers of alpha: [alpha, alpha^2, ..., alpha^(NUM_SUBRELATIONS-1)] + Fr[CONST_PROOF_SIZE_LOG_N] gateChallenges; + // Sumcheck + Fr libraChallenge; + Fr[CONST_PROOF_SIZE_LOG_N] sumCheckUChallenges; + // Shplemini + Fr rho; + Fr geminiR; + Fr shplonkNu; + Fr shplonkZ; + // Derived + Fr publicInputsDelta; +} + +library ZKTranscriptLib { + function generateTranscript( + Honk.ZKProof memory proof, + bytes32[] calldata publicInputs, + uint256 vkHash, + uint256 publicInputsSize, + uint256 logN + ) external pure returns (ZKTranscript memory t) { + Fr previousChallenge; + (t.relationParameters, previousChallenge) = + generateRelationParametersChallenges(proof, publicInputs, vkHash, publicInputsSize, previousChallenge); + + (t.alphas, previousChallenge) = generateAlphaChallenges(previousChallenge, proof); + + (t.gateChallenges, previousChallenge) = generateGateChallenges(previousChallenge, logN); + (t.libraChallenge, previousChallenge) = generateLibraChallenge(previousChallenge, proof); + (t.sumCheckUChallenges, previousChallenge) = generateSumcheckChallenges(proof, previousChallenge, logN); + + (t.rho, previousChallenge) = generateRhoChallenge(proof, previousChallenge); + + (t.geminiR, previousChallenge) = generateGeminiRChallenge(proof, previousChallenge, logN); + + (t.shplonkNu, previousChallenge) = generateShplonkNuChallenge(proof, previousChallenge, logN); + + (t.shplonkZ, previousChallenge) = generateShplonkZChallenge(proof, previousChallenge); + return t; + } + + function splitChallenge(Fr challenge) internal pure returns (Fr first, Fr second) { + uint256 challengeU256 = uint256(Fr.unwrap(challenge)); + // Split into two equal 127-bit chunks (254/2) + uint256 lo = challengeU256 & 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF; // 127 bits + uint256 hi = challengeU256 >> 127; + first = FrLib.from(lo); + second = FrLib.from(hi); + } + + function generateRelationParametersChallenges( + Honk.ZKProof memory proof, + bytes32[] calldata publicInputs, + uint256 vkHash, + uint256 publicInputsSize, + Fr previousChallenge + ) internal pure returns (Honk.RelationParameters memory rp, Fr nextPreviousChallenge) { + (rp.eta, previousChallenge) = generateEtaChallenge(proof, publicInputs, vkHash, publicInputsSize); + + (rp.beta, rp.gamma, nextPreviousChallenge) = generateBetaGammaChallenges(previousChallenge, proof); + } + + function generateEtaChallenge( + Honk.ZKProof memory proof, + bytes32[] calldata publicInputs, + uint256 vkHash, + uint256 publicInputsSize + ) internal pure returns (Fr eta, Fr previousChallenge) { + // Size: 1 (vkHash) + publicInputsSize + 8 (geminiMask(2) + 3 wires(6)) + bytes32[] memory round0 = new bytes32[](1 + publicInputsSize + 8); + round0[0] = bytes32(vkHash); + + for (uint256 i = 0; i < publicInputsSize - PAIRING_POINTS_SIZE; i++) { + require(uint256(publicInputs[i]) < P, Errors.ValueGeFieldOrder()); + round0[1 + i] = publicInputs[i]; + } + for (uint256 i = 0; i < PAIRING_POINTS_SIZE; i++) { + round0[1 + publicInputsSize - PAIRING_POINTS_SIZE + i] = FrLib.toBytes32(proof.pairingPointObject[i]); + } + + // For ZK flavors: hash the gemini masking poly commitment (sent right after public inputs) + round0[1 + publicInputsSize] = bytes32(proof.geminiMaskingPoly.x); + round0[1 + publicInputsSize + 1] = bytes32(proof.geminiMaskingPoly.y); + + // Create the first challenge + // Note: w4 is added to the challenge later on + round0[1 + publicInputsSize + 2] = bytes32(proof.w1.x); + round0[1 + publicInputsSize + 3] = bytes32(proof.w1.y); + round0[1 + publicInputsSize + 4] = bytes32(proof.w2.x); + round0[1 + publicInputsSize + 5] = bytes32(proof.w2.y); + round0[1 + publicInputsSize + 6] = bytes32(proof.w3.x); + round0[1 + publicInputsSize + 7] = bytes32(proof.w3.y); + + previousChallenge = FrLib.from(uint256(keccak256(abi.encodePacked(round0))) % P); + (eta,) = splitChallenge(previousChallenge); + } + + function generateBetaGammaChallenges(Fr previousChallenge, Honk.ZKProof memory proof) + internal + pure + returns (Fr beta, Fr gamma, Fr nextPreviousChallenge) + { + bytes32[7] memory round1; + round1[0] = FrLib.toBytes32(previousChallenge); + round1[1] = bytes32(proof.lookupReadCounts.x); + round1[2] = bytes32(proof.lookupReadCounts.y); + round1[3] = bytes32(proof.lookupReadTags.x); + round1[4] = bytes32(proof.lookupReadTags.y); + round1[5] = bytes32(proof.w4.x); + round1[6] = bytes32(proof.w4.y); + + nextPreviousChallenge = FrLib.from(uint256(keccak256(abi.encodePacked(round1))) % P); + (beta, gamma) = splitChallenge(nextPreviousChallenge); + } + + // Alpha challenges non-linearise the gate contributions + function generateAlphaChallenges(Fr previousChallenge, Honk.ZKProof memory proof) + internal + pure + returns (Fr[NUMBER_OF_ALPHAS] memory alphas, Fr nextPreviousChallenge) + { + // Generate the original sumcheck alpha 0 by hashing zPerm and zLookup + uint256[5] memory alpha0; + alpha0[0] = Fr.unwrap(previousChallenge); + alpha0[1] = proof.lookupInverses.x; + alpha0[2] = proof.lookupInverses.y; + alpha0[3] = proof.zPerm.x; + alpha0[4] = proof.zPerm.y; + + nextPreviousChallenge = FrLib.from(uint256(keccak256(abi.encodePacked(alpha0))) % P); + Fr alpha; + (alpha,) = splitChallenge(nextPreviousChallenge); + + // Compute powers of alpha for batching subrelations + alphas[0] = alpha; + for (uint256 i = 1; i < NUMBER_OF_ALPHAS; i++) { + alphas[i] = alphas[i - 1] * alpha; + } + } + + function generateGateChallenges(Fr previousChallenge, uint256 logN) + internal + pure + returns (Fr[CONST_PROOF_SIZE_LOG_N] memory gateChallenges, Fr nextPreviousChallenge) + { + previousChallenge = FrLib.from(uint256(keccak256(abi.encodePacked(Fr.unwrap(previousChallenge)))) % P); + (gateChallenges[0],) = splitChallenge(previousChallenge); + for (uint256 i = 1; i < logN; i++) { + gateChallenges[i] = gateChallenges[i - 1] * gateChallenges[i - 1]; + } + nextPreviousChallenge = previousChallenge; + } + + function generateLibraChallenge(Fr previousChallenge, Honk.ZKProof memory proof) + internal + pure + returns (Fr libraChallenge, Fr nextPreviousChallenge) + { + // 2 comm, 1 sum, 1 challenge + uint256[4] memory challengeData; + challengeData[0] = Fr.unwrap(previousChallenge); + challengeData[1] = proof.libraCommitments[0].x; + challengeData[2] = proof.libraCommitments[0].y; + challengeData[3] = Fr.unwrap(proof.libraSum); + nextPreviousChallenge = FrLib.from(uint256(keccak256(abi.encodePacked(challengeData))) % P); + (libraChallenge,) = splitChallenge(nextPreviousChallenge); + } + + function generateSumcheckChallenges(Honk.ZKProof memory proof, Fr prevChallenge, uint256 logN) + internal + pure + returns (Fr[CONST_PROOF_SIZE_LOG_N] memory sumcheckChallenges, Fr nextPreviousChallenge) + { + for (uint256 i = 0; i < logN; i++) { + Fr[ZK_BATCHED_RELATION_PARTIAL_LENGTH + 1] memory univariateChal; + univariateChal[0] = prevChallenge; + + for (uint256 j = 0; j < ZK_BATCHED_RELATION_PARTIAL_LENGTH; j++) { + univariateChal[j + 1] = proof.sumcheckUnivariates[i][j]; + } + prevChallenge = FrLib.from(uint256(keccak256(abi.encodePacked(univariateChal))) % P); + + (sumcheckChallenges[i],) = splitChallenge(prevChallenge); + } + nextPreviousChallenge = prevChallenge; + } + + // We add Libra claimed eval + 2 libra commitments (grand_sum, quotient) + function generateRhoChallenge(Honk.ZKProof memory proof, Fr prevChallenge) + internal + pure + returns (Fr rho, Fr nextPreviousChallenge) + { + uint256[NUMBER_OF_ENTITIES_ZK + 6] memory rhoChallengeElements; + rhoChallengeElements[0] = Fr.unwrap(prevChallenge); + uint256 i; + for (i = 1; i <= NUMBER_OF_ENTITIES_ZK; i++) { + rhoChallengeElements[i] = Fr.unwrap(proof.sumcheckEvaluations[i - 1]); + } + rhoChallengeElements[i] = Fr.unwrap(proof.libraEvaluation); + i += 1; + rhoChallengeElements[i] = proof.libraCommitments[1].x; + rhoChallengeElements[i + 1] = proof.libraCommitments[1].y; + i += 2; + rhoChallengeElements[i] = proof.libraCommitments[2].x; + rhoChallengeElements[i + 1] = proof.libraCommitments[2].y; + + nextPreviousChallenge = FrLib.from(uint256(keccak256(abi.encodePacked(rhoChallengeElements))) % P); + (rho,) = splitChallenge(nextPreviousChallenge); + } + + function generateGeminiRChallenge(Honk.ZKProof memory proof, Fr prevChallenge, uint256 logN) + internal + pure + returns (Fr geminiR, Fr nextPreviousChallenge) + { + uint256[] memory gR = new uint256[]((logN - 1) * 2 + 1); + gR[0] = Fr.unwrap(prevChallenge); + + for (uint256 i = 0; i < logN - 1; i++) { + gR[1 + i * 2] = proof.geminiFoldComms[i].x; + gR[2 + i * 2] = proof.geminiFoldComms[i].y; + } + + nextPreviousChallenge = FrLib.from(uint256(keccak256(abi.encodePacked(gR))) % P); + + (geminiR,) = splitChallenge(nextPreviousChallenge); + } + + function generateShplonkNuChallenge(Honk.ZKProof memory proof, Fr prevChallenge, uint256 logN) + internal + pure + returns (Fr shplonkNu, Fr nextPreviousChallenge) + { + uint256[] memory shplonkNuChallengeElements = new uint256[](logN + 1 + 4); + shplonkNuChallengeElements[0] = Fr.unwrap(prevChallenge); + + for (uint256 i = 1; i <= logN; i++) { + shplonkNuChallengeElements[i] = Fr.unwrap(proof.geminiAEvaluations[i - 1]); + } + + uint256 libraIdx = 0; + for (uint256 i = logN + 1; i <= logN + 4; i++) { + shplonkNuChallengeElements[i] = Fr.unwrap(proof.libraPolyEvals[libraIdx]); + libraIdx++; + } + + nextPreviousChallenge = FrLib.from(uint256(keccak256(abi.encodePacked(shplonkNuChallengeElements))) % P); + (shplonkNu,) = splitChallenge(nextPreviousChallenge); + } + + function generateShplonkZChallenge(Honk.ZKProof memory proof, Fr prevChallenge) + internal + pure + returns (Fr shplonkZ, Fr nextPreviousChallenge) + { + uint256[3] memory shplonkZChallengeElements; + shplonkZChallengeElements[0] = Fr.unwrap(prevChallenge); + + shplonkZChallengeElements[1] = proof.shplonkQ.x; + shplonkZChallengeElements[2] = proof.shplonkQ.y; + + nextPreviousChallenge = FrLib.from(uint256(keccak256(abi.encodePacked(shplonkZChallengeElements))) % P); + (shplonkZ,) = splitChallenge(nextPreviousChallenge); + } + + function loadProof(bytes calldata proof, uint256 logN) internal pure returns (Honk.ZKProof memory p) { + uint256 boundary = 0x0; + + // Pairing point object + for (uint256 i = 0; i < PAIRING_POINTS_SIZE; i++) { + uint256 limb = uint256(bytes32(proof[boundary:boundary + FIELD_ELEMENT_SIZE])); + // lo limbs (even index) < 2^136, hi limbs (odd index) < 2^120 + require(limb < 2 ** (i % 2 == 0 ? 136 : 120), Errors.ValueGeLimbMax()); + p.pairingPointObject[i] = FrLib.from(limb); + boundary += FIELD_ELEMENT_SIZE; + } + + // Gemini masking polynomial commitment (sent first in ZK flavors, right after pairing points) + p.geminiMaskingPoly = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); + boundary += GROUP_ELEMENT_SIZE; + + // Commitments + p.w1 = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); + boundary += GROUP_ELEMENT_SIZE; + p.w2 = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); + boundary += GROUP_ELEMENT_SIZE; + p.w3 = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); + boundary += GROUP_ELEMENT_SIZE; + + // Lookup / Permutation Helper Commitments + p.lookupReadCounts = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); + boundary += GROUP_ELEMENT_SIZE; + p.lookupReadTags = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); + boundary += GROUP_ELEMENT_SIZE; + p.w4 = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); + boundary += GROUP_ELEMENT_SIZE; + p.lookupInverses = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); + boundary += GROUP_ELEMENT_SIZE; + p.zPerm = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); + boundary += GROUP_ELEMENT_SIZE; + p.libraCommitments[0] = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); + boundary += GROUP_ELEMENT_SIZE; + + p.libraSum = bytesToFr(proof[boundary:boundary + FIELD_ELEMENT_SIZE]); + boundary += FIELD_ELEMENT_SIZE; + // Sumcheck univariates + for (uint256 i = 0; i < logN; i++) { + for (uint256 j = 0; j < ZK_BATCHED_RELATION_PARTIAL_LENGTH; j++) { + p.sumcheckUnivariates[i][j] = bytesToFr(proof[boundary:boundary + FIELD_ELEMENT_SIZE]); + boundary += FIELD_ELEMENT_SIZE; + } + } + + // Sumcheck evaluations (includes gemini_masking_poly eval at index 0 for ZK flavors) + for (uint256 i = 0; i < NUMBER_OF_ENTITIES_ZK; i++) { + p.sumcheckEvaluations[i] = bytesToFr(proof[boundary:boundary + FIELD_ELEMENT_SIZE]); + boundary += FIELD_ELEMENT_SIZE; + } + + p.libraEvaluation = bytesToFr(proof[boundary:boundary + FIELD_ELEMENT_SIZE]); + boundary += FIELD_ELEMENT_SIZE; + + p.libraCommitments[1] = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); + boundary += GROUP_ELEMENT_SIZE; + p.libraCommitments[2] = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); + boundary += GROUP_ELEMENT_SIZE; + + // Gemini + // Read gemini fold univariates + for (uint256 i = 0; i < logN - 1; i++) { + p.geminiFoldComms[i] = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); + boundary += GROUP_ELEMENT_SIZE; + } + + // Read gemini a evaluations + for (uint256 i = 0; i < logN; i++) { + p.geminiAEvaluations[i] = bytesToFr(proof[boundary:boundary + FIELD_ELEMENT_SIZE]); + boundary += FIELD_ELEMENT_SIZE; + } + + for (uint256 i = 0; i < 4; i++) { + p.libraPolyEvals[i] = bytesToFr(proof[boundary:boundary + FIELD_ELEMENT_SIZE]); + boundary += FIELD_ELEMENT_SIZE; + } + + // Shplonk + p.shplonkQ = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); + boundary += GROUP_ELEMENT_SIZE; + // KZG + p.kzgQuotient = bytesToG1Point(proof[boundary:boundary + GROUP_ELEMENT_SIZE]); + } +} + +library RelationsLib { + struct EllipticParams { + // Points + Fr x_1; + Fr y_1; + Fr x_2; + Fr y_2; + Fr y_3; + Fr x_3; + // push accumulators into memory + Fr x_double_identity; + } + + // Parameters used within the Memory Relation + // A struct is used to work around stack too deep. This relation has alot of variables + struct MemParams { + Fr memory_record_check; + Fr partial_record_check; + Fr next_gate_access_type; + Fr record_delta; + Fr index_delta; + Fr adjacent_values_match_if_adjacent_indices_match; + Fr adjacent_values_match_if_adjacent_indices_match_and_next_access_is_a_read_operation; + Fr access_check; + Fr next_gate_access_type_is_boolean; + Fr ROM_consistency_check_identity; + Fr RAM_consistency_check_identity; + Fr timestamp_delta; + Fr RAM_timestamp_check_identity; + Fr memory_identity; + Fr index_is_monotonically_increasing; + } + + // Parameters used within the Non-Native Field Relation + // A struct is used to work around stack too deep. This relation has alot of variables + struct NnfParams { + Fr limb_subproduct; + Fr non_native_field_gate_1; + Fr non_native_field_gate_2; + Fr non_native_field_gate_3; + Fr limb_accumulator_1; + Fr limb_accumulator_2; + Fr nnf_identity; + } + + struct PoseidonExternalParams { + Fr s1; + Fr s2; + Fr s3; + Fr s4; + Fr u1; + Fr u2; + Fr u3; + Fr u4; + Fr t0; + Fr t1; + Fr t2; + Fr t3; + Fr v1; + Fr v2; + Fr v3; + Fr v4; + Fr q_pos_by_scaling; + } + + struct PoseidonInternalParams { + Fr u1; + Fr u2; + Fr u3; + Fr u4; + Fr u_sum; + Fr v1; + Fr v2; + Fr v3; + Fr v4; + Fr s1; + Fr q_pos_by_scaling; + } + + Fr internal constant GRUMPKIN_CURVE_B_PARAMETER_NEGATED = Fr.wrap(17); // -(-17) + uint256 internal constant NEG_HALF_MODULO_P = 0x183227397098d014dc2822db40c0ac2e9419f4243cdcb848a1f0fac9f8000000; + + // Constants for the Non-native Field relation + Fr internal constant LIMB_SIZE = Fr.wrap(uint256(1) << 68); + Fr internal constant SUBLIMB_SHIFT = Fr.wrap(uint256(1) << 14); + + function accumulateRelationEvaluations( + Fr[NUMBER_OF_ENTITIES] memory purportedEvaluations, + Honk.RelationParameters memory rp, + Fr[NUMBER_OF_ALPHAS] memory subrelationChallenges, + Fr powPartialEval + ) internal pure returns (Fr accumulator) { + Fr[NUMBER_OF_SUBRELATIONS] memory evaluations; + + // Accumulate all relations in Ultra Honk - each with varying number of subrelations + accumulateArithmeticRelation(purportedEvaluations, evaluations, powPartialEval); + accumulatePermutationRelation(purportedEvaluations, rp, evaluations, powPartialEval); + accumulateLogDerivativeLookupRelation(purportedEvaluations, rp, evaluations, powPartialEval); + accumulateDeltaRangeRelation(purportedEvaluations, evaluations, powPartialEval); + accumulateEllipticRelation(purportedEvaluations, evaluations, powPartialEval); + accumulateMemoryRelation(purportedEvaluations, rp, evaluations, powPartialEval); + accumulateNnfRelation(purportedEvaluations, evaluations, powPartialEval); + accumulatePoseidonExternalRelation(purportedEvaluations, evaluations, powPartialEval); + accumulatePoseidonInternalRelation(purportedEvaluations, evaluations, powPartialEval); + + // batch the subrelations with the precomputed alpha powers to obtain the full honk relation + accumulator = scaleAndBatchSubrelations(evaluations, subrelationChallenges); + } + + /** + * Aesthetic helper function that is used to index by enum into proof.sumcheckEvaluations, it avoids + * the relation checking code being cluttered with uint256 type casting, which is often a different colour in code + * editors, and thus is noisy. + */ + function wire(Fr[NUMBER_OF_ENTITIES] memory p, WIRE _wire) internal pure returns (Fr) { + return p[uint256(_wire)]; + } + + /** + * Ultra Arithmetic Relation + * + */ + function accumulateArithmeticRelation( + Fr[NUMBER_OF_ENTITIES] memory p, + Fr[NUMBER_OF_SUBRELATIONS] memory evals, + Fr domainSep + ) internal pure { + // Relation 0 + Fr q_arith = wire(p, WIRE.Q_ARITH); + { + Fr neg_half = Fr.wrap(NEG_HALF_MODULO_P); + + Fr accum = (q_arith - Fr.wrap(3)) * (wire(p, WIRE.Q_M) * wire(p, WIRE.W_R) * wire(p, WIRE.W_L)) * neg_half; + accum = accum + (wire(p, WIRE.Q_L) * wire(p, WIRE.W_L)) + (wire(p, WIRE.Q_R) * wire(p, WIRE.W_R)) + + (wire(p, WIRE.Q_O) * wire(p, WIRE.W_O)) + (wire(p, WIRE.Q_4) * wire(p, WIRE.W_4)) + wire(p, WIRE.Q_C); + accum = accum + (q_arith - ONE) * wire(p, WIRE.W_4_SHIFT); + accum = accum * q_arith; + accum = accum * domainSep; + evals[0] = accum; + } + + // Relation 1 + { + Fr accum = wire(p, WIRE.W_L) + wire(p, WIRE.W_4) - wire(p, WIRE.W_L_SHIFT) + wire(p, WIRE.Q_M); + accum = accum * (q_arith - Fr.wrap(2)); + accum = accum * (q_arith - ONE); + accum = accum * q_arith; + accum = accum * domainSep; + evals[1] = accum; + } + } + + function accumulatePermutationRelation( + Fr[NUMBER_OF_ENTITIES] memory p, + Honk.RelationParameters memory rp, + Fr[NUMBER_OF_SUBRELATIONS] memory evals, + Fr domainSep + ) internal pure { + Fr grand_product_numerator; + Fr grand_product_denominator; + + { + Fr num = wire(p, WIRE.W_L) + wire(p, WIRE.ID_1) * rp.beta + rp.gamma; + num = num * (wire(p, WIRE.W_R) + wire(p, WIRE.ID_2) * rp.beta + rp.gamma); + num = num * (wire(p, WIRE.W_O) + wire(p, WIRE.ID_3) * rp.beta + rp.gamma); + num = num * (wire(p, WIRE.W_4) + wire(p, WIRE.ID_4) * rp.beta + rp.gamma); + + grand_product_numerator = num; + } + { + Fr den = wire(p, WIRE.W_L) + wire(p, WIRE.SIGMA_1) * rp.beta + rp.gamma; + den = den * (wire(p, WIRE.W_R) + wire(p, WIRE.SIGMA_2) * rp.beta + rp.gamma); + den = den * (wire(p, WIRE.W_O) + wire(p, WIRE.SIGMA_3) * rp.beta + rp.gamma); + den = den * (wire(p, WIRE.W_4) + wire(p, WIRE.SIGMA_4) * rp.beta + rp.gamma); + + grand_product_denominator = den; + } + + // Contribution 2 + { + Fr acc = (wire(p, WIRE.Z_PERM) + wire(p, WIRE.LAGRANGE_FIRST)) * grand_product_numerator; + + acc = acc + - ((wire(p, WIRE.Z_PERM_SHIFT) + (wire(p, WIRE.LAGRANGE_LAST) * rp.publicInputsDelta)) + * grand_product_denominator); + acc = acc * domainSep; + evals[2] = acc; + } + + // Contribution 3 + { + Fr acc = (wire(p, WIRE.LAGRANGE_LAST) * wire(p, WIRE.Z_PERM_SHIFT)) * domainSep; + evals[3] = acc; + } + } + + function accumulateLogDerivativeLookupRelation( + Fr[NUMBER_OF_ENTITIES] memory p, + Honk.RelationParameters memory rp, + Fr[NUMBER_OF_SUBRELATIONS] memory evals, + Fr domainSep + ) internal pure { + Fr table_term; + Fr lookup_term; + + // Calculate the write term (the table accumulation) + // table_term = table_1 + γ + table_2 * β + table_3 * β² + table_4 * β³ + { + Fr beta_sqr = rp.beta * rp.beta; + table_term = wire(p, WIRE.TABLE_1) + rp.gamma + (wire(p, WIRE.TABLE_2) * rp.beta) + + (wire(p, WIRE.TABLE_3) * beta_sqr) + (wire(p, WIRE.TABLE_4) * beta_sqr * rp.beta); + } + + // Calculate the read term + // lookup_term = derived_entry_1 + γ + derived_entry_2 * β + derived_entry_3 * β² + q_index * β³ + { + Fr beta_sqr = rp.beta * rp.beta; + Fr derived_entry_1 = wire(p, WIRE.W_L) + rp.gamma + (wire(p, WIRE.Q_R) * wire(p, WIRE.W_L_SHIFT)); + Fr derived_entry_2 = wire(p, WIRE.W_R) + wire(p, WIRE.Q_M) * wire(p, WIRE.W_R_SHIFT); + Fr derived_entry_3 = wire(p, WIRE.W_O) + wire(p, WIRE.Q_C) * wire(p, WIRE.W_O_SHIFT); + + lookup_term = derived_entry_1 + (derived_entry_2 * rp.beta) + (derived_entry_3 * beta_sqr) + + (wire(p, WIRE.Q_O) * beta_sqr * rp.beta); + } + + Fr lookup_inverse = wire(p, WIRE.LOOKUP_INVERSES) * table_term; + Fr table_inverse = wire(p, WIRE.LOOKUP_INVERSES) * lookup_term; + + Fr inverse_exists_xor = wire(p, WIRE.LOOKUP_READ_TAGS) + wire(p, WIRE.Q_LOOKUP) + - (wire(p, WIRE.LOOKUP_READ_TAGS) * wire(p, WIRE.Q_LOOKUP)); + + // Inverse calculated correctly relation + Fr accumulatorNone = lookup_term * table_term * wire(p, WIRE.LOOKUP_INVERSES) - inverse_exists_xor; + accumulatorNone = accumulatorNone * domainSep; + + // Inverse + Fr accumulatorOne = wire(p, WIRE.Q_LOOKUP) * lookup_inverse - wire(p, WIRE.LOOKUP_READ_COUNTS) * table_inverse; + + Fr read_tag = wire(p, WIRE.LOOKUP_READ_TAGS); + + Fr read_tag_boolean_relation = read_tag * read_tag - read_tag; + + evals[4] = accumulatorNone; + evals[5] = accumulatorOne; + evals[6] = read_tag_boolean_relation * domainSep; + } + + function accumulateDeltaRangeRelation( + Fr[NUMBER_OF_ENTITIES] memory p, + Fr[NUMBER_OF_SUBRELATIONS] memory evals, + Fr domainSep + ) internal pure { + Fr minus_one = ZERO - ONE; + Fr minus_two = ZERO - Fr.wrap(2); + Fr minus_three = ZERO - Fr.wrap(3); + + // Compute wire differences + Fr delta_1 = wire(p, WIRE.W_R) - wire(p, WIRE.W_L); + Fr delta_2 = wire(p, WIRE.W_O) - wire(p, WIRE.W_R); + Fr delta_3 = wire(p, WIRE.W_4) - wire(p, WIRE.W_O); + Fr delta_4 = wire(p, WIRE.W_L_SHIFT) - wire(p, WIRE.W_4); + + // Contribution 6 + { + Fr acc = delta_1; + acc = acc * (delta_1 + minus_one); + acc = acc * (delta_1 + minus_two); + acc = acc * (delta_1 + minus_three); + acc = acc * wire(p, WIRE.Q_RANGE); + acc = acc * domainSep; + evals[7] = acc; + } + + // Contribution 7 + { + Fr acc = delta_2; + acc = acc * (delta_2 + minus_one); + acc = acc * (delta_2 + minus_two); + acc = acc * (delta_2 + minus_three); + acc = acc * wire(p, WIRE.Q_RANGE); + acc = acc * domainSep; + evals[8] = acc; + } + + // Contribution 8 + { + Fr acc = delta_3; + acc = acc * (delta_3 + minus_one); + acc = acc * (delta_3 + minus_two); + acc = acc * (delta_3 + minus_three); + acc = acc * wire(p, WIRE.Q_RANGE); + acc = acc * domainSep; + evals[9] = acc; + } + + // Contribution 9 + { + Fr acc = delta_4; + acc = acc * (delta_4 + minus_one); + acc = acc * (delta_4 + minus_two); + acc = acc * (delta_4 + minus_three); + acc = acc * wire(p, WIRE.Q_RANGE); + acc = acc * domainSep; + evals[10] = acc; + } + } + + function accumulateEllipticRelation( + Fr[NUMBER_OF_ENTITIES] memory p, + Fr[NUMBER_OF_SUBRELATIONS] memory evals, + Fr domainSep + ) internal pure { + EllipticParams memory ep; + ep.x_1 = wire(p, WIRE.W_R); + ep.y_1 = wire(p, WIRE.W_O); + + ep.x_2 = wire(p, WIRE.W_L_SHIFT); + ep.y_2 = wire(p, WIRE.W_4_SHIFT); + ep.y_3 = wire(p, WIRE.W_O_SHIFT); + ep.x_3 = wire(p, WIRE.W_R_SHIFT); + + Fr q_sign = wire(p, WIRE.Q_L); + Fr q_is_double = wire(p, WIRE.Q_M); + + // Contribution 10 point addition, x-coordinate check + // q_elliptic * (x3 + x2 + x1)(x2 - x1)(x2 - x1) - y2^2 - y1^2 + 2(y2y1)*q_sign = 0 + Fr x_diff = (ep.x_2 - ep.x_1); + Fr y1_sqr = (ep.y_1 * ep.y_1); + { + // Move to top + Fr partialEval = domainSep; + + Fr y2_sqr = (ep.y_2 * ep.y_2); + Fr y1y2 = ep.y_1 * ep.y_2 * q_sign; + Fr x_add_identity = (ep.x_3 + ep.x_2 + ep.x_1); + x_add_identity = x_add_identity * x_diff * x_diff; + x_add_identity = x_add_identity - y2_sqr - y1_sqr + y1y2 + y1y2; + + evals[11] = x_add_identity * partialEval * wire(p, WIRE.Q_ELLIPTIC) * (ONE - q_is_double); + } + + // Contribution 11 point addition, x-coordinate check + // q_elliptic * (q_sign * y1 + y3)(x2 - x1) + (x3 - x1)(y2 - q_sign * y1) = 0 + { + Fr y1_plus_y3 = ep.y_1 + ep.y_3; + Fr y_diff = ep.y_2 * q_sign - ep.y_1; + Fr y_add_identity = y1_plus_y3 * x_diff + (ep.x_3 - ep.x_1) * y_diff; + evals[12] = y_add_identity * domainSep * wire(p, WIRE.Q_ELLIPTIC) * (ONE - q_is_double); + } + + // Contribution 10 point doubling, x-coordinate check + // (x3 + x1 + x1) (4y1*y1) - 9 * x1 * x1 * x1 * x1 = 0 + // N.B. we're using the equivalence x1*x1*x1 === y1*y1 - curve_b to reduce degree by 1 + { + Fr x_pow_4 = (y1_sqr + GRUMPKIN_CURVE_B_PARAMETER_NEGATED) * ep.x_1; + Fr y1_sqr_mul_4 = y1_sqr + y1_sqr; + y1_sqr_mul_4 = y1_sqr_mul_4 + y1_sqr_mul_4; + Fr x1_pow_4_mul_9 = x_pow_4 * Fr.wrap(9); + + // NOTE: pushed into memory (stack >:'( ) + ep.x_double_identity = (ep.x_3 + ep.x_1 + ep.x_1) * y1_sqr_mul_4 - x1_pow_4_mul_9; + + Fr acc = ep.x_double_identity * domainSep * wire(p, WIRE.Q_ELLIPTIC) * q_is_double; + evals[11] = evals[11] + acc; + } + + // Contribution 11 point doubling, y-coordinate check + // (y1 + y1) (2y1) - (3 * x1 * x1)(x1 - x3) = 0 + { + Fr x1_sqr_mul_3 = (ep.x_1 + ep.x_1 + ep.x_1) * ep.x_1; + Fr y_double_identity = x1_sqr_mul_3 * (ep.x_1 - ep.x_3) - (ep.y_1 + ep.y_1) * (ep.y_1 + ep.y_3); + evals[12] = evals[12] + y_double_identity * domainSep * wire(p, WIRE.Q_ELLIPTIC) * q_is_double; + } + } + + function accumulateMemoryRelation( + Fr[NUMBER_OF_ENTITIES] memory p, + Honk.RelationParameters memory rp, + Fr[NUMBER_OF_SUBRELATIONS] memory evals, + Fr domainSep + ) internal pure { + MemParams memory ap; + + // Compute eta powers locally + Fr eta_two = rp.eta * rp.eta; + Fr eta_three = eta_two * rp.eta; + + /** + * MEMORY + * + * A RAM memory record contains a tuple of the following fields: + * * i: `index` of memory cell being accessed + * * t: `timestamp` of memory cell being accessed (used for RAM, set to 0 for ROM) + * * v: `value` of memory cell being accessed + * * a: `access` type of record. read: 0 = read, 1 = write + * * r: `record` of memory cell. record = access + index * eta + timestamp * eta_two + value * eta_three + * + * A ROM memory record contains a tuple of the following fields: + * * i: `index` of memory cell being accessed + * * v: `value1` of memory cell being accessed (ROM tables can store up to 2 values per index) + * * v2:`value2` of memory cell being accessed (ROM tables can store up to 2 values per index) + * * r: `record` of memory cell. record = index * eta + value2 * eta_two + value1 * eta_three + * + * When performing a read/write access, the values of i, t, v, v2, a, r are stored in the following wires + + * selectors, depending on whether the gate is a RAM read/write or a ROM read + * + * | gate type | i | v2/t | v | a | r | + * | --------- | -- | ----- | -- | -- | -- | + * | ROM | w1 | w2 | w3 | -- | w4 | + * | RAM | w1 | w2 | w3 | qc | w4 | + * + * (for accesses where `index` is a circuit constant, it is assumed the circuit will apply a copy constraint on + * `w2` to fix its value) + * + * + */ + + /** + * Memory Record Check + * Partial degree: 1 + * Total degree: 4 + * + * A ROM/ROM access gate can be evaluated with the identity: + * + * qc + w1 \eta + w2 \eta_two + w3 \eta_three - w4 = 0 + * + * For ROM gates, qc = 0 + */ + ap.memory_record_check = wire(p, WIRE.W_O) * eta_three; + ap.memory_record_check = ap.memory_record_check + (wire(p, WIRE.W_R) * eta_two); + ap.memory_record_check = ap.memory_record_check + (wire(p, WIRE.W_L) * rp.eta); + ap.memory_record_check = ap.memory_record_check + wire(p, WIRE.Q_C); + ap.partial_record_check = ap.memory_record_check; // used in RAM consistency check; deg 1 or 4 + ap.memory_record_check = ap.memory_record_check - wire(p, WIRE.W_4); + + /** + * Contribution 13 & 14 + * ROM Consistency Check + * Partial degree: 1 + * Total degree: 4 + * + * For every ROM read, a set equivalence check is applied between the record witnesses, and a second set of + * records that are sorted. + * + * We apply the following checks for the sorted records: + * + * 1. w1, w2, w3 correctly map to 'index', 'v1, 'v2' for a given record value at w4 + * 2. index values for adjacent records are monotonically increasing + * 3. if, at gate i, index_i == index_{i + 1}, then value1_i == value1_{i + 1} and value2_i == value2_{i + 1} + * + */ + ap.index_delta = wire(p, WIRE.W_L_SHIFT) - wire(p, WIRE.W_L); + ap.record_delta = wire(p, WIRE.W_4_SHIFT) - wire(p, WIRE.W_4); + + ap.index_is_monotonically_increasing = ap.index_delta * (ap.index_delta - Fr.wrap(1)); // deg 2 + + ap.adjacent_values_match_if_adjacent_indices_match = (ap.index_delta * MINUS_ONE + ONE) * ap.record_delta; // deg 2 + + evals[14] = ap.adjacent_values_match_if_adjacent_indices_match * (wire(p, WIRE.Q_L) * wire(p, WIRE.Q_R)) + * (wire(p, WIRE.Q_MEMORY) * domainSep); // deg 5 + evals[15] = ap.index_is_monotonically_increasing * (wire(p, WIRE.Q_L) * wire(p, WIRE.Q_R)) + * (wire(p, WIRE.Q_MEMORY) * domainSep); // deg 5 + + ap.ROM_consistency_check_identity = ap.memory_record_check * (wire(p, WIRE.Q_L) * wire(p, WIRE.Q_R)); // deg 3 or 7 + + /** + * Contributions 15,16,17 + * RAM Consistency Check + * + * The 'access' type of the record is extracted with the expression `w_4 - ap.partial_record_check` + * (i.e. for an honest Prover `w1 * eta + w2 * eta^2 + w3 * eta^3 - w4 = access`. + * This is validated by requiring `access` to be boolean + * + * For two adjacent entries in the sorted list if _both_ + * A) index values match + * B) adjacent access value is 0 (i.e. next gate is a READ) + * then + * C) both values must match. + * The gate boolean check is + * (A && B) => C === !(A && B) || C === !A || !B || C + * + * N.B. it is the responsibility of the circuit writer to ensure that every RAM cell is initialized + * with a WRITE operation. + */ + Fr access_type = (wire(p, WIRE.W_4) - ap.partial_record_check); // will be 0 or 1 for honest Prover; deg 1 or 4 + ap.access_check = access_type * (access_type - Fr.wrap(1)); // check value is 0 or 1; deg 2 or 8 + + // reverse order we could re-use `ap.partial_record_check` 1 - ((w3' * eta + w2') * eta + w1') * eta + // deg 1 or 4 + ap.next_gate_access_type = wire(p, WIRE.W_O_SHIFT) * eta_three; + ap.next_gate_access_type = ap.next_gate_access_type + (wire(p, WIRE.W_R_SHIFT) * eta_two); + ap.next_gate_access_type = ap.next_gate_access_type + (wire(p, WIRE.W_L_SHIFT) * rp.eta); + ap.next_gate_access_type = wire(p, WIRE.W_4_SHIFT) - ap.next_gate_access_type; + + Fr value_delta = wire(p, WIRE.W_O_SHIFT) - wire(p, WIRE.W_O); + ap.adjacent_values_match_if_adjacent_indices_match_and_next_access_is_a_read_operation = + (ap.index_delta * MINUS_ONE + ONE) * value_delta * (ap.next_gate_access_type * MINUS_ONE + ONE); // deg 3 or 6 + + // We can't apply the RAM consistency check identity on the final entry in the sorted list (the wires in the + // next gate would make the identity fail). We need to validate that its 'access type' bool is correct. Can't + // do with an arithmetic gate because of the `eta` factors. We need to check that the *next* gate's access + // type is correct, to cover this edge case + // deg 2 or 4 + ap.next_gate_access_type_is_boolean = + ap.next_gate_access_type * ap.next_gate_access_type - ap.next_gate_access_type; + + // Putting it all together... + evals[16] = ap.adjacent_values_match_if_adjacent_indices_match_and_next_access_is_a_read_operation + * (wire(p, WIRE.Q_O)) * (wire(p, WIRE.Q_MEMORY) * domainSep); // deg 5 or 8 + evals[17] = ap.index_is_monotonically_increasing * (wire(p, WIRE.Q_O)) * (wire(p, WIRE.Q_MEMORY) * domainSep); // deg 4 + evals[18] = ap.next_gate_access_type_is_boolean * (wire(p, WIRE.Q_O)) * (wire(p, WIRE.Q_MEMORY) * domainSep); // deg 4 or 6 + + ap.RAM_consistency_check_identity = ap.access_check * (wire(p, WIRE.Q_O)); // deg 3 or 9 + + /** + * RAM Timestamp Consistency Check + * + * | w1 | w2 | w3 | w4 | + * | index | timestamp | timestamp_check | -- | + * + * Let delta_index = index_{i + 1} - index_{i} + * + * Iff delta_index == 0, timestamp_check = timestamp_{i + 1} - timestamp_i + * Else timestamp_check = 0 + */ + ap.timestamp_delta = wire(p, WIRE.W_R_SHIFT) - wire(p, WIRE.W_R); + ap.RAM_timestamp_check_identity = (ap.index_delta * MINUS_ONE + ONE) * ap.timestamp_delta - wire(p, WIRE.W_O); // deg 3 + + /** + * Complete Contribution 12 + * The complete RAM/ROM memory identity + * Partial degree: + */ + ap.memory_identity = ap.ROM_consistency_check_identity; // deg 3 or 6 + ap.memory_identity = + ap.memory_identity + ap.RAM_timestamp_check_identity * (wire(p, WIRE.Q_4) * wire(p, WIRE.Q_L)); // deg 4 + ap.memory_identity = ap.memory_identity + ap.memory_record_check * (wire(p, WIRE.Q_M) * wire(p, WIRE.Q_L)); // deg 3 or 6 + ap.memory_identity = ap.memory_identity + ap.RAM_consistency_check_identity; // deg 3 or 9 + + // (deg 3 or 9) + (deg 4) + (deg 3) + ap.memory_identity = ap.memory_identity * (wire(p, WIRE.Q_MEMORY) * domainSep); // deg 4 or 10 + evals[13] = ap.memory_identity; + } + + function accumulateNnfRelation( + Fr[NUMBER_OF_ENTITIES] memory p, + Fr[NUMBER_OF_SUBRELATIONS] memory evals, + Fr domainSep + ) internal pure { + NnfParams memory ap; + + /** + * Contribution 12 + * Non native field arithmetic gate 2 + * deg 4 + * + * _ _ + * / _ _ _ 14 \ + * q_2 . q_4 | (w_1 . w_2) + (w_1 . w_2) + (w_1 . w_4 + w_2 . w_3 - w_3) . 2 - w_3 - w_4 | + * \_ _/ + * + * + */ + ap.limb_subproduct = wire(p, WIRE.W_L) * wire(p, WIRE.W_R_SHIFT) + wire(p, WIRE.W_L_SHIFT) * wire(p, WIRE.W_R); + ap.non_native_field_gate_2 = + (wire(p, WIRE.W_L) * wire(p, WIRE.W_4) + wire(p, WIRE.W_R) * wire(p, WIRE.W_O) - wire(p, WIRE.W_O_SHIFT)); + ap.non_native_field_gate_2 = ap.non_native_field_gate_2 * LIMB_SIZE; + ap.non_native_field_gate_2 = ap.non_native_field_gate_2 - wire(p, WIRE.W_4_SHIFT); + ap.non_native_field_gate_2 = ap.non_native_field_gate_2 + ap.limb_subproduct; + ap.non_native_field_gate_2 = ap.non_native_field_gate_2 * wire(p, WIRE.Q_4); + + ap.limb_subproduct = ap.limb_subproduct * LIMB_SIZE; + ap.limb_subproduct = ap.limb_subproduct + (wire(p, WIRE.W_L_SHIFT) * wire(p, WIRE.W_R_SHIFT)); + ap.non_native_field_gate_1 = ap.limb_subproduct; + ap.non_native_field_gate_1 = ap.non_native_field_gate_1 - (wire(p, WIRE.W_O) + wire(p, WIRE.W_4)); + ap.non_native_field_gate_1 = ap.non_native_field_gate_1 * wire(p, WIRE.Q_O); + + ap.non_native_field_gate_3 = ap.limb_subproduct; + ap.non_native_field_gate_3 = ap.non_native_field_gate_3 + wire(p, WIRE.W_4); + ap.non_native_field_gate_3 = ap.non_native_field_gate_3 - (wire(p, WIRE.W_O_SHIFT) + wire(p, WIRE.W_4_SHIFT)); + ap.non_native_field_gate_3 = ap.non_native_field_gate_3 * wire(p, WIRE.Q_M); + + Fr non_native_field_identity = + ap.non_native_field_gate_1 + ap.non_native_field_gate_2 + ap.non_native_field_gate_3; + non_native_field_identity = non_native_field_identity * wire(p, WIRE.Q_R); + + // ((((w2' * 2^14 + w1') * 2^14 + w3) * 2^14 + w2) * 2^14 + w1 - w4) * qm + // deg 2 + ap.limb_accumulator_1 = wire(p, WIRE.W_R_SHIFT) * SUBLIMB_SHIFT; + ap.limb_accumulator_1 = ap.limb_accumulator_1 + wire(p, WIRE.W_L_SHIFT); + ap.limb_accumulator_1 = ap.limb_accumulator_1 * SUBLIMB_SHIFT; + ap.limb_accumulator_1 = ap.limb_accumulator_1 + wire(p, WIRE.W_O); + ap.limb_accumulator_1 = ap.limb_accumulator_1 * SUBLIMB_SHIFT; + ap.limb_accumulator_1 = ap.limb_accumulator_1 + wire(p, WIRE.W_R); + ap.limb_accumulator_1 = ap.limb_accumulator_1 * SUBLIMB_SHIFT; + ap.limb_accumulator_1 = ap.limb_accumulator_1 + wire(p, WIRE.W_L); + ap.limb_accumulator_1 = ap.limb_accumulator_1 - wire(p, WIRE.W_4); + ap.limb_accumulator_1 = ap.limb_accumulator_1 * wire(p, WIRE.Q_4); + + // ((((w3' * 2^14 + w2') * 2^14 + w1') * 2^14 + w4) * 2^14 + w3 - w4') * qm + // deg 2 + ap.limb_accumulator_2 = wire(p, WIRE.W_O_SHIFT) * SUBLIMB_SHIFT; + ap.limb_accumulator_2 = ap.limb_accumulator_2 + wire(p, WIRE.W_R_SHIFT); + ap.limb_accumulator_2 = ap.limb_accumulator_2 * SUBLIMB_SHIFT; + ap.limb_accumulator_2 = ap.limb_accumulator_2 + wire(p, WIRE.W_L_SHIFT); + ap.limb_accumulator_2 = ap.limb_accumulator_2 * SUBLIMB_SHIFT; + ap.limb_accumulator_2 = ap.limb_accumulator_2 + wire(p, WIRE.W_4); + ap.limb_accumulator_2 = ap.limb_accumulator_2 * SUBLIMB_SHIFT; + ap.limb_accumulator_2 = ap.limb_accumulator_2 + wire(p, WIRE.W_O); + ap.limb_accumulator_2 = ap.limb_accumulator_2 - wire(p, WIRE.W_4_SHIFT); + ap.limb_accumulator_2 = ap.limb_accumulator_2 * wire(p, WIRE.Q_M); + + Fr limb_accumulator_identity = ap.limb_accumulator_1 + ap.limb_accumulator_2; + limb_accumulator_identity = limb_accumulator_identity * wire(p, WIRE.Q_O); // deg 3 + + ap.nnf_identity = non_native_field_identity + limb_accumulator_identity; + ap.nnf_identity = ap.nnf_identity * (wire(p, WIRE.Q_NNF) * domainSep); + evals[19] = ap.nnf_identity; + } + + function accumulatePoseidonExternalRelation( + Fr[NUMBER_OF_ENTITIES] memory p, + Fr[NUMBER_OF_SUBRELATIONS] memory evals, + Fr domainSep + ) internal pure { + PoseidonExternalParams memory ep; + + ep.s1 = wire(p, WIRE.W_L) + wire(p, WIRE.Q_L); + ep.s2 = wire(p, WIRE.W_R) + wire(p, WIRE.Q_R); + ep.s3 = wire(p, WIRE.W_O) + wire(p, WIRE.Q_O); + ep.s4 = wire(p, WIRE.W_4) + wire(p, WIRE.Q_4); + + ep.u1 = ep.s1 * ep.s1 * ep.s1 * ep.s1 * ep.s1; + ep.u2 = ep.s2 * ep.s2 * ep.s2 * ep.s2 * ep.s2; + ep.u3 = ep.s3 * ep.s3 * ep.s3 * ep.s3 * ep.s3; + ep.u4 = ep.s4 * ep.s4 * ep.s4 * ep.s4 * ep.s4; + // matrix mul v = M_E * u with 14 additions + ep.t0 = ep.u1 + ep.u2; // u_1 + u_2 + ep.t1 = ep.u3 + ep.u4; // u_3 + u_4 + ep.t2 = ep.u2 + ep.u2 + ep.t1; // 2u_2 + // ep.t2 += ep.t1; // 2u_2 + u_3 + u_4 + ep.t3 = ep.u4 + ep.u4 + ep.t0; // 2u_4 + // ep.t3 += ep.t0; // u_1 + u_2 + 2u_4 + ep.v4 = ep.t1 + ep.t1; + ep.v4 = ep.v4 + ep.v4 + ep.t3; + // ep.v4 += ep.t3; // u_1 + u_2 + 4u_3 + 6u_4 + ep.v2 = ep.t0 + ep.t0; + ep.v2 = ep.v2 + ep.v2 + ep.t2; + // ep.v2 += ep.t2; // 4u_1 + 6u_2 + u_3 + u_4 + ep.v1 = ep.t3 + ep.v2; // 5u_1 + 7u_2 + u_3 + 3u_4 + ep.v3 = ep.t2 + ep.v4; // u_1 + 3u_2 + 5u_3 + 7u_4 + + ep.q_pos_by_scaling = wire(p, WIRE.Q_POSEIDON2_EXTERNAL) * domainSep; + evals[20] = evals[20] + ep.q_pos_by_scaling * (ep.v1 - wire(p, WIRE.W_L_SHIFT)); + + evals[21] = evals[21] + ep.q_pos_by_scaling * (ep.v2 - wire(p, WIRE.W_R_SHIFT)); + + evals[22] = evals[22] + ep.q_pos_by_scaling * (ep.v3 - wire(p, WIRE.W_O_SHIFT)); + + evals[23] = evals[23] + ep.q_pos_by_scaling * (ep.v4 - wire(p, WIRE.W_4_SHIFT)); + } + + function accumulatePoseidonInternalRelation( + Fr[NUMBER_OF_ENTITIES] memory p, + Fr[NUMBER_OF_SUBRELATIONS] memory evals, + Fr domainSep + ) internal pure { + PoseidonInternalParams memory ip; + + Fr[4] memory INTERNAL_MATRIX_DIAGONAL = [ + FrLib.from(0x10dc6e9c006ea38b04b1e03b4bd9490c0d03f98929ca1d7fb56821fd19d3b6e7), + FrLib.from(0x0c28145b6a44df3e0149b3d0a30b3bb599df9756d4dd9b84a86b38cfb45a740b), + FrLib.from(0x00544b8338791518b2c7645a50392798b21f75bb60e3596170067d00141cac15), + FrLib.from(0x222c01175718386f2e2e82eb122789e352e105a3b8fa852613bc534433ee428b) + ]; + + // add round constants + ip.s1 = wire(p, WIRE.W_L) + wire(p, WIRE.Q_L); + + // apply s-box round + ip.u1 = ip.s1 * ip.s1 * ip.s1 * ip.s1 * ip.s1; + ip.u2 = wire(p, WIRE.W_R); + ip.u3 = wire(p, WIRE.W_O); + ip.u4 = wire(p, WIRE.W_4); + + // matrix mul with v = M_I * u 4 muls and 7 additions + ip.u_sum = ip.u1 + ip.u2 + ip.u3 + ip.u4; + + ip.q_pos_by_scaling = wire(p, WIRE.Q_POSEIDON2_INTERNAL) * domainSep; + + ip.v1 = ip.u1 * INTERNAL_MATRIX_DIAGONAL[0] + ip.u_sum; + evals[24] = evals[24] + ip.q_pos_by_scaling * (ip.v1 - wire(p, WIRE.W_L_SHIFT)); + + ip.v2 = ip.u2 * INTERNAL_MATRIX_DIAGONAL[1] + ip.u_sum; + evals[25] = evals[25] + ip.q_pos_by_scaling * (ip.v2 - wire(p, WIRE.W_R_SHIFT)); + + ip.v3 = ip.u3 * INTERNAL_MATRIX_DIAGONAL[2] + ip.u_sum; + evals[26] = evals[26] + ip.q_pos_by_scaling * (ip.v3 - wire(p, WIRE.W_O_SHIFT)); + + ip.v4 = ip.u4 * INTERNAL_MATRIX_DIAGONAL[3] + ip.u_sum; + evals[27] = evals[27] + ip.q_pos_by_scaling * (ip.v4 - wire(p, WIRE.W_4_SHIFT)); + } + + // Batch subrelation evaluations using precomputed powers of alpha + // First subrelation is implicitly scaled by 1, subsequent ones use powers from the subrelationChallenges array + function scaleAndBatchSubrelations( + Fr[NUMBER_OF_SUBRELATIONS] memory evaluations, + Fr[NUMBER_OF_ALPHAS] memory subrelationChallenges + ) internal pure returns (Fr accumulator) { + accumulator = evaluations[0]; + + for (uint256 i = 1; i < NUMBER_OF_SUBRELATIONS; ++i) { + accumulator = accumulator + evaluations[i] * subrelationChallenges[i - 1]; + } + } +} + +library CommitmentSchemeLib { + using FrLib for Fr; + + // Avoid stack too deep + struct ShpleminiIntermediates { + Fr unshiftedScalar; + Fr shiftedScalar; + Fr unshiftedScalarNeg; + Fr shiftedScalarNeg; + // Scalar to be multiplied by [1]₁ + Fr constantTermAccumulator; + // Accumulator for powers of rho + Fr batchingChallenge; + // Linear combination of multilinear (sumcheck) evaluations and powers of rho + Fr batchedEvaluation; + Fr[4] denominators; + Fr[4] batchingScalars; + // 1/(z - r^{2^i}) for i = 0, ..., logSize, dynamically updated + Fr posInvertedDenominator; + // 1/(z + r^{2^i}) for i = 0, ..., logSize, dynamically updated + Fr negInvertedDenominator; + // ν^{2i} * 1/(z - r^{2^i}) + Fr scalingFactorPos; + // ν^{2i+1} * 1/(z + r^{2^i}) + Fr scalingFactorNeg; + // Fold_i(r^{2^i}) reconstructed by Verifier + Fr[] foldPosEvaluations; + } + + // Compute the evaluations Aₗ(r^{2ˡ}) for l = 0, ..., m-1 + function computeFoldPosEvaluations( + Fr[CONST_PROOF_SIZE_LOG_N] memory sumcheckUChallenges, + Fr batchedEvalAccumulator, + Fr[CONST_PROOF_SIZE_LOG_N] memory geminiEvaluations, + Fr[] memory geminiEvalChallengePowers, + uint256 logSize + ) internal view returns (Fr[] memory) { + Fr[] memory foldPosEvaluations = new Fr[](logSize); + for (uint256 i = logSize; i > 0; --i) { + Fr challengePower = geminiEvalChallengePowers[i - 1]; + Fr u = sumcheckUChallenges[i - 1]; + + Fr batchedEvalRoundAcc = + ((challengePower * batchedEvalAccumulator * Fr.wrap(2)) - geminiEvaluations[i - 1] + * (challengePower * (ONE - u) - u)); + // Divide by the denominator + batchedEvalRoundAcc = batchedEvalRoundAcc * (challengePower * (ONE - u) + u).invert(); + + batchedEvalAccumulator = batchedEvalRoundAcc; + foldPosEvaluations[i - 1] = batchedEvalRoundAcc; + } + return foldPosEvaluations; + } + + function computeSquares(Fr r, uint256 logN) internal pure returns (Fr[] memory) { + Fr[] memory squares = new Fr[](logN); + squares[0] = r; + for (uint256 i = 1; i < logN; ++i) { + squares[i] = squares[i - 1].sqr(); + } + return squares; + } +} + +uint256 constant Q = 21888242871839275222246405745257275088696311157297823662689037894645226208583; // EC group order. F_q + +// Fr utility + +function bytesToFr(bytes calldata proofSection) pure returns (Fr scalar) { + scalar = FrLib.fromBytes32(bytes32(proofSection)); +} + +// EC Point utilities +function bytesToG1Point(bytes calldata proofSection) pure returns (Honk.G1Point memory point) { + uint256 x = uint256(bytes32(proofSection[0x00:0x20])); + uint256 y = uint256(bytes32(proofSection[0x20:0x40])); + require(x < Q && y < Q, Errors.ValueGeGroupOrder()); + + // Reject the point at infinity (0,0). EVM precompiles silently treat (0,0) + // as the identity element, which could zero out commitments. + // On-curve validation (y² = x³ + 3) is handled by the ecAdd/ecMul precompiles + // per EIP-196, so we only need to catch this special case here. + require((x | y) != 0, Errors.PointAtInfinity()); + + point = Honk.G1Point({x: x, y: y}); +} + +function negateInplace(Honk.G1Point memory point) pure returns (Honk.G1Point memory) { + // When y == 0 (order-2 point), negation is the same point. Q - 0 = Q which is >= Q. + if (point.y != 0) { + point.y = Q - point.y; + } + return point; +} + +/** + * Convert the pairing points to G1 points. + * + * The pairing points are serialised as an array of 2 limbs representing two points + * (P0 and P1, used for lhs and rhs of pairing operation). + * + * There are 2 limbs (lo, hi) for each coordinate, so 4 limbs per point, 8 total. + * Layout: [P0.x_lo, P0.x_hi, P0.y_lo, P0.y_hi, P1.x_lo, P1.x_hi, P1.y_lo, P1.y_hi] + * + * @param pairingPoints The pairing points to convert. + * @return lhs P0 point + * @return rhs P1 point + */ +function convertPairingPointsToG1(Fr[PAIRING_POINTS_SIZE] memory pairingPoints) + pure + returns (Honk.G1Point memory lhs, Honk.G1Point memory rhs) +{ + // P0 (lhs): x = lo | (hi << 136) + uint256 lhsX = Fr.unwrap(pairingPoints[0]); + lhsX |= Fr.unwrap(pairingPoints[1]) << 136; + + uint256 lhsY = Fr.unwrap(pairingPoints[2]); + lhsY |= Fr.unwrap(pairingPoints[3]) << 136; + + // P1 (rhs): x = lo | (hi << 136) + uint256 rhsX = Fr.unwrap(pairingPoints[4]); + rhsX |= Fr.unwrap(pairingPoints[5]) << 136; + + uint256 rhsY = Fr.unwrap(pairingPoints[6]); + rhsY |= Fr.unwrap(pairingPoints[7]) << 136; + + // Reconstructed coordinates must be < Q to prevent malleability. + // Without this, two different limb encodings could map to the same curve point + // (via mulmod reduction in on-curve checks) but produce different transcript hashes. + require(lhsX < Q && lhsY < Q && rhsX < Q && rhsY < Q, Errors.ValueGeGroupOrder()); + + lhs.x = lhsX; + lhs.y = lhsY; + rhs.x = rhsX; + rhs.y = rhsY; +} + +/** + * Hash the pairing inputs from the present verification context with those extracted from the public inputs. + * + * @param proofPairingPoints Pairing points from the proof - (public inputs). + * @param accLhs Accumulator point for the left side - result of shplemini. + * @param accRhs Accumulator point for the right side - result of shplemini. + * @return recursionSeparator The recursion separator - generated from hashing the above. + */ +function generateRecursionSeparator( + Fr[PAIRING_POINTS_SIZE] memory proofPairingPoints, + Honk.G1Point memory accLhs, + Honk.G1Point memory accRhs +) pure returns (Fr recursionSeparator) { + // hash the proof aggregated X + // hash the proof aggregated Y + // hash the accum X + // hash the accum Y + + (Honk.G1Point memory proofLhs, Honk.G1Point memory proofRhs) = convertPairingPointsToG1(proofPairingPoints); + + uint256[8] memory recursionSeparatorElements; + + // Proof points + recursionSeparatorElements[0] = proofLhs.x; + recursionSeparatorElements[1] = proofLhs.y; + recursionSeparatorElements[2] = proofRhs.x; + recursionSeparatorElements[3] = proofRhs.y; + + // Accumulator points + recursionSeparatorElements[4] = accLhs.x; + recursionSeparatorElements[5] = accLhs.y; + recursionSeparatorElements[6] = accRhs.x; + recursionSeparatorElements[7] = accRhs.y; + + recursionSeparator = FrLib.from(uint256(keccak256(abi.encodePacked(recursionSeparatorElements))) % P); +} + +/** + * G1 Mul with Separator + * Using the ecAdd and ecMul precompiles + * + * @param basePoint The point to multiply. + * @param other The other point to add. + * @param recursionSeperator The separator to use for the multiplication. + * @return `(recursionSeperator * basePoint) + other`. + */ +function mulWithSeperator(Honk.G1Point memory basePoint, Honk.G1Point memory other, Fr recursionSeperator) + view + returns (Honk.G1Point memory) +{ + Honk.G1Point memory result; + + result = ecMul(recursionSeperator, basePoint); + result = ecAdd(result, other); + + return result; +} + +/** + * G1 Mul + * Takes a Fr value and a G1 point and uses the ecMul precompile to return the result. + * + * @param value The value to multiply the point by. + * @param point The point to multiply. + * @return result The result of the multiplication. + */ +function ecMul(Fr value, Honk.G1Point memory point) view returns (Honk.G1Point memory) { + Honk.G1Point memory result; + + assembly { + let free := mload(0x40) + // Write the point into memory (two 32 byte words) + // Memory layout: + // Address | value + // free | point.x + // free + 0x20| point.y + mstore(free, mload(point)) + mstore(add(free, 0x20), mload(add(point, 0x20))) + // Write the scalar into memory (one 32 byte word) + // Memory layout: + // Address | value + // free + 0x40| value + mstore(add(free, 0x40), value) + + // Call the ecMul precompile, it takes in the following + // [point.x, point.y, scalar], and returns the result back into the free memory location. + let success := staticcall(gas(), 0x07, free, 0x60, free, 0x40) + if iszero(success) { + revert(0, 0) + } + // Copy the result of the multiplication back into the result memory location. + // Memory layout: + // Address | value + // result | result.x + // result + 0x20| result.y + mstore(result, mload(free)) + mstore(add(result, 0x20), mload(add(free, 0x20))) + + mstore(0x40, add(free, 0x60)) + } + + return result; +} + +/** + * G1 Add + * Takes two G1 points and uses the ecAdd precompile to return the result. + * + * @param lhs The left hand side of the addition. + * @param rhs The right hand side of the addition. + * @return result The result of the addition. + */ +function ecAdd(Honk.G1Point memory lhs, Honk.G1Point memory rhs) view returns (Honk.G1Point memory) { + Honk.G1Point memory result; + + assembly { + let free := mload(0x40) + // Write lhs into memory (two 32 byte words) + // Memory layout: + // Address | value + // free | lhs.x + // free + 0x20| lhs.y + mstore(free, mload(lhs)) + mstore(add(free, 0x20), mload(add(lhs, 0x20))) + + // Write rhs into memory (two 32 byte words) + // Memory layout: + // Address | value + // free + 0x40| rhs.x + // free + 0x60| rhs.y + mstore(add(free, 0x40), mload(rhs)) + mstore(add(free, 0x60), mload(add(rhs, 0x20))) + + // Call the ecAdd precompile, it takes in the following + // [lhs.x, lhs.y, rhs.x, rhs.y], and returns their addition back into the free memory location. + let success := staticcall(gas(), 0x06, free, 0x80, free, 0x40) + if iszero(success) { revert(0, 0) } + + // Copy the result of the addition back into the result memory location. + // Memory layout: + // Address | value + // result | result.x + // result + 0x20| result.y + mstore(result, mload(free)) + mstore(add(result, 0x20), mload(add(free, 0x20))) + + mstore(0x40, add(free, 0x80)) + } + + return result; +} + +function rejectPointAtInfinity(Honk.G1Point memory point) pure { + require((point.x | point.y) != 0, Errors.PointAtInfinity()); +} + +/** + * Check if pairing point limbs are all zero (default/infinity). + * Default pairing points indicate no recursive verification occurred. + */ +function arePairingPointsDefault(Fr[PAIRING_POINTS_SIZE] memory pairingPoints) pure returns (bool) { + uint256 acc = 0; + for (uint256 i = 0; i < PAIRING_POINTS_SIZE; i++) { + acc |= Fr.unwrap(pairingPoints[i]); + } + return acc == 0; +} + +function pairing(Honk.G1Point memory rhs, Honk.G1Point memory lhs) view returns (bool decodedResult) { + bytes memory input = abi.encodePacked( + rhs.x, + rhs.y, + // Fixed G2 point + uint256(0x198e9393920d483a7260bfb731fb5d25f1aa493335a9e71297e485b7aef312c2), + uint256(0x1800deef121f1e76426a00665e5c4479674322d4f75edadd46debd5cd992f6ed), + uint256(0x090689d0585ff075ec9e99ad690c3395bc4b313370b38ef355acdadcd122975b), + uint256(0x12c85ea5db8c6deb4aab71808dcb408fe3d1e7690c43d37b4ce6cc0166fa7daa), + lhs.x, + lhs.y, + // G2 point from VK + uint256(0x260e01b251f6f1c7e7ff4e580791dee8ea51d87a358e038b4efe30fac09383c1), + uint256(0x0118c4d5b837bcc2bc89b5b398b5974e9f5944073b32078b7e231fec938883b0), + uint256(0x04fc6369f7110fe3d25156c1bb9a72859cf2a04641f99ba4ee413c80da6a5fe4), + uint256(0x22febda3c0c0632a56475b4214e5615e11e6dd3f96e6cea2854a87d4dacc5e55) + ); + + (bool success, bytes memory result) = address(0x08).staticcall(input); + decodedResult = success && abi.decode(result, (bool)); +} + +abstract contract BaseZKHonkVerifier is IVerifier { + using FrLib for Fr; + + struct PairingInputs { + Honk.G1Point P_0; + Honk.G1Point P_1; + } + + struct SmallSubgroupIpaIntermediates { + Fr[SUBGROUP_SIZE] challengePolyLagrange; + Fr challengePolyEval; + Fr lagrangeFirst; + Fr lagrangeLast; + Fr rootPower; + Fr[SUBGROUP_SIZE] denominators; // this has to disappear + Fr diff; + } + + // Constants for proof length calculation (matching UltraKeccakZKFlavor) + uint256 internal constant NUM_WITNESS_ENTITIES = 8 + NUM_MASKING_POLYNOMIALS; + uint256 internal constant NUM_ELEMENTS_COMM = 2; // uint256 elements for curve points + uint256 internal constant NUM_ELEMENTS_FR = 1; // uint256 elements for field elements + uint256 internal constant NUM_LIBRA_EVALUATIONS = 4; // libra evaluations + + uint256 internal constant LIBRA_COMMITMENTS = 3; + uint256 internal constant LIBRA_EVALUATIONS = 4; + uint256 internal constant LIBRA_UNIVARIATES_LENGTH = 9; + + uint256 internal constant SHIFTED_COMMITMENTS_START = 30; + uint256 internal constant PERMUTATION_ARGUMENT_VALUE_SEPARATOR = 1 << 28; + + uint256 internal immutable $N; + uint256 internal immutable $LOG_N; + uint256 internal immutable $VK_HASH; + uint256 internal immutable $NUM_PUBLIC_INPUTS; + uint256 internal immutable $MSMSize; + + constructor(uint256 _N, uint256 _logN, uint256 _vkHash, uint256 _numPublicInputs) { + $N = _N; + $LOG_N = _logN; + $VK_HASH = _vkHash; + $NUM_PUBLIC_INPUTS = _numPublicInputs; + $MSMSize = NUMBER_UNSHIFTED_ZK + _logN + LIBRA_COMMITMENTS + 2; + } + + function verify(bytes calldata proof, bytes32[] calldata publicInputs) + public + view + override + returns (bool verified) + { + // Calculate expected proof size based on $LOG_N + uint256 expectedProofSize = calculateProofSize($LOG_N); + + // Check the received proof is the expected size where each field element is 32 bytes + require( + proof.length == expectedProofSize, Errors.ProofLengthWrongWithLogN($LOG_N, proof.length, expectedProofSize) + ); + + Honk.VerificationKey memory vk = loadVerificationKey(); + Honk.ZKProof memory p = ZKTranscriptLib.loadProof(proof, $LOG_N); + + require(publicInputs.length == vk.publicInputsSize - PAIRING_POINTS_SIZE, Errors.PublicInputsLengthWrong()); + + // Generate the fiat shamir challenges for the whole protocol + ZKTranscript memory t = + ZKTranscriptLib.generateTranscript(p, publicInputs, $VK_HASH, $NUM_PUBLIC_INPUTS, $LOG_N); + + // Derive public input delta + t.relationParameters.publicInputsDelta = computePublicInputDelta( + publicInputs, + p.pairingPointObject, + t.relationParameters.beta, + t.relationParameters.gamma, /*pubInputsOffset=*/ + 1 + ); + + // Sumcheck + require(verifySumcheck(p, t), Errors.SumcheckFailed()); + require(verifyShplemini(p, vk, t), Errors.ShpleminiFailed()); + + verified = true; + } + + function computePublicInputDelta( + bytes32[] memory publicInputs, + Fr[PAIRING_POINTS_SIZE] memory pairingPointObject, + Fr beta, + Fr gamma, + uint256 offset + ) internal view returns (Fr publicInputDelta) { + Fr numerator = Fr.wrap(1); + Fr denominator = Fr.wrap(1); + + Fr numeratorAcc = gamma + (beta * FrLib.from(PERMUTATION_ARGUMENT_VALUE_SEPARATOR + offset)); + Fr denominatorAcc = gamma - (beta * FrLib.from(offset + 1)); + + { + for (uint256 i = 0; i < $NUM_PUBLIC_INPUTS - PAIRING_POINTS_SIZE; i++) { + Fr pubInput = FrLib.fromBytes32(publicInputs[i]); + + numerator = numerator * (numeratorAcc + pubInput); + denominator = denominator * (denominatorAcc + pubInput); + + numeratorAcc = numeratorAcc + beta; + denominatorAcc = denominatorAcc - beta; + } + + for (uint256 i = 0; i < PAIRING_POINTS_SIZE; i++) { + Fr pubInput = pairingPointObject[i]; + + numerator = numerator * (numeratorAcc + pubInput); + denominator = denominator * (denominatorAcc + pubInput); + + numeratorAcc = numeratorAcc + beta; + denominatorAcc = denominatorAcc - beta; + } + } + + // Fr delta = numerator / denominator; // TOOO: batch invert later? + publicInputDelta = FrLib.div(numerator, denominator); + } + + function verifySumcheck(Honk.ZKProof memory proof, ZKTranscript memory tp) internal view returns (bool verified) { + Fr roundTargetSum = tp.libraChallenge * proof.libraSum; // default 0 + Fr powPartialEvaluation = Fr.wrap(1); + + // We perform sumcheck reductions over log n rounds ( the multivariate degree ) + for (uint256 round; round < $LOG_N; ++round) { + Fr[ZK_BATCHED_RELATION_PARTIAL_LENGTH] memory roundUnivariate = proof.sumcheckUnivariates[round]; + Fr totalSum = roundUnivariate[0] + roundUnivariate[1]; + require(totalSum == roundTargetSum, Errors.SumcheckFailed()); + + Fr roundChallenge = tp.sumCheckUChallenges[round]; + + // Update the round target for the next rounf + roundTargetSum = computeNextTargetSum(roundUnivariate, roundChallenge); + powPartialEvaluation = + powPartialEvaluation * (Fr.wrap(1) + roundChallenge * (tp.gateChallenges[round] - Fr.wrap(1))); + } + + // Last round + // For ZK flavors: sumcheckEvaluations has 42 elements + // Index 0 is gemini_masking_poly, indices 1-41 are the regular entities used in relations + Fr[NUMBER_OF_ENTITIES] memory relationsEvaluations; + for (uint256 i = 0; i < NUMBER_OF_ENTITIES; i++) { + relationsEvaluations[i] = proof.sumcheckEvaluations[i + NUM_MASKING_POLYNOMIALS]; // Skip gemini_masking_poly at index 0 + } + Fr grandHonkRelationSum = RelationsLib.accumulateRelationEvaluations( + relationsEvaluations, tp.relationParameters, tp.alphas, powPartialEvaluation + ); + + Fr evaluation = Fr.wrap(1); + for (uint256 i = 2; i < $LOG_N; i++) { + evaluation = evaluation * tp.sumCheckUChallenges[i]; + } + + grandHonkRelationSum = + grandHonkRelationSum * (Fr.wrap(1) - evaluation) + proof.libraEvaluation * tp.libraChallenge; + verified = (grandHonkRelationSum == roundTargetSum); + } + + // Return the new target sum for the next sumcheck round + function computeNextTargetSum(Fr[ZK_BATCHED_RELATION_PARTIAL_LENGTH] memory roundUnivariates, Fr roundChallenge) + internal + view + returns (Fr targetSum) + { + Fr[ZK_BATCHED_RELATION_PARTIAL_LENGTH] memory BARYCENTRIC_LAGRANGE_DENOMINATORS = [ + Fr.wrap(0x0000000000000000000000000000000000000000000000000000000000009d80), + Fr.wrap(0x30644e72e131a029b85045b68181585d2833e84879b9709143e1f593efffec51), + Fr.wrap(0x00000000000000000000000000000000000000000000000000000000000005a0), + Fr.wrap(0x30644e72e131a029b85045b68181585d2833e84879b9709143e1f593effffd31), + Fr.wrap(0x0000000000000000000000000000000000000000000000000000000000000240), + Fr.wrap(0x30644e72e131a029b85045b68181585d2833e84879b9709143e1f593effffd31), + Fr.wrap(0x00000000000000000000000000000000000000000000000000000000000005a0), + Fr.wrap(0x30644e72e131a029b85045b68181585d2833e84879b9709143e1f593efffec51), + Fr.wrap(0x0000000000000000000000000000000000000000000000000000000000009d80) + ]; + + // To compute the next target sum, we evaluate the given univariate at a point u (challenge). + + // Performing Barycentric evaluations + // Compute B(x) + Fr numeratorValue = Fr.wrap(1); + for (uint256 i = 0; i < ZK_BATCHED_RELATION_PARTIAL_LENGTH; ++i) { + numeratorValue = numeratorValue * (roundChallenge - Fr.wrap(i)); + } + + Fr[ZK_BATCHED_RELATION_PARTIAL_LENGTH] memory denominatorInverses; + for (uint256 i = 0; i < ZK_BATCHED_RELATION_PARTIAL_LENGTH; ++i) { + denominatorInverses[i] = FrLib.invert(BARYCENTRIC_LAGRANGE_DENOMINATORS[i] * (roundChallenge - Fr.wrap(i))); + } + + for (uint256 i = 0; i < ZK_BATCHED_RELATION_PARTIAL_LENGTH; ++i) { + targetSum = targetSum + roundUnivariates[i] * denominatorInverses[i]; + } + + // Scale the sum by the value of B(x) + targetSum = targetSum * numeratorValue; + } + + function verifyShplemini(Honk.ZKProof memory proof, Honk.VerificationKey memory vk, ZKTranscript memory tp) + internal + view + returns (bool verified) + { + CommitmentSchemeLib.ShpleminiIntermediates memory mem; // stack + + // - Compute vector (r, r², ... , r²⁽ⁿ⁻¹⁾), where n = log_circuit_size + Fr[] memory powers_of_evaluation_challenge = CommitmentSchemeLib.computeSquares(tp.geminiR, $LOG_N); + // Arrays hold values that will be linearly combined for the gemini and shplonk batch openings + Fr[] memory scalars = new Fr[]($MSMSize); + Honk.G1Point[] memory commitments = new Honk.G1Point[]($MSMSize); + + mem.posInvertedDenominator = (tp.shplonkZ - powers_of_evaluation_challenge[0]).invert(); + mem.negInvertedDenominator = (tp.shplonkZ + powers_of_evaluation_challenge[0]).invert(); + + mem.unshiftedScalar = mem.posInvertedDenominator + (tp.shplonkNu * mem.negInvertedDenominator); + mem.shiftedScalar = + tp.geminiR.invert() * (mem.posInvertedDenominator - (tp.shplonkNu * mem.negInvertedDenominator)); + + scalars[0] = Fr.wrap(1); + commitments[0] = proof.shplonkQ; + + /* Batch multivariate opening claims, shifted and unshifted + * The vector of scalars is populated as follows: + * \f[ + * \left( + * - \left(\frac{1}{z-r} + \nu \times \frac{1}{z+r}\right), + * \ldots, + * - \rho^{i+k-1} \times \left(\frac{1}{z-r} + \nu \times \frac{1}{z+r}\right), + * - \rho^{i+k} \times \frac{1}{r} \times \left(\frac{1}{z-r} - \nu \times \frac{1}{z+r}\right), + * \ldots, + * - \rho^{k+m-1} \times \frac{1}{r} \times \left(\frac{1}{z-r} - \nu \times \frac{1}{z+r}\right) + * \right) + * \f] + * + * The following vector is concatenated to the vector of commitments: + * \f[ + * f_0, \ldots, f_{m-1}, f_{\text{shift}, 0}, \ldots, f_{\text{shift}, k-1} + * \f] + * + * Simultaneously, the evaluation of the multilinear polynomial + * \f[ + * \sum \rho^i \cdot f_i + \sum \rho^{i+k} \cdot f_{\text{shift}, i} + * \f] + * at the challenge point \f$ (u_0,\ldots, u_{n-1}) \f$ is computed. + * + * This approach minimizes the number of iterations over the commitments to multilinear polynomials + * and eliminates the need to store the powers of \f$ \rho \f$. + */ + // For ZK flavors: evaluations array is [gemini_masking_poly, qm, qc, ql, qr, ...] + // Start batching challenge at 1, not rho, to match non-ZK pattern + mem.batchingChallenge = Fr.wrap(1); + mem.batchedEvaluation = Fr.wrap(0); + + mem.unshiftedScalarNeg = mem.unshiftedScalar.neg(); + mem.shiftedScalarNeg = mem.shiftedScalar.neg(); + + // Process all NUMBER_UNSHIFTED_ZK evaluations (includes gemini_masking_poly at index 0) + for (uint256 i = 1; i <= NUMBER_UNSHIFTED_ZK; ++i) { + scalars[i] = mem.unshiftedScalarNeg * mem.batchingChallenge; + mem.batchedEvaluation = mem.batchedEvaluation + + (proof.sumcheckEvaluations[i - NUM_MASKING_POLYNOMIALS] * mem.batchingChallenge); + mem.batchingChallenge = mem.batchingChallenge * tp.rho; + } + // g commitments are accumulated at r + // For each of the to be shifted commitments perform the shift in place by + // adding to the unshifted value. + // We do so, as the values are to be used in batchMul later, and as + // `a * c + b * c = (a + b) * c` this will allow us to reduce memory and compute. + // Applied to w1, w2, w3, w4 and zPerm + for (uint256 i = 0; i < NUMBER_TO_BE_SHIFTED; ++i) { + uint256 scalarOff = i + SHIFTED_COMMITMENTS_START; + uint256 evaluationOff = i + NUMBER_UNSHIFTED_ZK; + + scalars[scalarOff] = scalars[scalarOff] + (mem.shiftedScalarNeg * mem.batchingChallenge); + mem.batchedEvaluation = + mem.batchedEvaluation + (proof.sumcheckEvaluations[evaluationOff] * mem.batchingChallenge); + mem.batchingChallenge = mem.batchingChallenge * tp.rho; + } + + commitments[1] = proof.geminiMaskingPoly; + + commitments[2] = vk.qm; + commitments[3] = vk.qc; + commitments[4] = vk.ql; + commitments[5] = vk.qr; + commitments[6] = vk.qo; + commitments[7] = vk.q4; + commitments[8] = vk.qLookup; + commitments[9] = vk.qArith; + commitments[10] = vk.qDeltaRange; + commitments[11] = vk.qElliptic; + commitments[12] = vk.qMemory; + commitments[13] = vk.qNnf; + commitments[14] = vk.qPoseidon2External; + commitments[15] = vk.qPoseidon2Internal; + commitments[16] = vk.s1; + commitments[17] = vk.s2; + commitments[18] = vk.s3; + commitments[19] = vk.s4; + commitments[20] = vk.id1; + commitments[21] = vk.id2; + commitments[22] = vk.id3; + commitments[23] = vk.id4; + commitments[24] = vk.t1; + commitments[25] = vk.t2; + commitments[26] = vk.t3; + commitments[27] = vk.t4; + commitments[28] = vk.lagrangeFirst; + commitments[29] = vk.lagrangeLast; + + // Accumulate proof points + commitments[30] = proof.w1; + commitments[31] = proof.w2; + commitments[32] = proof.w3; + commitments[33] = proof.w4; + commitments[34] = proof.zPerm; + commitments[35] = proof.lookupInverses; + commitments[36] = proof.lookupReadCounts; + commitments[37] = proof.lookupReadTags; + + /* Batch gemini claims from the prover + * place the commitments to gemini aᵢ to the vector of commitments, compute the contributions from + * aᵢ(−r²ⁱ) for i=1, … , n−1 to the constant term accumulator, add corresponding scalars + * + * 1. Moves the vector + * \f[ + * \left( \text{com}(A_1), \text{com}(A_2), \ldots, \text{com}(A_{n-1}) \right) + * \f] + * to the 'commitments' vector. + * + * 2. Computes the scalars: + * \f[ + * \frac{\nu^{2}}{z + r^2}, \frac{\nu^3}{z + r^4}, \ldots, \frac{\nu^{n-1}}{z + r^{2^{n-1}}} + * \f] + * and places them into the 'scalars' vector. + * + * 3. Accumulates the summands of the constant term: + * \f[ + * \sum_{i=2}^{n-1} \frac{\nu^{i} \cdot A_i(-r^{2^i})}{z + r^{2^i}} + * \f] + * and adds them to the 'constant_term_accumulator'. + */ + + // Add contributions from A₀(r) and A₀(-r) to constant_term_accumulator: + // Compute the evaluations Aₗ(r^{2ˡ}) for l = 0, ..., $LOG_N - 1 + Fr[] memory foldPosEvaluations = CommitmentSchemeLib.computeFoldPosEvaluations( + tp.sumCheckUChallenges, + mem.batchedEvaluation, + proof.geminiAEvaluations, + powers_of_evaluation_challenge, + $LOG_N + ); + + mem.constantTermAccumulator = foldPosEvaluations[0] * mem.posInvertedDenominator; + mem.constantTermAccumulator = + mem.constantTermAccumulator + (proof.geminiAEvaluations[0] * tp.shplonkNu * mem.negInvertedDenominator); + + mem.batchingChallenge = tp.shplonkNu.sqr(); + uint256 boundary = NUMBER_UNSHIFTED_ZK + 1; + + // Compute Shplonk constant term contributions from Aₗ(± r^{2ˡ}) for l = 1, ..., m-1; + // Compute scalar multipliers for each fold commitment + for (uint256 i = 0; i < $LOG_N - 1; ++i) { + bool dummy_round = i >= ($LOG_N - 1); + + if (!dummy_round) { + // Update inverted denominators + mem.posInvertedDenominator = (tp.shplonkZ - powers_of_evaluation_challenge[i + 1]).invert(); + mem.negInvertedDenominator = (tp.shplonkZ + powers_of_evaluation_challenge[i + 1]).invert(); + + // Compute the scalar multipliers for Aₗ(± r^{2ˡ}) and [Aₗ] + mem.scalingFactorPos = mem.batchingChallenge * mem.posInvertedDenominator; + mem.scalingFactorNeg = mem.batchingChallenge * tp.shplonkNu * mem.negInvertedDenominator; + scalars[boundary + i] = mem.scalingFactorNeg.neg() + mem.scalingFactorPos.neg(); + + // Accumulate the const term contribution given by + // v^{2l} * Aₗ(r^{2ˡ}) /(z-r^{2^l}) + v^{2l+1} * Aₗ(-r^{2ˡ}) /(z+ r^{2^l}) + Fr accumContribution = mem.scalingFactorNeg * proof.geminiAEvaluations[i + 1]; + accumContribution = accumContribution + mem.scalingFactorPos * foldPosEvaluations[i + 1]; + mem.constantTermAccumulator = mem.constantTermAccumulator + accumContribution; + } + // Update the running power of v + mem.batchingChallenge = mem.batchingChallenge * tp.shplonkNu * tp.shplonkNu; + + commitments[boundary + i] = proof.geminiFoldComms[i]; + } + + boundary += $LOG_N - 1; + + // Finalize the batch opening claim + mem.denominators[0] = Fr.wrap(1).div(tp.shplonkZ - tp.geminiR); + mem.denominators[1] = Fr.wrap(1).div(tp.shplonkZ - SUBGROUP_GENERATOR * tp.geminiR); + mem.denominators[2] = mem.denominators[0]; + mem.denominators[3] = mem.denominators[0]; + + for (uint256 i = 0; i < LIBRA_EVALUATIONS; i++) { + Fr scalingFactor = mem.denominators[i] * mem.batchingChallenge; + mem.batchingScalars[i] = scalingFactor.neg(); + mem.batchingChallenge = mem.batchingChallenge * tp.shplonkNu; + mem.constantTermAccumulator = mem.constantTermAccumulator + scalingFactor * proof.libraPolyEvals[i]; + } + scalars[boundary] = mem.batchingScalars[0]; + scalars[boundary + 1] = mem.batchingScalars[1] + mem.batchingScalars[2]; + scalars[boundary + 2] = mem.batchingScalars[3]; + + for (uint256 i = 0; i < LIBRA_COMMITMENTS; i++) { + commitments[boundary++] = proof.libraCommitments[i]; + } + + commitments[boundary] = Honk.G1Point({x: 1, y: 2}); + scalars[boundary++] = mem.constantTermAccumulator; + + require( + checkEvalsConsistency(proof.libraPolyEvals, tp.geminiR, tp.sumCheckUChallenges, proof.libraEvaluation), + Errors.ConsistencyCheckFailed() + ); + + Honk.G1Point memory quotient_commitment = proof.kzgQuotient; + + commitments[boundary] = quotient_commitment; + scalars[boundary] = tp.shplonkZ; // evaluation challenge + + PairingInputs memory pair; + pair.P_0 = batchMul(commitments, scalars); + pair.P_1 = negateInplace(quotient_commitment); + + // Aggregate pairing points (skip if default/infinity — no recursive verification occurred) + if (!arePairingPointsDefault(proof.pairingPointObject)) { + Fr recursionSeparator = generateRecursionSeparator(proof.pairingPointObject, pair.P_0, pair.P_1); + (Honk.G1Point memory P_0_other, Honk.G1Point memory P_1_other) = + convertPairingPointsToG1(proof.pairingPointObject); + + // Validate the points from the proof are on the curve + rejectPointAtInfinity(P_0_other); + rejectPointAtInfinity(P_1_other); + + // accumulate with aggregate points in proof + pair.P_0 = mulWithSeperator(pair.P_0, P_0_other, recursionSeparator); + pair.P_1 = mulWithSeperator(pair.P_1, P_1_other, recursionSeparator); + } + + return pairing(pair.P_0, pair.P_1); + } + + function checkEvalsConsistency( + Fr[LIBRA_EVALUATIONS] memory libraPolyEvals, + Fr geminiR, + Fr[CONST_PROOF_SIZE_LOG_N] memory uChallenges, + Fr libraEval + ) internal view returns (bool check) { + Fr one = Fr.wrap(1); + Fr vanishingPolyEval = geminiR.pow(SUBGROUP_SIZE) - one; + require(vanishingPolyEval != Fr.wrap(0), Errors.GeminiChallengeInSubgroup()); + + SmallSubgroupIpaIntermediates memory mem; + mem.challengePolyLagrange[0] = one; + for (uint256 round = 0; round < $LOG_N; round++) { + uint256 currIdx = 1 + LIBRA_UNIVARIATES_LENGTH * round; + mem.challengePolyLagrange[currIdx] = one; + for (uint256 idx = currIdx + 1; idx < currIdx + LIBRA_UNIVARIATES_LENGTH; idx++) { + mem.challengePolyLagrange[idx] = mem.challengePolyLagrange[idx - 1] * uChallenges[round]; + } + } + + mem.rootPower = one; + mem.challengePolyEval = Fr.wrap(0); + for (uint256 idx = 0; idx < SUBGROUP_SIZE; idx++) { + mem.denominators[idx] = mem.rootPower * geminiR - one; + mem.denominators[idx] = mem.denominators[idx].invert(); + mem.challengePolyEval = mem.challengePolyEval + mem.challengePolyLagrange[idx] * mem.denominators[idx]; + mem.rootPower = mem.rootPower * SUBGROUP_GENERATOR_INVERSE; + } + + Fr numerator = vanishingPolyEval * Fr.wrap(SUBGROUP_SIZE).invert(); + mem.challengePolyEval = mem.challengePolyEval * numerator; + mem.lagrangeFirst = mem.denominators[0] * numerator; + mem.lagrangeLast = mem.denominators[SUBGROUP_SIZE - 1] * numerator; + + mem.diff = mem.lagrangeFirst * libraPolyEvals[2]; + + mem.diff = mem.diff + (geminiR - SUBGROUP_GENERATOR_INVERSE) + * (libraPolyEvals[1] - libraPolyEvals[2] - libraPolyEvals[0] * mem.challengePolyEval); + mem.diff = mem.diff + mem.lagrangeLast * (libraPolyEvals[2] - libraEval) - vanishingPolyEval * libraPolyEvals[3]; + + check = mem.diff == Fr.wrap(0); + } + + // This implementation is the same as above with different constants + function batchMul(Honk.G1Point[] memory base, Fr[] memory scalars) + internal + view + returns (Honk.G1Point memory result) + { + uint256 limit = $MSMSize; + + // Validate all points are on the curve + for (uint256 i = 0; i < limit; ++i) { + rejectPointAtInfinity(base[i]); + } + + bool success = true; + assembly { + let free := mload(0x40) + + let count := 0x01 + for {} lt(count, add(limit, 1)) { count := add(count, 1) } { + // Get loop offsets + let base_base := add(base, mul(count, 0x20)) + let scalar_base := add(scalars, mul(count, 0x20)) + + mstore(add(free, 0x40), mload(mload(base_base))) + mstore(add(free, 0x60), mload(add(0x20, mload(base_base)))) + // Add scalar + mstore(add(free, 0x80), mload(scalar_base)) + + success := and(success, staticcall(gas(), 7, add(free, 0x40), 0x60, add(free, 0x40), 0x40)) + // accumulator = accumulator + accumulator_2 + success := and(success, staticcall(gas(), 6, free, 0x80, free, 0x40)) + } + + // Return the result + mstore(result, mload(free)) + mstore(add(result, 0x20), mload(add(free, 0x20))) + } + + require(success, Errors.ShpleminiFailed()); + } + + // Calculate proof size based on log_n (matching UltraKeccakZKFlavor formula) + function calculateProofSize(uint256 logN) internal pure returns (uint256) { + // Witness and Libra commitments + uint256 proofLength = NUM_WITNESS_ENTITIES * NUM_ELEMENTS_COMM; // witness commitments + proofLength += NUM_ELEMENTS_COMM * 3; // Libra concat, grand sum, quotient comms + Gemini masking + + // Sumcheck + proofLength += logN * ZK_BATCHED_RELATION_PARTIAL_LENGTH * NUM_ELEMENTS_FR; // sumcheck univariates + proofLength += NUMBER_OF_ENTITIES_ZK * NUM_ELEMENTS_FR; // sumcheck evaluations + + // Libra and Gemini + proofLength += NUM_ELEMENTS_FR * 2; // Libra sum, claimed eval + proofLength += logN * NUM_ELEMENTS_FR; // Gemini a evaluations + proofLength += NUM_LIBRA_EVALUATIONS * NUM_ELEMENTS_FR; // libra evaluations + + // PCS commitments + proofLength += (logN - 1) * NUM_ELEMENTS_COMM; // Gemini Fold commitments + proofLength += NUM_ELEMENTS_COMM * 2; // Shplonk Q and KZG W commitments + + // Pairing points + proofLength += PAIRING_POINTS_SIZE; // pairing inputs carried on public inputs + + return proofLength * 32; + } + + function loadVerificationKey() internal pure virtual returns (Honk.VerificationKey memory); +} + +contract HonkVerifier is BaseZKHonkVerifier(N, LOG_N, VK_HASH, NUMBER_OF_PUBLIC_INPUTS) { + function loadVerificationKey() internal pure override returns (Honk.VerificationKey memory) { + return HonkVerificationKey.loadVerificationKey(); + } +} diff --git a/pocs/civic-participation/resilient-civic-participation/contracts/test/PetitionRegistry.t.sol b/pocs/civic-participation/resilient-civic-participation/contracts/test/PetitionRegistry.t.sol new file mode 100644 index 0000000..83c5925 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/contracts/test/PetitionRegistry.t.sol @@ -0,0 +1,592 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.21; + +import {Test} from "forge-std/Test.sol"; +import {PetitionRegistry} from "../src/PetitionRegistry.sol"; +import {IPetitionRegistry} from "../src/interfaces/IPetitionRegistry.sol"; +import {IVerifier} from "../src/interfaces/IVerifier.sol"; +import {MockBatchVerifier} from "../src/mocks/MockBatchVerifier.sol"; +import {MockResolutionVerifier} from "../src/mocks/MockResolutionVerifier.sol"; +import {MockERC20} from "../src/mocks/MockERC20.sol"; + +/// @title PetitionRegistryTest +/// @notice State-machine and validation tests for `PetitionRegistry`, +/// driven by `MockBatchVerifier` and `MockResolutionVerifier` +/// so the suite focuses on the registry's branching logic +/// rather than the ZK verifier internals. The real-everything +/// end-to-end coverage lives in `tests/golden_path.rs`. +contract PetitionRegistryTest is Test { + PetitionRegistry internal registry; + MockBatchVerifier internal batchVerifier; + MockResolutionVerifier internal resolutionVerifier; + MockERC20 internal token; + + address internal organizer = address(0xc01); + address internal relayer = address(0xa11); + address internal resolver_ = address(0xee1); + address internal governance = address(0x90); + + bytes32 internal constant EMPTY_IMT_ROOT = bytes32(uint256(0xdeadbeef)); + bytes32 internal constant PINNED_SIGNER_VK_HASH = bytes32(uint256(0xabcd)); + uint64 internal constant BATCH_SIZE_MAX = 6; + uint64 internal constant COOLDOWN_BLOCKS = 600; + uint64 internal constant RESOLUTION_DEADLINE_BLOCKS = 100_800; + uint64 internal constant MAX_SIGNING_WINDOW_BLOCKS = 82_800; + + function setUp() public { + batchVerifier = new MockBatchVerifier(); + resolutionVerifier = new MockResolutionVerifier(); + token = new MockERC20("Mock USDC", "mUSDC", 6); + registry = new PetitionRegistry( + PetitionRegistry.InitArgs({ + batchVerifier: IVerifier(address(batchVerifier)), + resolutionVerifier: IVerifier(address(resolutionVerifier)), + bountyToken: address(token), + governance: governance, + alpha: 1, + alphaMin: 1, + alphaMax: 1_000, + srsHash: bytes32(0), + attrCount: 4, + emptyImtRoot: EMPTY_IMT_ROOT, + pinnedSignerVkHash: PINNED_SIGNER_VK_HASH + }) + ); + + token.mint(organizer, 1_000_000_000); + vm.prank(organizer); + token.approve(address(registry), type(uint256).max); + + // KZG point-evaluation precompile (0x0a) is opaque under foundry: + // `_verifyKzgOpening` treats non-empty return bytes as success. + // Mock with empty calldata-match so all 24 openings the + // registry verifies per batch resolve to success. + bytes memory anyCalldata = ""; + bytes memory kzgOk = abi.encode(uint256(4096), uint256(1)); + vm.mockCall(address(0x0a), anyCalldata, kzgOk); + } + + function _classSet(uint16 a, uint16 b) internal pure returns (uint16[] memory cs) { + cs = new uint16[](2); + cs[0] = a; + cs[1] = b; + } + + function _classThresholds(uint64 a, uint64 b) internal pure returns (uint64[] memory ct) { + ct = new uint64[](2); + ct[0] = a; + ct[1] = b; + } + + /// Valid predicate_def with class-binding tuple at index 0: + /// tuples[0] = (claim_index=2, operand=class_tag=826, HASH, EQ), + /// ops = [PUSH_TUPLE(0)]. + /// Length = 1 + 1*35 + 1 + 1*2 = 39 bytes. + function _predicateDef() internal pure returns (bytes memory pd) { + pd = new bytes(39); + pd[0] = 0x01; // tuple_count = 1 + pd[1] = 0x02; // tuples[0].claim_index = 2 + // tuples[0].operand[0..32] = 0x00000...033A (826 BE) + pd[1 + 1 + 30] = 0x03; + pd[1 + 1 + 31] = 0x3a; + pd[1 + 33] = 0x02; // tuples[0].type_tag = HASH + pd[1 + 34] = 0x10; // tuples[0].comparator = EQ + pd[36] = 0x01; // op_count = 1 + pd[37] = 0x20; // ops[0].code = PUSH_TUPLE + pd[38] = 0x00; // ops[0].operand = 0 + } + + function _defaultParams() internal view returns (IPetitionRegistry.PetitionParams memory p) { + p = IPetitionRegistry.PetitionParams({ + rRoot: bytes32(uint256(1)), + predicateDef: _predicateDef(), + salt: bytes32(uint256(3)), + classSet: _classSet(826, 840), + classThresholds: _classThresholds(3, 3), + classIndex: 2, + closeAtBlock: uint64(block.number + 30), + bounty: 1_000_000 + }); + } + + function _register(IPetitionRegistry.PetitionParams memory p) internal returns (bytes32 petitionId) { + vm.prank(organizer); + petitionId = registry.register(p); + } + + function _registerDefault() internal returns (bytes32 petitionId, IPetitionRegistry.PetitionParams memory p) { + p = _defaultParams(); + petitionId = _register(p); + } + + /// Build a `BatchPublicInputs` consistent with a freshly-registered petition. + function _batchPi( + bytes32 petitionId, + IPetitionRegistry.PetitionParams memory p, + bytes32 newRunningRoot, + bytes32 newIdtagRoot, + bytes32 versionedHash + ) internal view returns (IPetitionRegistry.BatchPublicInputs memory pi) { + bytes32[24] memory blsFields; + bytes32 predicateHash = registry.getPetition(petitionId).predicateHash; + uint32 petitionSlot = registry.getPetition(petitionId).slot; + pi = IPetitionRegistry.BatchPublicInputs({ + petitionId: petitionId, + rRoot: p.rRoot, + predicateHash: predicateHash, + classIndex: p.classIndex, + slot: petitionSlot, + batchSize: uint32(BATCH_SIZE_MAX), + priorRunningRoot: EMPTY_IMT_ROOT, + newRunningRoot: newRunningRoot, + priorIdentityTagSetRoot: EMPTY_IMT_ROOT, + newIdentityTagSetRoot: newIdtagRoot, + priorLeafCount: 0, + newLeafCount: BATCH_SIZE_MAX, + batchVersionedHash: versionedHash, + blsFields: blsFields, + signerVkHash: PINNED_SIGNER_VK_HASH + }); + } + + /// Submit one batch, set `blobhash(0)` via cheatcode, return the + /// new `runningRoot` and the versioned hash. KZG precompile mock + /// is installed in `setUp`. + function _publishBatch(bytes32 petitionId, IPetitionRegistry.PetitionParams memory p) + internal + returns (bytes32 newRunningRoot, bytes32 versionedHash) + { + newRunningRoot = bytes32(uint256(0xabc1)); + bytes32 newIdtagRoot = bytes32(uint256(0xabc2)); + versionedHash = bytes32(uint256(0x0142)) | (bytes32(uint256(0x01)) << 248); + + bytes32[] memory blobHashes = new bytes32[](1); + blobHashes[0] = versionedHash; + vm.blobhashes(blobHashes); + + IPetitionRegistry.BatchPublicInputs memory pi = + _batchPi(petitionId, p, newRunningRoot, newIdtagRoot, versionedHash); + + vm.prank(relayer); + registry.publishBatch(pi, hex"00", new bytes(48), new bytes(48 * 24)); + } + + /// Build resolution PI for a registered+batched petition. After CRIT-1 + /// cleanup the struct only carries outcome bits (b, bPerClass); every + /// other public input is sourced from PetitionRecord on-chain. + function _resolutionPi(bool b) internal pure returns (IPetitionRegistry.ResolutionPublicInputs memory pi) { + bool[] memory bpc = new bool[](2); + bpc[0] = b; + bpc[1] = b; + pi = IPetitionRegistry.ResolutionPublicInputs({b: b, bPerClass: bpc}); + } + + function test_Register_Succeeds() public { + IPetitionRegistry.PetitionParams memory p = _defaultParams(); + uint256 organizerPre = token.balanceOf(organizer); + bytes32 petitionId = _register(p); + + assertTrue(petitionId != bytes32(0), "petition id zero"); + assertEq(token.balanceOf(organizer), organizerPre - p.bounty, "organizer not debited"); + assertEq(token.balanceOf(address(registry)), p.bounty, "registry not credited"); + + PetitionRegistry.PetitionRecord memory rec = registry.getPetition(petitionId); + assertEq(uint8(rec.state), uint8(IPetitionRegistry.PetitionState.SigningOpen), "state != SigningOpen"); + assertEq(rec.bounty, p.bounty, "bounty mismatch"); + assertEq(rec.runningRoot, EMPTY_IMT_ROOT, "runningRoot != empty"); + assertEq(rec.identityTagSetRoot, EMPTY_IMT_ROOT, "idtagRoot != empty"); + assertEq(rec.organizer, organizer, "organizer mismatch"); + } + + function test_Register_RevertsOnPastCloseAtBlock() public { + IPetitionRegistry.PetitionParams memory p = _defaultParams(); + p.closeAtBlock = uint64(block.number); + vm.prank(organizer); + vm.expectRevert(PetitionRegistry.InvalidPetition.selector); + registry.register(p); + } + + function test_Register_RevertsOnSigningWindowTooLong() public { + IPetitionRegistry.PetitionParams memory p = _defaultParams(); + p.closeAtBlock = uint64(block.number + MAX_SIGNING_WINDOW_BLOCKS + 1); + vm.prank(organizer); + vm.expectRevert(PetitionRegistry.SigningWindowTooLong.selector); + registry.register(p); + } + + function test_Register_RevertsOnClassIndexOutOfRange() public { + IPetitionRegistry.PetitionParams memory p = _defaultParams(); + p.classIndex = 4; // attrCount = 4 in setUp; valid range is [0, 4) + vm.prank(organizer); + vm.expectRevert(PetitionRegistry.InvalidPetition.selector); + registry.register(p); + } + + function test_Register_RevertsOnEmptyClassSet() public { + IPetitionRegistry.PetitionParams memory p = _defaultParams(); + p.classSet = new uint16[](0); + p.classThresholds = new uint64[](0); + vm.prank(organizer); + vm.expectRevert(PetitionRegistry.ClassSetInvalid.selector); + registry.register(p); + } + + function test_Register_RevertsOnUnsortedClassSet() public { + IPetitionRegistry.PetitionParams memory p = _defaultParams(); + p.classSet = _classSet(840, 826); // descending + vm.prank(organizer); + vm.expectRevert(PetitionRegistry.ClassSetInvalid.selector); + registry.register(p); + } + + function test_Register_RevertsOnClassSetLengthMismatch() public { + IPetitionRegistry.PetitionParams memory p = _defaultParams(); + p.classThresholds = new uint64[](1); + p.classThresholds[0] = 3; + vm.prank(organizer); + vm.expectRevert(PetitionRegistry.ClassSetInvalid.selector); + registry.register(p); + } + + function test_Register_RevertsOnZeroThreshold() public { + IPetitionRegistry.PetitionParams memory p = _defaultParams(); + p.classThresholds = _classThresholds(0, 3); + vm.prank(organizer); + vm.expectRevert(PetitionRegistry.ClassThresholdsInvalid.selector); + registry.register(p); + } + + function test_Register_RevertsOnBountyBelowFloor() public { + IPetitionRegistry.PetitionParams memory p = _defaultParams(); + // floor = alpha(=1) * 10 * sum(3+3=6) * opCount(=1) = 60 + p.bounty = 59; + vm.prank(organizer); + vm.expectRevert(PetitionRegistry.BountyFloor.selector); + registry.register(p); + } + + function test_Register_SlotIncrementsAcrossPetitions() public { + IPetitionRegistry.PetitionParams memory p1 = _defaultParams(); + bytes32 id1 = _register(p1); + // Second registration with different close window so the + // derived petition_id differs. + IPetitionRegistry.PetitionParams memory p2 = _defaultParams(); + p2.closeAtBlock = uint64(block.number + 31); + bytes32 id2 = _register(p2); + assertTrue(id1 != id2, "petition ids collide across slots"); + assertEq(registry.s(), 2, "slot counter not advanced"); + } + + function test_PublishBatch_Succeeds() public { + (bytes32 petitionId, IPetitionRegistry.PetitionParams memory p) = _registerDefault(); + (bytes32 newRoot, bytes32 vh) = _publishBatch(petitionId, p); + + assertEq(registry.getBatchCount(petitionId), 1, "batch count != 1"); + PetitionRegistry.PetitionRecord memory rec = registry.getPetition(petitionId); + assertEq(rec.runningRoot, newRoot, "runningRoot not advanced"); + assertEq(rec.leafCount, BATCH_SIZE_MAX, "leafCount not advanced"); + PetitionRegistry.BatchRecord memory bat = registry.getBatch(petitionId, 0); + assertEq(bat.batchVersionedHash, vh, "batch versioned hash mismatch"); + assertEq(bat.relayer, relayer, "relayer not recorded"); + } + + function test_PublishBatch_RevertsOnBatchSizeOutOfRange() public { + (bytes32 petitionId, IPetitionRegistry.PetitionParams memory p) = _registerDefault(); + bytes32 vh = bytes32(uint256(0x0142)) | (bytes32(uint256(0x01)) << 248); + IPetitionRegistry.BatchPublicInputs memory pi = + _batchPi(petitionId, p, bytes32(uint256(0xabc1)), bytes32(uint256(0xabc2)), vh); + bytes32[] memory blobHashes = new bytes32[](1); + blobHashes[0] = vh; + vm.blobhashes(blobHashes); + + // Upper bound: batchSize > BATCH_SIZE_MAX must revert. + pi.batchSize = uint32(registry.BATCH_SIZE_MAX()) + 1; + vm.prank(relayer); + vm.expectRevert(PetitionRegistry.BatchSizeOutOfRange.selector); + registry.publishBatch(pi, hex"00", new bytes(48), new bytes(48 * 24)); + + // Lower bound: batchSize == 0 must revert. + pi.batchSize = 0; + vm.prank(relayer); + vm.expectRevert(PetitionRegistry.BatchSizeOutOfRange.selector); + registry.publishBatch(pi, hex"00", new bytes(48), new bytes(48 * 24)); + } + + function test_PublishBatch_RevertsOnLeafCountMismatch() public { + (bytes32 petitionId, IPetitionRegistry.PetitionParams memory p) = _registerDefault(); + bytes32 vh = bytes32(uint256(0x0142)) | (bytes32(uint256(0x01)) << 248); + IPetitionRegistry.BatchPublicInputs memory pi = + _batchPi(petitionId, p, bytes32(uint256(0xabc1)), bytes32(uint256(0xabc2)), vh); + pi.newLeafCount = pi.priorLeafCount + pi.batchSize + 1; + bytes32[] memory blobHashes = new bytes32[](1); + blobHashes[0] = vh; + vm.blobhashes(blobHashes); + vm.prank(relayer); + vm.expectRevert(PetitionRegistry.InvalidBatch.selector); + registry.publishBatch(pi, hex"00", new bytes(48), new bytes(48 * 24)); + } + + function test_PublishBatch_RevertsOnPriorRunningRootMismatch() public { + (bytes32 petitionId, IPetitionRegistry.PetitionParams memory p) = _registerDefault(); + bytes32 vh = bytes32(uint256(0x0142)) | (bytes32(uint256(0x01)) << 248); + IPetitionRegistry.BatchPublicInputs memory pi = + _batchPi(petitionId, p, bytes32(uint256(0xabc1)), bytes32(uint256(0xabc2)), vh); + pi.priorRunningRoot = bytes32(uint256(0xfeed)); // != EMPTY_IMT_ROOT + bytes32[] memory blobHashes = new bytes32[](1); + blobHashes[0] = vh; + vm.blobhashes(blobHashes); + vm.prank(relayer); + vm.expectRevert(PetitionRegistry.PriorStateMismatch.selector); + registry.publishBatch(pi, hex"00", new bytes(48), new bytes(48 * 24)); + } + + function test_PublishBatch_RevertsOnBlobHashMismatch() public { + (bytes32 petitionId, IPetitionRegistry.PetitionParams memory p) = _registerDefault(); + bytes32 vhDeclared = bytes32(uint256(0x0142)) | (bytes32(uint256(0x01)) << 248); + bytes32 vhActual = bytes32(uint256(0xbad)); + IPetitionRegistry.BatchPublicInputs memory pi = + _batchPi(petitionId, p, bytes32(uint256(0xabc1)), bytes32(uint256(0xabc2)), vhDeclared); + bytes32[] memory blobHashes = new bytes32[](1); + blobHashes[0] = vhActual; + vm.blobhashes(blobHashes); + vm.prank(relayer); + vm.expectRevert(PetitionRegistry.BlobHashMismatch.selector); + registry.publishBatch(pi, hex"00", new bytes(48), new bytes(48 * 24)); + } + + function test_PublishBatch_RevertsOnProofRejected() public { + (bytes32 petitionId, IPetitionRegistry.PetitionParams memory p) = _registerDefault(); + batchVerifier.setResult(false); + + bytes32 vh = bytes32(uint256(0x0142)) | (bytes32(uint256(0x01)) << 248); + IPetitionRegistry.BatchPublicInputs memory pi = + _batchPi(petitionId, p, bytes32(uint256(0xabc1)), bytes32(uint256(0xabc2)), vh); + bytes32[] memory blobHashes = new bytes32[](1); + blobHashes[0] = vh; + vm.blobhashes(blobHashes); + vm.prank(relayer); + vm.expectRevert(PetitionRegistry.ProofRejected.selector); + registry.publishBatch(pi, hex"00", new bytes(48), new bytes(48 * 24)); + } + + function test_PublishBatch_RevertsOnKzgCommitmentWrongLength() public { + (bytes32 petitionId, IPetitionRegistry.PetitionParams memory p) = _registerDefault(); + bytes32 vh = bytes32(uint256(0x0142)) | (bytes32(uint256(0x01)) << 248); + IPetitionRegistry.BatchPublicInputs memory pi = + _batchPi(petitionId, p, bytes32(uint256(0xabc1)), bytes32(uint256(0xabc2)), vh); + bytes32[] memory blobHashes = new bytes32[](1); + blobHashes[0] = vh; + vm.blobhashes(blobHashes); + vm.prank(relayer); + vm.expectRevert(PetitionRegistry.InvalidBatch.selector); + registry.publishBatch(pi, hex"00", new bytes(47), new bytes(48 * 24)); + } + + function test_PublishBatch_RevertsOnKzgProofsWrongLength() public { + (bytes32 petitionId, IPetitionRegistry.PetitionParams memory p) = _registerDefault(); + bytes32 vh = bytes32(uint256(0x0142)) | (bytes32(uint256(0x01)) << 248); + IPetitionRegistry.BatchPublicInputs memory pi = + _batchPi(petitionId, p, bytes32(uint256(0xabc1)), bytes32(uint256(0xabc2)), vh); + bytes32[] memory blobHashes = new bytes32[](1); + blobHashes[0] = vh; + vm.blobhashes(blobHashes); + vm.prank(relayer); + vm.expectRevert(PetitionRegistry.InvalidBatch.selector); + registry.publishBatch(pi, hex"00", new bytes(48), new bytes(48 * 23)); + } + + function test_PublishBatch_RevertsAfterCooldown() public { + (bytes32 petitionId, IPetitionRegistry.PetitionParams memory p) = _registerDefault(); + vm.roll(p.closeAtBlock); // SigningOpen -> Cooldown + bytes32 vh = bytes32(uint256(0x0142)) | (bytes32(uint256(0x01)) << 248); + IPetitionRegistry.BatchPublicInputs memory pi = + _batchPi(petitionId, p, bytes32(uint256(0xabc1)), bytes32(uint256(0xabc2)), vh); + bytes32[] memory blobHashes = new bytes32[](1); + blobHashes[0] = vh; + vm.blobhashes(blobHashes); + vm.prank(relayer); + vm.expectRevert(PetitionRegistry.InvalidState.selector); + registry.publishBatch(pi, hex"00", new bytes(48), new bytes(48 * 24)); + } + + function test_Resolve_Succeeds() public { + (bytes32 petitionId, IPetitionRegistry.PetitionParams memory p) = _registerDefault(); + _publishBatch(petitionId, p); + vm.roll(p.closeAtBlock + COOLDOWN_BLOCKS + 1); + + IPetitionRegistry.ResolutionPublicInputs memory pi = _resolutionPi(true); + uint256 callerPre = token.balanceOf(resolver_); + + vm.expectEmit(true, false, false, true, address(registry)); + emit IPetitionRegistry.PetitionResolved(petitionId, true, pi.bPerClass); + vm.expectEmit(true, false, false, true, address(registry)); + emit IPetitionRegistry.BountyPaid(petitionId, resolver_, p.bounty); + + vm.prank(resolver_); + registry.resolve(petitionId, pi, hex"01"); + + assertEq(token.balanceOf(resolver_), callerPre + p.bounty, "resolver bounty payout missing"); + PetitionRegistry.PetitionRecord memory rec = registry.getPetition(petitionId); + assertEq(uint8(rec.state), uint8(IPetitionRegistry.PetitionState.Resolved), "state != Resolved"); + assertTrue(rec.b, "rec.b mismatch"); + } + + function test_Resolve_RevertsBeforeDisputeWindow() public { + (bytes32 petitionId, IPetitionRegistry.PetitionParams memory p) = _registerDefault(); + _publishBatch(petitionId, p); + // SigningOpen -> Cooldown but NOT into DisputeWindow. + vm.roll(p.closeAtBlock); + IPetitionRegistry.ResolutionPublicInputs memory pi = _resolutionPi(true); + vm.prank(resolver_); + vm.expectRevert(PetitionRegistry.InvalidState.selector); + registry.resolve(petitionId, pi, hex"01"); + } + + /// CRIT-1 regression: after the cleanup, runningRoot is sourced from + /// rec.* and the caller cannot substitute it. Any staleness in the + /// verifier's view of running_root surfaces from the verifier as + /// ProofRejected. Covered transitively by test_Resolve_RevertsOnProofRejected; + /// no separate runningRoot/leafCount mismatch test is needed. + + function test_Resolve_RevertsOnBPerClassLengthMismatch() public { + (bytes32 petitionId, IPetitionRegistry.PetitionParams memory p) = _registerDefault(); + _publishBatch(petitionId, p); + vm.roll(p.closeAtBlock + COOLDOWN_BLOCKS + 1); + // Registered class_set has length 2; submit a length-3 outcome vector. + bool[] memory bpc = new bool[](3); + bpc[0] = true; + bpc[1] = true; + bpc[2] = true; + IPetitionRegistry.ResolutionPublicInputs memory pi = + IPetitionRegistry.ResolutionPublicInputs({b: true, bPerClass: bpc}); + vm.prank(resolver_); + vm.expectRevert(PetitionRegistry.PriorStateMismatch.selector); + registry.resolve(petitionId, pi, hex"01"); + } + + function test_Resolve_RevertsOnProofRejected() public { + (bytes32 petitionId, IPetitionRegistry.PetitionParams memory p) = _registerDefault(); + _publishBatch(petitionId, p); + vm.roll(p.closeAtBlock + COOLDOWN_BLOCKS + 1); + resolutionVerifier.setResult(false); + IPetitionRegistry.ResolutionPublicInputs memory pi = _resolutionPi(true); + vm.prank(resolver_); + vm.expectRevert(PetitionRegistry.ProofRejected.selector); + registry.resolve(petitionId, pi, hex"01"); + } + + function test_Resolve_RevertsOnSecondResolve() public { + (bytes32 petitionId, IPetitionRegistry.PetitionParams memory p) = _registerDefault(); + _publishBatch(petitionId, p); + vm.roll(p.closeAtBlock + COOLDOWN_BLOCKS + 1); + IPetitionRegistry.ResolutionPublicInputs memory pi = _resolutionPi(true); + vm.prank(resolver_); + registry.resolve(petitionId, pi, hex"01"); + // Second attempt: state is now `Resolved`, not `DisputeWindow`. + vm.prank(resolver_); + vm.expectRevert(PetitionRegistry.InvalidState.selector); + registry.resolve(petitionId, pi, hex"01"); + } + + function test_MarkUnresolved_Succeeds() public { + (bytes32 petitionId, IPetitionRegistry.PetitionParams memory p) = _registerDefault(); + _publishBatch(petitionId, p); + vm.roll(p.closeAtBlock + RESOLUTION_DEADLINE_BLOCKS + 1); + + uint256 organizerPre = token.balanceOf(organizer); + uint256 callerPre = token.balanceOf(resolver_); + // New formula: rebate = min(GAS_ESTIMATE * tx.gasprice, bounty * 1%). + // Force tx.gasprice high enough that the cap fires (= bounty * 1%). + vm.txGasPrice(1_000_000_000); // 1 gwei + uint256 rebateCap = (p.bounty * 100) / 10_000; + uint256 estimatedCost = uint256(100_000) * tx.gasprice; + uint256 rebate = estimatedCost < rebateCap ? estimatedCost : rebateCap; + uint256 refund = p.bounty - rebate; + + vm.expectEmit(true, false, false, false, address(registry)); + emit IPetitionRegistry.PetitionUnresolved(petitionId); + + vm.prank(resolver_); + registry.markUnresolved(petitionId); + + assertEq(token.balanceOf(organizer), organizerPre + refund, "organizer refund missing"); + assertEq(token.balanceOf(resolver_), callerPre + rebate, "caller rebate missing"); + PetitionRegistry.PetitionRecord memory rec = registry.getPetition(petitionId); + assertEq(uint8(rec.state), uint8(IPetitionRegistry.PetitionState.Unresolved), "state != Unresolved"); + assertEq(rec.runningRoot, registry.TOMBSTONE_RUNNING_ROOT(), "runningRoot != tombstone"); + } + + function test_MarkUnresolved_RevertsBeforeDeadline() public { + (bytes32 petitionId, IPetitionRegistry.PetitionParams memory p) = _registerDefault(); + _publishBatch(petitionId, p); + // In DisputeWindow but before the resolution deadline. + vm.roll(p.closeAtBlock + COOLDOWN_BLOCKS + 1); + vm.prank(resolver_); + vm.expectRevert(PetitionRegistry.TooEarly.selector); + registry.markUnresolved(petitionId); + } + + function test_MarkUnresolved_RevertsOutsideDisputeWindow() public { + (bytes32 petitionId, IPetitionRegistry.PetitionParams memory p) = _registerDefault(); + // Still in SigningOpen — never advanced. + vm.prank(resolver_); + vm.expectRevert(PetitionRegistry.InvalidState.selector); + registry.markUnresolved(petitionId); + } + + function test_UpdateAlpha_Succeeds() public { + vm.expectEmit(false, false, false, true, address(registry)); + emit IPetitionRegistry.AlphaUpdated(1, 50); + vm.prank(governance); + registry.updateAlpha(50); + assertEq(registry.alpha(), 50, "alpha not updated"); + } + + function test_UpdateAlpha_RevertsForNonGovernance() public { + vm.prank(organizer); + vm.expectRevert(PetitionRegistry.NotGovernance.selector); + registry.updateAlpha(50); + } + + function test_UpdateAlpha_RevertsBelowMin() public { + vm.prank(governance); + vm.expectRevert(PetitionRegistry.AlphaOutOfBounds.selector); + registry.updateAlpha(0); + } + + function test_UpdateAlpha_RevertsAboveMax() public { + vm.prank(governance); + vm.expectRevert(PetitionRegistry.AlphaOutOfBounds.selector); + registry.updateAlpha(1_001); + } + + function test_StateTransitions_AutoAdvance() public { + // Reverts roll back storage, so the only way to observe a + // state transition is via a successful call. The publishBatch + // happy path proves SigningOpen; the resolve happy path + // proves DisputeWindow. Here we assert the negative + // transitions: any state-touching call past `closeAtBlock` + // rejects publishBatch (state moved past SigningOpen), and + // before `closeAtBlock + COOLDOWN_BLOCKS` rejects resolve + // (state hasn't reached DisputeWindow yet). + (bytes32 petitionId, IPetitionRegistry.PetitionParams memory p) = _registerDefault(); + bytes32 vh = bytes32(uint256(0x0142)) | (bytes32(uint256(0x01)) << 248); + IPetitionRegistry.BatchPublicInputs memory pi = + _batchPi(petitionId, p, bytes32(uint256(0xabc1)), bytes32(uint256(0xabc2)), vh); + bytes32[] memory blobHashes = new bytes32[](1); + blobHashes[0] = vh; + vm.blobhashes(blobHashes); + + // At closeAtBlock: state advances to Cooldown -> InvalidState. + vm.roll(p.closeAtBlock); + vm.prank(relayer); + vm.expectRevert(PetitionRegistry.InvalidState.selector); + registry.publishBatch(pi, hex"00", new bytes(48), new bytes(48 * 24)); + + // Between Cooldown and DisputeWindow: resolve also rejects. + IPetitionRegistry.ResolutionPublicInputs memory rpi = _resolutionPi(true); + vm.prank(resolver_); + vm.expectRevert(PetitionRegistry.InvalidState.selector); + registry.resolve(petitionId, rpi, hex"01"); + } +} diff --git a/pocs/civic-participation/resilient-civic-participation/deployments.toml b/pocs/civic-participation/resilient-civic-participation/deployments.toml new file mode 100644 index 0000000..70c4603 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/deployments.toml @@ -0,0 +1,12 @@ +[31337] +endpoint_url = "http://localhost:8545" + +[31337.bool] +use_mock_verifier = "${USE_MOCK_VERIFIER}" + +[31337.address] +governance = "${GOVERNANCE}" +batch_verifier_address = "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512" +resolution_verifier_address = "0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0" +petition_registry_address = "0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9" +bounty_token_address = "0x5FbDB2315678afecb367f032d93F642f64180aa3" diff --git a/pocs/civic-participation/resilient-civic-participation/foundry.toml b/pocs/civic-participation/resilient-civic-participation/foundry.toml new file mode 100644 index 0000000..0668b0c --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/foundry.toml @@ -0,0 +1,29 @@ +[profile.default] +src = "contracts/src" +out = "contracts/out" +libs = ["contracts/dependencies", "dependencies"] +test = "contracts/test" +script = "contracts/script" +cache_path = "contracts/cache" +broadcast = "contracts/broadcast" +gas_limit = 30000000 +remappings = [ + "forge-std/=dependencies/forge-std-1.14.0/src/", + "@openzeppelin-contracts/=dependencies/@openzeppelin-contracts-5.0.0/", + "@poseidon-solidity/=dependencies/poseidon-solidity-0.0.5/", + "poseidon-solidity/=dependencies/poseidon-solidity-0.0.5/", + "@zk-kit/=dependencies/zk-kit-v1/", +] +fs_permissions = [{ access = "read-write", path = "./" }] +optimizer = true +optimizer_runs = 200 + +[soldeer] +remappings_generate = false +remappings_location = "config" + +[dependencies] +forge-std = "1.14.0" +"@openzeppelin-contracts" = "5.0.0" +poseidon-solidity = "0.0.5" +zk-kit = { version = "v1", git = "https://github.com/zk-kit/zk-kit.solidity.git", rev = "a171c845ec7fdc50cdd1fe96c14c27d707cdfbed" } diff --git a/pocs/civic-participation/resilient-civic-participation/rust-toolchain.toml b/pocs/civic-participation/resilient-civic-participation/rust-toolchain.toml new file mode 100644 index 0000000..69c009f --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.90" +components = ["rustfmt", "clippy"] +profile = "default" diff --git a/pocs/civic-participation/resilient-civic-participation/scripts/generate-verifiers.sh b/pocs/civic-participation/resilient-civic-participation/scripts/generate-verifiers.sh new file mode 100755 index 0000000..e3fc2cb --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/scripts/generate-verifiers.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Generate Solidity verifiers from Noir circuits. +# Requires: nargo, bb (barretenberg CLI) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +CIRCUITS_DIR="$PROJECT_ROOT/circuits" +VERIFIERS_DIR="$PROJECT_ROOT/contracts/src/verifiers" + +mkdir -p "$VERIFIERS_DIR" + +echo "=== Generating Solidity Verifiers ===" +echo "Circuits directory: $CIRCUITS_DIR" +echo "Output directory: $VERIFIERS_DIR" +echo "" + +# Inner (signer) circuit: target noir-recursive so the batch +# circuit can recursively verify the signer proofs in-circuit. No +# Solidity verifier is generated for signer since it is never verified +# on chain directly. +echo "Processing signer circuit (inner, recursive target)..." +cd "$CIRCUITS_DIR/signer" +nargo compile +bb write_vk -b "$CIRCUITS_DIR/target/rcp_signer.json" -o ./target -t noir-recursive +echo " signer VK (recursive) written to circuits/signer/target/vk" +cd "$PROJECT_ROOT" +echo "" + +# Outer circuits: target evm (Keccak oracle), to generate Solidity +# verifier contracts deployed by Deploy.s.sol. +for circuit in "batch" "resolution"; do + CIRCUIT_DIR="$CIRCUITS_DIR/$circuit" + PACKAGE_NAME="rcp_${circuit}" + echo "Processing $circuit circuit (outer, EVM target)..." + cd "$CIRCUIT_DIR" + nargo compile + bb write_vk -b "$CIRCUITS_DIR/target/${PACKAGE_NAME}.json" -o ./target -t evm + + CONTRACT_NAME="$(echo "${circuit:0:1}" | tr '[:lower:]' '[:upper:]')${circuit:1}Verifier" + OUTPUT_FILE="$VERIFIERS_DIR/${CONTRACT_NAME}.sol" + bb write_solidity_verifier -k ./target/vk -o "$OUTPUT_FILE" -t evm + echo " $CONTRACT_NAME -> $OUTPUT_FILE" + cd "$PROJECT_ROOT" + echo "" +done + +echo "=== All verifiers generated successfully ===" +echo "" +echo "Generated files:" +for circuit in "batch" "resolution"; do + CONTRACT_NAME="$(echo "${circuit:0:1}" | tr '[:lower:]' '[:upper:]')${circuit:1}Verifier" + echo " - $VERIFIERS_DIR/${CONTRACT_NAME}.sol" +done diff --git a/pocs/civic-participation/resilient-civic-participation/soldeer.lock b/pocs/civic-participation/resilient-civic-participation/soldeer.lock new file mode 100644 index 0000000..c0ae6e9 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/soldeer.lock @@ -0,0 +1,26 @@ +[[dependencies]] +name = "@openzeppelin-contracts" +version = "5.0.0" +url = "https://soldeer-revisions.s3.amazonaws.com/@openzeppelin-contracts/5_0_0_22-01-2024_13:14:00_contracts.zip" +checksum = "92a7f5b4ed80537a180d2775e2d2840d59ab1fa89442084213fb4fd5f7f55688" +integrity = "9394b509841c15163a23f6dd50bb60c8e720176fc6ae79232cac105ed7d4c1aa" + +[[dependencies]] +name = "forge-std" +version = "1.14.0" +url = "https://soldeer-revisions.s3.amazonaws.com/forge-std/1_14_0_05-01-2026_15:24:39_forge-std-1.14.zip" +checksum = "d10079d1b7aac1a36dd2eeb4c5d042e8ed27b00f4ef89530b7f0a6ecd18cada3" +integrity = "64133846def1b04b1726d2bf1292c19050a0e8cb832b60c6104c19595b9699f8" + +[[dependencies]] +name = "poseidon-solidity" +version = "0.0.5" +url = "https://soldeer-revisions.s3.amazonaws.com/poseidon-solidity/0_0_5_04-07-2024_06:57:44_contracts.zip" +checksum = "c622cf4f348014da6947d3ddeba267317f1ca3701466f92e0be64a7726c3b31c" +integrity = "fecc3410994eebc6edf14e86fd388c31d8095c4abbba4a5772e5287f381df2fd" + +[[dependencies]] +name = "zk-kit" +version = "v1" +git = "https://github.com/zk-kit/zk-kit.solidity.git" +rev = "a171c845ec7fdc50cdd1fe96c14c27d707cdfbed" diff --git a/pocs/civic-participation/resilient-civic-participation/src/adapters/bb_prover.rs b/pocs/civic-participation/resilient-civic-participation/src/adapters/bb_prover.rs new file mode 100644 index 0000000..fc7feac --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/adapters/bb_prover.rs @@ -0,0 +1,786 @@ +//! `BBProver`: shells `nargo` + `bb` for signer (recursive), batch, and resolution proofs. + +use std::{ + path::PathBuf, + process::Command, +}; + +use ark_bn254::Fr; +use ark_ff::PrimeField; +use serde::Serialize; + +use crate::{ + error::ProofError, + ports::proof::{ + BatchPositionWitness, + ProofBackend, + }, + poseidon::fr_from_be_bytes, + types::{ + BatchPublicInputs, + ResolutionPrivateInputs, + ResolutionPublicInputs, + SignerPrivateInputs, + SignerPublicInputs, + }, +}; + +use crate::{ + BATCH_SIZE_MAX as BATCH_SIZE, + RESOLUTION_CLASS_MAX as CLASS_MAX, +}; + +const RECURSIVE_PROOF_LENGTH: usize = 458; +const ULTRA_VK_LENGTH_IN_FIELDS: usize = 115; +const N_SIGNER_PUB: usize = 8; +const LEAF_MAX: usize = 200; +const IMT_PATH_LEN: usize = 24; + +fn fr_to_decimal(f: Fr) -> String { + f.into_bigint().to_string() +} + +fn bytes_to_fr_fields(bytes: &[u8]) -> Result, ProofError> { + if !bytes.len().is_multiple_of(32) { + return Err(ProofError::WitnessSerialization(format!( + "input must be a multiple of 32 bytes, got {}", + bytes.len() + ))); + } + Ok(bytes + .chunks_exact(32) + .map(|chunk| fr_to_decimal(fr_from_be_bytes(chunk))) + .collect()) +} + +/// RAII guard that wipes Prover.toml and witness.gz on drop. Ensures private +/// witness material does not persist on the filesystem after a proof attempt +/// completes (success or failure). Does NOT cover SIGKILL / process abort; +/// production signers should hold witness materials in tmpfs / mlock-pinned memory. +struct WitnessCleanup { + prover_toml: std::path::PathBuf, + witness_gz: std::path::PathBuf, +} + +impl Drop for WitnessCleanup { + fn drop(&mut self) { + if let Err(e) = std::fs::remove_file(&self.prover_toml) + && e.kind() != std::io::ErrorKind::NotFound + { + tracing::warn!( + path = %self.prover_toml.display(), + error = %e, + "WitnessCleanup: failed to remove Prover.toml; private witness material may persist" + ); + } + if let Err(e) = std::fs::remove_file(&self.witness_gz) + && e.kind() != std::io::ErrorKind::NotFound + { + tracing::warn!( + path = %self.witness_gz.display(), + error = %e, + "WitnessCleanup: failed to remove witness.gz" + ); + } + } +} + +#[derive(Serialize)] +struct SignerProverInput { + r_root: String, + petition_id: String, + predicate_hash: String, + class_index: String, + class_tag: String, + slot: String, + nullifier: String, + identity_tag: String, + identity_secret: String, + attrs: Vec, + attr_version: String, + chain_root: String, + ri_path_indices: Vec, + ri_path_elements: Vec, + ri_proof_length: String, + s_slot: String, + chain_path_indices: Vec, + chain_path_elements: Vec, + salt: String, + op_codes: Vec, + op_operands: Vec, + op_count: String, + tuple_claim_index: Vec, + tuple_operand: Vec, + tuple_type_tag: Vec, + tuple_comparator: Vec, + tuple_count: String, +} + +#[derive(Serialize)] +struct BatchProverInput { + petition_id: String, + r_root: String, + predicate_hash: String, + class_index: String, + slot: String, + batch_size: String, + prior_running_root: String, + new_running_root: String, + prior_identity_tag_set_root: String, + new_identity_tag_set_root: String, + prior_leaf_count: String, + new_leaf_count: String, + batch_versioned_hash: String, + bls_fields: Vec, + signer_vk: Vec, + signer_vk_hash: String, + signer_proofs: Vec>, + signer_public_inputs: Vec>, + running_imt_low_leaf_indices: Vec, + running_imt_low_indices: Vec>, + running_imt_low_elements: Vec>, + running_imt_low_values: Vec, + running_imt_low_next_indices: Vec, + running_imt_low_next_values: Vec, + running_imt_new_indices: Vec>, + running_imt_new_elements: Vec>, + running_imt_new_low_next_indices: Vec, + running_imt_intermediate_roots: Vec, + idtag_imt_low_leaf_indices: Vec, + idtag_imt_low_indices: Vec>, + idtag_imt_low_elements: Vec>, + idtag_imt_low_values: Vec, + idtag_imt_low_next_indices: Vec, + idtag_imt_low_next_values: Vec, + idtag_imt_new_indices: Vec>, + idtag_imt_new_elements: Vec>, + idtag_imt_new_low_next_indices: Vec, + idtag_imt_intermediate_roots: Vec, +} + +#[derive(Serialize)] +struct ResolutionProverInput { + predicate_hash: String, + r_root: String, + running_root: String, + leaf_count: String, + class_set: Vec, + class_set_len: String, + class_thresholds: Vec, + b: String, + b_per_class: Vec, + class_index: String, + nullifier: Vec, + class_tag: Vec, + imt_next_index: Vec, + imt_next_value: Vec, + imt_path_indices: Vec>, + imt_path_elements: Vec>, +} + +pub struct BBProver { + project_root: PathBuf, +} + +impl BBProver { + pub fn new(project_root: PathBuf) -> Self { + Self { project_root } + } + + fn circuit_dir(&self, circuit_name: &str) -> PathBuf { + self.project_root.join("circuits").join(circuit_name) + } + + fn circuit_json(&self, package_name: &str) -> PathBuf { + self.project_root + .join("circuits") + .join("target") + .join(format!("{package_name}.json")) + } + + fn shell_out( + &self, + circuit_name: &str, + prover_toml_content: &str, + verifier_target: &str, + ) -> Result, ProofError> { + let dir = self.circuit_dir(circuit_name); + let prover_toml = dir.join("Prover.toml"); + std::fs::write(&prover_toml, prover_toml_content) + .map_err(|e| ProofError::Generation(format!("write Prover.toml: {e}")))?; + + // RAII cleanup of witness materials: even on panic / proof + // failure / process unwinding, `Drop` runs and removes + // `Prover.toml` (containing s_slot + attr_vector + RI/chain + // paths) and the bb witness file. The + // guard does not survive SIGKILL or process abort; a + // production-grade signer should additionally hold these + // materials in tmpfs or mlock-pinned memory. + let pkg = format!("rcp_{circuit_name}"); + let bytecode = self.circuit_json(&pkg); + let workspace_target = self.project_root.join("circuits").join("target"); + let witness = workspace_target.join("witness.gz"); + let target_dir = dir.join("target"); + std::fs::create_dir_all(&target_dir) + .map_err(|e| ProofError::Generation(format!("mkdir target: {e}")))?; + + let _cleanup = WitnessCleanup { + prover_toml: prover_toml.clone(), + witness_gz: witness.clone(), + }; + + let exec = Command::new("nargo") + .args(["execute", "witness"]) + .current_dir(&dir) + .output() + .map_err(|e| ProofError::Generation(format!("spawn nargo: {e}")))?; + if !exec.status.success() { + return Err(ProofError::Generation(format!( + "nargo execute ({circuit_name}) failed: {}", + String::from_utf8_lossy(&exec.stderr) + ))); + } + + let prove = Command::new("bb") + .args([ + "prove", + "-b", + bytecode.to_str().unwrap(), + "-w", + witness.to_str().unwrap(), + "-k", + target_dir.join("vk").to_str().unwrap(), + "-o", + target_dir.to_str().unwrap(), + "-t", + verifier_target, + ]) + .output() + .map_err(|e| ProofError::Generation(format!("spawn bb prove: {e}")))?; + if !prove.status.success() { + return Err(ProofError::Generation(format!( + "bb prove ({circuit_name}) failed: {}", + String::from_utf8_lossy(&prove.stderr) + ))); + } + + std::fs::read(target_dir.join("proof")) + .map_err(|e| ProofError::Generation(format!("read proof: {e}"))) + } + + /// Read `circuits/signer/target/{vk, vk_hash}`; returns `(vk_fields, vk_hash_decimal)`. + fn read_signer_vk(&self) -> Result<(Vec, String), ProofError> { + let vk_path = self.circuit_dir("signer").join("target").join("vk"); + let vk_hash_path = self.circuit_dir("signer").join("target").join("vk_hash"); + let vk_bytes = std::fs::read(&vk_path) + .map_err(|e| ProofError::Generation(format!("read signer vk: {e}")))?; + if vk_bytes.len() != ULTRA_VK_LENGTH_IN_FIELDS * 32 { + return Err(ProofError::Generation(format!( + "signer vk has {} bytes; expected {}", + vk_bytes.len(), + ULTRA_VK_LENGTH_IN_FIELDS * 32 + ))); + } + let vk_fields = bytes_to_fr_fields(&vk_bytes)?; + let vk_hash_bytes = std::fs::read(&vk_hash_path) + .map_err(|e| ProofError::Generation(format!("read signer vk_hash: {e}")))?; + if vk_hash_bytes.len() != 32 { + return Err(ProofError::Generation(format!( + "signer vk_hash has {} bytes; expected 32", + vk_hash_bytes.len() + ))); + } + let vk_hash_decimal = fr_to_decimal(fr_from_be_bytes(&vk_hash_bytes)); + Ok((vk_fields, vk_hash_decimal)) + } + + /// Ensure signer VK + vk_hash exist (one-time `bb write_vk -t noir-recursive`). + pub fn ensure_signer_vk(&self) -> Result<(), ProofError> { + let dir = self.circuit_dir("signer"); + std::fs::create_dir_all(dir.join("target")) + .map_err(|e| ProofError::Generation(format!("mkdir signer target: {e}")))?; + let out = Command::new("bb") + .args([ + "write_vk", + "-b", + self.circuit_json("rcp_signer").to_str().unwrap(), + "-o", + dir.join("target").to_str().unwrap(), + "-t", + "noir-recursive", + ]) + .output() + .map_err(|e| ProofError::Generation(format!("spawn bb write_vk: {e}")))?; + if !out.status.success() { + return Err(ProofError::Generation(format!( + "bb write_vk (signer) failed: {}", + String::from_utf8_lossy(&out.stderr) + ))); + } + Ok(()) + } +} + +fn pad_bool_to(arr: &[u8], n: usize) -> Result, ProofError> { + if arr.len() > n { + return Err(ProofError::WitnessSerialization(format!( + "pad_bool_to: input length {} exceeds cap {n}", + arr.len() + ))); + } + let mut out: Vec = arr.iter().map(|b| *b != 0).collect(); + out.resize(n, false); + Ok(out) +} + +fn pad_fr_to(arr: &[Fr], n: usize) -> Result, ProofError> { + if arr.len() > n { + return Err(ProofError::WitnessSerialization(format!( + "pad_fr_to: input length {} exceeds cap {n}", + arr.len() + ))); + } + let mut out: Vec = arr.iter().map(|f| fr_to_decimal(*f)).collect(); + out.resize(n, "0".to_string()); + Ok(out) +} + +fn fr_array_to_decimals(arr: &[Fr]) -> Vec { + arr.iter().map(|f| fr_to_decimal(*f)).collect() +} + +fn imt_path_indices_bool( + p: &crate::ports::imt::ImtPath, +) -> Result, ProofError> { + pad_bool_to(&p.indices, IMT_PATH_LEN) +} + +fn imt_path_elements(p: &crate::ports::imt::ImtPath) -> Result, ProofError> { + if p.siblings.len() > IMT_PATH_LEN { + return Err(ProofError::WitnessSerialization(format!( + "imt_path_elements: {} siblings exceeds depth {IMT_PATH_LEN}", + p.siblings.len() + ))); + } + let mut out: Vec = p + .siblings + .iter() + .map(|s| fr_to_decimal(fr_from_be_bytes(s))) + .collect(); + out.resize(IMT_PATH_LEN, "0".to_string()); + Ok(out) +} + +fn signer_to_input( + public: &SignerPublicInputs, + private: &SignerPrivateInputs, +) -> Result { + let pdef = &private.predicate_def; + if pdef.ops.len() > 20 || pdef.tuples.len() > 20 { + return Err(ProofError::WitnessSerialization(format!( + "predicate ops {} or tuples {} exceeds 20", + pdef.ops.len(), + pdef.tuples.len() + ))); + } + let mut op_codes: Vec = pdef + .ops + .iter() + .map(|o| (o.code as u8).to_string()) + .collect(); + let mut op_operands: Vec = + pdef.ops.iter().map(|o| o.operand.to_string()).collect(); + while op_codes.len() < 20 { + op_codes.push("255".to_string()); // NOP + op_operands.push("0".to_string()); + } + let mut tuple_claim_index: Vec = pdef + .tuples + .iter() + .map(|t| t.claim_index.to_string()) + .collect(); + let mut tuple_operand: Vec = pdef + .tuples + .iter() + .map(|t| fr_to_decimal(fr_from_be_bytes(&t.operand))) + .collect(); + let mut tuple_type_tag: Vec = pdef + .tuples + .iter() + .map(|t| (t.type_tag as u8).to_string()) + .collect(); + let mut tuple_comparator: Vec = pdef + .tuples + .iter() + .map(|t| (t.comparator as u8).to_string()) + .collect(); + while tuple_claim_index.len() < 20 { + tuple_claim_index.push("0".to_string()); + tuple_operand.push("0".to_string()); + tuple_type_tag.push("1".to_string()); // INT64 sentinel + tuple_comparator.push("16".to_string()); // == sentinel + } + + let attrs: Vec = private + .attr_vector + .iter() + .take(4) + .map(|a| fr_to_decimal(*a)) + .collect::>(); + let mut attrs = attrs; + while attrs.len() < 4 { + attrs.push("0".to_string()); + } + + let ri_proof_length = private.ri_path_siblings.len() as u32; + let ri_path_indices = pad_bool_to(&private.ri_path_indices, 32)?; + let ri_path_elements = pad_fr_to(&private.ri_path_siblings, 32)?; + + let chain_path_indices = pad_bool_to(&private.chain_path_indices, IMT_PATH_LEN)?; + let chain_path_elements = pad_fr_to(&private.chain_path_siblings, IMT_PATH_LEN)?; + + Ok(SignerProverInput { + r_root: fr_to_decimal(public.r_root), + petition_id: fr_to_decimal(public.petition_id), + predicate_hash: fr_to_decimal(public.predicate_hash), + class_index: fr_to_decimal(public.class_index), + class_tag: fr_to_decimal(public.class_tag), + slot: fr_to_decimal(public.slot), + nullifier: fr_to_decimal(public.nullifier), + identity_tag: fr_to_decimal(public.identity_tag), + identity_secret: fr_to_decimal(private.identity_secret), + attrs, + attr_version: private.attr_version.to_string(), + chain_root: fr_to_decimal(private.chain_root), + ri_path_indices, + ri_path_elements, + ri_proof_length: ri_proof_length.to_string(), + s_slot: fr_to_decimal(private.s_slot), + chain_path_indices, + chain_path_elements, + salt: fr_to_decimal(private.salt), + op_codes, + op_operands, + op_count: pdef.ops.len().to_string(), + tuple_claim_index, + tuple_operand, + tuple_type_tag, + tuple_comparator, + tuple_count: pdef.tuples.len().to_string(), + }) +} + +impl ProofBackend for BBProver { + fn generate_signer_proof( + &self, + public: &SignerPublicInputs, + private: &SignerPrivateInputs, + ) -> Result, ProofError> { + let input = signer_to_input(public, private)?; + let toml = toml::to_string(&input) + .map_err(|e| ProofError::WitnessSerialization(format!("signer toml: {e}")))?; + self.shell_out("signer", &toml, "noir-recursive") + } + + fn generate_batch_proof( + &self, + public: &BatchPublicInputs, + positions: &[BatchPositionWitness], + ) -> Result, ProofError> { + if positions.len() != BATCH_SIZE { + return Err(ProofError::WitnessSerialization(format!( + "BBProver: batch must contain exactly {BATCH_SIZE} positions (PoC cap); got {}", + positions.len() + ))); + } + let (signer_vk, signer_vk_hash) = self.read_signer_vk()?; + + let mut signer_proofs: Vec> = Vec::with_capacity(BATCH_SIZE); + let mut signer_public_inputs: Vec> = Vec::with_capacity(BATCH_SIZE); + let mut running_low_leaf_indices: Vec = Vec::with_capacity(BATCH_SIZE); + let mut running_low_indices = Vec::with_capacity(BATCH_SIZE); + let mut running_low_elements = Vec::with_capacity(BATCH_SIZE); + let mut running_low_values = Vec::with_capacity(BATCH_SIZE); + let mut running_low_next_indices = Vec::with_capacity(BATCH_SIZE); + let mut running_low_next_values = Vec::with_capacity(BATCH_SIZE); + let mut running_new_indices = Vec::with_capacity(BATCH_SIZE); + let mut running_new_elements = Vec::with_capacity(BATCH_SIZE); + let mut running_new_low_next_indices = Vec::with_capacity(BATCH_SIZE); + let mut running_intermediate_roots = Vec::with_capacity(BATCH_SIZE); + let mut idtag_low_leaf_indices: Vec = Vec::with_capacity(BATCH_SIZE); + let mut idtag_low_indices = Vec::with_capacity(BATCH_SIZE); + let mut idtag_low_elements = Vec::with_capacity(BATCH_SIZE); + let mut idtag_low_values = Vec::with_capacity(BATCH_SIZE); + let mut idtag_low_next_indices = Vec::with_capacity(BATCH_SIZE); + let mut idtag_low_next_values = Vec::with_capacity(BATCH_SIZE); + let mut idtag_new_indices = Vec::with_capacity(BATCH_SIZE); + let mut idtag_new_elements = Vec::with_capacity(BATCH_SIZE); + let mut idtag_new_low_next_indices = Vec::with_capacity(BATCH_SIZE); + let mut idtag_intermediate_roots = Vec::with_capacity(BATCH_SIZE); + + for (i, p) in positions.iter().enumerate() { + if p.submission.proof_bytes.len() != RECURSIVE_PROOF_LENGTH * 32 { + return Err(ProofError::WitnessSerialization(format!( + "position {i}: signer proof has {} bytes; expected {}", + p.submission.proof_bytes.len(), + RECURSIVE_PROOF_LENGTH * 32 + ))); + } + signer_proofs.push(bytes_to_fr_fields(&p.submission.proof_bytes)?); + + let pi = &p.public_inputs; + let pi_fields: [Fr; N_SIGNER_PUB] = [ + pi.r_root, + pi.petition_id, + pi.predicate_hash, + pi.class_index, + pi.class_tag, + pi.slot, + pi.nullifier, + pi.identity_tag, + ]; + signer_public_inputs + .push(pi_fields.iter().map(|f| fr_to_decimal(*f)).collect()); + + let running = p.running_insert.as_ref().ok_or_else(|| { + ProofError::WitnessSerialization(format!( + "position {i}: BBProver requires running_insert (IMT witness)" + )) + })?; + let idtag = p.idtag_insert.as_ref().ok_or_else(|| { + ProofError::WitnessSerialization(format!( + "position {i}: BBProver requires idtag_insert (IMT witness)" + )) + })?; + + running_low_leaf_indices.push(running.low_leaf_index.to_string()); + running_low_indices.push(imt_path_indices_bool(&running.low_leaf_path)?); + running_low_elements.push(imt_path_elements(&running.low_leaf_path)?); + running_low_values.push(fr_to_decimal(fr_from_be_bytes( + &running.low_leaf_before.value, + ))); + running_low_next_indices.push(running.low_leaf_before.next_index.to_string()); + running_low_next_values.push(fr_to_decimal(fr_from_be_bytes( + &running.low_leaf_before.next_value, + ))); + running_new_indices.push(imt_path_indices_bool(&running.new_leaf_path)?); + running_new_elements.push(imt_path_elements(&running.new_leaf_path)?); + // new_low_next_index is the UPDATED low leaf's next-pointer, which + // is the absolute index of the new leaf. + running_new_low_next_indices.push(running.new_leaf_index.to_string()); + running_intermediate_roots + .push(fr_to_decimal(fr_from_be_bytes(&running.new_root))); + + idtag_low_leaf_indices.push(idtag.low_leaf_index.to_string()); + idtag_low_indices.push(imt_path_indices_bool(&idtag.low_leaf_path)?); + idtag_low_elements.push(imt_path_elements(&idtag.low_leaf_path)?); + idtag_low_values.push(fr_to_decimal(fr_from_be_bytes( + &idtag.low_leaf_before.value, + ))); + idtag_low_next_indices.push(idtag.low_leaf_before.next_index.to_string()); + idtag_low_next_values.push(fr_to_decimal(fr_from_be_bytes( + &idtag.low_leaf_before.next_value, + ))); + idtag_new_indices.push(imt_path_indices_bool(&idtag.new_leaf_path)?); + idtag_new_elements.push(imt_path_elements(&idtag.new_leaf_path)?); + idtag_new_low_next_indices.push(idtag.new_leaf_index.to_string()); + idtag_intermediate_roots + .push(fr_to_decimal(fr_from_be_bytes(&idtag.new_root))); + } + + let input = BatchProverInput { + petition_id: fr_to_decimal(public.petition_id), + r_root: fr_to_decimal(public.r_root), + predicate_hash: fr_to_decimal(public.predicate_hash), + class_index: fr_to_decimal(public.class_index), + slot: fr_to_decimal(public.slot), + batch_size: fr_to_decimal(public.batch_size), + prior_running_root: fr_to_decimal(public.prior_running_root), + new_running_root: fr_to_decimal(public.new_running_root), + prior_identity_tag_set_root: fr_to_decimal( + public.prior_identity_tag_set_root, + ), + new_identity_tag_set_root: fr_to_decimal(public.new_identity_tag_set_root), + prior_leaf_count: fr_to_decimal(public.prior_leaf_count), + new_leaf_count: fr_to_decimal(public.new_leaf_count), + batch_versioned_hash: fr_to_decimal(public.batch_versioned_hash), + bls_fields: public + .bls_fields + .iter() + .map(|f| fr_to_decimal(*f)) + .collect(), + signer_vk, + signer_vk_hash, + signer_proofs, + signer_public_inputs, + running_imt_low_leaf_indices: running_low_leaf_indices, + running_imt_low_indices: running_low_indices, + running_imt_low_elements: running_low_elements, + running_imt_low_values: running_low_values, + running_imt_low_next_indices: running_low_next_indices, + running_imt_low_next_values: running_low_next_values, + running_imt_new_indices: running_new_indices, + running_imt_new_elements: running_new_elements, + running_imt_new_low_next_indices: running_new_low_next_indices, + running_imt_intermediate_roots: running_intermediate_roots, + idtag_imt_low_leaf_indices: idtag_low_leaf_indices, + idtag_imt_low_indices: idtag_low_indices, + idtag_imt_low_elements: idtag_low_elements, + idtag_imt_low_values: idtag_low_values, + idtag_imt_low_next_indices: idtag_low_next_indices, + idtag_imt_low_next_values: idtag_low_next_values, + idtag_imt_new_indices: idtag_new_indices, + idtag_imt_new_elements: idtag_new_elements, + idtag_imt_new_low_next_indices: idtag_new_low_next_indices, + idtag_imt_intermediate_roots: idtag_intermediate_roots, + }; + let toml = toml::to_string(&input) + .map_err(|e| ProofError::WitnessSerialization(format!("batch toml: {e}")))?; + self.shell_out("batch", &toml, "evm") + } + + fn generate_resolution_proof( + &self, + public: &ResolutionPublicInputs, + private: &ResolutionPrivateInputs, + ) -> Result, ProofError> { + if private.witness_pairs.len() != private.leaves.len() { + return Err(ProofError::WitnessSerialization( + "resolution: witness_pairs/leaves length mismatch".into(), + )); + } + if private.imt_membership_paths.len() != private.leaves.len() { + return Err(ProofError::WitnessSerialization( + "resolution: imt_membership_paths/leaves length mismatch".into(), + )); + } + if private.leaves.len() > LEAF_MAX { + return Err(ProofError::WitnessSerialization(format!( + "resolution: leaves {} exceeds LEAF_MAX {LEAF_MAX}", + private.leaves.len() + ))); + } + if public.class_set.len() > CLASS_MAX { + return Err(ProofError::WitnessSerialization(format!( + "resolution: class_set {} exceeds CLASS_MAX {CLASS_MAX}", + public.class_set.len() + ))); + } + + let mut nullifier = Vec::with_capacity(LEAF_MAX); + let mut class_tag = Vec::with_capacity(LEAF_MAX); + let mut imt_next_index = Vec::with_capacity(LEAF_MAX); + let mut imt_next_value = Vec::with_capacity(LEAF_MAX); + let mut imt_path_indices = Vec::with_capacity(LEAF_MAX); + let mut imt_path_elements = Vec::with_capacity(LEAF_MAX); + for (i, (n, c)) in private.witness_pairs.iter().enumerate() { + nullifier.push(fr_to_decimal(*n)); + class_tag.push(fr_to_decimal(*c)); + let path = &private.imt_membership_paths[i]; + imt_next_index.push(path.next_index.to_string()); + imt_next_value.push(fr_to_decimal(path.next_value)); + let mut idxs: Vec = path.indices.iter().map(|b| *b != 0).collect(); + idxs.resize(IMT_PATH_LEN, false); + let mut els: Vec = + path.siblings.iter().map(|s| fr_to_decimal(*s)).collect(); + els.resize(IMT_PATH_LEN, "0".to_string()); + imt_path_indices.push(idxs); + imt_path_elements.push(els); + } + while nullifier.len() < LEAF_MAX { + nullifier.push("0".to_string()); + class_tag.push("0".to_string()); + imt_next_index.push("0".to_string()); + imt_next_value.push("0".to_string()); + imt_path_indices.push(vec![false; IMT_PATH_LEN]); + imt_path_elements.push(vec!["0".to_string(); IMT_PATH_LEN]); + } + + let mut class_set = fr_array_to_decimals(&public.class_set); + while class_set.len() < CLASS_MAX { + class_set.push("0".to_string()); + } + let mut class_thresholds = fr_array_to_decimals(&public.class_thresholds); + while class_thresholds.len() < CLASS_MAX { + class_thresholds.push("0".to_string()); + } + let mut b_per_class = fr_array_to_decimals(&public.b_per_class); + while b_per_class.len() < CLASS_MAX { + b_per_class.push("0".to_string()); + } + + let input = ResolutionProverInput { + predicate_hash: fr_to_decimal(public.predicate_hash), + r_root: fr_to_decimal(public.r_root), + running_root: fr_to_decimal(public.running_root), + leaf_count: fr_to_decimal(public.leaf_count), + class_set, + class_set_len: public.class_set.len().to_string(), + class_thresholds, + b: fr_to_decimal(public.b), + b_per_class, + class_index: fr_to_decimal(public.class_index), + nullifier, + class_tag, + imt_next_index, + imt_next_value, + imt_path_indices, + imt_path_elements, + }; + let toml = toml::to_string(&input).map_err(|e| { + ProofError::WitnessSerialization(format!("resolution toml: {e}")) + })?; + self.shell_out("resolution", &toml, "evm") + } + + fn verify_signer_proof( + &self, + _proof: &[u8], + _public: &SignerPublicInputs, + ) -> Result<(), ProofError> { + // Verification happens on chain or inside the batch SNARK; no local re-verify. + Ok(()) + } + + fn verify_batch_proof( + &self, + _proof: &[u8], + _public: &BatchPublicInputs, + ) -> Result<(), ProofError> { + Ok(()) + } + + fn verify_resolution_proof( + &self, + _proof: &[u8], + _public: &ResolutionPublicInputs, + ) -> Result<(), ProofError> { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_fr_to_decimal_smoke() { + assert_eq!(fr_to_decimal(Fr::from(0u64)), "0"); + assert_eq!(fr_to_decimal(Fr::from(42u64)), "42"); + } + + #[test] + fn test_bytes_to_fr_fields_chunks_correctly() { + let mut bytes = vec![0u8; 64]; + bytes[31] = 1; + bytes[63] = 2; + let fields = bytes_to_fr_fields(&bytes).unwrap(); + assert_eq!(fields, vec!["1".to_string(), "2".to_string()]); + } + + #[test] + fn test_bytes_to_fr_fields_rejects_unaligned_input() { + let err = bytes_to_fr_fields(&[0u8; 33]); + assert!(matches!(err, Err(ProofError::WitnessSerialization(_)))); + } +} diff --git a/pocs/civic-participation/resilient-civic-participation/src/adapters/blob_4844.rs b/pocs/civic-participation/resilient-civic-participation/src/adapters/blob_4844.rs new file mode 100644 index 0000000..5ca8f52 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/adapters/blob_4844.rs @@ -0,0 +1,386 @@ +//! Real EIP-4844 blob carrier backed by `c-kzg`. + +use std::{ + collections::HashMap, + sync::{ + Arc, + Mutex, + }, +}; + +use alloy::{ + consensus::BlobTransactionSidecar, + primitives::FixedBytes, +}; +use c_kzg::{ + Blob, + Bytes32 as CKzgBytes32, + Bytes48 as CKzgBytes48, + ethereum_kzg_settings, +}; +use sha2::{ + Digest, + Sha256, +}; + +use crate::{ + RECORDS_PER_BLOB, + error::BlobError, + ports::blob::BlobCarrier, + types::{ + Bytes32, + KzgOpening, + RecordEntry, + }, +}; + +const BYTES_PER_FIELD_ELEMENT: usize = 32; +const FIELD_ELEMENTS_PER_BLOB: usize = 4096; +const BLOB_BYTES: usize = BYTES_PER_FIELD_ELEMENT * FIELD_ELEMENTS_PER_BLOB; +const FE_PER_RECORD: usize = 4; + +/// Encode `records` into the 131_072-byte blob payload (4 field elements per record). +fn encode_blob(records: &[RecordEntry]) -> Result, BlobError> { + if records.len() > RECORDS_PER_BLOB { + return Err(BlobError::Capacity(records.len(), RECORDS_PER_BLOB)); + } + let mut blob = vec![0u8; BLOB_BYTES]; + for (i, rec) in records.iter().enumerate() { + let class_bytes = rec.class_tag.to_be_bytes(); + let mut record_bytes = [0u8; 96]; + record_bytes[..32].copy_from_slice(&rec.nullifier); + record_bytes[32..64].copy_from_slice(&rec.identity_tag); + record_bytes[64..66].copy_from_slice(&class_bytes); + // nullifier || identity_tag || class_tag || padding + + for j in 0..FE_PER_RECORD { + let fe_idx = i * FE_PER_RECORD + j; + let fe_off = fe_idx * BYTES_PER_FIELD_ELEMENT; + // Top byte zero so the value fits in BLS12-381 Fr. + blob[fe_off] = 0; + + let src_lo = 31 * j; + let src_hi = (31 * (j + 1)).min(96); + let content = &record_bytes[src_lo..src_hi]; + blob[fe_off + 1..fe_off + 1 + content.len()].copy_from_slice(content); + } + } + // Heap-allocated so the 128KiB blob doesn't blow the stack. + let mut out = vec![0u8; BLOB_BYTES].into_boxed_slice(); + out.copy_from_slice(&blob); + let arr: Box<[u8; BLOB_BYTES]> = out.try_into().map_err(|_| { + BlobError::Malformed("blob encoding length invariant violated".into()) + })?; + Ok(arr) +} + +fn versioned_hash_from_commitment(commitment: &[u8; 48]) -> Bytes32 { + let mut h = Sha256::new(); + h.update(commitment); + let mut out = [0u8; 32]; + out.copy_from_slice(&h.finalize()); + out[0] = 0x01; + out +} + +/// KZG evaluation point for the `idx`-th blob field element. EIP-4844 stores +/// blob FEs in bit-reversal-permuted evaluation form, so the polynomial value +/// at storage slot `idx` is `P(omega^bit_reverse(idx, 12))`. Passing the raw +/// integer would compute the proof at a different point and the on-chain +/// verifier (which derives `z` from the same bit-reversal table) would reject. +fn z_bytes_for_index(idx: u32) -> Result { + use std::sync::OnceLock; + static EVAL_POINTS: OnceLock<[[u8; 32]; crate::blob::KZG_OPENING_COUNT]> = + OnceLock::new(); + let points = EVAL_POINTS.get_or_init(crate::blob::canonical_eval_points); + let z = points.get(idx as usize).ok_or_else(|| { + BlobError::Malformed(format!( + "fe index {idx} outside KZG opening window of {}", + points.len() + )) + })?; + Ok(CKzgBytes32::new(*z)) +} + +struct BlobEntry { + blob_bytes: Vec, + commitment: [u8; 48], + blob_kzg_proof: [u8; 48], + records: Vec, +} + +#[derive(Clone)] +pub struct EIP4844BlobCarrier { + inner: Arc>>, +} + +impl Default for EIP4844BlobCarrier { + fn default() -> Self { + Self::new() + } +} + +impl EIP4844BlobCarrier { + pub fn new() -> Self { + Self { + inner: Arc::new(Mutex::new(HashMap::new())), + } + } + + /// Build the alloy `BlobTransactionSidecar` for a published `versioned_hash`. + pub fn make_sidecar( + &self, + versioned_hash: &Bytes32, + ) -> Result { + let guard = self.inner.lock().unwrap(); + let entry = guard.get(versioned_hash).ok_or(BlobError::NotFound)?; + let blob = alloy::eips::eip4844::Blob::from_slice(&entry.blob_bytes); + let commitment: FixedBytes<48> = FixedBytes::from(entry.commitment); + let proof: FixedBytes<48> = FixedBytes::from(entry.blob_kzg_proof); + Ok(BlobTransactionSidecar::new( + vec![blob], + vec![commitment], + vec![proof], + )) + } + + /// Versioned hash a (commitment, blob) pair would emit. + pub fn commitment_to_versioned_hash(commitment: &[u8; 48]) -> Bytes32 { + versioned_hash_from_commitment(commitment) + } + + /// Compute (commitment, per-point KZG proofs, y_k values) at `eval_points`. + #[allow(clippy::type_complexity)] + pub fn commitment_and_per_point_proofs( + &self, + versioned_hash: &Bytes32, + eval_points: &[[u8; 32]], + ) -> Result<([u8; 48], Vec, Vec<[u8; 32]>), BlobError> { + let guard = self.inner.lock().unwrap(); + let entry = guard.get(versioned_hash).ok_or(BlobError::NotFound)?; + let blob = Blob::from_bytes(&entry.blob_bytes).map_err(|e| { + BlobError::Malformed(format!("c-kzg Blob::from_bytes: {e:?}")) + })?; + let settings = ethereum_kzg_settings(0); + let mut proofs_concat = Vec::with_capacity(48 * eval_points.len()); + let mut ys = Vec::with_capacity(eval_points.len()); + for z_bytes in eval_points { + let z = CKzgBytes32::new(*z_bytes); + let (proof, y) = settings + .compute_kzg_proof(&blob, &z) + .map_err(|e| BlobError::Malformed(format!("compute_kzg_proof: {e:?}")))?; + let proof_bytes: [u8; 48] = *proof.to_bytes().as_ref(); + let y_bytes: [u8; 32] = *y.as_ref(); + proofs_concat.extend_from_slice(&proof_bytes); + ys.push(y_bytes); + } + Ok((entry.commitment, proofs_concat, ys)) + } +} + +impl BlobCarrier for EIP4844BlobCarrier { + fn publish(&mut self, records: &[RecordEntry]) -> Result { + let blob_arr = encode_blob(records)?; + let blob = Blob::from_bytes(blob_arr.as_ref()).map_err(|e| { + BlobError::Malformed(format!("c-kzg Blob::from_bytes: {e:?}")) + })?; + let settings = ethereum_kzg_settings(0); + let commitment = settings.blob_to_kzg_commitment(&blob).map_err(|e| { + BlobError::Malformed(format!("blob_to_kzg_commitment: {e:?}")) + })?; + let commitment_bytes: [u8; 48] = *commitment.to_bytes().as_ref(); + let blob_kzg_proof = settings + .compute_blob_kzg_proof(&blob, &CKzgBytes48::new(commitment_bytes)) + .map_err(|e| { + BlobError::Malformed(format!("compute_blob_kzg_proof: {e:?}")) + })?; + let blob_kzg_proof_bytes: [u8; 48] = *blob_kzg_proof.to_bytes().as_ref(); + let versioned_hash = versioned_hash_from_commitment(&commitment_bytes); + + let mut guard = self.inner.lock().unwrap(); + guard.insert( + versioned_hash, + BlobEntry { + blob_bytes: blob_arr.to_vec(), + commitment: commitment_bytes, + blob_kzg_proof: blob_kzg_proof_bytes, + records: records.to_vec(), + }, + ); + Ok(versioned_hash) + } + + fn open( + &self, + batch_versioned_hash: &Bytes32, + field_element_index: u32, + ) -> Result { + let guard = self.inner.lock().unwrap(); + let entry = guard.get(batch_versioned_hash).ok_or(BlobError::NotFound)?; + let blob = Blob::from_bytes(&entry.blob_bytes) + .map_err(|e| BlobError::Malformed(format!("Blob::from_bytes: {e:?}")))?; + let z = z_bytes_for_index(field_element_index)?; + let settings = ethereum_kzg_settings(0); + let (proof, y) = settings + .compute_kzg_proof(&blob, &z) + .map_err(|e| BlobError::Malformed(format!("compute_kzg_proof: {e:?}")))?; + let proof_bytes_arr: [u8; 48] = *proof.to_bytes().as_ref(); + let y_bytes: [u8; 32] = *y.as_ref(); + let mut proof_bytes_vec = Vec::with_capacity(48 + 48); + proof_bytes_vec.extend_from_slice(&proof_bytes_arr); + proof_bytes_vec.extend_from_slice(&entry.commitment); + Ok(KzgOpening { + field_element_index, + claimed_value: y_bytes, + proof_bytes: proof_bytes_vec, + }) + } + + fn verify( + &self, + batch_versioned_hash: &Bytes32, + opening: &KzgOpening, + ) -> Result<(), BlobError> { + let guard = self.inner.lock().unwrap(); + let entry = guard.get(batch_versioned_hash).ok_or(BlobError::NotFound)?; + if opening.proof_bytes.len() != 48 + 48 { + return Err(BlobError::InvalidOpening(format!( + "opening proof_bytes is {} bytes; expected 96", + opening.proof_bytes.len() + ))); + } + let mut proof_arr = [0u8; 48]; + proof_arr.copy_from_slice(&opening.proof_bytes[..48]); + let mut commitment_arr = [0u8; 48]; + commitment_arr.copy_from_slice(&opening.proof_bytes[48..]); + if commitment_arr != entry.commitment { + return Err(BlobError::InvalidOpening( + "opening commitment does not match stored blob".into(), + )); + } + let z = z_bytes_for_index(opening.field_element_index)?; + let y = CKzgBytes32::new(opening.claimed_value); + let proof = CKzgBytes48::new(proof_arr); + let commitment = CKzgBytes48::new(commitment_arr); + let settings = ethereum_kzg_settings(0); + let ok = settings + .verify_kzg_proof(&commitment, &z, &y, &proof) + .map_err(|e| BlobError::InvalidOpening(format!("verify_kzg_proof: {e:?}")))?; + if !ok { + return Err(BlobError::InvalidOpening( + "verify_kzg_proof returned false".into(), + )); + } + Ok(()) + } + + fn fetch_records( + &self, + batch_versioned_hash: &Bytes32, + ) -> Result, BlobError> { + let guard = self.inner.lock().unwrap(); + let entry = guard.get(batch_versioned_hash).ok_or(BlobError::NotFound)?; + Ok(entry.records.clone()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mk_record(seed: u8) -> RecordEntry { + let mut nullifier = [0u8; 32]; + let mut identity_tag = [0u8; 32]; + for i in 0..32 { + nullifier[i] = seed.wrapping_add(i as u8); + identity_tag[i] = seed.wrapping_mul(2).wrapping_add(i as u8); + } + RecordEntry { + nullifier, + identity_tag, + class_tag: seed as u16, + } + } + + #[test] + fn test_publish_and_fetch_round_trip() { + let mut carrier = EIP4844BlobCarrier::new(); + let records = vec![mk_record(0), mk_record(1), mk_record(2)]; + let vh = carrier.publish(&records).expect("publish"); + assert_eq!(vh[0], 0x01); + let fetched = carrier.fetch_records(&vh).unwrap(); + assert_eq!(fetched, records); + } + + #[test] + fn test_open_and_verify_round_trips_against_stored_blob() { + let mut carrier = EIP4844BlobCarrier::new(); + let records = vec![mk_record(42)]; + let vh = carrier.publish(&records).expect("publish"); + let opening = carrier.open(&vh, 0).expect("open"); + carrier.verify(&vh, &opening).expect("verify roundtrip"); + } + + #[test] + fn test_make_sidecar_returns_blob_with_correct_commitment() { + let carrier = EIP4844BlobCarrier::new(); + let mut c2 = carrier.clone(); + let vh = c2.publish(&[]).expect("publish empty"); + let sidecar = carrier.make_sidecar(&vh).expect("make_sidecar"); + assert_eq!(sidecar.blobs.len(), 1); + assert_eq!(sidecar.commitments.len(), 1); + assert_eq!(sidecar.proofs.len(), 1); + } + + #[test] + fn test_per_point_openings_round_trip_against_record_to_bls_fields() { + use crate::{ + blob::{ + FE_PER_RECORD, + canonical_eval_points, + record_to_bls_fields, + }, + poseidon::fr_to_be_bytes, + }; + + let mut carrier = EIP4844BlobCarrier::new(); + let records = vec![mk_record(11), mk_record(22), mk_record(33)]; + let vh = carrier.publish(&records).expect("publish"); + let eval_points = canonical_eval_points(); + let (_commitment, _proofs, ys) = carrier + .commitment_and_per_point_proofs(&vh, &eval_points) + .expect("commitment_and_per_point_proofs"); + + for (i, rec) in records.iter().enumerate() { + let expected = record_to_bls_fields(rec); + for j in 0..FE_PER_RECORD { + let y = ys[i * FE_PER_RECORD + j]; + let expected_be = fr_to_be_bytes(&expected[j]); + assert_eq!( + y, expected_be, + "record {i} fe {j}: KZG opening y disagrees with record_to_bls_fields" + ); + } + } + // Padding positions must be zero. + let pad_start = records.len() * FE_PER_RECORD; + for (offset, y) in ys[pad_start..eval_points.len()].iter().enumerate() { + assert_eq!( + *y, + [0u8; 32], + "padding position {} non-zero", + pad_start + offset + ); + } + } + + #[test] + fn test_publish_exceeding_record_cap_errors() { + let mut carrier = EIP4844BlobCarrier::new(); + let too_many: Vec = (0..(RECORDS_PER_BLOB + 1)) + .map(|i| mk_record(i as u8)) + .collect(); + let err = carrier.publish(&too_many); + assert!(matches!(err, Err(BlobError::Capacity(_, _)))); + } +} diff --git a/pocs/civic-participation/resilient-civic-participation/src/adapters/chain_registry.rs b/pocs/civic-participation/resilient-civic-participation/src/adapters/chain_registry.rs new file mode 100644 index 0000000..3f7a9fb --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/adapters/chain_registry.rs @@ -0,0 +1,398 @@ +//! Alloy adapter for the on-chain `PetitionRegistry`. + +#![allow(clippy::too_many_arguments)] // alloy `sol!` generates wide constructors. + +use alloy::{ + consensus::BlobTransactionSidecar, + network::{ + EthereumWallet, + TransactionBuilder4844, + }, + primitives::{ + Address as AlloyAddress, + Bytes, + FixedBytes, + }, + providers::{ + DynProvider, + Provider, + ProviderBuilder, + }, + signers::local::PrivateKeySigner, + sol, +}; + +use crate::types::{ + Address, + BatchPublicInputs, + PetitionId, + ResolutionPublicInputs, +}; + +sol! { + #[derive(Debug)] + #[sol(rpc)] + interface IPetitionRegistry { + struct PetitionParams { + bytes32 rRoot; + bytes predicateDef; + bytes32 salt; + uint16[] classSet; + uint64[] classThresholds; + uint8 classIndex; + uint64 closeAtBlock; + uint256 bounty; + } + + struct BatchPublicInputs { + bytes32 petitionId; + bytes32 rRoot; + bytes32 predicateHash; + uint8 classIndex; + uint32 slot; + uint32 batchSize; + bytes32 priorRunningRoot; + bytes32 newRunningRoot; + bytes32 priorIdentityTagSetRoot; + bytes32 newIdentityTagSetRoot; + uint64 priorLeafCount; + uint64 newLeafCount; + bytes32 batchVersionedHash; + bytes32[24] blsFields; + bytes32 signerVkHash; + } + + struct ResolutionPublicInputs { + bool b; + bool[] bPerClass; + } + + function register( + PetitionParams calldata params + ) external returns (bytes32 petitionId); + + function publishBatch( + BatchPublicInputs calldata pi, + bytes calldata batchProof, + bytes calldata kzgCommitment, + bytes calldata kzgProofs + ) external; + + function resolve( + bytes32 petitionId, + ResolutionPublicInputs calldata pi, + bytes calldata resolutionProof + ) external; + + function markUnresolved(bytes32 petitionId) external; + + function dispute( + bytes32 petitionId, + uint32 batchIndex, + uint32 positionI, + uint32 positionJ, + uint8 violationType, + bytes calldata kzgCommitment, + bytes calldata openingsBlob, + bytes calldata proofsBlob + ) external; + + function getBatchCount(bytes32 petitionId) external view returns (uint256); + + event PetitionRegistered( + bytes32 indexed petitionId, + uint32 slot, + bytes32 rRoot, + bytes32 predicateHash, + uint16[] classSet, + uint64[] classThresholds, + uint8 classIndex, + uint64 closeAtBlock, + uint256 bounty + ); + event BatchPublished( + bytes32 indexed petitionId, + uint32 indexed batchIndex, + bytes32 batchVersionedHash, + bytes32 newRunningRoot, + bytes32 newIdentityTagSetRoot, + uint64 newLeafCount + ); + event PetitionResolved(bytes32 indexed petitionId, bool b, bool[] bPerClass); + event BountyPaid(bytes32 indexed petitionId, address recipient, uint256 amount); + event BatchRepudiated( + bytes32 indexed petitionId, + uint32 indexed batchIndex, + bytes32 newRunningRoot, + bytes32 newIdentityTagSetRoot, + uint64 newLeafCount + ); + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ChainRegistryError { + #[error("alloy rpc: {0}")] + Rpc(String), + #[error("decoding: {0}")] + Decode(String), + #[error("expected event not found: {0}")] + EventNotFound(&'static str), +} + +#[derive(Clone)] +pub struct ChainPetitionRegistry { + pub provider: DynProvider, + pub address: AlloyAddress, +} + +impl ChainPetitionRegistry { + pub fn new(provider: DynProvider, address: AlloyAddress) -> Self { + Self { provider, address } + } + + /// Build a provider from an HTTP endpoint + private key (test convenience). + pub fn provider_from_pk(endpoint: &str, pk_hex: &str) -> DynProvider { + let signer: PrivateKeySigner = pk_hex.parse().expect("parse pk"); + let wallet = EthereumWallet::from(signer); + ProviderBuilder::new() + .with_simple_nonce_management() + .wallet(wallet) + .connect_http(endpoint.parse().expect("parse endpoint")) + .erased() + } + + pub async fn register( + &self, + params: IPetitionRegistry::PetitionParams, + ) -> Result { + let contract = IPetitionRegistry::new(self.address, &self.provider); + let receipt = contract + .register(params) + .send() + .await + .map_err(|e| ChainRegistryError::Rpc(format!("register send: {e}")))? + .get_receipt() + .await + .map_err(|e| ChainRegistryError::Rpc(format!("register receipt: {e}")))?; + for log in receipt.logs() { + if let Ok(ev) = log.log_decode::() { + return Ok(ev.inner.petitionId.0); + } + } + Err(ChainRegistryError::EventNotFound("PetitionRegistered")) + } + + /// Submit the batch as an EIP-4844 blob transaction; sidecar binds to `pi.batch_versioned_hash`. + pub async fn publish_batch( + &self, + pi: BatchPublicInputs, + batch_proof: Vec, + kzg_commitment: Vec, + kzg_proofs: Vec, + sidecar: BlobTransactionSidecar, + ) -> Result<(), ChainRegistryError> { + let contract = IPetitionRegistry::new(self.address, &self.provider); + let pi_sol = batch_public_inputs_to_sol(&pi); + let mut tx = contract + .publishBatch( + pi_sol, + Bytes::from(batch_proof), + Bytes::from(kzg_commitment), + Bytes::from(kzg_proofs), + ) + .into_transaction_request(); + tx = tx.with_blob_sidecar(sidecar); + + let pending = + self.provider.send_transaction(tx).await.map_err(|e| { + ChainRegistryError::Rpc(format!("publishBatch send: {e}")) + })?; + let receipt = pending + .get_receipt() + .await + .map_err(|e| ChainRegistryError::Rpc(format!("publishBatch receipt: {e}")))?; + if !receipt.status() { + return Err(ChainRegistryError::Rpc("publishBatch reverted".into())); + } + Ok(()) + } + + pub async fn resolve( + &self, + petition_id: PetitionId, + pi: ResolutionPublicInputs, + resolution_proof: Vec, + ) -> Result<(), ChainRegistryError> { + let contract = IPetitionRegistry::new(self.address, &self.provider); + let pi_sol = resolution_public_inputs_to_sol(&pi); + let receipt = contract + .resolve( + FixedBytes(petition_id), + pi_sol, + Bytes::from(resolution_proof), + ) + .send() + .await + .map_err(|e| ChainRegistryError::Rpc(format!("resolve send: {e}")))? + .get_receipt() + .await + .map_err(|e| ChainRegistryError::Rpc(format!("resolve receipt: {e}")))?; + if !receipt.status() { + return Err(ChainRegistryError::Rpc("resolve reverted".into())); + } + Ok(()) + } + + /// Submit a dispute against `batch_index`. Returns the `BatchRepudiated` + /// event payload if the dispute is upheld; the contract reverts on + /// failure. `kzg_commitment` is the same 48-byte commitment used to + /// publish the batch; `openings_blob` carries y-values (32 bytes each) + /// and `proofs_blob` carries 48-byte KZG proofs, in the canonical + /// position layout (`positionI`'s 4 FEs, then `positionJ`'s 4 for + /// violation types 0x02 and 0x03). + pub async fn dispute( + &self, + petition_id: PetitionId, + batch_index: u32, + position_i: u32, + position_j: u32, + violation_type: u8, + kzg_commitment: Vec, + openings_blob: Vec, + proofs_blob: Vec, + ) -> Result { + let contract = IPetitionRegistry::new(self.address, &self.provider); + let receipt = contract + .dispute( + FixedBytes(petition_id), + batch_index, + position_i, + position_j, + violation_type, + Bytes::from(kzg_commitment), + Bytes::from(openings_blob), + Bytes::from(proofs_blob), + ) + .send() + .await + .map_err(|e| ChainRegistryError::Rpc(format!("dispute send: {e}")))? + .get_receipt() + .await + .map_err(|e| ChainRegistryError::Rpc(format!("dispute receipt: {e}")))?; + if !receipt.status() { + return Err(ChainRegistryError::Rpc("dispute reverted".into())); + } + for log in receipt.logs() { + if let Ok(ev) = log.log_decode::() { + return Ok(ev.inner.data); + } + } + Err(ChainRegistryError::EventNotFound("BatchRepudiated")) + } + + pub async fn mark_unresolved( + &self, + petition_id: PetitionId, + ) -> Result<(), ChainRegistryError> { + let contract = IPetitionRegistry::new(self.address, &self.provider); + let receipt = contract + .markUnresolved(FixedBytes(petition_id)) + .send() + .await + .map_err(|e| ChainRegistryError::Rpc(format!("markUnresolved send: {e}")))? + .get_receipt() + .await + .map_err(|e| { + ChainRegistryError::Rpc(format!("markUnresolved receipt: {e}")) + })?; + if !receipt.status() { + return Err(ChainRegistryError::Rpc("markUnresolved reverted".into())); + } + Ok(()) + } + + pub async fn get_batch_count( + &self, + petition_id: PetitionId, + ) -> Result { + let contract = IPetitionRegistry::new(self.address, &self.provider); + let n = contract + .getBatchCount(FixedBytes(petition_id)) + .call() + .await + .map_err(|e| ChainRegistryError::Rpc(format!("getBatchCount: {e}")))?; + n.try_into() + .map_err(|_| ChainRegistryError::Decode("getBatchCount > u64::MAX".into())) + } +} + +pub fn batch_public_inputs_to_sol( + pi: &BatchPublicInputs, +) -> IPetitionRegistry::BatchPublicInputs { + use crate::poseidon::fr_to_be_bytes; + let mut bls_fields: [FixedBytes<32>; 24] = [FixedBytes::ZERO; 24]; + for (i, fe) in pi.bls_fields.iter().enumerate().take(24) { + bls_fields[i] = FixedBytes(fr_to_be_bytes(fe)); + } + IPetitionRegistry::BatchPublicInputs { + petitionId: FixedBytes(fr_to_be_bytes(&pi.petition_id)), + rRoot: FixedBytes(fr_to_be_bytes(&pi.r_root)), + predicateHash: FixedBytes(fr_to_be_bytes(&pi.predicate_hash)), + classIndex: fr_to_u8(&pi.class_index), + slot: fr_to_u32(&pi.slot), + batchSize: fr_to_u32(&pi.batch_size), + priorRunningRoot: FixedBytes(fr_to_be_bytes(&pi.prior_running_root)), + newRunningRoot: FixedBytes(fr_to_be_bytes(&pi.new_running_root)), + priorIdentityTagSetRoot: FixedBytes(fr_to_be_bytes( + &pi.prior_identity_tag_set_root, + )), + newIdentityTagSetRoot: FixedBytes(fr_to_be_bytes(&pi.new_identity_tag_set_root)), + priorLeafCount: fr_to_u64(&pi.prior_leaf_count), + newLeafCount: fr_to_u64(&pi.new_leaf_count), + batchVersionedHash: FixedBytes(fr_to_be_bytes(&pi.batch_versioned_hash)), + blsFields: bls_fields, + signerVkHash: FixedBytes(fr_to_be_bytes(&pi.signer_vk_hash)), + } +} + +/// Translates the prover-side `ResolutionPublicInputs` (which carries every +/// circuit public input for proof generation) into the on-chain submission struct. +pub fn resolution_public_inputs_to_sol( + pi: &ResolutionPublicInputs, +) -> IPetitionRegistry::ResolutionPublicInputs { + use ark_ff::Zero; + IPetitionRegistry::ResolutionPublicInputs { + b: !pi.b.is_zero(), + bPerClass: pi.b_per_class.iter().map(|f| !f.is_zero()).collect(), + } +} + +fn fr_to_be_tail(fr: &ark_bn254::Fr) -> [u8; N] { + let be = crate::poseidon::fr_to_be_bytes(fr); + let mut out = [0u8; N]; + out.copy_from_slice(&be[32 - N..]); + out +} + +fn fr_to_u8(fr: &ark_bn254::Fr) -> u8 { + crate::poseidon::fr_to_be_bytes(fr)[31] +} + +fn fr_to_u32(fr: &ark_bn254::Fr) -> u32 { + u32::from_be_bytes(fr_to_be_tail(fr)) +} + +fn fr_to_u64(fr: &ark_bn254::Fr) -> u64 { + u64::from_be_bytes(fr_to_be_tail(fr)) +} + +pub fn address_to_alloy(a: &Address) -> AlloyAddress { + AlloyAddress::from_slice(a) +} + +pub fn address_from_alloy(a: AlloyAddress) -> Address { + let mut out = [0u8; 20]; + out.copy_from_slice(a.as_slice()); + out +} diff --git a/pocs/civic-participation/resilient-civic-participation/src/adapters/direct_relay_submission.rs b/pocs/civic-participation/resilient-civic-participation/src/adapters/direct_relay_submission.rs new file mode 100644 index 0000000..6206f6e --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/adapters/direct_relay_submission.rs @@ -0,0 +1,143 @@ +//! In-process signer-to-relayer channel with per-relay FIFO inboxes. + +use std::{ + collections::{ + HashMap, + VecDeque, + }, + sync::Mutex, +}; + +use crate::{ + ports::submission::{ + RelaySubmission, + SubmissionError, + }, + types::{ + Bytes32, + SignerSubmission, + }, +}; + +pub struct DirectRelaySubmission { + inboxes: Mutex>>, +} + +impl DirectRelaySubmission { + pub fn new(relay_ids: impl IntoIterator) -> Self { + let inboxes = relay_ids + .into_iter() + .map(|id| (id, VecDeque::new())) + .collect(); + Self { + inboxes: Mutex::new(inboxes), + } + } + + fn lock( + &self, + ) -> std::sync::MutexGuard<'_, HashMap>> { + self.inboxes + .lock() + .expect("DirectRelaySubmission inboxes poisoned") + } + + /// Test-only relayer pull; production relays must not expose their inbox. + #[cfg(test)] + pub fn pull(&self, relay_id: &Bytes32) -> Option { + self.lock().get_mut(relay_id)?.pop_front() + } + + /// Test-only; queue depth would leak timing signals in production. + #[cfg(test)] + pub fn pending(&self, relay_id: &Bytes32) -> usize { + self.lock().get(relay_id).map(|q| q.len()).unwrap_or(0) + } + + /// Test-only inbox drain. + #[cfg(test)] + pub fn drain(&self, relay_id: &Bytes32) -> Vec { + let mut g = self.lock(); + match g.get_mut(relay_id) { + Some(q) => q.drain(..).collect(), + None => Vec::new(), + } + } +} + +impl RelaySubmission for DirectRelaySubmission { + fn submit( + &self, + relay_id: &Bytes32, + submission: SignerSubmission, + ) -> Result<(), SubmissionError> { + self.lock() + .get_mut(relay_id) + .ok_or(SubmissionError::UnknownRelay)? + .push_back(submission); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fake_submission(seed: u8) -> SignerSubmission { + SignerSubmission { + petition_id: [seed; 32], + r_root: [seed; 32], + predicate_hash: [seed; 32], + class_index: 0, + slot: seed as u32, + nullifier: [seed; 32], + identity_tag: [seed; 32], + class_tag: seed as u16, + proof_bytes: vec![seed; 8], + } + } + + #[test] + fn test_submit_then_pull() { + let relay = [0x11u8; 32]; + let s = DirectRelaySubmission::new([relay]); + s.submit(&relay, fake_submission(0xaa)).unwrap(); + assert_eq!(s.pending(&relay), 1); + let pulled = s.pull(&relay).unwrap(); + assert_eq!(pulled.slot, 0xaa); + } + + #[test] + fn test_unknown_relay_rejected() { + let known = [0x11u8; 32]; + let other = [0x22u8; 32]; + let s = DirectRelaySubmission::new([known]); + let err = s.submit(&other, fake_submission(0)); + assert!(matches!(err, Err(SubmissionError::UnknownRelay))); + } + + #[test] + fn test_drain_returns_all_and_empties_inbox() { + let r = [0x33u8; 32]; + let s = DirectRelaySubmission::new([r]); + for i in 0..3 { + s.submit(&r, fake_submission(i)).unwrap(); + } + let drained = s.drain(&r); + assert_eq!(drained.len(), 3); + assert_eq!(s.pending(&r), 0); + } + + #[test] + fn test_fifo_order() { + let r = [0x44u8; 32]; + let s = DirectRelaySubmission::new([r]); + for i in 0..3 { + s.submit(&r, fake_submission(i)).unwrap(); + } + for i in 0..3 { + let p = s.pull(&r).unwrap(); + assert_eq!(p.slot, i as u32); + } + } +} diff --git a/pocs/civic-participation/resilient-civic-participation/src/adapters/in_memory_blob.rs b/pocs/civic-participation/resilient-civic-participation/src/adapters/in_memory_blob.rs new file mode 100644 index 0000000..e106025 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/adapters/in_memory_blob.rs @@ -0,0 +1,221 @@ +//! In-process EIP-4844 blob carrier. The "opening proof" is a SHA-256 tag +//! that lets `verify` re-derive what `open` returned; the security boundary +//! is the locally stored payload, not the digest (forging would require the +//! same blob bytes to be present). + +use std::collections::HashMap; + +use sha2::{ + Digest, + Sha256, +}; + +use crate::{ + blob::{ + compute_batch_versioned_hash, + encode_blob, + }, + error::BlobError, + ports::blob::BlobCarrier, + types::{ + Bytes32, + KzgOpening, + RecordEntry, + }, +}; + +struct BlobEntry { + fes: Vec<[u8; 32]>, + records: Vec, +} + +#[derive(Default)] +pub struct InMemoryBlobCarrier { + blobs: HashMap, +} + +impl InMemoryBlobCarrier { + pub fn new() -> Self { + Self::default() + } + + fn opening_proof_bytes( + versioned_hash: &Bytes32, + index: u32, + value: &Bytes32, + ) -> Vec { + let mut h = Sha256::new(); + h.update(b"RCP-mock-kzg/v1"); + h.update(versioned_hash); + h.update(index.to_be_bytes()); + h.update(value); + h.finalize().to_vec() + } +} + +impl BlobCarrier for InMemoryBlobCarrier { + fn publish(&mut self, records: &[RecordEntry]) -> Result { + let fes = encode_blob(records)?; + let vh = compute_batch_versioned_hash(&fes); + self.blobs.insert( + vh, + BlobEntry { + fes, + records: records.to_vec(), + }, + ); + Ok(vh) + } + + fn open( + &self, + batch_versioned_hash: &Bytes32, + field_element_index: u32, + ) -> Result { + let entry = self + .blobs + .get(batch_versioned_hash) + .ok_or(BlobError::NotFound)?; + let idx = field_element_index as usize; + if idx >= entry.fes.len() { + return Err(BlobError::Malformed(format!( + "fe index {idx} out of range (blob len {})", + entry.fes.len() + ))); + } + let value = entry.fes[idx]; + Ok(KzgOpening { + field_element_index, + claimed_value: value, + proof_bytes: Self::opening_proof_bytes( + batch_versioned_hash, + field_element_index, + &value, + ), + }) + } + + fn verify( + &self, + batch_versioned_hash: &Bytes32, + opening: &KzgOpening, + ) -> Result<(), BlobError> { + let entry = self + .blobs + .get(batch_versioned_hash) + .ok_or(BlobError::NotFound)?; + let idx = opening.field_element_index as usize; + if idx >= entry.fes.len() { + return Err(BlobError::InvalidOpening(format!( + "fe index {idx} out of range (blob len {})", + entry.fes.len() + ))); + } + if entry.fes[idx] != opening.claimed_value { + return Err(BlobError::InvalidOpening( + "opening claims a value the published blob does not carry".into(), + )); + } + let expected = Self::opening_proof_bytes( + batch_versioned_hash, + opening.field_element_index, + &opening.claimed_value, + ); + if expected != opening.proof_bytes { + return Err(BlobError::InvalidOpening( + "mock KZG opening proof mismatch".into(), + )); + } + Ok(()) + } + + fn fetch_records( + &self, + batch_versioned_hash: &Bytes32, + ) -> Result, BlobError> { + self.blobs + .get(batch_versioned_hash) + .map(|e| e.records.clone()) + .ok_or(BlobError::NotFound) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_record(i: u64) -> RecordEntry { + let mut nullifier = [0u8; 32]; + nullifier[24..].copy_from_slice(&i.to_be_bytes()); + let mut identity_tag = [0u8; 32]; + identity_tag[24..].copy_from_slice(&(i + 1).to_be_bytes()); + RecordEntry { + nullifier, + identity_tag, + class_tag: (i as u16) + 100, + } + } + + #[test] + fn test_publish_open_verify_roundtrip() { + let mut bc = InMemoryBlobCarrier::new(); + let r = sample_record(1); + let vh = bc.publish(&[r]).unwrap(); + let opening = bc.open(&vh, 0).unwrap(); + bc.verify(&vh, &opening).unwrap(); + assert_eq!(opening.field_element_index, 0); + } + + #[test] + fn test_fetch_records_returns_published_set() { + let mut bc = InMemoryBlobCarrier::new(); + let records = vec![sample_record(1), sample_record(2), sample_record(3)]; + let vh = bc.publish(&records).unwrap(); + let fetched = bc.fetch_records(&vh).unwrap(); + assert_eq!(fetched, records); + } + + #[test] + fn test_open_unknown_versioned_hash_errors() { + let bc = InMemoryBlobCarrier::new(); + let err = bc.open(&[0u8; 32], 0); + assert!(matches!(err, Err(BlobError::NotFound))); + } + + #[test] + fn test_verify_rejects_tampered_value() { + let mut bc = InMemoryBlobCarrier::new(); + let vh = bc.publish(&[sample_record(1)]).unwrap(); + let mut opening = bc.open(&vh, 0).unwrap(); + opening.claimed_value[0] ^= 0x01; + let err = bc.verify(&vh, &opening); + assert!(matches!(err, Err(BlobError::InvalidOpening(_)))); + } + + #[test] + fn test_verify_rejects_wrong_versioned_hash() { + let mut bc = InMemoryBlobCarrier::new(); + let vh = bc.publish(&[sample_record(1)]).unwrap(); + let opening = bc.open(&vh, 0).unwrap(); + let bad_vh = [0x55; 32]; + let err = bc.verify(&bad_vh, &opening); + assert!(matches!(err, Err(BlobError::NotFound))); + } + + #[test] + fn test_verify_rejects_forged_value_for_existing_blob() { + // Forged opening must be rejected even when proof_bytes re-derives cleanly. + let mut bc = InMemoryBlobCarrier::new(); + let vh = bc.publish(&[sample_record(1)]).unwrap(); + let real_opening = bc.open(&vh, 0).unwrap(); + let mut forged = real_opening.clone(); + forged.claimed_value = [0x66; 32]; + forged.proof_bytes = InMemoryBlobCarrier::opening_proof_bytes( + &vh, + forged.field_element_index, + &forged.claimed_value, + ); + let err = bc.verify(&vh, &forged); + assert!(matches!(err, Err(BlobError::InvalidOpening(_)))); + } +} diff --git a/pocs/civic-participation/resilient-civic-participation/src/adapters/in_memory_ri.rs b/pocs/civic-participation/resilient-civic-participation/src/adapters/in_memory_ri.rs new file mode 100644 index 0000000..7a25226 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/adapters/in_memory_ri.rs @@ -0,0 +1,157 @@ +//! In-process ResilientIdentity stand-in backed by `zk-kit-lean-imt`. + +use std::collections::HashMap; + +use lean_imt::hashed_tree::{ + HashedLeanIMT, + LeanIMTHasher, +}; + +use crate::{ + error::MerkleError, + ports::ri::{ + RiCredentialLayer, + RiPath, + }, + poseidon::{ + fr_from_be_bytes, + fr_to_be_bytes, + hash_merkle_node, + }, + types::Bytes32, +}; + +/// `LeanIMTHasher` adapter using Poseidon1 `hash_merkle_node`. +pub struct PoseidonHasher; + +impl LeanIMTHasher<32> for PoseidonHasher { + fn hash(input: &[u8]) -> [u8; 32] { + let left: &[u8; 32] = input[..32] + .try_into() + .expect("LeanIMTHasher contract: 64-byte input"); + let right: &[u8; 32] = input[32..] + .try_into() + .expect("LeanIMTHasher contract: 64-byte input"); + let result = hash_merkle_node(fr_from_be_bytes(left), fr_from_be_bytes(right)); + fr_to_be_bytes(&result) + } +} + +pub struct InMemoryRi { + tree: HashedLeanIMT<32, PoseidonHasher>, + root_first_seen: HashMap, +} + +impl InMemoryRi { + pub fn new() -> Self { + let tree = HashedLeanIMT::<32, PoseidonHasher>::new(&[], PoseidonHasher) + .expect("HashedLeanIMT::new"); + let mut s = Self { + tree, + root_first_seen: HashMap::new(), + }; + let r = s.root(); + s.root_first_seen.entry(r).or_insert(0); + s + } +} + +impl Default for InMemoryRi { + fn default() -> Self { + Self::new() + } +} + +impl RiCredentialLayer for InMemoryRi { + fn append_leaf(&mut self, attr_hash: Bytes32, posted_at_block: u64) -> u32 { + let idx = self.tree.size(); + self.tree.insert(&attr_hash); + let r = self.root(); + self.root_first_seen.entry(r).or_insert(posted_at_block); + idx as u32 + } + + fn root(&self) -> Bytes32 { + self.tree.root().unwrap_or([0u8; 32]) + } + + fn merkle_path(&self, leaf_index: u32) -> Result { + if self.tree.size() == 0 { + return Err(MerkleError::EmptyTree); + } + if (leaf_index as usize) >= self.tree.size() { + return Err(MerkleError::OutOfRange( + leaf_index as usize, + self.tree.size(), + )); + } + let proof = self + .tree + .generate_proof(leaf_index as usize) + .map_err(|e| MerkleError::ProofFailure(format!("{e:?}")))?; + let siblings: Vec = proof.siblings.clone(); + // lean-imt encodes path direction in `proof.index`: bit i = 0 means left child. + let indices: Vec = (0..proof.siblings.len()) + .map(|i| ((proof.index >> i) & 1) as u8) + .collect(); + Ok(RiPath { siblings, indices }) + } + + fn root_first_seen(&self, root: &Bytes32) -> Option { + self.root_first_seen.get(root).copied() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fr_be(v: u64) -> Bytes32 { + let mut b = [0u8; 32]; + b[24..].copy_from_slice(&v.to_be_bytes()); + b + } + + #[test] + fn test_empty_tree_root_recorded() { + let ri = InMemoryRi::new(); + let r = ri.root(); + assert_eq!(ri.root_first_seen(&r), Some(0)); + } + + #[test] + fn test_append_records_block() { + let mut ri = InMemoryRi::new(); + ri.append_leaf(fr_be(7), 100); + let r = ri.root(); + assert_eq!(ri.root_first_seen(&r), Some(100)); + } + + #[test] + fn test_path_verifies_against_root() { + let mut ri = InMemoryRi::new(); + ri.append_leaf(fr_be(1), 0); + ri.append_leaf(fr_be(2), 0); + ri.append_leaf(fr_be(3), 0); + let root_be = ri.root(); + let path = ri.merkle_path(1).unwrap(); + let mut current = fr_from_be_bytes(&fr_be(2)); + for (s, &dir) in path.siblings.iter().zip(path.indices.iter()) { + let s_fr = fr_from_be_bytes(s); + current = if dir == 0 { + hash_merkle_node(current, s_fr) + } else { + hash_merkle_node(s_fr, current) + }; + } + assert_eq!(fr_to_be_bytes(¤t), root_be); + } + + #[test] + fn test_path_out_of_range() { + let mut ri = InMemoryRi::new(); + ri.append_leaf(fr_be(1), 0); + let err = ri.merkle_path(5); + assert!(matches!(err, Err(MerkleError::OutOfRange(5, 1)))); + } +} diff --git a/pocs/civic-participation/resilient-civic-participation/src/adapters/mock_proof.rs b/pocs/civic-participation/resilient-civic-participation/src/adapters/mock_proof.rs new file mode 100644 index 0000000..3ad14a5 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/adapters/mock_proof.rs @@ -0,0 +1,414 @@ +//! Deterministic sentinel proof backend; verify re-derives the digest from public inputs. + +use ark_bn254::Fr; +use sha2::{ + Digest, + Sha256, +}; + +use crate::{ + error::ProofError, + ports::proof::{ + BatchPositionWitness, + ProofBackend, + }, + poseidon::fr_to_be_bytes, + types::{ + BatchPublicInputs, + ResolutionPrivateInputs, + ResolutionPublicInputs, + SignerPrivateInputs, + SignerPublicInputs, + }, +}; + +#[derive(Debug, Clone, Copy, Default)] +pub struct MockProofBackend; + +impl MockProofBackend { + fn signer_digest(public: &SignerPublicInputs) -> [u8; 32] { + let mut h = Sha256::new(); + h.update(b"RCP-mock/signer/v1"); + for fr in [ + public.r_root, + public.petition_id, + public.predicate_hash, + public.class_index, + public.class_tag, + public.slot, + public.nullifier, + public.identity_tag, + ] { + h.update(fr_to_be_bytes(&fr)); + } + let mut out = [0u8; 32]; + out.copy_from_slice(&h.finalize()); + out + } + + fn batch_digest(public: &BatchPublicInputs) -> [u8; 32] { + let mut h = Sha256::new(); + h.update(b"RCP-mock/batch/v1"); + for fr in [ + public.petition_id, + public.r_root, + public.predicate_hash, + public.class_index, + public.slot, + public.batch_size, + public.prior_running_root, + public.new_running_root, + public.prior_identity_tag_set_root, + public.new_identity_tag_set_root, + public.prior_leaf_count, + public.new_leaf_count, + public.batch_versioned_hash, + public.signer_vk_hash, + ] { + h.update(fr_to_be_bytes(&fr)); + } + for fe in &public.bls_fields { + h.update(fr_to_be_bytes(fe)); + } + let mut out = [0u8; 32]; + out.copy_from_slice(&h.finalize()); + out + } + + fn resolution_digest(public: &ResolutionPublicInputs) -> [u8; 32] { + let mut h = Sha256::new(); + h.update(b"RCP-mock/resolution/v1"); + for fr in [ + public.predicate_hash, + public.r_root, + public.running_root, + public.leaf_count, + public.b, + public.class_index, + ] { + h.update(fr_to_be_bytes(&fr)); + } + for c in &public.class_set { + h.update(fr_to_be_bytes(c)); + } + for t in &public.class_thresholds { + h.update(fr_to_be_bytes(t)); + } + for bp in &public.b_per_class { + h.update(fr_to_be_bytes(bp)); + } + let mut out = [0u8; 32]; + out.copy_from_slice(&h.finalize()); + out + } +} + +impl ProofBackend for MockProofBackend { + fn generate_signer_proof( + &self, + public: &SignerPublicInputs, + private: &SignerPrivateInputs, + ) -> Result, ProofError> { + // Cross-check non-empty witness so empty-witness paths surface as errors. + if private.attr_vector.is_empty() { + return Err(ProofError::WitnessSerialization("empty attr_vector".into())); + } + if private.chain_root == Fr::from(0u64) { + return Err(ProofError::WitnessSerialization( + "chain_root must be non-zero".into(), + )); + } + let mut out = Self::signer_digest(public).to_vec(); + out.extend_from_slice(b"signer-mock"); + Ok(out) + } + + fn generate_batch_proof( + &self, + public: &BatchPublicInputs, + positions: &[BatchPositionWitness], + ) -> Result, ProofError> { + if positions.is_empty() { + return Err(ProofError::WitnessSerialization( + "batch has no positions".into(), + )); + } + // Recursive-verification stand-in: re-derive digest, assert equality per position. + for (i, p) in positions.iter().enumerate() { + let expected = Self::signer_digest(&p.public_inputs); + if p.submission.proof_bytes.len() < 32 + || p.submission.proof_bytes[..32] != expected + { + return Err(ProofError::Verification(format!( + "batch position {i} signer proof failed mock recursion check" + ))); + } + } + let mut out = Self::batch_digest(public).to_vec(); + out.extend_from_slice(b"batch-mock"); + Ok(out) + } + + fn generate_resolution_proof( + &self, + public: &ResolutionPublicInputs, + private: &ResolutionPrivateInputs, + ) -> Result, ProofError> { + if private.leaves.is_empty() { + return Err(ProofError::WitnessSerialization( + "resolution has no leaves".into(), + )); + } + if private.imt_membership_paths.len() != private.leaves.len() { + return Err(ProofError::WitnessSerialization(format!( + "resolution leaves {} != imt membership paths {}", + private.leaves.len(), + private.imt_membership_paths.len() + ))); + } + let mut out = Self::resolution_digest(public).to_vec(); + out.extend_from_slice(b"resolution-mock"); + Ok(out) + } + + fn verify_signer_proof( + &self, + proof: &[u8], + public: &SignerPublicInputs, + ) -> Result<(), ProofError> { + let expected = Self::signer_digest(public); + if proof.len() < 32 || proof[..32] != expected { + return Err(ProofError::Verification("signer proof mismatch".into())); + } + Ok(()) + } + + fn verify_batch_proof( + &self, + proof: &[u8], + public: &BatchPublicInputs, + ) -> Result<(), ProofError> { + let expected = Self::batch_digest(public); + if proof.len() < 32 || proof[..32] != expected { + return Err(ProofError::Verification("batch proof mismatch".into())); + } + Ok(()) + } + + fn verify_resolution_proof( + &self, + proof: &[u8], + public: &ResolutionPublicInputs, + ) -> Result<(), ProofError> { + let expected = Self::resolution_digest(public); + if proof.len() < 32 || proof[..32] != expected { + return Err(ProofError::Verification("resolution proof mismatch".into())); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::ImtMembershipFr; + + fn sample_signer_public() -> SignerPublicInputs { + SignerPublicInputs { + r_root: Fr::from(1u64), + petition_id: Fr::from(2u64), + predicate_hash: Fr::from(3u64), + class_index: Fr::from(0u64), + class_tag: Fr::from(840u64), + slot: Fr::from(0u64), + nullifier: Fr::from(4u64), + identity_tag: Fr::from(5u64), + } + } + + fn sample_signer_private() -> SignerPrivateInputs { + SignerPrivateInputs { + identity_secret: Fr::from(0xdeadbeefu64), + attr_vector: vec![Fr::from(1u64); 4], + attr_version: 0, + chain_root: Fr::from(7u64), + ri_path_siblings: vec![], + ri_path_indices: vec![], + s_slot: Fr::from(8u64), + chain_path_siblings: vec![], + chain_path_indices: vec![], + salt: Fr::from(11u64), + predicate_def: crate::predicate::PredicateDef { + tuples: vec![crate::predicate::Tuple { + claim_index: 0, + operand: [0u8; 32], + type_tag: crate::types::TypeTag::Int64, + comparator: crate::types::Comparator::Eq, + }], + ops: vec![crate::predicate::Op { + code: crate::types::OpCode::PushTuple, + operand: 0, + }], + }, + } + } + + fn sample_batch_public() -> BatchPublicInputs { + BatchPublicInputs { + petition_id: Fr::from(2u64), + r_root: Fr::from(1u64), + predicate_hash: Fr::from(3u64), + class_index: Fr::from(0u64), + slot: Fr::from(0u64), + batch_size: Fr::from(1u64), + prior_running_root: Fr::from(0u64), + new_running_root: Fr::from(123u64), + prior_identity_tag_set_root: Fr::from(0u64), + new_identity_tag_set_root: Fr::from(456u64), + prior_leaf_count: Fr::from(0u64), + new_leaf_count: Fr::from(1u64), + batch_versioned_hash: Fr::from(789u64), + bls_fields: vec![ + Fr::from(0u64); + crate::BATCH_SIZE_MAX * crate::blob::FE_PER_RECORD + ], + signer_vk_hash: Fr::from(0u64), + } + } + + #[test] + fn test_signer_proof_roundtrip_verifies() { + let backend = MockProofBackend; + let public = sample_signer_public(); + let private = sample_signer_private(); + let proof = backend.generate_signer_proof(&public, &private).unwrap(); + backend.verify_signer_proof(&proof, &public).unwrap(); + } + + #[test] + fn test_signer_proof_mismatch_rejected() { + let backend = MockProofBackend; + let public = sample_signer_public(); + let private = sample_signer_private(); + let proof = backend.generate_signer_proof(&public, &private).unwrap(); + let mut tampered = public.clone(); + tampered.nullifier = Fr::from(99u64); + assert!(backend.verify_signer_proof(&proof, &tampered).is_err()); + } + + #[test] + fn test_batch_proof_requires_valid_position_proofs() { + let backend = MockProofBackend; + let public = sample_batch_public(); + let positions = vec![BatchPositionWitness { + submission: crate::types::SignerSubmission { + petition_id: [0u8; 32], + r_root: [0u8; 32], + predicate_hash: [0u8; 32], + class_index: 0, + slot: 0, + nullifier: [0u8; 32], + identity_tag: [0u8; 32], + class_tag: 0, + proof_bytes: vec![0xff; 64], + }, + public_inputs: sample_signer_public(), + running_insert: None, + idtag_insert: None, + }]; + let err = backend.generate_batch_proof(&public, &positions); + assert!(matches!(err, Err(ProofError::Verification(_)))); + } + + #[test] + fn test_batch_proof_round_trips_when_signer_proofs_are_valid() { + let backend = MockProofBackend; + let signer_public = sample_signer_public(); + let signer_private = sample_signer_private(); + let signer_proof = backend + .generate_signer_proof(&signer_public, &signer_private) + .unwrap(); + let mut batch_public = sample_batch_public(); + batch_public.r_root = signer_public.r_root; + batch_public.petition_id = signer_public.petition_id; + batch_public.predicate_hash = signer_public.predicate_hash; + batch_public.class_index = signer_public.class_index; + batch_public.slot = signer_public.slot; + + let positions = vec![BatchPositionWitness { + submission: crate::types::SignerSubmission { + petition_id: [0u8; 32], + r_root: [0u8; 32], + predicate_hash: [0u8; 32], + class_index: 0, + slot: 0, + nullifier: [0u8; 32], + identity_tag: [0u8; 32], + class_tag: 0, + proof_bytes: signer_proof, + }, + running_insert: None, + idtag_insert: None, + public_inputs: signer_public, + }]; + let proof = backend + .generate_batch_proof(&batch_public, &positions) + .unwrap(); + backend.verify_batch_proof(&proof, &batch_public).unwrap(); + } + + #[test] + fn test_resolution_proof_roundtrip_verifies() { + let backend = MockProofBackend; + let public = ResolutionPublicInputs { + predicate_hash: Fr::from(3u64), + r_root: Fr::from(1u64), + running_root: Fr::from(123u64), + leaf_count: Fr::from(1u64), + class_set: vec![Fr::from(840u64)], + class_thresholds: vec![Fr::from(1u64)], + b: Fr::from(1u64), + b_per_class: vec![Fr::from(1u64)], + class_index: Fr::from(0u64), + }; + let private = ResolutionPrivateInputs { + leaves: vec![Fr::from(7u64)], + imt_membership_paths: vec![ImtMembershipFr { + leaf_hash: Fr::from(7u64), + leaf_index: 1, + next_index: 0, + next_value: Fr::from(0u64), + siblings: vec![], + indices: vec![], + }], + witness_pairs: vec![(Fr::from(1u64), Fr::from(840u64))], + }; + let proof = backend + .generate_resolution_proof(&public, &private) + .unwrap(); + backend.verify_resolution_proof(&proof, &public).unwrap(); + } + + #[test] + fn test_resolution_proof_rejects_empty_leaves() { + let backend = MockProofBackend; + let public = ResolutionPublicInputs { + predicate_hash: Fr::from(3u64), + r_root: Fr::from(1u64), + running_root: Fr::from(123u64), + leaf_count: Fr::from(0u64), + class_set: vec![Fr::from(840u64)], + class_thresholds: vec![Fr::from(0u64)], + b: Fr::from(1u64), + b_per_class: vec![Fr::from(1u64)], + class_index: Fr::from(0u64), + }; + let private = ResolutionPrivateInputs { + leaves: vec![], + imt_membership_paths: vec![], + witness_pairs: vec![], + }; + let err = backend.generate_resolution_proof(&public, &private); + assert!(matches!(err, Err(ProofError::WitnessSerialization(_)))); + } +} diff --git a/pocs/civic-participation/resilient-civic-participation/src/adapters/mod.rs b/pocs/civic-participation/resilient-civic-participation/src/adapters/mod.rs new file mode 100644 index 0000000..48f4290 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/adapters/mod.rs @@ -0,0 +1,22 @@ +pub mod bb_prover; +pub mod blob_4844; +pub mod chain_registry; +pub mod in_memory_blob; +pub mod in_memory_ri; + +// Test-only adapters. NOT for production deployment. +// +// `MockProofBackend` accepts any proof bytes whose prefix matches the +// SHA-256 of the public inputs; production binaries MUST NOT ship it. +// `direct_relay_submission` is an in-process channel that bypasses the +// anonymous-transport boundary the SPEC threat model assumes; useful +// for tests, fatal for production. +// +// Both modules are gated behind `cfg(test)` AND a `test-mocks` Cargo +// feature so they are reachable from integration tests (which build the +// lib as an external crate without `cfg(test)`) without being included +// in default release builds. +#[cfg(any(test, feature = "test-mocks"))] +pub mod direct_relay_submission; +#[cfg(any(test, feature = "test-mocks"))] +pub mod mock_proof; diff --git a/pocs/civic-participation/resilient-civic-participation/src/blob.rs b/pocs/civic-participation/resilient-civic-participation/src/blob.rs new file mode 100644 index 0000000..122f4c5 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/blob.rs @@ -0,0 +1,342 @@ +//! EIP-4844 blob payload encoder / decoder (SPEC Blob Payload). + +use ark_bls12_381::Fr as BlsFr; +use ark_bn254::Fr; +use ark_ff::{ + BigInteger, + FftField, + Field, + PrimeField, +}; +use sha2::{ + Digest, + Sha256, +}; + +use crate::{ + RECORD_LEN, + RECORDS_PER_BLOB, + error::BlobError, + poseidon::fr_from_be_bytes, + types::{ + Bytes32, + RecordEntry, + }, +}; + +/// Number of distinct (record, field-element) positions that the +/// contract verifies via KZG point-evaluation on `publishBatch`. +/// Equal to `BATCH_SIZE_MAX * FE_PER_RECORD` = 6 * 4. +pub const KZG_OPENING_COUNT: usize = crate::BATCH_SIZE_MAX * FE_PER_RECORD; + +/// EIP-4844 blob size: 4096 BLS12-381 field elements. +const BLOB_FIELD_ELEMENTS: u64 = 4096; + +pub const FE_PER_RECORD: usize = 4; +pub const CONTENT_PER_FE: usize = 31; + +pub fn fe_index(i: usize, j: usize) -> u32 { + (FE_PER_RECORD * i + j) as u32 +} + +/// Encode a record into its 96-byte canonical form. +pub fn encode_record(r: &RecordEntry) -> [u8; RECORD_LEN] { + let mut out = [0u8; RECORD_LEN]; + out[0..32].copy_from_slice(&r.nullifier); + out[32..64].copy_from_slice(&r.identity_tag); + out[64..66].copy_from_slice(&r.class_tag.to_be_bytes()); + out +} + +/// Decode a record from its 96-byte canonical form. +pub fn decode_record(bytes: &[u8; RECORD_LEN]) -> RecordEntry { + let mut nullifier = [0u8; 32]; + nullifier.copy_from_slice(&bytes[0..32]); + let mut identity_tag = [0u8; 32]; + identity_tag.copy_from_slice(&bytes[32..64]); + let class_tag = u16::from_be_bytes([bytes[64], bytes[65]]); + RecordEntry { + nullifier, + identity_tag, + class_tag, + } +} + +/// Encode `records` into the field-element byte string, padded to `RECORDS_PER_BLOB`. +pub fn encode_blob(records: &[RecordEntry]) -> Result, BlobError> { + if records.len() > RECORDS_PER_BLOB { + return Err(BlobError::Capacity(records.len(), RECORDS_PER_BLOB)); + } + let mut out: Vec<[u8; 32]> = Vec::with_capacity(RECORDS_PER_BLOB * FE_PER_RECORD); + for i in 0..RECORDS_PER_BLOB { + let record_bytes = if i < records.len() { + encode_record(&records[i]) + } else { + [0u8; RECORD_LEN] + }; + for j in 0..FE_PER_RECORD { + let mut fe = [0u8; 32]; + let start = j * CONTENT_PER_FE; + let end = ((j + 1) * CONTENT_PER_FE).min(RECORD_LEN); + let take = end - start; + fe[1..1 + take].copy_from_slice(&record_bytes[start..end]); + out.push(fe); + } + } + Ok(out) +} + +/// Decode the field-element byte string back to records. +pub fn decode_blob(field_elements: &[[u8; 32]]) -> Result, BlobError> { + if field_elements.len() != RECORDS_PER_BLOB * FE_PER_RECORD { + return Err(BlobError::Malformed(format!( + "blob has {} field elements, expected {}", + field_elements.len(), + RECORDS_PER_BLOB * FE_PER_RECORD + ))); + } + let mut records = Vec::with_capacity(RECORDS_PER_BLOB); + for i in 0..RECORDS_PER_BLOB { + let mut record_bytes = [0u8; RECORD_LEN]; + for j in 0..FE_PER_RECORD { + let fe = &field_elements[FE_PER_RECORD * i + j]; + let start = j * CONTENT_PER_FE; + let end = ((j + 1) * CONTENT_PER_FE).min(RECORD_LEN); + let take = end - start; + record_bytes[start..end].copy_from_slice(&fe[1..1 + take]); + } + if record_bytes[..66].iter().all(|&b| b == 0) { + break; + } + records.push(decode_record(&record_bytes)); + } + Ok(records) +} + +/// `batch_versioned_hash`: SHA-256 over field-element bytes; high byte zeroed +/// so the value fits in a BN254 scalar without reduction. +pub fn compute_batch_versioned_hash(field_elements: &[[u8; 32]]) -> Bytes32 { + let mut h = Sha256::new(); + for fe in field_elements { + h.update(fe); + } + let mut out = [0u8; 32]; + out.copy_from_slice(&h.finalize()); + out[0] = 0; + out +} + +/// Recompute the 4 BLS12-381 field elements occupied by `record` per +/// SPEC Blob Payload. The returned values are BN254 scalars whose +/// numeric value matches the corresponding 32-byte BLS field element +/// (each fe has its high byte zero, so the value fits in either field +/// without reduction). Mirrors `circuits/lib/src/blob.nr::record_to_fields`. +pub fn record_to_bls_fields(record: &RecordEntry) -> [Fr; FE_PER_RECORD] { + let bytes = encode_record(record); + let mut out = [Fr::from(0u64); FE_PER_RECORD]; + for (j, slot) in out.iter_mut().enumerate() { + let start = j * CONTENT_PER_FE; + let end = ((j + 1) * CONTENT_PER_FE).min(RECORD_LEN); + let mut fe_bytes = [0u8; 32]; + fe_bytes[1..1 + (end - start)].copy_from_slice(&bytes[start..end]); + *slot = fr_from_be_bytes(&fe_bytes); + } + out +} + +/// Bit-reversal permutation for a `log_n`-bit index. EIP-4844 stores blob +/// field elements in bit-reversal-permuted evaluation form: the k-th stored +/// 32-byte chunk equals the polynomial evaluated at `omega^{bit_reverse(k, log_n)}`. +fn bit_reverse(k: u32, log_n: u32) -> u32 { + k.reverse_bits() >> (32 - log_n) +} + +/// Canonical evaluation points used by the SPEC's constraint 8 KZG +/// binding: `z_k = omega^{bit_reverse(k, 12)}` where `omega` is the +/// primitive 4096th root of unity in BLS12-381 Fr (EIP-4844 stores blobs +/// in bit-reversal-permuted evaluation form). Returns `KZG_OPENING_COUNT` +/// 32-byte big-endian encodings. +/// +/// The contract uses the same `omega` and bit-reversal table (hardcoded) +/// to derive `z_k` on chain; the relayer uses these bytes to call +/// `c-kzg::compute_kzg_proof(blob, z_k)`. +pub fn canonical_eval_points() -> [[u8; 32]; KZG_OPENING_COUNT] { + let omega = BlsFr::get_root_of_unity(BLOB_FIELD_ELEMENTS) + .expect("BLS12-381 Fr must have a 4096th root of unity"); + let log_n = BLOB_FIELD_ELEMENTS.trailing_zeros(); + let mut out = [[0u8; 32]; KZG_OPENING_COUNT]; + for (k, slot) in out.iter_mut().enumerate() { + let exponent = bit_reverse(k as u32, log_n) as u64; + let z = omega.pow([exponent]); + *slot = bls_fr_to_be_bytes(&z); + } + out +} + +/// `omega` (primitive 4096th root of unity in BLS12-381 Fr), encoded +/// big-endian. Exposed so the Solidity constant table can be +/// generated/checked. +pub fn bls_omega_4096_be() -> [u8; 32] { + let omega = BlsFr::get_root_of_unity(BLOB_FIELD_ELEMENTS) + .expect("BLS12-381 Fr must have a 4096th root of unity"); + bls_fr_to_be_bytes(&omega) +} + +fn bls_fr_to_be_bytes(fr: &BlsFr) -> [u8; 32] { + let be = fr.into_bigint().to_bytes_be(); + let mut out = [0u8; 32]; + out[32 - be.len()..].copy_from_slice(&be); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_record(i: u64) -> RecordEntry { + let mut nullifier = [0u8; 32]; + nullifier[24..].copy_from_slice(&i.to_be_bytes()); + let mut identity_tag = [0u8; 32]; + identity_tag[24..].copy_from_slice(&(i + 1).to_be_bytes()); + RecordEntry { + nullifier, + identity_tag, + class_tag: (i as u16) + 100, + } + } + + #[test] + fn test_encode_decode_record_roundtrip() { + let r = sample_record(7); + let bytes = encode_record(&r); + let back = decode_record(&bytes); + assert_eq!(r, back); + } + + #[test] + fn test_encode_blob_pads_to_full_capacity() { + let records = [sample_record(1), sample_record(2), sample_record(3)]; + let fes = encode_blob(&records).unwrap(); + assert_eq!(fes.len(), RECORDS_PER_BLOB * FE_PER_RECORD); + let back = decode_blob(&fes).unwrap(); + assert_eq!(back.len(), records.len()); + for (a, b) in records.iter().zip(back.iter()) { + assert_eq!(a, b); + } + } + + #[test] + fn test_encode_blob_rejects_overflow() { + let records = vec![sample_record(0); RECORDS_PER_BLOB + 1]; + let err = encode_blob(&records); + assert!(matches!(err, Err(BlobError::Capacity(_, _)))); + } + + #[test] + fn test_compute_batch_versioned_hash_deterministic_and_distinct() { + let r1 = [sample_record(1)]; + let r2 = [sample_record(2)]; + let h1 = compute_batch_versioned_hash(&encode_blob(&r1).unwrap()); + let h2 = compute_batch_versioned_hash(&encode_blob(&r2).unwrap()); + let h1b = compute_batch_versioned_hash(&encode_blob(&r1).unwrap()); + assert_eq!(h1, h1b); + assert_ne!(h1, h2); + } + + #[test] + fn test_fe_index_layout() { + assert_eq!(fe_index(0, 0), 0); + assert_eq!(fe_index(0, 3), 3); + assert_eq!(fe_index(1, 0), 4); + assert_eq!(fe_index(999, 3), 3999); + } + + #[test] + fn test_decode_blob_rejects_wrong_length() { + let fe = vec![[0u8; 32]; 10]; + let err = decode_blob(&fe); + assert!(matches!(err, Err(BlobError::Malformed(_)))); + } + + #[test] + fn test_field_element_high_byte_zero() { + let r = sample_record(42); + let fes = encode_blob(&[r]).unwrap(); + for (i, fe) in fes.iter().enumerate() { + assert_eq!(fe[0], 0, "fe {i} has non-zero high byte"); + } + } + + /// Codegen helper masquerading as a test. Run with + /// `cargo test print_omega_constants -- --ignored --nocapture` to dump the + /// Solidity-side constants (`omega`, BLS Fr modulus, 24 canonical z_k points). + #[test] + #[ignore] + fn print_omega_constants() { + let omega = bls_omega_4096_be(); + eprintln!("BLS_FR_OMEGA_4096 = 0x{}", hex::encode(omega)); + eprintln!( + "BLS_FR_MODULUS = 0x73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000001" + ); + let pts = canonical_eval_points(); + for (k, p) in pts.iter().enumerate() { + eprintln!("z[{k}] = 0x{}", hex::encode(p)); + } + } + + #[test] + fn test_canonical_eval_points_first_is_one() { + let pts = canonical_eval_points(); + let mut expected = [0u8; 32]; + expected[31] = 1; + assert_eq!(pts[0], expected, "z_0 must equal Fr(1)"); + } + + #[test] + fn test_canonical_eval_points_distinct() { + let pts = canonical_eval_points(); + for i in 0..KZG_OPENING_COUNT { + for j in (i + 1)..KZG_OPENING_COUNT { + assert_ne!(pts[i], pts[j], "z_{i} == z_{j}"); + } + } + } + + #[test] + fn test_record_to_bls_fields_high_byte_zero() { + let r = sample_record(7); + let fes = record_to_bls_fields(&r); + for (k, fe) in fes.iter().enumerate() { + let be = crate::poseidon::fr_to_be_bytes(fe); + assert_eq!(be[0], 0, "fe {k} has non-zero high byte"); + } + } + + #[test] + fn test_record_to_bls_fields_matches_encode_blob() { + // Cross-check: record_to_bls_fields agrees with the per-position + // bytes that encode_blob writes into the blob payload. + let records = [sample_record(7), sample_record(13)]; + let fes_bytes = encode_blob(&records).unwrap(); + for (i, r) in records.iter().enumerate() { + let fes = record_to_bls_fields(r); + for j in 0..FE_PER_RECORD { + let blob_fe = &fes_bytes[i * FE_PER_RECORD + j]; + let computed_fe = crate::poseidon::fr_to_be_bytes(&fes[j]); + assert_eq!( + blob_fe, &computed_fe, + "record {i} fe {j}: encode_blob bytes != record_to_bls_fields" + ); + } + } + } + + #[test] + fn test_record_padding_preserved() { + let r = sample_record(123); + let bytes = encode_record(&r); + for b in &bytes[66..96] { + assert_eq!(*b, 0); + } + } +} diff --git a/pocs/civic-participation/resilient-civic-participation/src/clock.rs b/pocs/civic-participation/resilient-civic-participation/src/clock.rs new file mode 100644 index 0000000..89534de --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/clock.rs @@ -0,0 +1,60 @@ +//! Block-number abstraction for the Registry state machine. + +use std::sync::atomic::{ + AtomicU64, + Ordering, +}; + +pub trait BlockClock: Send + Sync { + fn block_number(&self) -> u64; +} + +#[derive(Debug, Default)] +pub struct MockBlockClock { + inner: AtomicU64, +} + +impl MockBlockClock { + pub fn new(at: u64) -> Self { + Self { + inner: AtomicU64::new(at), + } + } + pub fn set(&self, at: u64) { + self.inner.store(at, Ordering::SeqCst) + } + pub fn advance(&self, by: u64) { + self.inner.fetch_add(by, Ordering::SeqCst); + } +} + +impl BlockClock for MockBlockClock { + fn block_number(&self) -> u64 { + self.inner.load(Ordering::SeqCst) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mock_block_clock_starts_at_initial_value() { + let c = MockBlockClock::new(100); + assert_eq!(c.block_number(), 100); + } + + #[test] + fn mock_block_clock_advance_adds_blocks() { + let c = MockBlockClock::new(100); + c.advance(7); + assert_eq!(c.block_number(), 107); + } + + #[test] + fn mock_block_clock_set_replaces_value() { + let c = MockBlockClock::new(100); + c.set(500); + assert_eq!(c.block_number(), 500); + } +} diff --git a/pocs/civic-participation/resilient-civic-participation/src/disputant/core.rs b/pocs/civic-participation/resilient-civic-participation/src/disputant/core.rs new file mode 100644 index 0000000..797bd20 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/disputant/core.rs @@ -0,0 +1,276 @@ +//! Disputant actor: build SPEC Dispute envelopes against a published batch. + +use crate::{ + blob::fe_index, + disputant::{ + error::DisputantError, + types::DisputeContext, + }, + ports::blob::BlobCarrier, + poseidon::{ + fr_from_be_bytes, + hash_leaf, + }, + types::{ + Dispute, + KzgOpening, + RecordEntry, + ViolationType, + }, +}; +use ark_bn254::Fr; + +pub struct Disputant { + pub blob_carrier: B, +} + +impl Disputant { + pub fn new(blob_carrier: B) -> Self { + Self { blob_carrier } + } + + /// Read a record by querying 4 field-element openings. + fn read_record( + &self, + batch_versioned_hash: &[u8; 32], + position: u32, + ) -> Result<(RecordEntry, [KzgOpening; 4]), DisputantError> { + let openings = [ + self.blob_carrier + .open(batch_versioned_hash, fe_index(position as usize, 0))?, + self.blob_carrier + .open(batch_versioned_hash, fe_index(position as usize, 1))?, + self.blob_carrier + .open(batch_versioned_hash, fe_index(position as usize, 2))?, + self.blob_carrier + .open(batch_versioned_hash, fe_index(position as usize, 3))?, + ]; + let mut record_bytes = [0u8; crate::RECORD_LEN]; + for (j, o) in openings.iter().enumerate() { + let start = j * crate::blob::CONTENT_PER_FE; + let end = ((j + 1) * crate::blob::CONTENT_PER_FE).min(crate::RECORD_LEN); + let take = end - start; + record_bytes[start..end].copy_from_slice(&o.claimed_value[1..1 + take]); + } + let record = crate::blob::decode_record(&record_bytes); + Ok((record, openings)) + } + + /// SPEC Dispute 0x01: class_tag at position `i` not in `class_set`. + pub fn build_class_tag_out_of_set( + &self, + ctx: &DisputeContext, + position_i: u32, + ) -> Result { + let (rec_i, openings_i) = + self.read_record(&ctx.batch_versioned_hash, position_i)?; + if ctx.class_set.contains(&rec_i.class_tag) { + return Err(DisputantError::PredicateNotViolated); + } + let mut openings = openings_i.to_vec(); + openings.sort_by_key(|o| o.field_element_index); + Ok(Dispute { + petition_id: ctx.petition_id, + batch_index: ctx.batch_index, + violation_type: ViolationType::ClassTagOutOfSet, + position_i, + position_j: None, + openings, + }) + } + + /// SPEC Dispute 0x02: positions `i != j` share an identity_tag. + pub fn build_intra_batch_duplicate_identity_tag( + &self, + ctx: &DisputeContext, + position_i: u32, + position_j: u32, + ) -> Result { + if position_i == position_j { + return Err(DisputantError::PredicateNotViolated); + } + let (rec_i, openings_i) = + self.read_record(&ctx.batch_versioned_hash, position_i)?; + let (rec_j, openings_j) = + self.read_record(&ctx.batch_versioned_hash, position_j)?; + if rec_i.identity_tag != rec_j.identity_tag { + return Err(DisputantError::PredicateNotViolated); + } + let mut openings: Vec = + openings_i.into_iter().chain(openings_j).collect(); + openings.sort_by_key(|o| o.field_element_index); + Ok(Dispute { + petition_id: ctx.petition_id, + batch_index: ctx.batch_index, + violation_type: ViolationType::IntraBatchDuplicateIdentityTag, + position_i, + position_j: Some(position_j), + openings, + }) + } + + /// SPEC Dispute 0x03: leaf ordering violation at `(i, i+1)`. + pub fn build_leaf_ordering_violation( + &self, + ctx: &DisputeContext, + position_i: u32, + ) -> Result { + let position_j = position_i.checked_add(1).ok_or_else(|| { + DisputantError::Blob(crate::error::BlobError::Malformed( + "position_i overflow".into(), + )) + })?; + let (rec_i, openings_i) = + self.read_record(&ctx.batch_versioned_hash, position_i)?; + let (rec_ip1, openings_ip1) = + self.read_record(&ctx.batch_versioned_hash, position_j)?; + let leaf_i = hash_leaf( + fr_from_be_bytes(&rec_i.nullifier), + Fr::from(rec_i.class_tag as u64), + ); + let leaf_ip1 = hash_leaf( + fr_from_be_bytes(&rec_ip1.nullifier), + Fr::from(rec_ip1.class_tag as u64), + ); + if !leaf_ordering_violated(&leaf_i, &leaf_ip1) { + return Err(DisputantError::PredicateNotViolated); + } + let mut openings: Vec = + openings_i.into_iter().chain(openings_ip1).collect(); + openings.sort_by_key(|o| o.field_element_index); + Ok(Dispute { + petition_id: ctx.petition_id, + batch_index: ctx.batch_index, + violation_type: ViolationType::LeafOrderingViolation, + position_i, + position_j: Some(position_j), + openings, + }) + } +} + +/// Violation predicate: `leaf_i >= leaf_{i+1}` under BE byte ordering. +pub(crate) fn leaf_ordering_violated(leaf_i: &Fr, leaf_ip1: &Fr) -> bool { + let a = crate::poseidon::fr_to_be_bytes(leaf_i); + let b = crate::poseidon::fr_to_be_bytes(leaf_ip1); + a >= b +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::adapters::in_memory_blob::InMemoryBlobCarrier; + + fn sample_record(seed: u64, class_tag: u16) -> RecordEntry { + let mut nullifier = [0u8; 32]; + nullifier[24..].copy_from_slice(&seed.to_be_bytes()); + let mut identity_tag = [0u8; 32]; + identity_tag[24..].copy_from_slice(&(seed + 100).to_be_bytes()); + RecordEntry { + nullifier, + identity_tag, + class_tag, + } + } + + fn dispute_ctx(vh: [u8; 32], class_set: Vec) -> DisputeContext { + let mut pid = [0u8; 32]; + pid[24..].copy_from_slice(&7u64.to_be_bytes()); + DisputeContext { + petition_id: pid, + batch_versioned_hash: vh, + batch_index: 0, + class_set, + } + } + + #[test] + fn test_build_class_tag_out_of_set_detects_violation() { + let mut bc = InMemoryBlobCarrier::new(); + let records = vec![sample_record(1, 100), sample_record(2, 999)]; + let vh = bc.publish(&records).unwrap(); + let d = Disputant::new(bc); + let ctx = dispute_ctx(vh, vec![100, 200]); + let dispute = d.build_class_tag_out_of_set(&ctx, 1).unwrap(); + assert_eq!(dispute.violation_type, ViolationType::ClassTagOutOfSet); + assert_eq!(dispute.openings.len(), 4); + } + + #[test] + fn test_build_class_tag_out_of_set_no_violation() { + let mut bc = InMemoryBlobCarrier::new(); + let records = vec![sample_record(1, 100), sample_record(2, 200)]; + let vh = bc.publish(&records).unwrap(); + let d = Disputant::new(bc); + let ctx = dispute_ctx(vh, vec![100, 200]); + let err = d.build_class_tag_out_of_set(&ctx, 1); + assert!(matches!(err, Err(DisputantError::PredicateNotViolated))); + } + + #[test] + fn test_build_intra_batch_duplicate_identity_tag_detects_violation() { + let mut bc = InMemoryBlobCarrier::new(); + let a = sample_record(1, 100); + let mut b = sample_record(2, 100); + b.identity_tag = a.identity_tag; + let records = vec![a, b]; + let vh = bc.publish(&records).unwrap(); + let d = Disputant::new(bc); + let ctx = dispute_ctx(vh, vec![100, 200]); + let dispute = d + .build_intra_batch_duplicate_identity_tag(&ctx, 0, 1) + .unwrap(); + assert_eq!( + dispute.violation_type, + ViolationType::IntraBatchDuplicateIdentityTag + ); + assert_eq!(dispute.position_j, Some(1)); + } + + #[test] + fn test_build_intra_batch_duplicate_identity_tag_no_violation() { + let mut bc = InMemoryBlobCarrier::new(); + let records = vec![sample_record(1, 100), sample_record(2, 100)]; + let vh = bc.publish(&records).unwrap(); + let d = Disputant::new(bc); + let ctx = dispute_ctx(vh, vec![100, 200]); + let err = d.build_intra_batch_duplicate_identity_tag(&ctx, 0, 1); + assert!(matches!(err, Err(DisputantError::PredicateNotViolated))); + } + + #[test] + fn test_build_leaf_ordering_violation_detects_violation() { + let mut bc = InMemoryBlobCarrier::new(); + let records = vec![sample_record(99, 100), sample_record(1, 100)]; + let leaf0 = hash_leaf( + fr_from_be_bytes(&records[0].nullifier), + Fr::from(records[0].class_tag as u64), + ); + let leaf1 = hash_leaf( + fr_from_be_bytes(&records[1].nullifier), + Fr::from(records[1].class_tag as u64), + ); + let records = if crate::poseidon::fr_to_be_bytes(&leaf0) + < crate::poseidon::fr_to_be_bytes(&leaf1) + { + vec![records[1], records[0]] + } else { + records + }; + let leaf0 = hash_leaf( + fr_from_be_bytes(&records[0].nullifier), + Fr::from(records[0].class_tag as u64), + ); + let leaf1 = hash_leaf( + fr_from_be_bytes(&records[1].nullifier), + Fr::from(records[1].class_tag as u64), + ); + assert!(super::leaf_ordering_violated(&leaf0, &leaf1)); + + let vh = bc.publish(&records).unwrap(); + let d = Disputant::new(bc); + let ctx = dispute_ctx(vh, vec![100, 200]); + let dispute = d.build_leaf_ordering_violation(&ctx, 0).unwrap(); + assert_eq!(dispute.violation_type, ViolationType::LeafOrderingViolation); + } +} diff --git a/pocs/civic-participation/resilient-civic-participation/src/disputant/error.rs b/pocs/civic-participation/resilient-civic-participation/src/disputant/error.rs new file mode 100644 index 0000000..cd56752 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/disputant/error.rs @@ -0,0 +1,11 @@ +use thiserror::Error; + +use crate::error::BlobError; + +#[derive(Debug, Error)] +pub enum DisputantError { + #[error("violation predicate did not hold against the supplied records")] + PredicateNotViolated, + #[error("blob: {0}")] + Blob(#[from] BlobError), +} diff --git a/pocs/civic-participation/resilient-civic-participation/src/disputant/mod.rs b/pocs/civic-participation/resilient-civic-participation/src/disputant/mod.rs new file mode 100644 index 0000000..415de17 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/disputant/mod.rs @@ -0,0 +1,6 @@ +pub mod error; +pub mod types; + +mod core; +pub use core::Disputant; +pub(crate) use core::leaf_ordering_violated; diff --git a/pocs/civic-participation/resilient-civic-participation/src/disputant/types.rs b/pocs/civic-participation/resilient-civic-participation/src/disputant/types.rs new file mode 100644 index 0000000..519955d --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/disputant/types.rs @@ -0,0 +1,20 @@ +//! Disputant types. + +use serde::{ + Deserialize, + Serialize, +}; + +use crate::types::{ + Bytes32, + PetitionId, +}; + +/// Context gathered before building a dispute. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DisputeContext { + pub petition_id: PetitionId, + pub batch_versioned_hash: Bytes32, + pub batch_index: u32, + pub class_set: Vec, +} diff --git a/pocs/civic-participation/resilient-civic-participation/src/error.rs b/pocs/civic-participation/resilient-civic-participation/src/error.rs new file mode 100644 index 0000000..cfc2a02 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/error.rs @@ -0,0 +1,79 @@ +//! Shared error types; per-actor errors wrap these. + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ProofError { + #[error("proof generation failed: {0}")] + Generation(String), + #[error("witness serialization failed: {0}")] + WitnessSerialization(String), + #[error("proof verification failed: {0}")] + Verification(String), +} + +#[derive(Debug, Error)] +pub enum MerkleError { + #[error("leaf index {0} out of range (size {1})")] + OutOfRange(usize, usize), + #[error("empty tree has no proof")] + EmptyTree, + #[error("merkle proof construction failed: {0}")] + ProofFailure(String), +} + +#[derive(Debug, Error)] +pub enum ImtError { + #[error("duplicate insertion: value already present in IMT")] + DuplicateInsertion, + #[error("tree depth exhausted (max {0})")] + CapacityExhausted(usize), + #[error("low-leaf invariants violated: {0}")] + LowLeafInvariant(String), + #[error("merkle: {0}")] + Merkle(#[from] MerkleError), +} + +#[derive(Debug, Error)] +pub enum BlobError { + #[error("record count {0} exceeds blob capacity {1}")] + Capacity(usize, usize), + #[error("malformed blob payload: {0}")] + Malformed(String), + #[error("KZG opening verification failed: {0}")] + InvalidOpening(String), + #[error("blob not found for batch_versioned_hash")] + NotFound, +} + +#[derive(Debug, Error)] +pub enum PredicateError { + #[error("predicate exceeds serialized length cap")] + TooLarge, + #[error("predicate has {0} tuples; bound is 1..=20")] + TupleCountOutOfRange(usize), + #[error("predicate has {0} ops; bound is 1..=20")] + OpCountOutOfRange(usize), + #[error("unknown type tag {0:#x}")] + BadTypeTag(u8), + #[error("unknown comparator {0:#x}")] + BadComparator(u8), + #[error("unknown opcode {0:#x}")] + BadOpcode(u8), + #[error("PUSH_TUPLE operand index {0} out of range (tuple_count = {1})")] + OperandOutOfRange(u8, u8), + #[error("malformed serialized predicate: {0}")] + Malformed(String), + #[error("evaluation stack underflow at op index {0}")] + StackUnderflow(usize), + #[error("evaluation produced non-singleton stack (size {0})")] + NonSingletonResult(usize), + #[error( + "missing class-binding clause `attr[class_index] == class_tag` at top-level outside OR" + )] + MissingClassBinding, + #[error("INT64 operand exceeds 64-bit range")] + Int64OperandOutOfRange, + #[error("type/comparator mismatch: {0}")] + TypeMismatch(String), +} diff --git a/pocs/civic-participation/resilient-civic-participation/src/fsrt.rs b/pocs/civic-participation/resilient-civic-participation/src/fsrt.rs new file mode 100644 index 0000000..be08eb1 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/fsrt.rs @@ -0,0 +1,502 @@ +//! FSRT chain (SPEC FSRT Chain). + +use ark_bn254::Fr; + +use crate::{ + FSRT_DEPTH, + FSRT_SLOT_COUNT, + poseidon::{ + fr_from_be_bytes, + fr_to_be_bytes, + fsrt_prg_step, + hash_merkle_node, + }, + types::{ + Bytes32, + SignerStateBytes, + }, +}; + +/// Eager expansion result, held briefly during enrollment. +pub struct ExpandedChain { + pub v_values: Vec, + pub s_seeds: Vec, +} + +/// Eagerly expand `s_0` into `n_override` (or `FSRT_SLOT_COUNT`) per-slot values. +pub fn expand_chain(s_0: Fr, n_override: Option) -> ExpandedChain { + let n = n_override.unwrap_or(FSRT_SLOT_COUNT) as usize; + let mut v_values = Vec::with_capacity(n); + let mut s_seeds = Vec::with_capacity(n); + let mut s_i = s_0; + for _ in 0..n { + s_seeds.push(s_i); + let (v_i, s_next) = fsrt_prg_step(s_i); + v_values.push(v_i); + s_i = s_next; + } + ExpandedChain { v_values, s_seeds } +} + +/// Depth-`FSRT_DEPTH` Merkle root over `v_values` via sparse construction. +pub fn compute_chain_root(v_values: &[Fr]) -> Fr { + let empty = compute_empty_subtree(); + if v_values.is_empty() { + return empty[FSRT_DEPTH]; + } + let mut layer: Vec = v_values.to_vec(); + let mut level = 0usize; + while level < FSRT_DEPTH { + if layer.len() == 1 { + return empty[level..FSRT_DEPTH] + .iter() + .fold(layer[0], |current, sib| hash_merkle_node(current, *sib)); + } + if !layer.len().is_multiple_of(2) { + layer.push(empty[level]); + } + let mut next = Vec::with_capacity(layer.len() / 2); + for pair in layer.chunks(2) { + next.push(hash_merkle_node(pair[0], pair[1])); + } + layer = next; + level += 1; + } + layer[0] +} + +/// Merkle path from `v_values[index]` to the root. +pub fn compute_path(v_values: &[Fr], index: u32) -> (Vec, Vec) { + let empty = compute_empty_subtree(); + let mut siblings = Vec::with_capacity(FSRT_DEPTH); + let mut indices = Vec::with_capacity(FSRT_DEPTH); + + let mut layer: Vec = v_values.to_vec(); + let mut idx = index as usize; + for level in 0..FSRT_DEPTH { + if layer.len() <= 1 { + for empty_sib in &empty[level..FSRT_DEPTH] { + siblings.push(*empty_sib); + indices.push(0); + } + break; + } + if !layer.len().is_multiple_of(2) { + layer.push(empty[level]); + } + let sib_idx = idx ^ 1; + let sib = if sib_idx < layer.len() { + layer[sib_idx] + } else { + empty[level] + }; + siblings.push(sib); + indices.push((idx % 2) as u8); + let mut next = Vec::with_capacity(layer.len() / 2); + for pair in layer.chunks(2) { + next.push(hash_merkle_node(pair[0], pair[1])); + } + layer = next; + idx /= 2; + } + (siblings, indices) +} + +/// Caterpillar log-space frontier (SPEC Off-Chain Signer State). +#[derive(Debug, Clone)] +pub struct Caterpillar { + frontier: [Fr; FSRT_DEPTH], + active: [Option; FSRT_DEPTH], + t: u32, + empty_subtree: [Fr; FSRT_DEPTH + 1], +} + +impl Caterpillar { + /// Empty frontier at slot 0. + pub fn empty() -> Self { + let empty_subtree = compute_empty_subtree(); + Self { + frontier: [Fr::from(0u64); FSRT_DEPTH], + active: [None; FSRT_DEPTH], + t: 0, + empty_subtree, + } + } + + pub fn slot(&self) -> u32 { + self.t + } + + /// Advance the frontier past `slot` by absorbing `v_slot`. + pub fn advance(&mut self, slot: u32, v_slot: Fr) { + assert_eq!(slot, self.t, "caterpillar: out-of-order advance"); + let mut carry = v_slot; + let mut idx = self.t; + for level in 0..FSRT_DEPTH { + if idx.is_multiple_of(2) { + self.active[level] = Some(carry); + break; + } else { + let left = self.active[level].unwrap_or(self.empty_subtree[level]); + self.frontier[level] = left; + carry = hash_merkle_node(left, carry); + self.active[level] = None; + idx /= 2; + } + } + self.t = self + .t + .checked_add(1) + .expect("caterpillar: slot counter overflow; re-enroll required"); + } + + /// Merkle path for the slot the signer is about to sign at (`= self.t`). + pub fn path_for_current_slot(&self) -> (Vec, Vec) { + let mut siblings = Vec::with_capacity(FSRT_DEPTH); + let mut indices = Vec::with_capacity(FSRT_DEPTH); + let mut idx = self.t; + for level in 0..FSRT_DEPTH { + let sib = if idx.is_multiple_of(2) { + self.empty_subtree[level] + } else { + self.active[level].unwrap_or(self.empty_subtree[level]) + }; + siblings.push(sib); + indices.push((idx % 2) as u8); + idx /= 2; + } + (siblings, indices) + } + + /// Byte form for `SignerStateBytes.caterpillar`. + pub fn to_bytes(&self) -> [Bytes32; FSRT_DEPTH] { + let mut out = [[0u8; 32]; FSRT_DEPTH]; + for (i, slot) in out.iter_mut().enumerate() { + let value = self.active[i].unwrap_or(self.empty_subtree[i]); + *slot = fr_to_be_bytes(&value); + } + out + } +} + +fn compute_empty_subtree() -> [Fr; FSRT_DEPTH + 1] { + let mut levels = [Fr::from(0u64); FSRT_DEPTH + 1]; + levels[0] = Fr::from(0u64); + for i in 1..=FSRT_DEPTH { + levels[i] = hash_merkle_node(levels[i - 1], levels[i - 1]); + } + levels +} + +/// Per-signer FSRT runtime state. `v_values` / `s_seeds` are wiped +/// via volatile writes past the last journaled slot, and on `Drop`, +/// to limit the window in which a memory-image compromise reveals +/// prior signed slots. The Vec heap allocation itself is freed by +/// the global allocator and is NOT explicitly wiped; production +/// deployments should hold the seeds in `mlock`-pinned memory. +#[derive(Debug)] +pub struct SignerChainState { + pub s_curr: Fr, + pub t: u32, + pub caterpillar: Caterpillar, + pub chain_root: Fr, + pub attr_version: u32, + v_values: Vec, + s_seeds: Vec, +} + +impl Drop for SignerChainState { + fn drop(&mut self) { + // Best-effort wipe via volatile writes. `compiler_fence` alone + // does not prevent dead-store elimination; only `write_volatile` + // creates a barrier LLVM cannot remove. + unsafe { + for v in self.v_values.iter_mut() { + core::ptr::write_volatile(v as *mut Fr, Fr::from(0u64)); + } + for s in self.s_seeds.iter_mut() { + core::ptr::write_volatile(s as *mut Fr, Fr::from(0u64)); + } + core::ptr::write_volatile(&mut self.s_curr as *mut Fr, Fr::from(0u64)); + } + core::sync::atomic::fence(core::sync::atomic::Ordering::SeqCst); + } +} + +impl SignerChainState { + /// Fresh state at enrollment. + pub fn enroll(s_0: Fr, chain_len: u32, attr_version: u32) -> Self { + let expanded = expand_chain(s_0, Some(chain_len)); + let chain_root = compute_chain_root(&expanded.v_values); + Self { + s_curr: s_0, + t: 0, + caterpillar: Caterpillar::empty(), + chain_root, + attr_version, + v_values: expanded.v_values, + s_seeds: expanded.s_seeds, + } + } + + /// Advance the ratchet head and caterpillar to `target_slot` (monotone). + pub fn advance_to(&mut self, target_slot: u32) { + assert!(target_slot < self.v_values.len() as u32); + assert!(target_slot >= self.t, "FSRT advance must be monotone"); + while self.t < target_slot { + let v = self.v_values[self.t as usize]; + self.caterpillar.advance(self.t, v); + self.t += 1; + } + } + + /// Chain length the ratchet was enrolled for. + pub fn chain_len(&self) -> u32 { + self.v_values.len() as u32 + } + + /// `v_slot` at the current `t`. + pub fn v_at_current_slot(&self) -> Fr { + self.v_values[self.t as usize] + } + + /// Per-slot seed `s_slot`; zero if zeroized. + pub fn s_at(&self, slot: u32) -> Fr { + self.s_seeds + .get(slot as usize) + .copied() + .unwrap_or(Fr::from(0u64)) + } + + /// Merkle path from `v[self.t]` to `chain_root`. + pub fn merkle_path_for_current_slot(&self) -> (Vec, Vec) { + compute_path(&self.v_values, self.t) + } + + /// SPEC Per-Signature Generation step 5 + FSRT Chain. Writes the + /// post-signing state to `journal_path` atomically (tmp file + + /// fsync + atomic rename + parent-directory fsync), and only after + /// the write durably lands does the in-memory mutation commit. This + /// ordering ensures a power-loss between in-memory and on-disk + /// state cannot leave the signer with a "future" in-memory ratchet + /// against a stale on-disk snapshot (which would let the signer + /// re-sign the same slot with a different v_slot). + pub fn journal_finalized_signing( + &mut self, + slot: u32, + journal_path: &std::path::Path, + ) -> Result { + assert_eq!( + slot, self.t, + "journal_finalized_signing: slot mismatch (self.t = {}, slot = {})", + self.t, slot + ); + + // 1. Compute the would-be next state (without mutating self). + let s_slot = self.s_seeds[slot as usize]; + let (_, s_next) = fsrt_prg_step(s_slot); + let v_slot = self.v_values[slot as usize]; + + // 2. Materialize the post-mutation state-bytes in memory. + let mut next_caterpillar = self.caterpillar.clone(); + next_caterpillar.advance(slot, v_slot); + let next_t = slot + 1; + let next_state = SignerStateBytes { + s_curr: fr_to_be_bytes(&s_next), + t: next_t, + caterpillar: next_caterpillar.to_bytes(), + chain_root: fr_to_be_bytes(&self.chain_root), + attr_version: self.attr_version, + }; + + // 3. Persist atomically: tmp file -> fsync -> rename -> + // parent-dir fsync. Only on success do we mutate self. + let parent = journal_path.parent().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "no parent dir") + })?; + let mut tmp = tempfile::NamedTempFile::new_in(parent)?; + // Serialize the state as bincode-ish: just concatenate raw + // fields. The exact wire format is the signer's responsibility; + // here we write enough bytes to detect truncation on read-back. + use std::io::Write; + tmp.write_all(&next_state.s_curr)?; + tmp.write_all(&next_state.t.to_be_bytes())?; + for chunk in &next_state.caterpillar { + tmp.write_all(chunk)?; + } + tmp.write_all(&next_state.chain_root)?; + tmp.write_all(&next_state.attr_version.to_be_bytes())?; + tmp.as_file().sync_all()?; + tmp.persist(journal_path) + .map_err(|e: tempfile::PersistError| e.error)?; + // Parent-dir fsync makes the rename(2) durable across power loss + // on POSIX. rename(2) itself is atomic for content but the + // dirent change is not durable until the dir is synced. + let dir = std::fs::File::open(parent)?; + dir.sync_all()?; + + // 4. Commit in-memory state ONLY after successful persistence. + self.s_curr = s_next; + self.caterpillar = next_caterpillar; + self.t = next_t; + // 5. Wipe past seeds via volatile writes (post-commit). + for i in 0..=slot as usize { + if i < self.v_values.len() { + unsafe { + core::ptr::write_volatile( + &mut self.v_values[i] as *mut Fr, + Fr::from(0u64), + ); + } + } + if i < self.s_seeds.len() { + unsafe { + core::ptr::write_volatile( + &mut self.s_seeds[i] as *mut Fr, + Fr::from(0u64), + ); + } + } + } + core::sync::atomic::fence(core::sync::atomic::Ordering::SeqCst); + + Ok(next_state) + } + + /// SPEC's 840-byte off-chain state. + pub fn to_bytes(&self) -> SignerStateBytes { + SignerStateBytes { + s_curr: fr_to_be_bytes(&self.s_curr), + t: self.t, + caterpillar: self.caterpillar.to_bytes(), + chain_root: fr_to_be_bytes(&self.chain_root), + attr_version: self.attr_version, + } + } +} + +/// Lift `chain_root` from wire bytes. +pub fn chain_root_from_bytes(state: &SignerStateBytes) -> Fr { + fr_from_be_bytes(&state.chain_root) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_expand_chain_is_deterministic() { + let s_0 = Fr::from(0x1234u64); + let a = expand_chain(s_0, Some(8)); + let b = expand_chain(s_0, Some(8)); + assert_eq!(a.v_values, b.v_values); + assert_eq!(a.s_seeds, b.s_seeds); + } + + #[test] + fn test_journal_finalized_signing_zeroizes_past_slots() { + let mut s = SignerChainState::enroll(Fr::from(13u64), 8, 0); + s.advance_to(0); + let s_before = s.s_at(0); + assert_ne!(s_before, Fr::from(0u64)); + let v_before = s.v_at_current_slot(); + assert_ne!(v_before, Fr::from(0u64)); + let tmpdir = tempfile::tempdir().unwrap(); + let journal_path = tmpdir.path().join("signer.journal"); + let _ = s.journal_finalized_signing(0, &journal_path).unwrap(); + assert_eq!(s.t, 1); + assert_eq!(s.s_at(0), Fr::from(0u64)); + assert_eq!(s.v_values[0], Fr::from(0u64)); + let (_, expected_s1) = crate::poseidon::fsrt_prg_step(s_before); + assert_eq!(s.s_curr, expected_s1); + } + + #[test] + fn test_expand_chain_different_seeds_diverge() { + let a = expand_chain(Fr::from(1u64), Some(8)); + let b = expand_chain(Fr::from(2u64), Some(8)); + assert_ne!(a.v_values, b.v_values); + } + + #[test] + fn test_compute_chain_root_distinct_inputs() { + let r1 = compute_chain_root(&[Fr::from(1u64), Fr::from(2u64)]); + let r2 = compute_chain_root(&[Fr::from(2u64), Fr::from(1u64)]); + assert_ne!(r1, r2); + } + + #[test] + fn test_compute_path_root_matches_full_tree() { + let v: Vec = (1..=4).map(|i| Fr::from(i as u64)).collect(); + let root = compute_chain_root(&v); + for i in 0..v.len() as u32 { + let (siblings, indices) = compute_path(&v, i); + let mut current = v[i as usize]; + for (s, &dir) in siblings.iter().zip(indices.iter()) { + current = if dir == 0 { + hash_merkle_node(current, *s) + } else { + hash_merkle_node(*s, current) + }; + } + assert_eq!(current, root, "path failed for index {i}"); + } + } + + #[test] + fn test_signer_chain_state_advance_monotone() { + let mut s = SignerChainState::enroll(Fr::from(7u64), 8, 0); + assert_eq!(s.t, 0); + s.advance_to(3); + assert_eq!(s.t, 3); + } + + #[test] + #[should_panic(expected = "FSRT advance must be monotone")] + fn test_signer_chain_state_advance_cannot_regress() { + let mut s = SignerChainState::enroll(Fr::from(7u64), 8, 0); + s.advance_to(5); + s.advance_to(2); + } + + #[test] + fn test_signer_chain_state_journal_overwrites_s_curr() { + let mut s = SignerChainState::enroll(Fr::from(7u64), 8, 0); + let pre = s.s_curr; + let tmpdir = tempfile::tempdir().unwrap(); + let journal_path = tmpdir.path().join("signer.journal"); + let _ = s.journal_finalized_signing(0, &journal_path).unwrap(); + assert_eq!(s.t, 1); + let (_, expected_s1) = crate::poseidon::fsrt_prg_step(pre); + assert_eq!(s.s_curr, expected_s1); + assert_ne!(s.s_curr, pre); + } + + #[test] + fn test_signer_chain_state_to_bytes_carries_metadata() { + let s = SignerChainState::enroll(Fr::from(7u64), 8, 5); + let b = s.to_bytes(); + assert_eq!(b.attr_version, 5); + assert_eq!(b.t, 0); + assert_ne!(b.chain_root, [0u8; 32]); + } + + #[test] + fn test_caterpillar_advance_tracks_slot_counter() { + let v: Vec = (1..=8).map(|i| Fr::from(i as u64)).collect(); + let mut c = Caterpillar::empty(); + for (i, &v_i) in v.iter().enumerate() { + assert_eq!(c.slot(), i as u32); + c.advance(i as u32, v_i); + } + assert_eq!(c.slot(), v.len() as u32); + } + + #[test] + fn test_chain_root_from_bytes_roundtrip() { + let s = SignerChainState::enroll(Fr::from(7u64), 8, 0); + let bytes = s.to_bytes(); + let restored = chain_root_from_bytes(&bytes); + assert_eq!(restored, s.chain_root); + } +} diff --git a/pocs/civic-participation/resilient-civic-participation/src/imt.rs b/pocs/civic-participation/resilient-civic-participation/src/imt.rs new file mode 100644 index 0000000..5111e03 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/imt.rs @@ -0,0 +1,463 @@ +//! Depth-D sorted-linked-list IMT engine, parameterized by `IMT_DEPTH`. + +use ark_bn254::Fr; + +use crate::{ + IMT_DEPTH, + error::ImtError, + ports::imt::{ + ImtInsertWitness, + ImtLeaf, + ImtMembership, + ImtNonMembership, + ImtPath, + ImtStore, + }, + poseidon::{ + fr_from_be_bytes, + fr_to_be_bytes, + hash_merkle_node, + poseidon4, + }, + types::Bytes32, +}; + +/// Internal leaf as `Fr`; converted to `Bytes32` at the port boundary. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct InternalLeaf { + value: Fr, + next_index: u32, + next_value: Fr, +} + +impl InternalLeaf { + fn empty() -> Self { + Self { + value: Fr::from(0u64), + next_index: 0, + next_value: Fr::from(0u64), + } + } + fn hash(&self) -> Fr { + // Matches Noir `hash_imt_leaf`: width-5 over `(value, next_index, next_value, 0)`. + poseidon4( + self.value, + Fr::from(self.next_index as u64), + self.next_value, + Fr::from(0u64), + ) + } + fn to_wire(self) -> ImtLeaf { + ImtLeaf { + value: fr_to_be_bytes(&self.value), + next_index: self.next_index, + next_value: fr_to_be_bytes(&self.next_value), + } + } +} + +/// Depth-`IMT_DEPTH` IMT with a sparse `HashMap` per level. +#[derive(Clone)] +pub struct IndexedMerkleTree { + leaves: Vec, + /// `tree[0]` is leaf hashes by index; `tree[D]` is the root. + tree: Vec>, + empty_subtree: [Fr; IMT_DEPTH + 1], +} + +impl IndexedMerkleTree { + /// Fresh tree with the empty low leaf `(0, 0, 0)` at index `0`. + pub fn new() -> Self { + let empty_subtree = compute_empty_subtree(); + let tree: Vec> = (0..=IMT_DEPTH) + .map(|_| std::collections::HashMap::new()) + .collect(); + let mut t = Self { + leaves: vec![InternalLeaf::empty()], + tree, + empty_subtree, + }; + t.set_leaf_hash(0, InternalLeaf::empty().hash()); + t + } + + fn set_leaf_hash(&mut self, i: u32, h: Fr) { + self.tree[0].insert(i as u64, h); + let mut idx: u64 = i as u64; + for level in 0..IMT_DEPTH { + let me = self.tree[level] + .get(&idx) + .copied() + .unwrap_or(self.empty_subtree[level]); + let sib = self.tree[level] + .get(&(idx ^ 1)) + .copied() + .unwrap_or(self.empty_subtree[level]); + let (left, right) = if idx.is_multiple_of(2) { + (me, sib) + } else { + (sib, me) + }; + let parent_idx = idx / 2; + let parent = hash_merkle_node(left, right); + self.tree[level + 1].insert(parent_idx, parent); + idx = parent_idx; + } + } + + pub fn root_fr(&self) -> Fr { + self.tree[IMT_DEPTH] + .get(&0) + .copied() + .unwrap_or(self.empty_subtree[IMT_DEPTH]) + } + + /// Merkle path for leaf index `i`. + fn path_for(&self, i: u32) -> ImtPath { + let mut siblings = Vec::with_capacity(IMT_DEPTH); + let mut indices = Vec::with_capacity(IMT_DEPTH); + let mut idx: u64 = i as u64; + for level in 0..IMT_DEPTH { + let sibling_idx = idx ^ 1; + let sibling = self.tree[level] + .get(&sibling_idx) + .copied() + .unwrap_or(self.empty_subtree[level]); + siblings.push(fr_to_be_bytes(&sibling)); + indices.push((idx % 2) as u8); + idx /= 2; + } + ImtPath { siblings, indices } + } + + fn find_low_leaf_index(&self, target: Fr) -> Result { + // Linear scan; PoC scale only. + let mut low_idx: Option = None; + for (i, leaf) in self.leaves.iter().enumerate() { + if leaf.value == target { + return Err(ImtError::DuplicateInsertion); + } + let is_low = leaf.value < target + && (leaf.next_value > target || leaf.next_value == Fr::from(0u64)); + if is_low { + low_idx = Some(i); + } + } + low_idx + .map(|i| i as u32) + .ok_or_else(|| ImtError::LowLeafInvariant("no low leaf found".into())) + } +} + +impl Default for IndexedMerkleTree { + fn default() -> Self { + Self::new() + } +} + +impl ImtStore for IndexedMerkleTree { + fn root(&self) -> Bytes32 { + fr_to_be_bytes(&self.root_fr()) + } + + fn size(&self) -> usize { + self.leaves.len() + } + + fn membership(&self, value: &Bytes32) -> Option { + let v = fr_from_be_bytes(value); + for (i, leaf) in self.leaves.iter().enumerate() { + if leaf.value == v { + return Some(ImtMembership { + leaf: leaf.to_wire(), + leaf_index: i as u32, + path: self.path_for(i as u32), + }); + } + } + None + } + + fn non_membership(&self, value: &Bytes32) -> Option { + let v = fr_from_be_bytes(value); + let mut low: Option<(usize, InternalLeaf)> = None; + for (i, leaf) in self.leaves.iter().enumerate() { + if leaf.value == v { + return None; + } + let is_low = leaf.value < v + && (leaf.next_value > v || leaf.next_value == Fr::from(0u64)); + if is_low { + low = Some((i, *leaf)); + } + } + low.map(|(i, leaf)| ImtNonMembership { + low_leaf: leaf.to_wire(), + low_leaf_index: i as u32, + low_leaf_path: self.path_for(i as u32), + }) + } + + fn insert(&mut self, value: &Bytes32) -> Result { + let v = fr_from_be_bytes(value); + + let cap = 1usize << IMT_DEPTH; + if self.leaves.len() >= cap { + return Err(ImtError::CapacityExhausted(IMT_DEPTH)); + } + + let low_idx = self.find_low_leaf_index(v)?; + let low_leaf_before = self.leaves[low_idx as usize]; + let new_index = self.leaves.len() as u32; + + let low_leaf_path_before = self.path_for(low_idx); + + let low_leaf_after = InternalLeaf { + value: low_leaf_before.value, + next_index: new_index, + next_value: v, + }; + let new_leaf = InternalLeaf { + value: v, + next_index: low_leaf_before.next_index, + next_value: low_leaf_before.next_value, + }; + + self.leaves[low_idx as usize] = low_leaf_after; + self.set_leaf_hash(low_idx, low_leaf_after.hash()); + self.leaves.push(new_leaf); + self.set_leaf_hash(new_index, new_leaf.hash()); + + let new_leaf_path = self.path_for(new_index); + let new_root = self.root_fr(); + + Ok(ImtInsertWitness { + low_leaf_before: low_leaf_before.to_wire(), + low_leaf_after: low_leaf_after.to_wire(), + low_leaf_index: low_idx, + low_leaf_path: low_leaf_path_before, + new_leaf: new_leaf.to_wire(), + new_leaf_index: new_index, + new_leaf_path, + new_root: fr_to_be_bytes(&new_root), + }) + } +} + +/// Precomputed `empty_subtree[i] = hash_subtree_of_height_i_of_zeros`. +fn compute_empty_subtree() -> [Fr; IMT_DEPTH + 1] { + let mut levels = [Fr::from(0u64); IMT_DEPTH + 1]; + // Level 0 = `hash_4(0, 0, 0, 0)`, matching Noir `hash_imt_leaf`. + levels[0] = poseidon4( + Fr::from(0u64), + Fr::from(0u64), + Fr::from(0u64), + Fr::from(0u64), + ); + for i in 1..=IMT_DEPTH { + levels[i] = hash_merkle_node(levels[i - 1], levels[i - 1]); + } + levels +} + +#[cfg(test)] +mod tests { + use super::*; + + fn be(n: u64) -> Bytes32 { + let mut b = [0u8; 32]; + b[24..].copy_from_slice(&n.to_be_bytes()); + b + } + + fn verify_path(leaf_hash: Fr, path: &ImtPath, expected_root: Fr) -> bool { + let mut current = leaf_hash; + for (sibling, &index) in path.siblings.iter().zip(path.indices.iter()) { + let s = fr_from_be_bytes(sibling); + current = if index == 0 { + hash_merkle_node(current, s) + } else { + hash_merkle_node(s, current) + }; + } + current == expected_root + } + + #[test] + fn test_fresh_tree_has_root_with_empty_low_leaf() { + let t = IndexedMerkleTree::new(); + let r1 = t.root_fr(); + let t2 = IndexedMerkleTree::new(); + let r2 = t2.root_fr(); + assert_eq!(r1, r2); + } + + #[test] + fn test_insert_advances_root_and_size() { + let mut t = IndexedMerkleTree::new(); + let r0 = t.root_fr(); + let _ = t.insert(&be(100)).unwrap(); + assert_ne!(t.root_fr(), r0); + assert_eq!(t.size(), 2); // empty + 100 + } + + #[test] + fn test_insert_witness_root_matches_tree_root() { + let mut t = IndexedMerkleTree::new(); + let w = t.insert(&be(42)).unwrap(); + assert_eq!(w.new_root, fr_to_be_bytes(&t.root_fr())); + } + + #[test] + fn test_duplicate_insertion_rejected() { + let mut t = IndexedMerkleTree::new(); + let _ = t.insert(&be(10)).unwrap(); + let err = t.insert(&be(10)); + assert!(matches!(err, Err(ImtError::DuplicateInsertion))); + } + + #[test] + fn test_membership_returns_path_that_verifies() { + let mut t = IndexedMerkleTree::new(); + let _ = t.insert(&be(7)).unwrap(); + let _ = t.insert(&be(3)).unwrap(); + let _ = t.insert(&be(11)).unwrap(); + let root = t.root_fr(); + let m = t.membership(&be(7)).unwrap(); + let leaf_fr = poseidon4( + fr_from_be_bytes(&m.leaf.value), + Fr::from(m.leaf.next_index as u64), + fr_from_be_bytes(&m.leaf.next_value), + Fr::from(0u64), + ); + assert!(verify_path(leaf_fr, &m.path, root)); + } + + #[test] + fn test_non_membership_returns_low_leaf_that_brackets() { + let mut t = IndexedMerkleTree::new(); + let _ = t.insert(&be(10)).unwrap(); + let _ = t.insert(&be(30)).unwrap(); + let _ = t.insert(&be(50)).unwrap(); + let nm = t.non_membership(&be(20)).unwrap(); + assert_eq!(nm.low_leaf.value, be(10)); + assert_eq!(nm.low_leaf.next_value, be(30)); + } + + #[test] + fn test_non_membership_returns_none_when_value_present() { + let mut t = IndexedMerkleTree::new(); + let _ = t.insert(&be(10)).unwrap(); + assert!(t.non_membership(&be(10)).is_none()); + } + + #[test] + fn test_membership_returns_none_when_absent() { + let t = IndexedMerkleTree::new(); + assert!(t.membership(&be(99)).is_none()); + } + + #[test] + fn test_in_order_inserts_keep_sorted_linked_list() { + let mut t = IndexedMerkleTree::new(); + for v in [10u64, 20, 30, 40] { + let _ = t.insert(&be(v)).unwrap(); + } + let mut idx = 0u32; + let mut last_value = Fr::from(0u64); + let mut count = 0; + loop { + let leaf = t.leaves[idx as usize]; + if count > 0 { + assert!(leaf.value > last_value); + last_value = leaf.value; + } + if leaf.next_value == Fr::from(0u64) { + break; + } + idx = leaf.next_index; + count += 1; + if count > 100 { + panic!("linked list cycle"); + } + } + } + + #[test] + fn test_out_of_order_inserts_still_sorted_via_low_leaf_updates() { + let mut t = IndexedMerkleTree::new(); + for v in [30u64, 10, 50, 20, 40] { + let _ = t.insert(&be(v)).unwrap(); + } + let mut idx = 0u32; + let mut last = Fr::from(0u64); + let mut visited = 0; + loop { + let leaf = t.leaves[idx as usize]; + if visited > 0 { + assert!(leaf.value > last, "values not ascending"); + last = leaf.value; + } + if leaf.next_value == Fr::from(0u64) { + break; + } + idx = leaf.next_index; + visited += 1; + if visited > 100 { + panic!("linked list cycle"); + } + } + } + + #[test] + fn test_root_changes_with_each_insert() { + let mut t = IndexedMerkleTree::new(); + let mut last = t.root_fr(); + for v in [10u64, 20, 30] { + let _ = t.insert(&be(v)).unwrap(); + let r = t.root_fr(); + assert_ne!(r, last); + last = r; + } + } + + #[test] + fn test_insert_witness_satisfies_noir_constraints() { + // Confirm the Rust witness fields satisfy the new Noir IMT constraints: + // - low_leaf_index decomposes to low_leaf_path.indices (24 LSB bits) + // - new_leaf_index decomposes to new_leaf_path.indices (24 LSB bits) + // - low_leaf_after.next_index == new_leaf_index + // - low_leaf_index != new_leaf_index (no collision) + // - new_leaf_index == prior_leaf_count + 1 (sentinel at index 0) + let mut t = IndexedMerkleTree::new(); + let prior_size = t.size(); + let w = t.insert(&be(100)).unwrap(); + + // Aztec sentinel + 1 real insert = new_leaf_index = 1. + assert_eq!(w.new_leaf_index, prior_size as u32); + assert_eq!(w.low_leaf_after.next_index, w.new_leaf_index); + assert_ne!(w.low_leaf_index, w.new_leaf_index); + + // Bit decomposition of low_leaf_index == low_leaf_path.indices + let mut x = w.low_leaf_index as u64; + for k in 0..IMT_DEPTH { + assert_eq!(w.low_leaf_path.indices[k] as u64, x & 1, "low bit {k}"); + x >>= 1; + } + // Bit decomposition of new_leaf_index == new_leaf_path.indices + let mut x = w.new_leaf_index as u64; + for k in 0..IMT_DEPTH { + assert_eq!(w.new_leaf_path.indices[k] as u64, x & 1, "new bit {k}"); + x >>= 1; + } + } + + #[test] + fn test_imt_size_includes_sentinel() { + // The sentinel empty leaf at index 0 counts in size(); the Noir + // verify_insert uses absolute index = prior_leaf_count + 1 to + // account for this. + let t = IndexedMerkleTree::new(); + assert_eq!(t.size(), 1); + } +} diff --git a/pocs/civic-participation/resilient-civic-participation/src/lib.rs b/pocs/civic-participation/resilient-civic-participation/src/lib.rs new file mode 100644 index 0000000..1669675 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/lib.rs @@ -0,0 +1,66 @@ +//! Resilient Civic Participation: Rust crate. See `SPEC.md`. + +pub mod adapters; +pub mod blob; +pub mod clock; +pub mod disputant; +pub mod error; +pub mod fsrt; +pub mod imt; +pub mod organizer; +pub mod ports; +pub mod poseidon; +pub mod predicate; +pub mod registry; +pub mod relayer; +pub mod resolver; +pub mod signer; +pub mod types; + +// Domain separator strings; scalar form lives in `poseidon::domain`. +pub const DOMAIN_NULLIFIER: &[u8] = b"RCP/nullifier/v1"; +pub const DOMAIN_IDTAG: &[u8] = b"RCP/identity_tag/v1"; +pub const DOMAIN_LEAF: &[u8] = b"RCP/leaf/v1"; +pub const DOMAIN_FSRT_PRG: &[u8] = b"RCP/fsrt_prg/v1"; +pub const DOMAIN_PRED: &[u8] = b"RCP/predicate/v1"; +pub const DOMAIN_ATTR: &[u8] = b"RCP/attr_hash/v1"; +pub const DOMAIN_BATCH_SNARK: &[u8] = b"RCP/batch_snark/v1"; +pub const DOMAIN_PETITION: &[u8] = b"RCP/petition_id/v1"; +pub const DOMAIN_RESOLUTION_SNARK: &[u8] = b"RCP/resolution_snark/v1"; + +pub const FSRT_DEPTH: usize = 24; +pub const FSRT_SLOT_COUNT: u32 = 1u32 << FSRT_DEPTH as u32; +/// PoC batch cap. SPEC permits up to 100; the recursive batch SNARK's +/// circuit size scales linearly in this value, so the PoC caps it at 6. +/// Must match `BATCH_SIZE_MAX` in `circuits/lib/src/lib.nr`. +pub const BATCH_SIZE_MAX: usize = 6; +pub const RECORDS_PER_BLOB: usize = 1000; +pub const ATTR_COUNT: usize = 4; +pub const PREDICATE_OP_MAX: usize = 20; +pub const PREDICATE_TUPLE_MAX: usize = 20; +pub const PREDICATE_BYTES_MAX: usize = 1024; +pub const RECORD_LEN: usize = 96; + +pub const BLOCKS_PER_DAY: u64 = 24 * 60 * 60 / 12; +pub const MAX_SIGNING_WINDOW_BLOCKS: u64 = 11 * BLOCKS_PER_DAY + BLOCKS_PER_DAY / 2; +pub const COOLDOWN_BLOCKS: u64 = 2 * 60 * 60 / 12; +pub const RESOLUTION_DEADLINE_BLOCKS: u64 = 14 * BLOCKS_PER_DAY; +pub const MIN_R_AGE_BLOCKS: u64 = 30 * BLOCKS_PER_DAY; +pub const IMT_DEPTH: usize = 24; +pub const RESOLUTION_CLASS_MAX: usize = 16; +/// Maximum cardinality of an Organizer's `class_set`. Must match the +/// Resolution circuit's `CLASS_MAX = 16` and the Solidity registry's +/// `if (n > 16) revert ClassSetInvalid();` check at the same value. +pub const MAX_CLASS_SET_LEN: usize = 16; + +// Const-coupling assertions: these constants are hardcoded in Noir +// circuit literals (e.g., `[bool; 24]`, `[Field; 4]`, `CLASS_MAX = 16`) +// and in Solidity (`RESOLUTION_CLASS_MAX`, `BATCH_SIZE_MAX`). Any change +// requires regenerating verifiers via `scripts/generate-verifiers.sh` +// and updating the Noir + Solidity literals. The arrays below force +// a compile error if a value drifts. +const _IMT_DEPTH_COUPLING: [(); 24] = [(); IMT_DEPTH]; +const _FSRT_DEPTH_COUPLING: [(); 24] = [(); FSRT_DEPTH]; +const _ATTR_COUNT_COUPLING: [(); 4] = [(); ATTR_COUNT]; +const _BATCH_SIZE_MAX_COUPLING: [(); 6] = [(); BATCH_SIZE_MAX]; +const _CLASS_MAX_COUPLING: [(); 16] = [(); RESOLUTION_CLASS_MAX]; diff --git a/pocs/civic-participation/resilient-civic-participation/src/organizer/core.rs b/pocs/civic-participation/resilient-civic-participation/src/organizer/core.rs new file mode 100644 index 0000000..8ca2fd6 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/organizer/core.rs @@ -0,0 +1,235 @@ +//! Organizer actor: assembles and validates a `PetitionDraft`. + +use crate::{ + MAX_CLASS_SET_LEN, + MAX_SIGNING_WINDOW_BLOCKS, + error::PredicateError, + organizer::{ + error::OrganizerError, + types::PetitionDraft, + }, + predicate::PredicateDef, + types::{ + Address, + Bytes32, + ClassTag, + U256Be, + }, +}; + +pub struct Organizer { + pub organizer_address: Address, +} + +impl Organizer { + pub fn new(organizer_address: Address) -> Self { + Self { organizer_address } + } + + /// Assemble a `PetitionDraft`, mirroring every Registry structural check. + #[allow(clippy::too_many_arguments)] + pub fn build_petition( + &self, + r_root: Bytes32, + predicate_def: PredicateDef, + salt: Bytes32, + class_set: Vec, + class_thresholds: Vec, + class_index: u8, + registration_block: u64, + close_at_block: u64, + bounty: U256Be, + ) -> Result { + if class_set.is_empty() || class_set.len() > MAX_CLASS_SET_LEN { + return Err(OrganizerError::ClassSetSize( + class_set.len(), + MAX_CLASS_SET_LEN, + )); + } + if !class_set.windows(2).all(|w| w[0] < w[1]) { + return Err(OrganizerError::ClassSetNotSorted); + } + if class_thresholds.len() != class_set.len() { + return Err(OrganizerError::ThresholdLenMismatch { + thresholds: class_thresholds.len(), + class_set: class_set.len(), + }); + } + + if class_index as usize >= MAX_CLASS_SET_LEN { + return Err(OrganizerError::ClassIndexOutOfRange(class_index)); + } + + predicate_def.validate()?; + let operand = predicate_def.find_class_binding_operand(class_index)?; + if !class_set.contains(&operand) { + return Err(OrganizerError::Predicate( + PredicateError::MissingClassBinding, + )); + } + + if close_at_block <= registration_block { + return Err(OrganizerError::CloseInPast); + } + if close_at_block - registration_block > MAX_SIGNING_WINDOW_BLOCKS { + return Err(OrganizerError::SigningWindowTooLong); + } + + Ok(PetitionDraft { + organizer: self.organizer_address, + r_root, + predicate_def, + salt, + class_set, + class_thresholds, + class_index, + close_at_block, + bounty, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + predicate::{ + Op, + PredicateDef, + Tuple, + }, + types::{ + Comparator, + OpCode, + TypeTag, + }, + }; + + fn class_tag_operand(tag: ClassTag) -> Bytes32 { + let mut b = [0u8; 32]; + b[30..].copy_from_slice(&tag.to_be_bytes()); + b + } + + fn class_only_predicate(class_index: u8, class_tag: ClassTag) -> PredicateDef { + PredicateDef { + tuples: vec![Tuple { + claim_index: class_index, + operand: class_tag_operand(class_tag), + type_tag: TypeTag::Int64, + comparator: Comparator::Eq, + }], + ops: vec![Op { + code: OpCode::PushTuple, + operand: 0, + }], + } + } + + #[test] + fn test_build_petition_basic_success() { + let org = Organizer::new([0xab; 20]); + let draft = org + .build_petition( + [0x77; 32], + class_only_predicate(2, 840), + [0x99; 32], + vec![840], + vec![100], + 2, + 1000, + 2000, + U256Be::from_u64(1_000_000), + ) + .unwrap(); + assert_eq!(draft.class_set, vec![840]); + } + + #[test] + fn test_build_petition_rejects_empty_class_set() { + let org = Organizer::new([0xab; 20]); + let err = org.build_petition( + [0x77; 32], + class_only_predicate(2, 840), + [0x99; 32], + vec![], + vec![], + 2, + 1000, + 2000, + U256Be::from_u64(1_000_000), + ); + assert!(matches!(err, Err(OrganizerError::ClassSetSize(0, _)))); + } + + #[test] + fn test_build_petition_rejects_unsorted_class_set() { + let org = Organizer::new([0xab; 20]); + let err = org.build_petition( + [0x77; 32], + class_only_predicate(2, 840), + [0x99; 32], + vec![900, 840], + vec![1, 1], + 2, + 1000, + 2000, + U256Be::from_u64(1_000_000), + ); + assert!(matches!(err, Err(OrganizerError::ClassSetNotSorted))); + } + + #[test] + fn test_build_petition_rejects_threshold_mismatch() { + let org = Organizer::new([0xab; 20]); + let err = org.build_petition( + [0x77; 32], + class_only_predicate(2, 840), + [0x99; 32], + vec![840, 900], + vec![100], + 2, + 1000, + 2000, + U256Be::from_u64(1_000_000), + ); + assert!(matches!( + err, + Err(OrganizerError::ThresholdLenMismatch { .. }) + )); + } + + #[test] + fn test_build_petition_rejects_close_in_past() { + let org = Organizer::new([0xab; 20]); + let err = org.build_petition( + [0x77; 32], + class_only_predicate(2, 840), + [0x99; 32], + vec![840], + vec![1], + 2, + 1000, + 999, + U256Be::from_u64(1_000_000), + ); + assert!(matches!(err, Err(OrganizerError::CloseInPast))); + } + + #[test] + fn test_build_petition_rejects_long_signing_window() { + let org = Organizer::new([0xab; 20]); + let err = org.build_petition( + [0x77; 32], + class_only_predicate(2, 840), + [0x99; 32], + vec![840], + vec![1], + 2, + 1000, + 1000 + MAX_SIGNING_WINDOW_BLOCKS + 1, + U256Be::from_u64(1_000_000), + ); + assert!(matches!(err, Err(OrganizerError::SigningWindowTooLong))); + } +} diff --git a/pocs/civic-participation/resilient-civic-participation/src/organizer/error.rs b/pocs/civic-participation/resilient-civic-participation/src/organizer/error.rs new file mode 100644 index 0000000..c2c72c1 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/organizer/error.rs @@ -0,0 +1,21 @@ +use thiserror::Error; + +use crate::error::PredicateError; + +#[derive(Debug, Error)] +pub enum OrganizerError { + #[error("class_set has {0} entries; bound is 1..={1}")] + ClassSetSize(usize, usize), + #[error("class_thresholds.len() ({thresholds}) != class_set.len() ({class_set})")] + ThresholdLenMismatch { thresholds: usize, class_set: usize }, + #[error("class_index {0} not in class_set")] + ClassIndexOutOfRange(u8), + #[error("class_set must be strictly increasing")] + ClassSetNotSorted, + #[error("predicate: {0}")] + Predicate(#[from] PredicateError), + #[error("close_at_block is in the past relative to registration_block")] + CloseInPast, + #[error("signing window exceeds the SPEC limit of 11.5 days")] + SigningWindowTooLong, +} diff --git a/pocs/civic-participation/resilient-civic-participation/src/organizer/mod.rs b/pocs/civic-participation/resilient-civic-participation/src/organizer/mod.rs new file mode 100644 index 0000000..c4c4dd6 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/organizer/mod.rs @@ -0,0 +1,5 @@ +pub mod error; +pub mod types; + +mod core; +pub use core::Organizer; diff --git a/pocs/civic-participation/resilient-civic-participation/src/organizer/types.rs b/pocs/civic-participation/resilient-civic-participation/src/organizer/types.rs new file mode 100644 index 0000000..4973840 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/organizer/types.rs @@ -0,0 +1,30 @@ +//! Organizer-side types. + +use serde::{ + Deserialize, + Serialize, +}; + +use crate::{ + predicate::PredicateDef, + types::{ + Address, + Bytes32, + ClassTag, + U256Be, + }, +}; + +/// SPEC Petition Registration `register(petition_data, B)` args. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PetitionDraft { + pub organizer: Address, + pub r_root: Bytes32, + pub predicate_def: PredicateDef, + pub salt: Bytes32, + pub class_set: Vec, + pub class_thresholds: Vec, + pub class_index: u8, + pub close_at_block: u64, + pub bounty: U256Be, +} diff --git a/pocs/civic-participation/resilient-civic-participation/src/ports/blob.rs b/pocs/civic-participation/resilient-civic-participation/src/ports/blob.rs new file mode 100644 index 0000000..19af889 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/ports/blob.rs @@ -0,0 +1,35 @@ +//! Blob-carrier port; EIP-4844 in production, in-memory in the PoC adapter. + +use crate::{ + error::BlobError, + types::{ + Bytes32, + KzgOpening, + RecordEntry, + }, +}; + +pub trait BlobCarrier: Send + Sync { + /// Publish a batch's record payload; returns `batch_versioned_hash`. + fn publish(&mut self, records: &[RecordEntry]) -> Result; + + /// Open a single BLS12-381 field element at `field_element_index`. + fn open( + &self, + batch_versioned_hash: &Bytes32, + field_element_index: u32, + ) -> Result; + + /// Verify a single opening against `batch_versioned_hash`. + fn verify( + &self, + batch_versioned_hash: &Bytes32, + opening: &KzgOpening, + ) -> Result<(), BlobError>; + + /// Read back the full record set for `batch_versioned_hash`. + fn fetch_records( + &self, + batch_versioned_hash: &Bytes32, + ) -> Result, BlobError>; +} diff --git a/pocs/civic-participation/resilient-civic-participation/src/ports/imt.rs b/pocs/civic-participation/resilient-civic-participation/src/ports/imt.rs new file mode 100644 index 0000000..c00e4eb --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/ports/imt.rs @@ -0,0 +1,84 @@ +//! Indexed Merkle Tree port (Aztec-style, depth 24). + +use serde::{ + Deserialize, + Serialize, +}; + +use crate::{ + error::ImtError, + types::Bytes32, +}; + +/// Sorted-linked-list IMT entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct ImtLeaf { + pub value: Bytes32, + pub next_index: u32, + pub next_value: Bytes32, +} + +impl ImtLeaf { + pub const ZERO: Self = Self { + value: [0u8; 32], + next_index: 0, + next_value: [0u8; 32], + }; + pub fn is_zero(&self) -> bool { + *self == Self::ZERO + } +} + +/// Merkle path (siblings + indices) for an IMT leaf hash. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImtPath { + pub siblings: Vec, + pub indices: Vec, +} + +/// Membership witness for a leaf. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImtMembership { + pub leaf: ImtLeaf, + pub leaf_index: u32, + pub path: ImtPath, +} + +/// Non-membership witness via the bracketing low leaf. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImtNonMembership { + pub low_leaf: ImtLeaf, + pub low_leaf_index: u32, + pub low_leaf_path: ImtPath, +} + +/// IMT insertion witness consumed by batch SNARK constraint 6. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImtInsertWitness { + pub low_leaf_before: ImtLeaf, + pub low_leaf_after: ImtLeaf, + pub low_leaf_index: u32, + pub low_leaf_path: ImtPath, + pub new_leaf: ImtLeaf, + pub new_leaf_index: u32, + pub new_leaf_path: ImtPath, + /// Root after inserting `new_leaf` and rewriting the low leaf. + pub new_root: Bytes32, +} + +pub trait ImtStore: Send + Sync { + /// Current root (BE 32 bytes); `[0u8; 32]` means uninitialized. + fn root(&self) -> Bytes32; + + /// Inserted-value count (low-leaf + appended leaves). + fn size(&self) -> usize; + + /// Membership witness if `value` is present. + fn membership(&self, value: &Bytes32) -> Option; + + /// Non-membership witness for `value`; `None` if present. + fn non_membership(&self, value: &Bytes32) -> Option; + + /// Insert `value`; errors on duplicates and capacity exhaustion. + fn insert(&mut self, value: &Bytes32) -> Result; +} diff --git a/pocs/civic-participation/resilient-civic-participation/src/ports/mod.rs b/pocs/civic-participation/resilient-civic-participation/src/ports/mod.rs new file mode 100644 index 0000000..aadb768 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/ports/mod.rs @@ -0,0 +1,7 @@ +//! Hexagonal seams; mock/in-memory implementations live in `crate::adapters`. + +pub mod blob; +pub mod imt; +pub mod proof; +pub mod ri; +pub mod submission; diff --git a/pocs/civic-participation/resilient-civic-participation/src/ports/proof.rs b/pocs/civic-participation/resilient-civic-participation/src/ports/proof.rs new file mode 100644 index 0000000..812046f --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/ports/proof.rs @@ -0,0 +1,65 @@ +//! Proof-backend port for signer, batch, and resolution SNARKs. + +use crate::{ + error::ProofError, + ports::imt::ImtInsertWitness, + types::{ + BatchPublicInputs, + ResolutionPrivateInputs, + ResolutionPublicInputs, + SignerPrivateInputs, + SignerPublicInputs, + SignerSubmission, + }, +}; + +/// Per-position batch witness: signer SNARK plus relayer-extracted tuple. +#[derive(Debug, Clone)] +pub struct BatchPositionWitness { + pub submission: SignerSubmission, + /// Cached `Fr` form of the public inputs, populated by the relayer. + pub public_inputs: SignerPublicInputs, + /// Running-root IMT insertion witness; required by the BB backend. + pub running_insert: Option, + /// Identity-tag-set IMT insertion witness. + pub idtag_insert: Option, +} + +pub trait ProofBackend: Send + Sync { + fn generate_signer_proof( + &self, + public: &SignerPublicInputs, + private: &SignerPrivateInputs, + ) -> Result, ProofError>; + + fn generate_batch_proof( + &self, + public: &BatchPublicInputs, + positions: &[BatchPositionWitness], + ) -> Result, ProofError>; + + fn generate_resolution_proof( + &self, + public: &ResolutionPublicInputs, + private: &ResolutionPrivateInputs, + ) -> Result, ProofError>; + + /// Registry-side verification (on-chain in production). + fn verify_signer_proof( + &self, + proof: &[u8], + public: &SignerPublicInputs, + ) -> Result<(), ProofError>; + + fn verify_batch_proof( + &self, + proof: &[u8], + public: &BatchPublicInputs, + ) -> Result<(), ProofError>; + + fn verify_resolution_proof( + &self, + proof: &[u8], + public: &ResolutionPublicInputs, + ) -> Result<(), ProofError>; +} diff --git a/pocs/civic-participation/resilient-civic-participation/src/ports/ri.rs b/pocs/civic-participation/resilient-civic-participation/src/ports/ri.rs new file mode 100644 index 0000000..833d55c --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/ports/ri.rs @@ -0,0 +1,31 @@ +//! ResilientIdentity credential-layer port. + +use serde::{ + Deserialize, + Serialize, +}; + +use crate::{ + error::MerkleError, + types::Bytes32, +}; + +/// RI Merkle path; siblings are `Bytes32` big-endian. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RiPath { + pub siblings: Vec, + pub indices: Vec, +} + +pub trait RiCredentialLayer: Send + Sync { + /// Append `attr_hash` as the next RI leaf; returns the leaf index. + fn append_leaf(&mut self, attr_hash: Bytes32, posted_at_block: u64) -> u32; + + fn root(&self) -> Bytes32; + + /// Merkle path for `leaf_index`; signers MUST query via an anonymous transport to preserve unlinkability. + fn merkle_path(&self, leaf_index: u32) -> Result; + + /// Block at which `root` was first current, or `None`. + fn root_first_seen(&self, root: &Bytes32) -> Option; +} diff --git a/pocs/civic-participation/resilient-civic-participation/src/ports/submission.rs b/pocs/civic-participation/resilient-civic-participation/src/ports/submission.rs new file mode 100644 index 0000000..41c463b --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/ports/submission.rs @@ -0,0 +1,22 @@ +//! Signer-to-relayer submission seam (in-process adapter ships with the PoC). + +use thiserror::Error; + +use crate::types::SignerSubmission; + +#[derive(Debug, Error)] +pub enum SubmissionError { + #[error("relay not in roster")] + UnknownRelay, + #[error("relay rejected submission: {0}")] + Rejected(String), +} + +pub trait RelaySubmission: Send + Sync { + /// Push a submission addressed to `relay_id`. + fn submit( + &self, + relay_id: &[u8; 32], + submission: SignerSubmission, + ) -> Result<(), SubmissionError>; +} diff --git a/pocs/civic-participation/resilient-civic-participation/src/poseidon.rs b/pocs/civic-participation/resilient-civic-participation/src/poseidon.rs new file mode 100644 index 0000000..c35e3eb --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/poseidon.rs @@ -0,0 +1,542 @@ +//! Poseidon1 hashing primitives (SPEC Primitives). + +use std::sync::OnceLock; + +use ark_bn254::Fr; +use ark_ff::{ + BigInteger, + Field, + PrimeField, +}; +use light_poseidon::{ + Poseidon, + PoseidonHasher, + PoseidonParameters, +}; +/// Decode a 32-byte big-endian buffer into an Fr. +pub fn fr_from_be_bytes(bytes: &[u8]) -> Fr { + Fr::from_be_bytes_mod_order(bytes) +} + +/// Encode an Fr into 32 bytes, big-endian. +pub fn fr_to_be_bytes(fr: &Fr) -> [u8; 32] { + let be = fr.into_bigint().to_bytes_be(); + let mut out = [0u8; 32]; + out[32 - be.len()..].copy_from_slice(&be); + out +} + +/// Width-2 Poseidon1; merkle nodes use this without a domain tag. +pub fn poseidon2(a: Fr, b: Fr) -> Fr { + let mut h = Poseidon::::new_circom(2).expect("circom-2"); + h.hash(&[a, b]).expect("poseidon2") +} + +pub fn poseidon3(a: Fr, b: Fr, c: Fr) -> Fr { + let mut h = Poseidon::::new_circom(3).expect("circom-3"); + h.hash(&[a, b, c]).expect("poseidon3") +} + +pub fn poseidon4(a: Fr, b: Fr, c: Fr, d: Fr) -> Fr { + let mut h = Poseidon::::new_circom(4).expect("circom-4"); + h.hash(&[a, b, c, d]).expect("poseidon4") +} + +pub fn poseidon5(inputs: [Fr; 5]) -> Fr { + let mut h = Poseidon::::new_circom(5).expect("circom-5"); + h.hash(&inputs).expect("poseidon5") +} + +/// Variable-width hash for `2..=12` inputs. +pub fn poseidon_n(inputs: &[Fr]) -> Fr { + let n = inputs.len(); + assert!( + (2..=12).contains(&n), + "poseidon_n: width must be 2..=12, got {n}" + ); + let mut h = Poseidon::::new_circom(n).expect("circom-n"); + h.hash(inputs).expect("poseidon_n") +} + +/// BN254 t=5 Poseidon parameters (circom-style), cached on first use. +fn bn254_t5_params() -> &'static PoseidonParameters { + static PARAMS: OnceLock> = OnceLock::new(); + PARAMS.get_or_init(|| { + light_poseidon::parameters::bn254_x5::get_poseidon_parameters::(5) + .expect("bn254 x5 t=5 params") + }) +} + +/// Standard Poseidon-128 permutation over `[Fr; 5]` (BN254, t=5, R_F=8, +/// R_P=60, alpha=5). Produces the same end-state as the Noir +/// `poseidon::permute(consts::x5_5_config(), state)` instance the +/// circuits use; the equivalence is empirically pinned by the +/// `hash_4` cross-check test below. +pub fn permute_t5(state: &mut [Fr; 5]) { + let params = bn254_t5_params(); + let half = params.full_rounds / 2; + let total = params.full_rounds + params.partial_rounds; + let alpha = [params.alpha]; + + for round in 0..half { + for i in 0..5 { + state[i] += params.ark[round * 5 + i]; + } + for s in state.iter_mut() { + *s = s.pow(alpha); + } + apply_mds_t5(state, ¶ms.mds); + } + for round in half..(half + params.partial_rounds) { + for i in 0..5 { + state[i] += params.ark[round * 5 + i]; + } + state[0] = state[0].pow(alpha); + apply_mds_t5(state, ¶ms.mds); + } + for round in (half + params.partial_rounds)..total { + for i in 0..5 { + state[i] += params.ark[round * 5 + i]; + } + for s in state.iter_mut() { + *s = s.pow(alpha); + } + apply_mds_t5(state, ¶ms.mds); + } +} + +fn apply_mds_t5(state: &mut [Fr; 5], mds: &[Vec]) { + let mut next = [Fr::from(0u64); 5]; + for i in 0..5 { + for j in 0..5 { + next[i] += state[j] * mds[i][j]; + } + } + *state = next; +} + +/// Poseidon1 sponge: rate 4, capacity 1, `t = 5`, 0x80-prefixed padding. +pub struct Poseidon1Sponge { + state: [Fr; 5], +} + +impl Poseidon1Sponge { + /// Fresh sponge with `domain` seated in the capacity element. + pub fn with_domain(domain: Fr) -> Self { + Self { + state: [ + domain, + Fr::from(0u64), + Fr::from(0u64), + Fr::from(0u64), + Fr::from(0u64), + ], + } + } + + /// Absorb with 0x80-prefixed length-padding to a multiple of `r = 4`. + pub fn absorb(&mut self, message: &[Fr]) { + let mut padded: Vec = message.to_vec(); + padded.push(Fr::from(0x80u64)); + while !padded.len().is_multiple_of(4) { + padded.push(Fr::from(0u64)); + } + for chunk in padded.chunks(4) { + for (i, &x) in chunk.iter().enumerate() { + self.state[i + 1] += x; + } + self.permute(); + } + } + + /// Squeeze `n` scalars, re-permuting between each `r = 4` block. + pub fn squeeze(&mut self, n: usize) -> Vec { + let mut out = Vec::with_capacity(n); + while out.len() < n { + for i in 0..4 { + if out.len() == n { + return out; + } + out.push(self.state[i + 1]); + } + if out.len() < n { + self.permute(); + } + } + out + } + + fn permute(&mut self) { + permute_t5(&mut self.state); + } +} + +// Domain tags as small distinct integers, matching `circuits/lib/src/domain.nr`. +pub fn domain_nullifier() -> Fr { + Fr::from(1u64) +} +pub fn domain_identity_tag() -> Fr { + Fr::from(2u64) +} +pub fn domain_leaf() -> Fr { + Fr::from(3u64) +} +pub fn domain_fsrt_prg() -> Fr { + Fr::from(4u64) +} +pub fn domain_predicate() -> Fr { + Fr::from(5u64) +} +pub fn domain_attr() -> Fr { + Fr::from(6u64) +} +pub fn domain_batch_snark() -> Fr { + Fr::from(7u64) +} +pub fn domain_petition() -> Fr { + Fr::from(8u64) +} +pub fn domain_resolution_snark() -> Fr { + Fr::from(9u64) +} + +/// `nullifier = Poseidon1(DOMAIN_NULLIFIER, v_slot, petition_id, +/// class_index, class_tag, identity_secret)`. identity_secret binds the +/// nullifier to a specific signer secret (also bound into attr_hash); +/// this enforces "one signature per RI leaf per petition" even when +/// s_0 is compromised in isolation. +pub fn hash_nullifier( + v_slot: Fr, + petition_id: Fr, + class_index: Fr, + class_tag: Fr, + identity_secret: Fr, +) -> Fr { + poseidon_n(&[ + domain_nullifier(), + v_slot, + petition_id, + class_index, + class_tag, + identity_secret, + ]) +} + +/// `identity_tag = Poseidon1(DOMAIN_IDTAG, v_slot, petition_id)`. +pub fn hash_identity_tag(v_slot: Fr, petition_id: Fr) -> Fr { + poseidon3(domain_identity_tag(), v_slot, petition_id) +} + +/// `leaf = Poseidon1(DOMAIN_LEAF, nullifier, class_tag)`. +pub fn hash_leaf(nullifier: Fr, class_tag: Fr) -> Fr { + poseidon3(domain_leaf(), nullifier, class_tag) +} + +/// Merkle internal node, no domain tag. +pub fn hash_merkle_node(left: Fr, right: Fr) -> Fr { + poseidon2(left, right) +} + +/// `attr_hash = hash_4(hash_5([DOMAIN_ATTR, attr_0..attr_3]), +/// chain_root, attr_version, identity_secret)`. identity_secret binds +/// the RI leaf to a specific signer-held secret, so an attacker who +/// learns the FSRT seed alone cannot enroll under the same RI leaf. +pub fn hash_attr( + attr_vector: &[Fr], + chain_root: Fr, + attr_version: u32, + identity_secret: Fr, +) -> Fr { + assert!( + attr_vector.len() == 4, + "hash_attr expects exactly 4 attrs (ATTR_COUNT)" + ); + let h1 = poseidon5([ + domain_attr(), + attr_vector[0], + attr_vector[1], + attr_vector[2], + attr_vector[3], + ]); + poseidon4( + h1, + chain_root, + Fr::from(attr_version as u64), + identity_secret, + ) +} + +/// Iterated `hash_2` chain over `[DOMAIN_PRED, canonical..., petition_id, salt]`. +pub fn hash_predicate(canonical_predicate_def: &[Fr], petition_id: Fr, salt: Fr) -> Fr { + let mut acc = domain_predicate(); + for x in canonical_predicate_def { + acc = poseidon2(acc, *x); + } + acc = poseidon2(acc, petition_id); + poseidon2(acc, salt) +} + +/// FSRT PRG step (SPEC FSRT Chain): +/// `(v_i, s_{i+1}) = Poseidon1Sponge.absorb(DOMAIN_FSRT_PRG, s_i).squeeze(2)`. +pub fn fsrt_prg_step(s_i: Fr) -> (Fr, Fr) { + let mut sponge = Poseidon1Sponge::with_domain(domain_fsrt_prg()); + sponge.absorb(&[s_i]); + let out = sponge.squeeze(2); + (out[0], out[1]) +} + +/// `petition_id = keccak256(DOMAIN_PETITION_ID || chain_id || registry || organizer || S || predicate_hash_pre_id || close_at_block)`. +/// Matches `PetitionRegistry._derivePetitionId`: prefix is `keccak256("RCP/petition_id/v1")`, +/// and the top byte of the output is masked so the result fits in BN254 Fr. +pub fn derive_petition_id( + chain_id: u64, + registry_address: &[u8; 20], + organizer: &[u8; 20], + s_at_registration: u32, + predicate_hash_pre_id: &[u8; 32], + close_at_block: u64, +) -> [u8; 32] { + use sha3::{ + Digest, + Keccak256, + }; + + let domain = Keccak256::digest(crate::DOMAIN_PETITION); + let mut h = Keccak256::new(); + h.update(domain); + h.update(chain_id.to_be_bytes()); + h.update(registry_address); + h.update(organizer); + h.update(s_at_registration.to_be_bytes()); + h.update(predicate_hash_pre_id); + h.update(close_at_block.to_be_bytes()); + let mut out = [0u8; 32]; + out.copy_from_slice(&h.finalize()); + out[0] = 0; + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_poseidon_helpers_deterministic() { + let a = Fr::from(1u64); + let b = Fr::from(2u64); + assert_eq!(poseidon2(a, b), poseidon2(a, b)); + assert_eq!(poseidon3(a, b, a), poseidon3(a, b, a)); + assert_eq!(poseidon5([a, b, a, b, a]), poseidon5([a, b, a, b, a])); + } + + #[test] + fn test_poseidon_order_matters() { + let a = Fr::from(1u64); + let b = Fr::from(2u64); + assert_ne!(poseidon2(a, b), poseidon2(b, a)); + } + + #[test] + fn test_fr_be_bytes_roundtrip() { + let f = Fr::from(123456789u64); + let be = fr_to_be_bytes(&f); + assert_eq!(fr_from_be_bytes(&be), f); + } + + #[test] + fn test_domain_constants_distinct() { + let domains: [Fr; 9] = [ + domain_nullifier(), + domain_identity_tag(), + domain_leaf(), + domain_fsrt_prg(), + domain_predicate(), + domain_attr(), + domain_batch_snark(), + domain_petition(), + domain_resolution_snark(), + ]; + for i in 0..domains.len() { + for j in (i + 1)..domains.len() { + assert_ne!( + domains[i], domains[j], + "domain constants {i} and {j} collided" + ); + } + } + } + + #[test] + fn test_hash_nullifier_and_identity_tag_are_distinct_under_same_inputs() { + let v = Fr::from(11u64); + let p = Fr::from(22u64); + let c_idx = Fr::from(0u64); + let c_tag = Fr::from(840u64); + let id_secret = Fr::from(0xc0ffeeu64); + let n = hash_nullifier(v, p, c_idx, c_tag, id_secret); + let t = hash_identity_tag(v, p); + assert_ne!(n, t); + } + + #[test] + fn test_hash_leaf_is_deterministic_and_sensitive() { + let n = Fr::from(7u64); + let c = Fr::from(42u64); + let a = hash_leaf(n, c); + let b = hash_leaf(n, c); + assert_eq!(a, b); + let c2 = hash_leaf(n + Fr::from(1u64), c); + assert_ne!(a, c2); + } + + #[test] + fn test_hash_attr_includes_chain_root_and_version() { + let attrs = [ + Fr::from(1u64), + Fr::from(2u64), + Fr::from(3u64), + Fr::from(4u64), + ]; + let cr = Fr::from(99u64); + let id_secret = Fr::from(0xc0ffeeu64); + let h1 = hash_attr(&attrs, cr, 0, id_secret); + let h2 = hash_attr(&attrs, cr, 1, id_secret); + assert_ne!(h1, h2); + let h3 = hash_attr(&attrs, cr + Fr::from(1u64), 0, id_secret); + assert_ne!(h1, h3); + let h4 = hash_attr(&attrs, cr, 0, id_secret + Fr::from(1u64)); + assert_ne!(h1, h4); + } + + #[test] + fn test_fsrt_prg_step_deterministic_and_two_outputs() { + let s = Fr::from(0x12345678u64); + let (a1, b1) = fsrt_prg_step(s); + let (a2, b2) = fsrt_prg_step(s); + assert_eq!((a1, b1), (a2, b2)); + assert_ne!(a1, b1); + } + + #[test] + fn test_fsrt_prg_step_different_seeds_diverge() { + let s1 = Fr::from(1u64); + let s2 = Fr::from(2u64); + assert_ne!(fsrt_prg_step(s1), fsrt_prg_step(s2)); + } + + #[test] + fn test_fsrt_prg_second_output_varies_with_seed() { + let outputs: Vec = [1u64, 2, 3, 7, 13, 100, 0x1234] + .iter() + .map(|&seed| fsrt_prg_step(Fr::from(seed)).1) + .collect(); + for i in 0..outputs.len() { + for j in (i + 1)..outputs.len() { + assert_ne!( + outputs[i], outputs[j], + "FSRT PRG produces colliding s_{{i+1}} for distinct s_i" + ); + } + } + } + + #[test] + fn test_derive_petition_id_changes_with_every_input() { + let base = derive_petition_id(1, &[0xaa; 20], &[0xbb; 20], 0, &[0xcc; 32], 1000); + let p2 = derive_petition_id(2, &[0xaa; 20], &[0xbb; 20], 0, &[0xcc; 32], 1000); + assert_ne!(base, p2); + let p3 = derive_petition_id(1, &[0xab; 20], &[0xbb; 20], 0, &[0xcc; 32], 1000); + assert_ne!(base, p3); + let p4 = derive_petition_id(1, &[0xaa; 20], &[0xbc; 20], 0, &[0xcc; 32], 1000); + assert_ne!(base, p4); + let p5 = derive_petition_id(1, &[0xaa; 20], &[0xbb; 20], 1, &[0xcc; 32], 1000); + assert_ne!(base, p5); + let p6 = derive_petition_id(1, &[0xaa; 20], &[0xbb; 20], 0, &[0xcd; 32], 1000); + assert_ne!(base, p6); + let p7 = derive_petition_id(1, &[0xaa; 20], &[0xbb; 20], 0, &[0xcc; 32], 1001); + assert_ne!(base, p7); + } + + #[test] + fn test_sponge_absorb_one_squeeze_one_distinct_per_input() { + let mut a = Poseidon1Sponge::with_domain(domain_fsrt_prg()); + a.absorb(&[Fr::from(1u64)]); + let out_a = a.squeeze(1)[0]; + + let mut b = Poseidon1Sponge::with_domain(domain_fsrt_prg()); + b.absorb(&[Fr::from(2u64)]); + let out_b = b.squeeze(1)[0]; + + assert_ne!(out_a, out_b); + } + + #[test] + fn test_sponge_domain_changes_output() { + let mut a = Poseidon1Sponge::with_domain(domain_fsrt_prg()); + a.absorb(&[Fr::from(1u64)]); + let out_a = a.squeeze(1)[0]; + + let mut b = Poseidon1Sponge::with_domain(domain_predicate()); + b.absorb(&[Fr::from(1u64)]); + let out_b = b.squeeze(1)[0]; + + assert_ne!(out_a, out_b); + } + + #[test] + #[ignore = "prints values used to pin Noir cross-impl tests"] + fn print_fsrt_test_vectors() { + for seed in [1u64, 2, 7, 0x1234_5678u64] { + let (v, s) = fsrt_prg_step(Fr::from(seed)); + println!( + "seed={seed:#x}: v=0x{}, s=0x{}", + hex::encode(fr_to_be_bytes(&v)), + hex::encode(fr_to_be_bytes(&s)), + ); + } + } + + #[test] + fn test_permute_t5_matches_light_poseidon_hash_4() { + // hash_4(inputs) = state[0] after permute([0, inputs[0..4]]). + // Pinning this equivalence against light-poseidon ensures the + // Noir circuits (which use poseidon::permute on the same t=5 + // parameter set) and Rust produce identical sponge outputs. + let trials: [[u64; 4]; 4] = [ + [0, 0, 0, 0], + [1, 2, 3, 4], + [0x1234_5678, 0xabcd_ef00, 0xdead_beef, 0xfeed_face], + [u64::MAX, u64::MAX - 1, u64::MAX - 2, u64::MAX - 3], + ]; + for trial in trials.iter() { + let inputs = [ + Fr::from(trial[0]), + Fr::from(trial[1]), + Fr::from(trial[2]), + Fr::from(trial[3]), + ]; + let mut h = Poseidon::::new_circom(4).expect("circom-4"); + let expected = h.hash(&inputs).expect("hash"); + + let mut state = [Fr::from(0u64); 5]; + state[1..5].copy_from_slice(&inputs); + permute_t5(&mut state); + + assert_eq!( + state[0], expected, + "permute_t5 disagrees with light_poseidon::hash_4 on {trial:?}" + ); + } + } + + #[test] + fn test_hash_predicate_changes_with_inputs() { + let canonical = vec![Fr::from(1u64), Fr::from(2u64), Fr::from(3u64)]; + let salt_a = Fr::from(10u64); + let salt_b = Fr::from(11u64); + let pid = Fr::from(99u64); + let h_a = hash_predicate(&canonical, pid, salt_a); + let h_b = hash_predicate(&canonical, pid, salt_b); + assert_ne!(h_a, h_b); + } +} diff --git a/pocs/civic-participation/resilient-civic-participation/src/predicate.rs b/pocs/civic-participation/resilient-civic-participation/src/predicate.rs new file mode 100644 index 0000000..4314dc4 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/predicate.rs @@ -0,0 +1,740 @@ +//! Predicate grammar, encoding, evaluator, canonical hashing (SPEC Predicate). + +use ark_bn254::Fr; +use serde::{ + Deserialize, + Serialize, +}; + +use crate::{ + PREDICATE_BYTES_MAX, + PREDICATE_OP_MAX, + PREDICATE_TUPLE_MAX, + error::PredicateError, + poseidon::fr_from_be_bytes, + types::{ + Bytes32, + ClassTag, + Comparator, + OpCode, + TypeTag, + }, +}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Tuple { + pub claim_index: u8, + pub operand: Bytes32, + pub type_tag: TypeTag, + pub comparator: Comparator, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct Op { + pub code: OpCode, + pub operand: u8, +} + +/// In-memory predicate definition. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PredicateDef { + pub tuples: Vec, + pub ops: Vec, +} + +impl PredicateDef { + pub fn op_count(&self) -> usize { + self.ops.len() + } + pub fn tuple_count(&self) -> usize { + self.tuples.len() + } + + /// Structural validation; class binding via `validate_class_binding`. + pub fn validate(&self) -> Result<(), PredicateError> { + if !(1..=PREDICATE_TUPLE_MAX).contains(&self.tuples.len()) { + return Err(PredicateError::TupleCountOutOfRange(self.tuples.len())); + } + if !(1..=PREDICATE_OP_MAX).contains(&self.ops.len()) { + return Err(PredicateError::OpCountOutOfRange(self.ops.len())); + } + for op in &self.ops { + if op.code == OpCode::PushTuple { + if (op.operand as usize) >= self.tuples.len() { + return Err(PredicateError::OperandOutOfRange( + op.operand, + self.tuples.len() as u8, + )); + } + } else if op.operand != 0 { + return Err(PredicateError::Malformed(format!( + "non-PUSH op {:?} has nonzero operand {}", + op.code, op.operand + ))); + } + } + for (i, t) in self.tuples.iter().enumerate() { + match (t.type_tag, t.comparator) { + (TypeTag::Int64, _) => { + if !is_int64_in_range(&t.operand) { + return Err(PredicateError::Int64OperandOutOfRange); + } + } + (TypeTag::Hash, Comparator::Eq) => {} + (TypeTag::Hash, _) => { + return Err(PredicateError::TypeMismatch(format!( + "tuple {i}: HASH supports `==` only" + ))); + } + (TypeTag::Bool, Comparator::Eq) => { + if !is_bool_operand(&t.operand) { + return Err(PredicateError::TypeMismatch(format!( + "tuple {i}: BOOL operand must be 0 or 1" + ))); + } + } + (TypeTag::Bool, _) => { + return Err(PredicateError::TypeMismatch(format!( + "tuple {i}: BOOL supports `==` only" + ))); + } + } + } + Ok(()) + } + + /// Serialized length: `1 + tuple_count * 35 + 1 + op_count * 2`. + pub fn serialized_len(&self) -> usize { + 1 + self.tuples.len() * 35 + 1 + self.ops.len() * 2 + } + + /// Wire encoding per SPEC Predicate. + pub fn encode(&self) -> Result, PredicateError> { + self.validate()?; + let len = self.serialized_len(); + if len > PREDICATE_BYTES_MAX { + return Err(PredicateError::TooLarge); + } + let mut out = Vec::with_capacity(len); + out.push(self.tuples.len() as u8); + for t in &self.tuples { + out.push(t.claim_index); + out.extend_from_slice(&t.operand); + out.push(t.type_tag as u8); + out.push(t.comparator as u8); + } + out.push(self.ops.len() as u8); + for o in &self.ops { + out.push(o.code as u8); + out.push(o.operand); + } + Ok(out) + } + + /// Wire decoding per SPEC Predicate. + pub fn decode(bytes: &[u8]) -> Result { + if bytes.len() > PREDICATE_BYTES_MAX { + return Err(PredicateError::TooLarge); + } + if bytes.len() < 2 { + return Err(PredicateError::Malformed("buffer too short".into())); + } + let tuple_count = bytes[0] as usize; + if tuple_count > PREDICATE_TUPLE_MAX { + return Err(PredicateError::TupleCountOutOfRange(tuple_count)); + } + let expected_tuple_bytes = tuple_count * 35; + if bytes.len() < 1 + expected_tuple_bytes + 1 { + return Err(PredicateError::Malformed("truncated tuples".into())); + } + let mut idx = 1usize; + let mut tuples = Vec::with_capacity(tuple_count); + for _ in 0..tuple_count { + let claim_index = bytes[idx]; + idx += 1; + let mut operand = [0u8; 32]; + operand.copy_from_slice(&bytes[idx..idx + 32]); + idx += 32; + let type_tag = + TypeTag::try_from(bytes[idx]).map_err(PredicateError::BadTypeTag)?; + idx += 1; + let comparator = Comparator::try_from(bytes[idx]) + .map_err(PredicateError::BadComparator)?; + idx += 1; + tuples.push(Tuple { + claim_index, + operand, + type_tag, + comparator, + }); + } + let op_count = bytes[idx] as usize; + idx += 1; + let expected_op_bytes = op_count * 2; + if bytes.len() < idx + expected_op_bytes { + return Err(PredicateError::Malformed("truncated ops".into())); + } + let mut ops = Vec::with_capacity(op_count); + for _ in 0..op_count { + let code = OpCode::try_from(bytes[idx]).map_err(PredicateError::BadOpcode)?; + idx += 1; + let operand = bytes[idx]; + idx += 1; + ops.push(Op { code, operand }); + } + let def = Self { tuples, ops }; + def.validate()?; + Ok(def) + } + + /// Evaluate the predicate over `attr_vector`. + pub fn evaluate(&self, attr_vector: &[Fr]) -> Result { + self.validate()?; + let mut stack: Vec = Vec::with_capacity(self.ops.len()); + for (i, op) in self.ops.iter().enumerate() { + let pop_two = + |stack: &mut Vec| -> Result<(bool, bool), PredicateError> { + let b = stack.pop().ok_or(PredicateError::StackUnderflow(i))?; + let a = stack.pop().ok_or(PredicateError::StackUnderflow(i))?; + Ok((a, b)) + }; + match op.code { + OpCode::PushTuple => { + let t = &self.tuples[op.operand as usize]; + stack.push(eval_tuple(t, attr_vector)?); + } + OpCode::And => { + let (a, b) = pop_two(&mut stack)?; + stack.push(a && b); + } + OpCode::Or => { + let (a, b) = pop_two(&mut stack)?; + stack.push(a || b); + } + OpCode::Not => { + let a = stack.pop().ok_or(PredicateError::StackUnderflow(i))?; + stack.push(!a); + } + OpCode::Nop => {} + } + } + if stack.len() != 1 { + return Err(PredicateError::NonSingletonResult(stack.len())); + } + Ok(stack[0]) + } + + /// Strict variant: predicate binds `class_index` to the specific `class_tag` + /// at top level outside any OR. + pub fn validate_class_binding( + &self, + class_index: u8, + class_tag: ClassTag, + ) -> Result<(), PredicateError> { + let mut class_tag_be = [0u8; 32]; + class_tag_be[30..].copy_from_slice(&class_tag.to_be_bytes()); + let binding_tuple_indices: Vec = self + .tuples + .iter() + .enumerate() + .filter_map(|(i, t)| { + (t.claim_index == class_index + && t.comparator == Comparator::Eq + && t.operand == class_tag_be) + .then_some(i) + }) + .collect(); + if binding_tuple_indices.is_empty() { + return Err(PredicateError::MissingClassBinding); + } + match evaluate_class_binding_taint(&self.ops, &binding_tuple_indices)? { + Tag::Bound => Ok(()), + _ => Err(PredicateError::MissingClassBinding), + } + } + + /// Loose variant: returns one of the class-binding tuple's `class_tag` operands. + /// Mirrors `PetitionRegistry._validateClassBinding`: every tuple matching + /// `(claim_index, EQ, high-30-zero)` is treated as Bound, so multi-class + /// predicates like `attr[i] == A OR attr[i] == B` succeed. + pub fn find_class_binding_operand( + &self, + class_index: u8, + ) -> Result { + let mut binding_tuple_indices = Vec::new(); + let mut binding_operands = Vec::new(); + for (i, t) in self.tuples.iter().enumerate() { + if t.claim_index == class_index + && t.comparator == Comparator::Eq + && t.operand[..30].iter().all(|&b| b == 0) + { + binding_tuple_indices.push(i); + binding_operands.push(u16::from_be_bytes([t.operand[30], t.operand[31]])); + } + } + if binding_tuple_indices.is_empty() { + return Err(PredicateError::MissingClassBinding); + } + match evaluate_class_binding_taint(&self.ops, &binding_tuple_indices)? { + Tag::Bound => Ok(binding_operands[0]), + _ => Err(PredicateError::MissingClassBinding), + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Tag { + Bound, + Free, + Tainted, +} + +/// Shared taint-analysis for `validate_class_binding` and `find_class_binding_operand`. +/// `OR(Bound, Free) -> Tainted` (mirroring the Solidity rule), `OR(Bound, Bound) -> Bound`. +fn evaluate_class_binding_taint( + ops: &[Op], + binding_tuple_indices: &[usize], +) -> Result { + let mut stack: Vec = Vec::with_capacity(ops.len()); + for (i, op) in ops.iter().enumerate() { + match op.code { + OpCode::PushTuple => { + if binding_tuple_indices.contains(&(op.operand as usize)) { + stack.push(Tag::Bound); + } else { + stack.push(Tag::Free); + } + } + OpCode::And => { + let (b, a) = pop_two(&mut stack, i)?; + stack.push(match (a, b) { + (Tag::Bound, _) | (_, Tag::Bound) => Tag::Bound, + (Tag::Tainted, _) | (_, Tag::Tainted) => Tag::Tainted, + _ => Tag::Free, + }); + } + OpCode::Or => { + let (b, a) = pop_two(&mut stack, i)?; + stack.push(if a == Tag::Bound && b == Tag::Bound { + Tag::Bound + } else if a == Tag::Bound + || b == Tag::Bound + || a == Tag::Tainted + || b == Tag::Tainted + { + Tag::Tainted + } else { + Tag::Free + }); + } + OpCode::Not => { + let a = stack.pop().ok_or(PredicateError::StackUnderflow(i))?; + stack.push(match a { + Tag::Bound => Tag::Tainted, + other => other, + }); + } + OpCode::Nop => {} + } + } + if stack.len() != 1 { + return Err(PredicateError::NonSingletonResult(stack.len())); + } + Ok(stack[0]) +} + +fn pop_two(stack: &mut Vec, op_idx: usize) -> Result<(Tag, Tag), PredicateError> { + let b = stack.pop().ok_or(PredicateError::StackUnderflow(op_idx))?; + let a = stack.pop().ok_or(PredicateError::StackUnderflow(op_idx))?; + Ok((b, a)) +} + +fn is_int64_in_range(bytes: &Bytes32) -> bool { + bytes[..24].iter().all(|&b| b == 0) +} + +fn is_bool_operand(bytes: &Bytes32) -> bool { + bytes[..31].iter().all(|&b| b == 0) && bytes[31] <= 1 +} + +fn eval_tuple(t: &Tuple, attr_vector: &[Fr]) -> Result { + if (t.claim_index as usize) >= attr_vector.len() { + return Err(PredicateError::Malformed(format!( + "claim_index {} out of range (attr_count {})", + t.claim_index, + attr_vector.len() + ))); + } + let attr_fr = attr_vector[t.claim_index as usize]; + let operand_fr = fr_from_be_bytes(&t.operand); + + match (t.type_tag, t.comparator) { + (TypeTag::Bool, Comparator::Eq) | (TypeTag::Hash, Comparator::Eq) => { + Ok(attr_fr == operand_fr) + } + (TypeTag::Int64, Comparator::Eq) => Ok(attr_fr == operand_fr), + (TypeTag::Int64, Comparator::Le) => { + let attr_u = fr_to_u64_low(&attr_fr); + let op_u = u64::from_be_bytes(t.operand[24..32].try_into().unwrap()); + Ok(attr_u <= op_u) + } + (TypeTag::Int64, Comparator::Ge) => { + let attr_u = fr_to_u64_low(&attr_fr); + let op_u = u64::from_be_bytes(t.operand[24..32].try_into().unwrap()); + Ok(attr_u >= op_u) + } + _ => Err(PredicateError::TypeMismatch(format!( + "tuple type {:?} comparator {:?} not allowed", + t.type_tag, t.comparator + ))), + } +} + +fn fr_to_u64_low(fr: &Fr) -> u64 { + let be = crate::poseidon::fr_to_be_bytes(fr); + u64::from_be_bytes(be[24..32].try_into().unwrap()) +} + +/// Canonical scalar decomposition: 34 BN254 scalars (SPEC Predicate). +/// First segment holds a u16-BE length marker then up to 29 content bytes; +/// remaining 33 segments hold up to 31 content bytes each (1052 total capacity). +pub fn canonical_scalars(encoded: &[u8]) -> Result, PredicateError> { + const SEGMENTS: usize = 34; + const FIRST_CONTENT_BYTES: usize = 29; + const LATER_CONTENT_BYTES: usize = 31; + const CAPACITY_BYTES: usize = + FIRST_CONTENT_BYTES + (SEGMENTS - 1) * LATER_CONTENT_BYTES; + const _: () = assert!(PREDICATE_BYTES_MAX <= CAPACITY_BYTES); + + if encoded.len() > PREDICATE_BYTES_MAX { + return Err(PredicateError::TooLarge); + } + let mut scalars: Vec = Vec::with_capacity(SEGMENTS); + let mut cursor = 0usize; + for seg in 0..SEGMENTS { + let mut buf = [0u8; 32]; + let (content_off, content_max) = if seg == 0 { + buf[1] = ((encoded.len() >> 8) & 0xff) as u8; + buf[2] = (encoded.len() & 0xff) as u8; + (3, FIRST_CONTENT_BYTES) + } else { + (1, LATER_CONTENT_BYTES) + }; + let n = content_max.min(encoded.len().saturating_sub(cursor)); + buf[content_off..content_off + n].copy_from_slice(&encoded[cursor..cursor + n]); + cursor += n; + scalars.push(fr_from_be_bytes(&buf)); + } + Ok(scalars) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn class_tag_operand(tag: ClassTag) -> Bytes32 { + let mut b = [0u8; 32]; + b[30..].copy_from_slice(&tag.to_be_bytes()); + b + } + + fn int64_operand(v: u64) -> Bytes32 { + let mut b = [0u8; 32]; + b[24..].copy_from_slice(&v.to_be_bytes()); + b + } + + fn bool_operand(v: bool) -> Bytes32 { + let mut b = [0u8; 32]; + b[31] = if v { 1 } else { 0 }; + b + } + + fn simple_class_only_predicate(class_index: u8, class_tag: ClassTag) -> PredicateDef { + PredicateDef { + tuples: vec![Tuple { + claim_index: class_index, + operand: class_tag_operand(class_tag), + type_tag: TypeTag::Int64, + comparator: Comparator::Eq, + }], + ops: vec![Op { + code: OpCode::PushTuple, + operand: 0, + }], + } + } + + #[test] + fn test_simple_class_only_validates_and_evaluates() { + let p = simple_class_only_predicate(2, 840); + p.validate().unwrap(); + p.validate_class_binding(2, 840).unwrap(); + let attrs = vec![ + Fr::from(1u64), + Fr::from(0u64), + Fr::from(840u64), + Fr::from(0u64), + ]; + assert!(p.evaluate(&attrs).unwrap()); + } + + #[test] + fn test_class_binding_with_extra_and_clause() { + let p = PredicateDef { + tuples: vec![ + Tuple { + claim_index: 0, + operand: int64_operand(65), + type_tag: TypeTag::Int64, + comparator: Comparator::Le, + }, + Tuple { + claim_index: 2, + operand: class_tag_operand(840), + type_tag: TypeTag::Int64, + comparator: Comparator::Eq, + }, + ], + ops: vec![ + Op { + code: OpCode::PushTuple, + operand: 0, + }, + Op { + code: OpCode::PushTuple, + operand: 1, + }, + Op { + code: OpCode::And, + operand: 0, + }, + ], + }; + p.validate().unwrap(); + p.validate_class_binding(2, 840).unwrap(); + let attrs = vec![ + Fr::from(30u64), + Fr::from(0u64), + Fr::from(840u64), + Fr::from(0u64), + ]; + assert!(p.evaluate(&attrs).unwrap()); + let attrs_fail = vec![ + Fr::from(99u64), + Fr::from(0u64), + Fr::from(840u64), + Fr::from(0u64), + ]; + assert!(!p.evaluate(&attrs_fail).unwrap()); + } + + #[test] + fn test_class_binding_inside_or_rejected() { + let p = PredicateDef { + tuples: vec![ + Tuple { + claim_index: 0, + operand: int64_operand(65), + type_tag: TypeTag::Int64, + comparator: Comparator::Le, + }, + Tuple { + claim_index: 2, + operand: class_tag_operand(840), + type_tag: TypeTag::Int64, + comparator: Comparator::Eq, + }, + ], + ops: vec![ + Op { + code: OpCode::PushTuple, + operand: 0, + }, + Op { + code: OpCode::PushTuple, + operand: 1, + }, + Op { + code: OpCode::Or, + operand: 0, + }, + ], + }; + let err = p.validate_class_binding(2, 840); + assert!(matches!(err, Err(PredicateError::MissingClassBinding))); + } + + #[test] + fn test_validate_rejects_too_many_tuples() { + let tuples = (0..21) + .map(|_| Tuple { + claim_index: 0, + operand: bool_operand(true), + type_tag: TypeTag::Bool, + comparator: Comparator::Eq, + }) + .collect::>(); + let ops = vec![Op { + code: OpCode::PushTuple, + operand: 0, + }]; + let p = PredicateDef { tuples, ops }; + let err = p.validate(); + assert!(matches!(err, Err(PredicateError::TupleCountOutOfRange(_)))); + } + + #[test] + fn test_validate_rejects_push_operand_out_of_range() { + let p = PredicateDef { + tuples: vec![Tuple { + claim_index: 0, + operand: bool_operand(true), + type_tag: TypeTag::Bool, + comparator: Comparator::Eq, + }], + ops: vec![Op { + code: OpCode::PushTuple, + operand: 5, + }], + }; + let err = p.validate(); + assert!(matches!(err, Err(PredicateError::OperandOutOfRange(5, 1)))); + } + + #[test] + fn test_encode_decode_roundtrip() { + let p = simple_class_only_predicate(2, 840); + let encoded = p.encode().unwrap(); + assert_eq!(encoded.len(), p.serialized_len()); + let restored = PredicateDef::decode(&encoded).unwrap(); + assert_eq!(restored, p); + } + + #[test] + fn test_decode_rejects_truncated_buffer() { + let p = simple_class_only_predicate(2, 840); + let encoded = p.encode().unwrap(); + let trunc = &encoded[..encoded.len() - 1]; + let err = PredicateDef::decode(trunc); + assert!(matches!(err, Err(PredicateError::Malformed(_)))); + } + + #[test] + fn test_canonical_scalars_returns_34_segments() { + let p = simple_class_only_predicate(2, 840); + let encoded = p.encode().unwrap(); + let scalars = canonical_scalars(&encoded).unwrap(); + assert_eq!(scalars.len(), 34); + } + + #[test] + fn test_canonical_scalars_changes_with_inputs() { + let p1 = simple_class_only_predicate(2, 840); + let p2 = simple_class_only_predicate(2, 250); + let e1 = p1.encode().unwrap(); + let e2 = p2.encode().unwrap(); + let s1 = canonical_scalars(&e1).unwrap(); + let s2 = canonical_scalars(&e2).unwrap(); + assert_ne!(s1, s2); + } + + #[test] + fn test_int64_operand_out_of_range_rejected() { + let bad_operand = { + let mut b = [0u8; 32]; + b[10] = 1; + b + }; + let p = PredicateDef { + tuples: vec![Tuple { + claim_index: 0, + operand: bad_operand, + type_tag: TypeTag::Int64, + comparator: Comparator::Eq, + }], + ops: vec![Op { + code: OpCode::PushTuple, + operand: 0, + }], + }; + let err = p.validate(); + assert!(matches!(err, Err(PredicateError::Int64OperandOutOfRange))); + } + + #[test] + fn test_hash_only_equality() { + let p = PredicateDef { + tuples: vec![Tuple { + claim_index: 0, + operand: [0xaa; 32], + type_tag: TypeTag::Hash, + comparator: Comparator::Le, + }], + ops: vec![Op { + code: OpCode::PushTuple, + operand: 0, + }], + }; + let err = p.validate(); + assert!(matches!(err, Err(PredicateError::TypeMismatch(_)))); + } + + #[test] + fn test_bool_only_eq_and_0_or_1() { + let p_bad = PredicateDef { + tuples: vec![Tuple { + claim_index: 0, + operand: int64_operand(2), + type_tag: TypeTag::Bool, + comparator: Comparator::Eq, + }], + ops: vec![Op { + code: OpCode::PushTuple, + operand: 0, + }], + }; + let err = p_bad.validate(); + assert!(matches!(err, Err(PredicateError::TypeMismatch(_)))); + } + + #[test] + fn test_not_op_evaluates() { + let p = PredicateDef { + tuples: vec![ + Tuple { + claim_index: 0, + operand: bool_operand(true), + type_tag: TypeTag::Bool, + comparator: Comparator::Eq, + }, + Tuple { + claim_index: 1, + operand: class_tag_operand(840), + type_tag: TypeTag::Int64, + comparator: Comparator::Eq, + }, + ], + ops: vec![ + Op { + code: OpCode::PushTuple, + operand: 0, + }, + Op { + code: OpCode::Not, + operand: 0, + }, + Op { + code: OpCode::PushTuple, + operand: 1, + }, + Op { + code: OpCode::And, + operand: 0, + }, + ], + }; + p.validate().unwrap(); + p.validate_class_binding(1, 840).unwrap(); + } +} diff --git a/pocs/civic-participation/resilient-civic-participation/src/registry/core.rs b/pocs/civic-participation/resilient-civic-participation/src/registry/core.rs new file mode 100644 index 0000000..bf6835f --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/registry/core.rs @@ -0,0 +1,950 @@ +//! Petition Registry state machine (Rust mirror of the Solidity contract). + +use std::{ + collections::HashMap, + sync::Arc, +}; + +use ark_bn254::Fr; +use ark_ff::Zero; + +use crate::{ + BATCH_SIZE_MAX, + COOLDOWN_BLOCKS, + FSRT_SLOT_COUNT, + MIN_R_AGE_BLOCKS, + RESOLUTION_DEADLINE_BLOCKS, + blob::compute_batch_versioned_hash, + clock::BlockClock, + disputant::leaf_ordering_violated, + error::ProofError, + imt::IndexedMerkleTree, + organizer::types::PetitionDraft, + ports::{ + blob::BlobCarrier, + imt::ImtStore, + proof::ProofBackend, + ri::RiCredentialLayer, + }, + poseidon::{ + derive_petition_id, + fr_from_be_bytes, + fr_to_be_bytes, + hash_leaf, + hash_predicate, + }, + predicate::canonical_scalars, + registry::{ + error::{ + BatchPriorMismatch, + RegistryError, + }, + types::{ + AlphaUpdatedEvent, + BatchPublishedEvent, + BatchRepudiatedEvent, + BountyPaidEvent, + BountyRefundedEvent, + PetitionRegisteredEvent, + PetitionResolvedEvent, + PetitionStateView, + PetitionUnresolvedEvent, + RegisteredPetition, + TOMBSTONE_MARKER, + }, + }, + types::{ + Address, + BatchRecord, + BatchState, + BatchSubmission, + Bytes32, + Dispute, + GlobalState, + PetitionId, + PetitionRecord, + PetitionState, + ResolutionSubmission, + U256Be, + ViolationType, + }, +}; + +/// Per-batch on-chain state; payload lives on the blob carrier. +#[derive(Debug, Clone)] +struct InternalBatch { + record: BatchRecord, +} + +/// Per-petition cached state; IMTs are recomputed on insert so the mock backend cannot mask a bad relayer. +struct PetitionEntry { + record: PetitionRecord, + batches: Vec, + running_imt: IndexedMerkleTree, + identity_tag_imt: IndexedMerkleTree, +} + +pub struct PetitionRegistry +where + P: ProofBackend, + R: RiCredentialLayer, + B: BlobCarrier, +{ + pub address: Address, + pub global: GlobalState, + pub clock: Arc, + pub proof_backend: P, + pub ri: R, + pub blob_carrier: B, + petitions: HashMap, + /// Initial empty-IMT root used as the first `prior_running_root`. + empty_imt_root: Bytes32, +} + +impl PetitionRegistry +where + P: ProofBackend, + R: RiCredentialLayer, + B: BlobCarrier, +{ + pub fn new( + address: Address, + global: GlobalState, + clock: Arc, + proof_backend: P, + ri: R, + blob_carrier: B, + ) -> Self { + let empty = IndexedMerkleTree::new(); + Self { + address, + global, + clock, + proof_backend, + ri, + blob_carrier, + petitions: HashMap::new(), + empty_imt_root: empty.root(), + } + } + + fn now(&self) -> u64 { + self.clock.block_number() + } + + /// Advance the petition lifecycle based on the current block. + fn step_state( + &mut self, + petition_id: &PetitionId, + ) -> Result { + let block = self.now(); + let entry = self.petition_mut(petition_id)?; + loop { + let next = match entry.record.state { + PetitionState::Registered => Some(PetitionState::SigningOpen), + PetitionState::SigningOpen if block >= entry.record.close_at_block => { + Some(PetitionState::SigningClosed) + } + PetitionState::SigningClosed => Some(PetitionState::Cooldown), + PetitionState::Cooldown + if block >= entry.record.close_at_block + COOLDOWN_BLOCKS => + { + Some(PetitionState::DisputeWindow) + } + _ => None, + }; + if let Some(next) = next { + entry.record.state = next; + } else { + break; + } + } + Ok(entry.record.state) + } + + fn petition(&self, id: &PetitionId) -> Result<&PetitionEntry, RegistryError> { + self.petitions.get(id).ok_or(RegistryError::UnknownPetition) + } + + fn petition_mut( + &mut self, + id: &PetitionId, + ) -> Result<&mut PetitionEntry, RegistryError> { + self.petitions + .get_mut(id) + .ok_or(RegistryError::UnknownPetition) + } + + /// Read-side view for Organizers, Relayers, Disputants, and Resolvers. + pub fn state_view( + &self, + id: &PetitionId, + ) -> Result { + let entry = self.petition(id)?; + Ok(PetitionStateView { + petition_id: entry.record.petition_id, + r_root: entry.record.r_root, + predicate_hash: entry.record.predicate_hash, + class_set: entry.record.class_set.clone(), + class_thresholds: entry.record.class_thresholds.clone(), + class_index: entry.record.class_index, + slot: entry.record.slot, + running_root: entry.record.running_root, + identity_tag_set_root: entry.record.identity_tag_set_root, + leaf_count: entry.record.leaf_count, + next_batch_index: entry.record.next_batch_index, + }) + } + + pub fn current_state(&self, id: &PetitionId) -> Result { + Ok(self.petition(id)?.record.state) + } + + pub fn active_batch_versioned_hashes( + &self, + id: &PetitionId, + ) -> Result, RegistryError> { + let entry = self.petition(id)?; + Ok(entry + .batches + .iter() + .filter(|b| b.record.state == BatchState::Active) + .map(|b| b.record.batch_versioned_hash) + .collect()) + } + + /// Register a petition; returns the `PetitionRegisteredEvent`. + pub fn register( + &mut self, + draft: PetitionDraft, + ) -> Result<(RegisteredPetition, PetitionRegisteredEvent), RegistryError> { + draft.predicate_def.validate()?; + // Structural checks first; predicate-binding lookup last so its + // error surface is not masked by an empty class_set. + if draft.class_set.is_empty() { + return Err(RegistryError::Predicate( + crate::error::PredicateError::Malformed( + "class_set must be non-empty".into(), + ), + )); + } + if draft.class_thresholds.len() != draft.class_set.len() { + return Err(RegistryError::Predicate( + crate::error::PredicateError::Malformed( + "class_thresholds.len() != class_set.len()".into(), + ), + )); + } + // Zero thresholds make `b_per_class[i]` trivially true and collapse `B_min` to zero. + if draft.class_thresholds.contains(&0) { + return Err(RegistryError::Predicate( + crate::error::PredicateError::Malformed( + "every class_thresholds[i] must be >= 1".into(), + ), + )); + } + if !draft.class_set.windows(2).all(|w| w[0] < w[1]) { + return Err(RegistryError::Predicate( + crate::error::PredicateError::Malformed( + "class_set must be strictly increasing".into(), + ), + )); + } + if (draft.class_index as usize) >= self.global.n as usize { + return Err(RegistryError::Predicate( + crate::error::PredicateError::Malformed(format!( + "class_index {} >= n ({})", + draft.class_index, self.global.n + )), + )); + } + let operand = draft + .predicate_def + .find_class_binding_operand(draft.class_index)?; + if !draft.class_set.contains(&operand) { + return Err(RegistryError::Predicate( + crate::error::PredicateError::MissingClassBinding, + )); + } + + let root_first_seen = + self.ri.root_first_seen(&draft.r_root).ok_or_else(|| { + RegistryError::Predicate(crate::error::PredicateError::Malformed( + "R is not a known RI root".into(), + )) + })?; + let now = self.now(); + if now < root_first_seen || now - root_first_seen < MIN_R_AGE_BLOCKS { + return Err(RegistryError::RRootTooYoung); + } + if draft.close_at_block <= now { + return Err(RegistryError::Predicate( + crate::error::PredicateError::Malformed( + "close_at_block must be in the future".into(), + ), + )); + } + if draft.close_at_block - now > crate::MAX_SIGNING_WINDOW_BLOCKS { + return Err(RegistryError::Predicate( + crate::error::PredicateError::Malformed( + "signing window exceeds 11.5 days".into(), + ), + )); + } + + // Derive petition_id using pre-id predicate hash (petition_id = 0). + let encoded = draft.predicate_def.encode()?; + let canonical = canonical_scalars(&encoded)?; + let pre_id_hash = + hash_predicate(&canonical, Fr::from(0u64), fr_from_be_bytes(&draft.salt)); + let pre_id_hash_be = fr_to_be_bytes(&pre_id_hash); + let s_at_registration = self.global.s; + if s_at_registration >= FSRT_SLOT_COUNT { + return Err(RegistryError::SlotCounterExhausted(s_at_registration)); + } + let petition_id = derive_petition_id( + self.global.chain_id, + &self.address, + &draft.organizer, + s_at_registration, + &pre_id_hash_be, + draft.close_at_block, + ); + if self.petitions.contains_key(&petition_id) { + return Err(RegistryError::DuplicatePetition); + } + let predicate_hash = hash_predicate( + &canonical, + fr_from_be_bytes(&petition_id), + fr_from_be_bytes(&draft.salt), + ); + let predicate_hash_be = fr_to_be_bytes(&predicate_hash); + + if !(self.global.alpha_min..=self.global.alpha_max).contains(&self.global.alpha) { + return Err(RegistryError::AlphaOutOfBounds { + alpha: self.global.alpha, + min: self.global.alpha_min, + max: self.global.alpha_max, + }); + } + let alpha_at_registration = self.global.alpha; + // u128 arithmetic prevents u64 overflow silently collapsing the bounty floor. + let threshold_sum: u128 = draft + .class_thresholds + .iter() + .try_fold(0u128, |acc, t| acc.checked_add(*t as u128)) + .ok_or_else(|| { + RegistryError::Predicate(crate::error::PredicateError::Malformed( + "class_thresholds sum overflow".into(), + )) + })?; + let n_expected: u128 = threshold_sum.checked_mul(10).ok_or_else(|| { + RegistryError::Predicate(crate::error::PredicateError::Malformed( + "N_expected overflow".into(), + )) + })?; + let predicate_op_count: u128 = draft.predicate_def.op_count() as u128; + let b_min: u128 = (alpha_at_registration as u128) + .checked_mul(n_expected) + .and_then(|x| x.checked_mul(predicate_op_count)) + .ok_or_else(|| { + RegistryError::Predicate(crate::error::PredicateError::Malformed( + "B_min overflow".into(), + )) + })?; + let bounty_u128 = u256_to_u128(&draft.bounty)?; + if bounty_u128 < b_min { + return Err(RegistryError::BountyBelowMinimum { + bounty: bounty_u128, + min: b_min, + }); + } + + let slot = s_at_registration; + self.global.s = self + .global + .s + .checked_add(1) + .ok_or(RegistryError::SlotCounterExhausted(s_at_registration))?; + + let petition_record = PetitionRecord { + petition_id, + slot, + r_root: draft.r_root, + predicate_def: encoded, + predicate_hash: predicate_hash_be, + salt: draft.salt, + class_set: draft.class_set.clone(), + class_thresholds: draft.class_thresholds.clone(), + class_index: draft.class_index, + close_at_block: draft.close_at_block, + bounty: draft.bounty, + alpha_at_registration, + organizer: draft.organizer, + running_root: self.empty_imt_root, + identity_tag_set_root: self.empty_imt_root, + leaf_count: 0, + next_batch_index: 0, + resolution_proof: Vec::new(), + b: false, + b_per_class: Vec::new(), + state: PetitionState::Registered, + registration_block: now, + }; + self.petitions.insert( + petition_id, + PetitionEntry { + record: petition_record, + batches: Vec::new(), + running_imt: IndexedMerkleTree::new(), + identity_tag_imt: IndexedMerkleTree::new(), + }, + ); + self.step_state(&petition_id)?; + + let event = PetitionRegisteredEvent { + petition_id, + slot, + r_root: draft.r_root, + predicate_hash: predicate_hash_be, + class_set: draft.class_set, + class_thresholds: draft.class_thresholds, + class_index: draft.class_index, + close_at_block: draft.close_at_block, + bounty: bounty_u128, + }; + Ok(( + RegisteredPetition { + petition_id, + slot, + predicate_hash: predicate_hash_be, + registered_at_block: now, + }, + event, + )) + } + + /// Verify batch SNARK, assert petition bindings + prior state, apply new roots. + pub fn publish_batch( + &mut self, + petition_id: &PetitionId, + relayer: Address, + submission: BatchSubmission, + ) -> Result { + let state = self.step_state(petition_id)?; + if state != PetitionState::SigningOpen { + return Err(RegistryError::BadState(state, "publish_batch")); + } + // Cross-check the blob carrier by recomputing `batch_versioned_hash`. + let local_vh = + compute_batch_versioned_hash(&crate::blob::encode_blob(&submission.records)?); + let public = &submission.public_inputs; + if local_vh != fr_to_be_bytes(&public.batch_versioned_hash) { + return Err(RegistryError::Blob(crate::error::BlobError::Malformed( + "batch_versioned_hash mismatch".into(), + ))); + } + self.proof_backend + .verify_batch_proof(&submission.proof_bytes, public) + .map_err(RegistryError::BadBatchProof)?; + let submitted_at_block = self.now(); + let entry = self.petition_mut(petition_id)?; + let pid_fr = fr_from_be_bytes(&entry.record.petition_id); + let r_fr = fr_from_be_bytes(&entry.record.r_root); + let ph_fr = fr_from_be_bytes(&entry.record.predicate_hash); + let ci_fr = Fr::from(entry.record.class_index as u64); + if public.petition_id != pid_fr + || public.r_root != r_fr + || public.predicate_hash != ph_fr + || public.class_index != ci_fr + { + return Err(RegistryError::BadBatchProof(ProofError::Verification( + "batch public-input petition bindings mismatch".into(), + ))); + } + if public.slot != Fr::from(entry.record.slot as u64) { + return Err(RegistryError::BadBatchProof(ProofError::Verification( + "batch public-input slot mismatch".into(), + ))); + } + let batch_size_u = fr_to_u64(public.batch_size)?; + if batch_size_u == 0 || batch_size_u > BATCH_SIZE_MAX as u64 { + return Err(RegistryError::BatchSizeOutOfRange(batch_size_u as usize)); + } + if submission.records.len() as u64 != batch_size_u { + return Err(RegistryError::BatchSizeOutOfRange(submission.records.len())); + } + let prior_rr_be = fr_to_be_bytes(&public.prior_running_root); + let prior_idt_be = fr_to_be_bytes(&public.prior_identity_tag_set_root); + let prior_lc_u = fr_to_u64(public.prior_leaf_count)?; + if prior_rr_be != entry.record.running_root + || prior_idt_be != entry.record.identity_tag_set_root + || prior_lc_u != entry.record.leaf_count + { + return Err(RegistryError::BatchPriorMismatch(Box::new( + BatchPriorMismatch { + expected_rr: entry.record.running_root, + expected_idt: entry.record.identity_tag_set_root, + expected_lc: entry.record.leaf_count, + got_rr: prior_rr_be, + got_idt: prior_idt_be, + got_lc: prior_lc_u, + }, + ))); + } + let new_lc_u = fr_to_u64(public.new_leaf_count)?; + let expected_new_lc = prior_lc_u.checked_add(batch_size_u).ok_or_else(|| { + RegistryError::BadBatchProof(ProofError::Verification( + "prior_leaf_count + batch_size overflow".into(), + )) + })?; + if new_lc_u != expected_new_lc { + return Err(RegistryError::BadBatchProof(ProofError::Verification( + "new_leaf_count != prior_leaf_count + batch_size".into(), + ))); + } + // Re-execute SNARK constraints on cloned IMTs; commit atomically. + let mut scratch_running_imt = entry.running_imt.clone(); + let mut scratch_identity_tag_imt = entry.identity_tag_imt.clone(); + let mut prev_leaf_be: Option<[u8; 32]> = None; + let mut last_running_root_be = prior_rr_be; + let mut last_identity_tag_root_be = prior_idt_be; + for (i, record) in submission.records.iter().enumerate() { + // Defense-in-depth: mock backend cannot enforce constraint 4, so block out-of-set class_tag here. + let entry_class_set = &entry.record.class_set; + if !entry_class_set.contains(&record.class_tag) { + return Err(RegistryError::BadBatchProof(ProofError::Verification( + format!( + "class_tag {} at position {i} not in petition's class_set", + record.class_tag + ), + ))); + } + let nullifier_fr = fr_from_be_bytes(&record.nullifier); + let class_tag_fr = Fr::from(record.class_tag as u64); + let leaf_fr = hash_leaf(nullifier_fr, class_tag_fr); + let leaf_be = fr_to_be_bytes(&leaf_fr); + if let Some(prev) = prev_leaf_be + && prev >= leaf_be + { + return Err(RegistryError::BadBatchProof(ProofError::Verification( + format!("leaf ordering violation at position {i}"), + ))); + } + prev_leaf_be = Some(leaf_be); + let leaf_witness = scratch_running_imt.insert(&leaf_be).map_err(|e| { + RegistryError::BadBatchProof(ProofError::Verification(format!( + "running_imt insert at position {i}: {e}" + ))) + })?; + last_running_root_be = leaf_witness.new_root; + let id_witness = scratch_identity_tag_imt + .insert(&record.identity_tag) + .map_err(|e| { + RegistryError::BadBatchProof(ProofError::Verification(format!( + "identity_tag_imt insert at position {i}: {e}" + ))) + })?; + last_identity_tag_root_be = id_witness.new_root; + } + let claimed_new_rr_be = fr_to_be_bytes(&public.new_running_root); + let claimed_new_idt_be = fr_to_be_bytes(&public.new_identity_tag_set_root); + if claimed_new_rr_be != last_running_root_be { + return Err(RegistryError::BadBatchProof(ProofError::Verification( + "submitted new_running_root disagrees with registry IMT".into(), + ))); + } + if claimed_new_idt_be != last_identity_tag_root_be { + return Err(RegistryError::BadBatchProof(ProofError::Verification( + "submitted new_identity_tag_set_root disagrees with registry IMT".into(), + ))); + } + entry.running_imt = scratch_running_imt; + entry.identity_tag_imt = scratch_identity_tag_imt; + let new_rr_be = claimed_new_rr_be; + let new_idt_be = claimed_new_idt_be; + let batch_index = entry.record.next_batch_index; + entry.record.running_root = new_rr_be; + entry.record.identity_tag_set_root = new_idt_be; + entry.record.leaf_count = new_lc_u; + entry.record.next_batch_index = batch_index.checked_add(1).ok_or_else(|| { + RegistryError::BadBatchProof(ProofError::Verification( + "next_batch_index overflow".into(), + )) + })?; + + entry.batches.push(InternalBatch { + record: BatchRecord { + petition_id: *petition_id, + batch_index, + batch_versioned_hash: local_vh, + new_running_root: new_rr_be, + new_identity_tag_set_root: new_idt_be, + prior_running_root: prior_rr_be, + prior_identity_tag_set_root: prior_idt_be, + prior_leaf_count: prior_lc_u, + new_leaf_count: new_lc_u, + relayer, + submitted_at_block, + state: BatchState::Active, + }, + }); + + Ok(BatchPublishedEvent { + petition_id: *petition_id, + batch_index, + batch_versioned_hash: local_vh, + new_running_root: new_rr_be, + new_identity_tag_set_root: new_idt_be, + new_leaf_count: new_lc_u, + }) + } + + /// Validate openings, run violation predicate, repudiate batch, roll back state. + pub fn dispute( + &mut self, + dispute: Dispute, + ) -> Result { + let state = self.step_state(&dispute.petition_id)?; + if state != PetitionState::DisputeWindow { + return Err(RegistryError::BadState(state, "dispute")); + } + // Pre-fetch `batch_versioned_hash` without a mut borrow so verification runs uncontended. + let vh = { + let entry = self.petition(&dispute.petition_id)?; + let batch_idx = dispute.batch_index as usize; + if batch_idx >= entry.batches.len() { + return Err(RegistryError::DisputeUnknownBatch(dispute.batch_index)); + } + if entry.batches[batch_idx].record.state == BatchState::Repudiated { + return Err(RegistryError::DisputeBatchAlreadyRepudiated( + dispute.batch_index, + )); + } + entry.batches[batch_idx].record.batch_versioned_hash + }; + for opening in &dispute.openings { + self.blob_carrier + .verify(&vh, opening) + .map_err(RegistryError::BadDisputeOpening)?; + } + let empty_imt_root = self.empty_imt_root; + let entry = self.petition_mut(&dispute.petition_id)?; + let batch_idx = dispute.batch_index as usize; + let evidence_i = + derive_evidence(&dispute, dispute.position_i).ok_or_else(|| { + RegistryError::BadDisputeOpening(crate::error::BlobError::Malformed( + "could not derive evidence_i from openings".into(), + )) + })?; + match dispute.violation_type { + ViolationType::ClassTagOutOfSet => { + if entry.record.class_set.contains(&evidence_i.class_tag) { + return Err(RegistryError::DisputePredicateNotMet); + } + } + ViolationType::IntraBatchDuplicateIdentityTag => { + let pos_j = dispute.position_j.ok_or_else(|| { + RegistryError::BadDisputeOpening(crate::error::BlobError::Malformed( + "missing position_j".into(), + )) + })?; + if pos_j == dispute.position_i { + return Err(RegistryError::DisputePredicateNotMet); + } + let ev_j = derive_evidence(&dispute, pos_j).ok_or_else(|| { + RegistryError::BadDisputeOpening(crate::error::BlobError::Malformed( + "could not derive evidence_j from openings".into(), + )) + })?; + if evidence_i.identity_tag != ev_j.identity_tag { + return Err(RegistryError::DisputePredicateNotMet); + } + } + ViolationType::LeafOrderingViolation => { + let pos_j = dispute.position_j.ok_or_else(|| { + RegistryError::BadDisputeOpening(crate::error::BlobError::Malformed( + "missing position_j".into(), + )) + })?; + let expected_j = dispute.position_i.checked_add(1).ok_or_else(|| { + RegistryError::BadDisputeOpening(crate::error::BlobError::Malformed( + "position_i overflow".into(), + )) + })?; + if pos_j != expected_j { + return Err(RegistryError::DisputePredicateNotMet); + } + let ev_j = derive_evidence(&dispute, pos_j).ok_or_else(|| { + RegistryError::BadDisputeOpening(crate::error::BlobError::Malformed( + "could not derive evidence_j from openings".into(), + )) + })?; + let leaf_i = hash_leaf( + fr_from_be_bytes(&evidence_i.nullifier), + Fr::from(evidence_i.class_tag as u64), + ); + let leaf_ip1 = hash_leaf( + fr_from_be_bytes(&ev_j.nullifier), + Fr::from(ev_j.class_tag as u64), + ); + if !leaf_ordering_violated(&leaf_i, &leaf_ip1) { + return Err(RegistryError::DisputePredicateNotMet); + } + } + } + + // Rollback in scratch buffers; commit only if every step succeeds. + let active_vhs_to_replay: Vec = entry + .batches + .iter() + .take(batch_idx) + .filter(|b| b.record.state == BatchState::Active) + .map(|b| b.record.batch_versioned_hash) + .collect(); + let mut scratch_records: Vec = Vec::new(); + for vh in &active_vhs_to_replay { + let records = self + .blob_carrier + .fetch_records(vh) + .map_err(RegistryError::Blob)?; + scratch_records.extend(records); + } + let mut scratch_running_imt = IndexedMerkleTree::new(); + let mut scratch_identity_tag_imt = IndexedMerkleTree::new(); + for record in &scratch_records { + let nullifier_fr = fr_from_be_bytes(&record.nullifier); + let class_tag_fr = Fr::from(record.class_tag as u64); + let leaf_fr = hash_leaf(nullifier_fr, class_tag_fr); + let _ = scratch_running_imt.insert(&fr_to_be_bytes(&leaf_fr))?; + let _ = scratch_identity_tag_imt.insert(&record.identity_tag)?; + } + let (rb_rr, rb_idt, rb_lc) = { + let entry = self.petition(&dispute.petition_id)?; + roll_back_to_predecessor(&entry.batches, batch_idx, empty_imt_root) + }; + let entry = self.petition_mut(&dispute.petition_id)?; + for b in entry.batches[batch_idx..].iter_mut() { + b.record.state = BatchState::Repudiated; + } + entry.record.running_root = rb_rr; + entry.record.identity_tag_set_root = rb_idt; + entry.record.leaf_count = rb_lc; + entry.record.next_batch_index = dispute.batch_index; + entry.running_imt = scratch_running_imt; + entry.identity_tag_imt = scratch_identity_tag_imt; + + Ok(BatchRepudiatedEvent { + petition_id: dispute.petition_id, + batch_index: dispute.batch_index, + new_running_root: rb_rr, + new_identity_tag_set_root: rb_idt, + new_leaf_count: rb_lc, + }) + } + + /// Validate the resolution SNARK; apply outcome bits and transition to `Resolved`. + pub fn resolve( + &mut self, + petition_id: &PetitionId, + resolver: Address, + submission: ResolutionSubmission, + ) -> Result<(PetitionResolvedEvent, BountyPaidEvent), RegistryError> { + let state = self.step_state(petition_id)?; + if state != PetitionState::DisputeWindow { + return Err(RegistryError::BadState(state, "resolve")); + } + // Verify before taking a mut borrow. + self.proof_backend + .verify_resolution_proof(&submission.proof_bytes, &submission.public_inputs) + .map_err(RegistryError::BadResolutionProof)?; + let entry = self.petition_mut(petition_id)?; + let public = &submission.public_inputs; + let leaf_count_pi = fr_to_u64(public.leaf_count)?; + if fr_to_be_bytes(&public.predicate_hash) != entry.record.predicate_hash + || fr_to_be_bytes(&public.r_root) != entry.record.r_root + || fr_to_be_bytes(&public.running_root) != entry.record.running_root + || leaf_count_pi != entry.record.leaf_count + { + return Err(RegistryError::ResolutionStateMismatch); + } + if public.class_set.len() != entry.record.class_set.len() + || public.class_thresholds.len() != entry.record.class_thresholds.len() + || public.b_per_class.len() != entry.record.class_set.len() + { + return Err(RegistryError::ResolutionStateMismatch); + } + for (i, c) in entry.record.class_set.iter().enumerate() { + if public.class_set[i] != Fr::from(*c as u64) { + return Err(RegistryError::ResolutionStateMismatch); + } + if public.class_thresholds[i] != Fr::from(entry.record.class_thresholds[i]) { + return Err(RegistryError::ResolutionStateMismatch); + } + } + let b = !public.b.is_zero(); + let b_per_class: Vec = + public.b_per_class.iter().map(|x| !x.is_zero()).collect(); + entry.record.b = b; + entry.record.b_per_class = b_per_class.clone(); + entry.record.state = PetitionState::Resolved; + entry.record.resolution_proof = submission.proof_bytes; + let bounty_amount = u256_to_u128(&entry.record.bounty)?; + Ok(( + PetitionResolvedEvent { + petition_id: *petition_id, + b, + b_per_class, + }, + BountyPaidEvent { + petition_id: *petition_id, + recipient: resolver, + amount: bounty_amount, + }, + )) + } + + /// Refund bounty (minus caller gas rebate), write tombstone, transition to `Unresolved`. + pub fn mark_unresolved( + &mut self, + petition_id: &PetitionId, + caller: Address, + gas_rebate: u128, + ) -> Result< + ( + PetitionUnresolvedEvent, + BountyRefundedEvent, + BountyRefundedEvent, + ), + RegistryError, + > { + let state = self.step_state(petition_id)?; + let now = self.now(); + let entry = self.petition_mut(petition_id)?; + let deadline = entry + .record + .close_at_block + .checked_add(RESOLUTION_DEADLINE_BLOCKS) + .ok_or(RegistryError::BadState(state, "mark_unresolved"))?; + if now < deadline { + return Err(RegistryError::BadState(state, "mark_unresolved")); + } + // Only DisputeWindow may transition to Unresolved. + if state != PetitionState::DisputeWindow { + return Err(RegistryError::BadState(state, "mark_unresolved")); + } + let bounty_total = u256_to_u128(&entry.record.bounty)?; + // Cap rebate at 1% so caller cannot starve the organizer of the refund. + let rebate_cap = bounty_total / 100; + let rebate = gas_rebate.min(rebate_cap); + let refund = bounty_total - rebate; + entry.record.running_root = TOMBSTONE_MARKER; + entry.record.state = PetitionState::Unresolved; + Ok(( + PetitionUnresolvedEvent { + petition_id: *petition_id, + }, + BountyRefundedEvent { + petition_id: *petition_id, + recipient: entry.record.organizer, + amount: refund, + }, + BountyRefundedEvent { + petition_id: *petition_id, + recipient: caller, + amount: rebate, + }, + )) + } + + /// Governance hook; PoC accepts any caller, production MUST gate by role. + pub fn update_alpha( + &mut self, + new_alpha: u64, + ) -> Result { + if !(self.global.alpha_min..=self.global.alpha_max).contains(&new_alpha) { + return Err(RegistryError::AlphaOutOfBounds { + alpha: new_alpha, + min: self.global.alpha_min, + max: self.global.alpha_max, + }); + } + let old_alpha = self.global.alpha; + self.global.alpha = new_alpha; + Ok(AlphaUpdatedEvent { + old_alpha, + new_alpha, + }) + } +} + +fn fr_to_u64(fr: Fr) -> Result { + let be = fr_to_be_bytes(&fr); + if be[..24].iter().any(|&b| b != 0) { + return Err(RegistryError::BadBatchProof(ProofError::Verification( + "public-input field element exceeds u64 range".into(), + ))); + } + Ok(u64::from_be_bytes(be[24..32].try_into().unwrap())) +} + +fn u256_to_u128(v: &U256Be) -> Result { + // High 16 bytes MUST be zero for a u128 fit. + let bytes = v.as_bytes(); + if bytes[..16].iter().any(|&b| b != 0) { + return Err(RegistryError::Predicate( + crate::error::PredicateError::Malformed("bounty exceeds u128 range".into()), + )); + } + Ok(u128::from_be_bytes(bytes[16..32].try_into().unwrap())) +} + +fn derive_evidence( + dispute: &Dispute, + position: u32, +) -> Option { + let base = position.checked_mul(4)?; + let last = base.checked_add(3)?; + let max_fe_index = (crate::RECORDS_PER_BLOB * crate::blob::FE_PER_RECORD) as u32; + if last >= max_fe_index { + return None; + } + // Reject duplicate openings for the same field-element index. + let mut by_offset: [Option<&crate::types::KzgOpening>; 4] = [None; 4]; + for o in &dispute.openings { + if let Some(off) = o.field_element_index.checked_sub(base) + && off < 4 + { + if by_offset[off as usize].is_some() { + return None; + } + by_offset[off as usize] = Some(o); + } + } + let mut record_bytes = [0u8; crate::RECORD_LEN]; + for (j, slot) in by_offset.iter().enumerate() { + let o = (*slot)?; + let start = j * crate::blob::CONTENT_PER_FE; + let end = ((j + 1) * crate::blob::CONTENT_PER_FE).min(crate::RECORD_LEN); + let take = end - start; + record_bytes[start..end].copy_from_slice(&o.claimed_value[1..1 + take]); + } + Some(crate::blob::decode_record(&record_bytes)) +} + +fn roll_back_to_predecessor( + batches: &[InternalBatch], + repudiated_idx: usize, + empty_imt_root: Bytes32, +) -> (Bytes32, Bytes32, u64) { + for prev in batches[..repudiated_idx].iter().rev() { + if prev.record.state == BatchState::Active { + return ( + prev.record.new_running_root, + prev.record.new_identity_tag_set_root, + prev.record.new_leaf_count, + ); + } + } + (empty_imt_root, empty_imt_root, 0) +} diff --git a/pocs/civic-participation/resilient-civic-participation/src/registry/error.rs b/pocs/civic-participation/resilient-civic-participation/src/registry/error.rs new file mode 100644 index 0000000..8ddb67d --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/registry/error.rs @@ -0,0 +1,76 @@ +use thiserror::Error; + +use crate::error::{ + BlobError, + ImtError, + PredicateError, + ProofError, +}; + +/// Boxed payload for [`RegistryError::BatchPriorMismatch`]; keeps the enum small. +#[derive(Debug)] +pub struct BatchPriorMismatch { + pub expected_rr: [u8; 32], + pub expected_idt: [u8; 32], + pub expected_lc: u64, + pub got_rr: [u8; 32], + pub got_idt: [u8; 32], + pub got_lc: u64, +} + +impl std::fmt::Display for BatchPriorMismatch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "expected running_root={:?}, identity_tag_set_root={:?}, leaf_count={}; got running_root={:?}, identity_tag_set_root={:?}, leaf_count={}", + self.expected_rr, + self.expected_idt, + self.expected_lc, + self.got_rr, + self.got_idt, + self.got_lc, + ) + } +} + +#[derive(Debug, Error)] +pub enum RegistryError { + #[error("petition with the same petition_id already registered")] + DuplicatePetition, + #[error("petition not found")] + UnknownPetition, + #[error("petition state {0:?} disallows operation `{1}`")] + BadState(crate::types::PetitionState, &'static str), + #[error("R has not been published on RI for the SPEC-required minimum age")] + RRootTooYoung, + #[error("alpha {alpha} outside governance bounds [{min}, {max}]")] + AlphaOutOfBounds { alpha: u64, min: u64, max: u64 }, + #[error("bounty {bounty} below required minimum {min}")] + BountyBelowMinimum { bounty: u128, min: u128 }, + #[error("petition runs out of FSRT slots: S = {0}")] + SlotCounterExhausted(u32), + #[error("predicate: {0}")] + Predicate(#[from] PredicateError), + #[error("batch SNARK verification failed: {0}")] + BadBatchProof(ProofError), + #[error("batch prior-state mismatch: {0}")] + BatchPriorMismatch(Box), + #[error("batch size {0} outside [1, BATCH_SIZE_MAX]")] + BatchSizeOutOfRange(usize), + #[error("resolution SNARK verification failed: {0}")] + BadResolutionProof(ProofError), + #[error("resolution public inputs disagree with registry state")] + ResolutionStateMismatch, + #[error("dispute opening invalid: {0}")] + BadDisputeOpening(BlobError), + #[error("dispute violation predicate did not hold against the supplied evidence")] + DisputePredicateNotMet, + #[error("dispute references unknown batch_index {0}")] + DisputeUnknownBatch(u32), + #[error("dispute references already-repudiated batch_index {0}")] + DisputeBatchAlreadyRepudiated(u32), + #[error("imt error: {0}")] + Imt(#[from] ImtError), + #[error("blob error: {0}")] + Blob(#[from] BlobError), +} diff --git a/pocs/civic-participation/resilient-civic-participation/src/registry/mod.rs b/pocs/civic-participation/resilient-civic-participation/src/registry/mod.rs new file mode 100644 index 0000000..e1de5e4 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/registry/mod.rs @@ -0,0 +1,5 @@ +pub mod error; +pub mod types; + +mod core; +pub use core::PetitionRegistry; diff --git a/pocs/civic-participation/resilient-civic-participation/src/registry/types.rs b/pocs/civic-participation/resilient-civic-participation/src/registry/types.rs new file mode 100644 index 0000000..fc792ce --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/registry/types.rs @@ -0,0 +1,117 @@ +//! Registry types. + +use serde::{ + Deserialize, + Serialize, +}; + +use crate::types::{ + Address, + Bytes32, + ClassTag, + PetitionId, +}; + +/// Tombstone written into `running_root` when the petition becomes `Unresolved`. +pub const TOMBSTONE_MARKER: Bytes32 = { + let mut b = [0u8; 32]; + b[31] = 1; + b +}; + +/// Result of `Registry::register`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RegisteredPetition { + pub petition_id: PetitionId, + pub slot: u32, + pub predicate_hash: Bytes32, + pub registered_at_block: u64, +} + +/// Event emitted by `register`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PetitionRegisteredEvent { + pub petition_id: PetitionId, + pub slot: u32, + pub r_root: Bytes32, + pub predicate_hash: Bytes32, + pub class_set: Vec, + pub class_thresholds: Vec, + pub class_index: u8, + pub close_at_block: u64, + pub bounty: u128, +} + +/// Emitted by `publish_batch`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BatchPublishedEvent { + pub petition_id: PetitionId, + pub batch_index: u32, + pub batch_versioned_hash: Bytes32, + pub new_running_root: Bytes32, + pub new_identity_tag_set_root: Bytes32, + pub new_leaf_count: u64, +} + +/// Emitted by `dispute`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BatchRepudiatedEvent { + pub petition_id: PetitionId, + pub batch_index: u32, + pub new_running_root: Bytes32, + pub new_identity_tag_set_root: Bytes32, + pub new_leaf_count: u64, +} + +/// Emitted by `resolve`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PetitionResolvedEvent { + pub petition_id: PetitionId, + pub b: bool, + pub b_per_class: Vec, +} + +/// Emitted by `mark_unresolved`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PetitionUnresolvedEvent { + pub petition_id: PetitionId, +} + +/// `BountyPaid` event paid to the winning resolver. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BountyPaidEvent { + pub petition_id: PetitionId, + pub recipient: Address, + pub amount: u128, +} + +/// `BountyRefunded` event for organizer refund and caller gas rebate. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BountyRefundedEvent { + pub petition_id: PetitionId, + pub recipient: Address, + pub amount: u128, +} + +/// `AlphaUpdated` event emitted by `update_alpha`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AlphaUpdatedEvent { + pub old_alpha: u64, + pub new_alpha: u64, +} + +/// Petition state snapshot for Relayer, Resolver, and Disputant. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PetitionStateView { + pub petition_id: PetitionId, + pub r_root: Bytes32, + pub predicate_hash: Bytes32, + pub class_set: Vec, + pub class_thresholds: Vec, + pub class_index: u8, + pub slot: u32, + pub running_root: Bytes32, + pub identity_tag_set_root: Bytes32, + pub leaf_count: u64, + pub next_batch_index: u32, +} diff --git a/pocs/civic-participation/resilient-civic-participation/src/relayer/core.rs b/pocs/civic-participation/resilient-civic-participation/src/relayer/core.rs new file mode 100644 index 0000000..cfcca58 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/relayer/core.rs @@ -0,0 +1,437 @@ +//! Relayer: aggregate signer submissions, build batch SNARK, publish blob. + +use std::collections::HashMap; + +use ark_bn254::Fr; + +use crate::{ + BATCH_SIZE_MAX, + imt::IndexedMerkleTree, + ports::{ + blob::BlobCarrier, + imt::ImtStore, + proof::{ + BatchPositionWitness, + ProofBackend, + }, + }, + poseidon::{ + fr_from_be_bytes, + fr_to_be_bytes, + hash_leaf, + }, + relayer::{ + error::RelayerError, + types::{ + BatchPosition, + PetitionView, + }, + }, + types::{ + Address, + BatchPublicInputs, + BatchSubmission, + Bytes32, + ClassTag, + RecordEntry, + SignerPublicInputs, + SignerSubmission, + }, +}; + +/// Per-petition IMT replicas the relayer maintains to build batch witnesses. +pub struct RelayerPetitionState { + pub running_imt: IndexedMerkleTree, + pub identity_tag_imt: IndexedMerkleTree, + pub leaf_count: u64, +} + +impl RelayerPetitionState { + pub fn new() -> Self { + Self { + running_imt: IndexedMerkleTree::new(), + identity_tag_imt: IndexedMerkleTree::new(), + leaf_count: 0, + } + } +} + +impl Default for RelayerPetitionState { + fn default() -> Self { + Self::new() + } +} + +pub struct Relayer +where + P: ProofBackend, + B: BlobCarrier, +{ + pub address: Address, + pub proof_backend: P, + pub blob_carrier: B, +} + +impl Relayer +where + P: ProofBackend, + B: BlobCarrier, +{ + pub fn new(address: Address, proof_backend: P, blob_carrier: B) -> Self { + Self { + address, + proof_backend, + blob_carrier, + } + } + + /// Verify a signer submission against `petition`. + fn verify_submission( + &self, + idx: usize, + s: &SignerSubmission, + petition: &PetitionView, + ) -> Result<(SignerPublicInputs, Fr, ClassTag), RelayerError> { + if s.petition_id != petition.petition_id { + return Err(RelayerError::PetitionIdMismatch(idx)); + } + if !petition.class_set.contains(&s.class_tag) { + return Err(RelayerError::ClassTagOutOfSet(s.class_tag, idx)); + } + + let public = SignerPublicInputs { + r_root: fr_from_be_bytes(&s.r_root), + petition_id: fr_from_be_bytes(&s.petition_id), + predicate_hash: fr_from_be_bytes(&s.predicate_hash), + class_index: Fr::from(s.class_index as u64), + class_tag: Fr::from(s.class_tag as u64), + slot: Fr::from(s.slot as u64), + nullifier: fr_from_be_bytes(&s.nullifier), + identity_tag: fr_from_be_bytes(&s.identity_tag), + }; + self.proof_backend + .verify_signer_proof(&s.proof_bytes, &public) + .map_err(|e| RelayerError::SignerProofInvalid(idx, e))?; + let leaf = hash_leaf(public.nullifier, public.class_tag); + Ok((public, leaf, s.class_tag)) + } + + /// Build a `BatchSubmission` and `BatchPosition`s; advances `petition_state`. + pub fn build_batch( + &mut self, + petition: &PetitionView, + petition_state: &mut RelayerPetitionState, + submissions: Vec, + ) -> Result<(BatchSubmission, Vec), RelayerError> { + if submissions.is_empty() { + return Err(RelayerError::EmptyBatch); + } + if submissions.len() > BATCH_SIZE_MAX { + return Err(RelayerError::BatchSizeExceeded( + submissions.len(), + BATCH_SIZE_MAX, + )); + } + if petition_state.running_imt.root() != petition.running_root + || petition_state.identity_tag_imt.root() != petition.identity_tag_set_root + { + return Err(RelayerError::StateDiverged); + } + + let mut verified: Vec<(SignerSubmission, SignerPublicInputs, Fr, ClassTag)> = + Vec::with_capacity(submissions.len()); + for (i, s) in submissions.into_iter().enumerate() { + let (public, leaf, ct) = self.verify_submission(i, &s, petition)?; + verified.push((s, public, leaf, ct)); + } + + // Record first-seen index per nullifier / identity_tag so we can + // report exact collision positions without a quadratic rescan. + let mut seen_nullifiers: HashMap = HashMap::new(); + let mut seen_ids: HashMap = HashMap::new(); + for (i, (s, _, _, _)) in verified.iter().enumerate() { + if let Some(&j) = seen_nullifiers.get(&s.nullifier) { + return Err(RelayerError::DuplicateNullifier(j, i)); + } + if let Some(&j) = seen_ids.get(&s.identity_tag) { + return Err(RelayerError::DuplicateIdentityTag(j, i)); + } + seen_nullifiers.insert(s.nullifier, i); + seen_ids.insert(s.identity_tag, i); + } + + // Canonical BN254 ordering: ascending big-endian leaf bytes. + verified.sort_by_cached_key(|t| fr_to_be_bytes(&t.2)); + + let prior_running_root = petition_state.running_imt.root(); + let prior_identity_tag_root = petition_state.identity_tag_imt.root(); + let prior_leaf_count = petition_state.leaf_count; + + let mut positions: Vec = Vec::with_capacity(verified.len()); + let mut records: Vec = Vec::with_capacity(verified.len()); + for (s, _public, leaf_fr, _class_tag) in verified.iter() { + let leaf_be = fr_to_be_bytes(leaf_fr); + let leaf_insert = petition_state.running_imt.insert(&leaf_be)?; + let identity_tag_insert = + petition_state.identity_tag_imt.insert(&s.identity_tag)?; + records.push(RecordEntry { + nullifier: s.nullifier, + identity_tag: s.identity_tag, + class_tag: s.class_tag, + }); + positions.push(BatchPosition { + submission: s.clone(), + leaf_insert, + identity_tag_insert, + }); + } + petition_state.leaf_count = prior_leaf_count + .checked_add(positions.len() as u64) + .ok_or(RelayerError::LeafCountOverflow)?; + + let new_running_root = petition_state.running_imt.root(); + let new_identity_tag_root = petition_state.identity_tag_imt.root(); + + let batch_versioned_hash = self.blob_carrier.publish(&records)?; + + // Constraint 8 re-derives these; contract verifies KZG openings against `batch_versioned_hash`. + let fe_per_batch = BATCH_SIZE_MAX * crate::blob::FE_PER_RECORD; + let mut bls_fields: Vec = Vec::with_capacity(fe_per_batch); + bls_fields.extend(records.iter().flat_map(crate::blob::record_to_bls_fields)); + bls_fields.resize(fe_per_batch, Fr::from(0u64)); + + let public_inputs = BatchPublicInputs { + petition_id: fr_from_be_bytes(&petition.petition_id), + r_root: fr_from_be_bytes(&petition.r_root), + predicate_hash: fr_from_be_bytes(&petition.predicate_hash), + class_index: Fr::from(petition.class_index as u64), + slot: Fr::from(petition.slot as u64), + batch_size: Fr::from(positions.len() as u64), + prior_running_root: fr_from_be_bytes(&prior_running_root), + new_running_root: fr_from_be_bytes(&new_running_root), + prior_identity_tag_set_root: fr_from_be_bytes(&prior_identity_tag_root), + new_identity_tag_set_root: fr_from_be_bytes(&new_identity_tag_root), + prior_leaf_count: Fr::from(prior_leaf_count), + new_leaf_count: Fr::from(petition_state.leaf_count), + batch_versioned_hash: fr_from_be_bytes(&batch_versioned_hash), + bls_fields, + signer_vk_hash: fr_from_be_bytes(&petition.signer_vk_hash), + }; + let position_witnesses: Vec = verified + .into_iter() + .zip(positions.iter()) + .map(|((s, public, _, _), bp)| BatchPositionWitness { + submission: s, + public_inputs: public, + running_insert: Some(bp.leaf_insert.clone()), + idtag_insert: Some(bp.identity_tag_insert.clone()), + }) + .collect(); + let proof_bytes = self + .proof_backend + .generate_batch_proof(&public_inputs, &position_witnesses)?; + + Ok(( + BatchSubmission { + public_inputs, + records, + proof_bytes, + }, + positions, + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + adapters::{ + in_memory_blob::InMemoryBlobCarrier, + mock_proof::MockProofBackend, + }, + poseidon::{ + hash_identity_tag, + hash_nullifier, + }, + }; + + fn fake_submission( + petition: &PetitionView, + slot: u32, + class_tag: u16, + secret: u64, + ) -> SignerSubmission { + let backend = MockProofBackend; + let identity_secret_fr = Fr::from(0xdeadbeefu64); + let nullifier_fr = hash_nullifier( + Fr::from(secret), + fr_from_be_bytes(&petition.petition_id), + Fr::from(petition.class_index as u64), + Fr::from(class_tag as u64), + identity_secret_fr, + ); + let identity_tag_fr = + hash_identity_tag(Fr::from(secret), fr_from_be_bytes(&petition.petition_id)); + let public = SignerPublicInputs { + r_root: fr_from_be_bytes(&petition.r_root), + petition_id: fr_from_be_bytes(&petition.petition_id), + predicate_hash: fr_from_be_bytes(&petition.predicate_hash), + class_index: Fr::from(petition.class_index as u64), + class_tag: Fr::from(class_tag as u64), + slot: Fr::from(slot as u64), + nullifier: nullifier_fr, + identity_tag: identity_tag_fr, + }; + let private = crate::types::SignerPrivateInputs { + identity_secret: identity_secret_fr, + attr_vector: vec![Fr::from(1u64); 4], + attr_version: 0, + chain_root: Fr::from(7u64), + ri_path_siblings: vec![], + ri_path_indices: vec![], + s_slot: Fr::from(8u64), + chain_path_siblings: vec![], + chain_path_indices: vec![], + salt: Fr::from(11u64), + predicate_def: crate::predicate::PredicateDef { + tuples: vec![crate::predicate::Tuple { + claim_index: 0, + operand: [0u8; 32], + type_tag: crate::types::TypeTag::Int64, + comparator: crate::types::Comparator::Eq, + }], + ops: vec![crate::predicate::Op { + code: crate::types::OpCode::PushTuple, + operand: 0, + }], + }, + }; + let proof_bytes = backend.generate_signer_proof(&public, &private).unwrap(); + SignerSubmission { + petition_id: petition.petition_id, + r_root: petition.r_root, + predicate_hash: petition.predicate_hash, + class_index: petition.class_index, + slot, + nullifier: fr_to_be_bytes(&nullifier_fr), + identity_tag: fr_to_be_bytes(&identity_tag_fr), + class_tag, + proof_bytes, + } + } + + fn empty_petition_view() -> PetitionView { + let mut id = [0u8; 32]; + id[24..].copy_from_slice(&42u64.to_be_bytes()); + let mut r = [0u8; 32]; + r[24..].copy_from_slice(&7u64.to_be_bytes()); + let mut ph = [0u8; 32]; + ph[24..].copy_from_slice(&88u64.to_be_bytes()); + let initial_imt = IndexedMerkleTree::new(); + let imt_root = initial_imt.root(); + PetitionView { + petition_id: id, + r_root: r, + predicate_hash: ph, + class_index: 1, + class_set: vec![100, 200], + slot: 0, + running_root: imt_root, + identity_tag_set_root: imt_root, + leaf_count: 0, + signer_vk_hash: [0u8; 32], + } + } + + #[test] + fn test_relayer_builds_batch_in_canonical_order() { + let petition = empty_petition_view(); + let mut state = RelayerPetitionState::new(); + let mut relayer = + Relayer::new([0xaa; 20], MockProofBackend, InMemoryBlobCarrier::new()); + let s1 = fake_submission(&petition, 0, 100, 1); + let s2 = fake_submission(&petition, 0, 200, 2); + let s3 = fake_submission(&petition, 0, 100, 3); + let (batch, positions) = relayer + .build_batch( + &petition, + &mut state, + vec![s1.clone(), s2.clone(), s3.clone()], + ) + .unwrap(); + assert_eq!(batch.records.len(), 3); + for w in positions.windows(2) { + let leaf_a = hash_leaf( + fr_from_be_bytes(&w[0].submission.nullifier), + Fr::from(w[0].submission.class_tag as u64), + ); + let leaf_b = hash_leaf( + fr_from_be_bytes(&w[1].submission.nullifier), + Fr::from(w[1].submission.class_tag as u64), + ); + assert!(fr_to_be_bytes(&leaf_a) < fr_to_be_bytes(&leaf_b)); + } + } + + #[test] + fn test_relayer_rejects_intra_batch_duplicate_nullifier() { + let petition = empty_petition_view(); + let mut state = RelayerPetitionState::new(); + let mut relayer = + Relayer::new([0xaa; 20], MockProofBackend, InMemoryBlobCarrier::new()); + let s = fake_submission(&petition, 0, 100, 1); + let err = relayer.build_batch(&petition, &mut state, vec![s.clone(), s.clone()]); + assert!(matches!(err, Err(RelayerError::DuplicateNullifier(_, _)))); + } + + #[test] + fn test_relayer_rejects_class_tag_out_of_set() { + let petition = empty_petition_view(); + let mut state = RelayerPetitionState::new(); + let mut relayer = + Relayer::new([0xaa; 20], MockProofBackend, InMemoryBlobCarrier::new()); + let bad = fake_submission(&petition, 0, 999, 1); + let err = relayer.build_batch(&petition, &mut state, vec![bad]); + assert!(matches!(err, Err(RelayerError::ClassTagOutOfSet(999, 0)))); + } + + #[test] + fn test_relayer_rejects_empty_batch() { + let petition = empty_petition_view(); + let mut state = RelayerPetitionState::new(); + let mut relayer = + Relayer::new([0xaa; 20], MockProofBackend, InMemoryBlobCarrier::new()); + let err = relayer.build_batch(&petition, &mut state, vec![]); + assert!(matches!(err, Err(RelayerError::EmptyBatch))); + } + + #[test] + fn test_relayer_emits_blob_with_records_in_canonical_order() { + let petition = empty_petition_view(); + let mut state = RelayerPetitionState::new(); + let mut relayer = + Relayer::new([0xaa; 20], MockProofBackend, InMemoryBlobCarrier::new()); + let s1 = fake_submission(&petition, 0, 100, 1); + let s2 = fake_submission(&petition, 0, 200, 2); + let (batch, _) = relayer + .build_batch(&petition, &mut state, vec![s1, s2]) + .unwrap(); + let bvh = + crate::poseidon::fr_to_be_bytes(&batch.public_inputs.batch_versioned_hash); + let fetched = relayer.blob_carrier.fetch_records(&bvh).unwrap(); + assert_eq!(fetched.len(), 2); + } + + #[test] + fn test_relayer_state_diverges_rejected() { + let petition = empty_petition_view(); + let mut state = RelayerPetitionState::new(); + let _ = state.running_imt.insert(&[0x11u8; 32]).unwrap(); + let mut relayer = + Relayer::new([0xaa; 20], MockProofBackend, InMemoryBlobCarrier::new()); + let s = fake_submission(&petition, 0, 100, 1); + let err = relayer.build_batch(&petition, &mut state, vec![s]); + assert!(matches!(err, Err(RelayerError::StateDiverged))); + } +} diff --git a/pocs/civic-participation/resilient-civic-participation/src/relayer/error.rs b/pocs/civic-participation/resilient-civic-participation/src/relayer/error.rs new file mode 100644 index 0000000..dd30c41 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/relayer/error.rs @@ -0,0 +1,35 @@ +use thiserror::Error; + +use crate::error::{ + BlobError, + ImtError, + ProofError, +}; + +#[derive(Debug, Error)] +pub enum RelayerError { + #[error("batch is empty")] + EmptyBatch, + #[error("batch size {0} exceeds BATCH_SIZE_MAX {1}")] + BatchSizeExceeded(usize, usize), + #[error("intra-batch duplicate nullifier at positions {0} and {1}")] + DuplicateNullifier(usize, usize), + #[error("intra-batch duplicate identity_tag at positions {0} and {1}")] + DuplicateIdentityTag(usize, usize), + #[error("signer SNARK verification failed at position {0}: {1}")] + SignerProofInvalid(usize, ProofError), + #[error("class_tag {0} at position {1} is not in petition's class_set")] + ClassTagOutOfSet(u16, usize), + #[error("petition_id mismatch at position {0}")] + PetitionIdMismatch(usize), + #[error("relayer local state diverges from petition view")] + StateDiverged, + #[error("leaf_count overflow")] + LeafCountOverflow, + #[error("blob: {0}")] + Blob(#[from] BlobError), + #[error("imt: {0}")] + Imt(#[from] ImtError), + #[error("proof: {0}")] + Proof(#[from] ProofError), +} diff --git a/pocs/civic-participation/resilient-civic-participation/src/relayer/mod.rs b/pocs/civic-participation/resilient-civic-participation/src/relayer/mod.rs new file mode 100644 index 0000000..f19d292 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/relayer/mod.rs @@ -0,0 +1,5 @@ +pub mod core; +pub mod error; +pub mod types; + +pub use core::Relayer; diff --git a/pocs/civic-participation/resilient-civic-participation/src/relayer/types.rs b/pocs/civic-participation/resilient-civic-participation/src/relayer/types.rs new file mode 100644 index 0000000..ce3b4d2 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/relayer/types.rs @@ -0,0 +1,41 @@ +//! Relayer types. + +use serde::{ + Deserialize, + Serialize, +}; + +use crate::{ + ports::imt::ImtInsertWitness, + types::{ + Bytes32, + ClassTag, + PetitionId, + SignerSubmission, + }, +}; + +/// Registry-supplied petition coordinates used to build a batch. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PetitionView { + pub petition_id: PetitionId, + pub r_root: Bytes32, + pub predicate_hash: Bytes32, + pub class_index: u8, + pub class_set: Vec, + pub slot: u32, + pub running_root: Bytes32, + pub identity_tag_set_root: Bytes32, + pub leaf_count: u64, + /// Deploy-pinned signer VK hash; the batch SNARK must commit to + /// this value as a public input. + pub signer_vk_hash: Bytes32, +} + +/// Per-position state the batch witness consumes. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BatchPosition { + pub submission: SignerSubmission, + pub leaf_insert: ImtInsertWitness, + pub identity_tag_insert: ImtInsertWitness, +} diff --git a/pocs/civic-participation/resilient-civic-participation/src/resolver/core.rs b/pocs/civic-participation/resilient-civic-participation/src/resolver/core.rs new file mode 100644 index 0000000..8d77c79 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/resolver/core.rs @@ -0,0 +1,337 @@ +//! Resolver: reconstruct leaves from blobs, count classes, prove resolution. + +use ark_bn254::Fr; + +use crate::{ + imt::IndexedMerkleTree, + ports::{ + blob::BlobCarrier, + imt::ImtStore, + proof::ProofBackend, + }, + poseidon::{ + fr_from_be_bytes, + fr_to_be_bytes, + hash_leaf, + }, + resolver::{ + error::ResolverError, + types::ResolverView, + }, + types::{ + ImtMembershipFr, + ResolutionPrivateInputs, + ResolutionPublicInputs, + ResolutionSubmission, + }, +}; + +pub struct Resolver +where + P: ProofBackend, + B: BlobCarrier, +{ + pub proof_backend: P, + pub blob_carrier: B, +} + +impl Resolver +where + P: ProofBackend, + B: BlobCarrier, +{ + pub fn new(proof_backend: P, blob_carrier: B) -> Self { + Self { + proof_backend, + blob_carrier, + } + } + + /// Reconstruct `L` from active-batch blobs; verifies `running_root` and `leaf_count`. + pub fn reconstruct( + &self, + view: &ResolverView, + ) -> Result, ResolverError> { + if view.active_batch_versioned_hashes.is_empty() { + return Err(ResolverError::NoBatches); + } + let mut imt = IndexedMerkleTree::new(); + let mut entries: Vec<(Fr, Fr, Fr)> = Vec::new(); + for vh in &view.active_batch_versioned_hashes { + let records = self.blob_carrier.fetch_records(vh)?; + for record in records { + let nullifier_fr = fr_from_be_bytes(&record.nullifier); + let class_tag_fr = Fr::from(record.class_tag as u64); + let leaf_fr = hash_leaf(nullifier_fr, class_tag_fr); + let _ = imt.insert(&fr_to_be_bytes(&leaf_fr))?; + entries.push((leaf_fr, nullifier_fr, class_tag_fr)); + } + } + if (entries.len() as u64) != view.leaf_count { + return Err(ResolverError::LeafCountMismatch( + entries.len() as u64, + view.leaf_count, + )); + } + if imt.root() != view.running_root { + return Err(ResolverError::RootMismatch); + } + Ok(entries) + } + + /// Build the resolution SNARK. + pub fn build_submission( + &self, + view: &ResolverView, + entries: &[(Fr, Fr, Fr)], + ) -> Result { + let (b, b_per_class) = compute_outcome(view, entries); + + // Two-pass: insert all leaves to materialize the final IMT, then + // query membership per leaf to read the final-state path and + // linked-list pointers. Insertion-time witnesses are stale for + // every leaf except the last because later inserts mutate + // siblings along the earlier leaves' paths and rewrite the + // bracketing low leaf's next_index/next_value. + let mut imt = IndexedMerkleTree::new(); + let mut leaves = Vec::with_capacity(entries.len()); + let mut witness_pairs = Vec::with_capacity(entries.len()); + for (leaf_fr, nullifier_fr, class_tag_fr) in entries { + imt.insert(&fr_to_be_bytes(leaf_fr))?; + leaves.push(*leaf_fr); + witness_pairs.push((*nullifier_fr, *class_tag_fr)); + } + let mut imt_membership = Vec::with_capacity(entries.len()); + for (leaf_fr, _, _) in entries { + let m = imt + .membership(&fr_to_be_bytes(leaf_fr)) + .ok_or(ResolverError::RootMismatch)?; + imt_membership.push(ImtMembershipFr { + leaf_hash: hash_imt_leaf(&m.leaf), + leaf_index: m.leaf_index, + next_index: m.leaf.next_index, + next_value: fr_from_be_bytes(&m.leaf.next_value), + siblings: m + .path + .siblings + .iter() + .map(|s| fr_from_be_bytes(s)) + .collect(), + indices: m.path.indices.clone(), + }); + } + + let public = ResolutionPublicInputs { + predicate_hash: fr_from_be_bytes(&view.predicate_hash), + r_root: fr_from_be_bytes(&view.r_root), + running_root: fr_from_be_bytes(&view.running_root), + leaf_count: Fr::from(view.leaf_count), + class_set: view.class_set.iter().map(|c| Fr::from(*c as u64)).collect(), + class_thresholds: view + .class_thresholds + .iter() + .map(|t| Fr::from(*t)) + .collect(), + b: Fr::from(b as u64), + b_per_class: b_per_class.iter().map(|x| Fr::from(*x as u64)).collect(), + class_index: Fr::from(view.class_index as u64), + }; + let private = ResolutionPrivateInputs { + leaves, + imt_membership_paths: imt_membership, + witness_pairs, + }; + let proof_bytes = self + .proof_backend + .generate_resolution_proof(&public, &private)?; + Ok(ResolutionSubmission { + public_inputs: public, + proof_bytes, + }) + } + + /// `reconstruct` + `build_submission`. + pub fn resolve( + &self, + view: &ResolverView, + ) -> Result { + let entries = self.reconstruct(view)?; + self.build_submission(view, &entries) + } +} + +fn hash_imt_leaf(leaf: &crate::ports::imt::ImtLeaf) -> Fr { + use crate::poseidon::poseidon4; + poseidon4( + fr_from_be_bytes(&leaf.value), + Fr::from(leaf.next_index as u64), + fr_from_be_bytes(&leaf.next_value), + Fr::from(0u64), + ) +} + +/// Per-class counts and outcome bits `b`, `b_per_class`. `class_set` is +/// strictly increasing (registry-enforced) so we binary-search by tag. +pub fn compute_outcome( + view: &ResolverView, + entries: &[(Fr, Fr, Fr)], +) -> (bool, Vec) { + let mut counts = vec![0u64; view.class_set.len()]; + for (_, _, class_tag_fr) in entries { + let bytes = fr_to_be_bytes(class_tag_fr); + let class_tag = u16::from_be_bytes([bytes[30], bytes[31]]); + if let Ok(i) = view.class_set.binary_search(&class_tag) { + counts[i] += 1; + } + } + let b_per_class: Vec = counts + .iter() + .zip(view.class_thresholds.iter()) + .map(|(c, t)| *c >= *t) + .collect(); + let b = b_per_class.iter().all(|x| *x); + (b, b_per_class) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + adapters::{ + in_memory_blob::InMemoryBlobCarrier, + mock_proof::MockProofBackend, + }, + types::RecordEntry, + }; + + fn sample_record(seed: u64, class_tag: u16) -> RecordEntry { + let mut nullifier = [0u8; 32]; + nullifier[24..].copy_from_slice(&seed.to_be_bytes()); + let mut identity_tag = [0u8; 32]; + identity_tag[24..].copy_from_slice(&(seed + 100).to_be_bytes()); + RecordEntry { + nullifier, + identity_tag, + class_tag, + } + } + + fn build_view_from_records( + view_records: &[Vec], + bc: &mut InMemoryBlobCarrier, + ) -> ResolverView { + let mut imt = IndexedMerkleTree::new(); + let mut vhs = Vec::new(); + let mut leaf_count: u64 = 0; + // Mirror relayer's canonical leaf ordering per batch. + let mut sorted_batches: Vec> = Vec::new(); + for batch in view_records { + let mut copy = batch.clone(); + copy.sort_by(|a, b| { + let la = hash_leaf( + fr_from_be_bytes(&a.nullifier), + Fr::from(a.class_tag as u64), + ); + let lb = hash_leaf( + fr_from_be_bytes(&b.nullifier), + Fr::from(b.class_tag as u64), + ); + fr_to_be_bytes(&la).cmp(&fr_to_be_bytes(&lb)) + }); + sorted_batches.push(copy); + } + for batch in &sorted_batches { + for r in batch { + let leaf_fr = hash_leaf( + fr_from_be_bytes(&r.nullifier), + Fr::from(r.class_tag as u64), + ); + imt.insert(&fr_to_be_bytes(&leaf_fr)).unwrap(); + leaf_count += 1; + } + let vh = bc.publish(batch).unwrap(); + vhs.push(vh); + } + ResolverView { + petition_id: [0x42; 32], + r_root: [0x77; 32], + predicate_hash: [0x88; 32], + running_root: imt.root(), + leaf_count, + class_set: vec![100, 200], + class_thresholds: vec![1, 1], + class_index: 1, + active_batch_versioned_hashes: vhs, + } + } + + #[test] + fn test_reconstruct_validates_root_and_leaf_count() { + let mut bc = InMemoryBlobCarrier::new(); + let view = build_view_from_records( + &[ + vec![sample_record(1, 100), sample_record(2, 200)], + vec![sample_record(3, 100), sample_record(4, 200)], + ], + &mut bc, + ); + let resolver = Resolver::new(MockProofBackend, bc); + let entries = resolver.reconstruct(&view).unwrap(); + assert_eq!(entries.len() as u64, view.leaf_count); + } + + #[test] + fn test_compute_outcome_b_per_class() { + let mut bc = InMemoryBlobCarrier::new(); + let view = build_view_from_records( + &[vec![sample_record(1, 100), sample_record(2, 200)]], + &mut bc, + ); + let resolver = Resolver::new(MockProofBackend, bc); + let entries = resolver.reconstruct(&view).unwrap(); + let (b, per) = compute_outcome(&view, &entries); + assert!(b); + assert_eq!(per, vec![true, true]); + } + + #[test] + fn test_compute_outcome_threshold_not_met() { + let mut bc = InMemoryBlobCarrier::new(); + let mut view = build_view_from_records(&[vec![sample_record(1, 100)]], &mut bc); + view.class_thresholds = vec![1, 1]; + view.class_set = vec![100, 200]; + let resolver = Resolver::new(MockProofBackend, bc); + let entries = resolver.reconstruct(&view).unwrap(); + let (b, per) = compute_outcome(&view, &entries); + assert!(!b); + assert_eq!(per, vec![true, false]); + } + + #[test] + fn test_resolve_end_to_end_emits_valid_submission() { + let mut bc = InMemoryBlobCarrier::new(); + let view = build_view_from_records( + &[ + vec![sample_record(1, 100), sample_record(2, 200)], + vec![sample_record(3, 100), sample_record(4, 200)], + ], + &mut bc, + ); + let resolver = Resolver::new(MockProofBackend, bc); + let submission = resolver.resolve(&view).unwrap(); + let backend = MockProofBackend; + backend + .verify_resolution_proof(&submission.proof_bytes, &submission.public_inputs) + .unwrap(); + } + + #[test] + fn test_reconstruct_rejects_root_mismatch() { + let mut bc = InMemoryBlobCarrier::new(); + let mut view = build_view_from_records(&[vec![sample_record(1, 100)]], &mut bc); + view.running_root = [0xee; 32]; + let resolver = Resolver::new(MockProofBackend, bc); + let err = resolver.reconstruct(&view); + assert!(matches!(err, Err(ResolverError::RootMismatch))); + } +} diff --git a/pocs/civic-participation/resilient-civic-participation/src/resolver/error.rs b/pocs/civic-participation/resilient-civic-participation/src/resolver/error.rs new file mode 100644 index 0000000..bae071c --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/resolver/error.rs @@ -0,0 +1,27 @@ +use thiserror::Error; + +use crate::error::{ + BlobError, + ImtError, + ProofError, +}; + +#[derive(Debug, Error)] +pub enum ResolverError { + #[error("petition state does not allow resolution")] + BadState, + #[error("no active batches recorded; nothing to reconstruct")] + NoBatches, + #[error("reconstructed leaf count {0} disagrees with on-chain leaf_count {1}")] + LeafCountMismatch(u64, u64), + #[error("reconstructed running_root disagrees with on-chain running_root")] + RootMismatch, + #[error("reconstructed leaves are not strictly sorted at position {0}")] + LeafOrderingFailure(usize), + #[error("blob: {0}")] + Blob(#[from] BlobError), + #[error("imt: {0}")] + Imt(#[from] ImtError), + #[error("proof: {0}")] + Proof(#[from] ProofError), +} diff --git a/pocs/civic-participation/resilient-civic-participation/src/resolver/mod.rs b/pocs/civic-participation/resilient-civic-participation/src/resolver/mod.rs new file mode 100644 index 0000000..5d4201a --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/resolver/mod.rs @@ -0,0 +1,5 @@ +pub mod core; +pub mod error; +pub mod types; + +pub use core::Resolver; diff --git a/pocs/civic-participation/resilient-civic-participation/src/resolver/types.rs b/pocs/civic-participation/resilient-civic-participation/src/resolver/types.rs new file mode 100644 index 0000000..02f1e45 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/resolver/types.rs @@ -0,0 +1,27 @@ +//! Resolver types. + +use serde::{ + Deserialize, + Serialize, +}; + +use crate::types::{ + Bytes32, + ClassTag, + PetitionId, +}; + +/// Registry handle used by the resolver to reconstruct the leaf set. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResolverView { + pub petition_id: PetitionId, + pub r_root: Bytes32, + pub predicate_hash: Bytes32, + pub running_root: Bytes32, + pub leaf_count: u64, + pub class_set: Vec, + pub class_thresholds: Vec, + pub class_index: u8, + /// `batch_versioned_hash` per active batch, in batch_index order. + pub active_batch_versioned_hashes: Vec, +} diff --git a/pocs/civic-participation/resilient-civic-participation/src/signer/core.rs b/pocs/civic-participation/resilient-civic-participation/src/signer/core.rs new file mode 100644 index 0000000..49235aa --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/signer/core.rs @@ -0,0 +1,407 @@ +//! Signer actor: enroll, sign, journal. + +use ark_bn254::Fr; +use rand::RngCore; + +use crate::{ + fsrt::SignerChainState, + ports::{ + proof::ProofBackend, + ri::RiCredentialLayer, + }, + poseidon::{ + fr_from_be_bytes, + fr_to_be_bytes, + hash_attr, + hash_identity_tag, + hash_nullifier, + }, + signer::{ + error::SignerError, + types::{ + EnrollmentArtifact, + PetitionMeta, + }, + }, + types::{ + SignerCredentials, + SignerPrivateInputs, + SignerPublicInputs, + SignerStateBytes, + SignerSubmission, + }, +}; + +/// Per-signer container. +pub struct Signer +where + P: ProofBackend, + R: RiCredentialLayer, +{ + pub credentials: SignerCredentials, + pub chain: SignerChainState, + pub proof_backend: P, + pub ri: R, +} + +impl Signer +where + P: ProofBackend, + R: RiCredentialLayer, +{ + /// Construct a signer and run enrollment. + pub fn enroll( + proof_backend: P, + mut ri: R, + attr_vector: Vec, + chain_len: u32, + attr_version: u32, + posted_at_block: u64, + seed: Option<[u8; 32]>, + ) -> (Self, EnrollmentArtifact) { + let s_0_bytes = match seed { + Some(b) => b, + None => { + // OsRng pulls entropy from the OS CSPRNG (getrandom on + // Linux, /dev/urandom on macOS, BCryptGenRandom on + // Windows). Use try_fill_bytes so an entropy-starvation + // condition (early boot, sandbox without /dev/urandom) + // propagates as a panic-with-context rather than a + // silent default. For PoC code we accept the panic; + // production code should plumb a Result. + let mut b = [0u8; 32]; + use rand::rngs::OsRng; + OsRng + .try_fill_bytes(&mut b) + .expect("OsRng entropy unavailable; cannot enroll"); + b + } + }; + let s_0 = fr_from_be_bytes(&s_0_bytes); + + // identity_secret is sampled INDEPENDENTLY of s_0. Per SPEC the + // signer holds (s_0, identity_secret) as two separate per-signer + // secrets; the RI leaf binds to both, and the nullifier binds to + // identity_secret so leaking s_0 alone does not allow signing + // under the victim's RI leaf. + let identity_secret_bytes: [u8; 32] = { + let mut b = [0u8; 32]; + use rand::rngs::OsRng; + OsRng + .try_fill_bytes(&mut b) + .expect("OsRng entropy unavailable; cannot enroll"); + b + }; + let identity_secret_fr = fr_from_be_bytes(&identity_secret_bytes); + + let chain = SignerChainState::enroll(s_0, chain_len, attr_version); + + let attr_hash_fr = hash_attr( + &attr_vector, + chain.chain_root, + attr_version, + identity_secret_fr, + ); + let attr_hash_be = fr_to_be_bytes(&attr_hash_fr); + + let ri_leaf_index = ri.append_leaf(attr_hash_be, posted_at_block); + + let credentials = SignerCredentials { + identity_secret: identity_secret_bytes, + attr_vector: attr_vector.iter().map(fr_to_be_bytes).collect(), + ri_leaf_index, + }; + let signer = Self { + credentials, + chain, + proof_backend, + ri, + }; + let artifact = EnrollmentArtifact { + attr_hash: attr_hash_be, + chain_root: fr_to_be_bytes(&signer.chain.chain_root), + attr_version, + }; + (signer, artifact) + } + + /// SPEC Per-Signature Generation. + pub fn sign( + &mut self, + petition: &PetitionMeta, + ) -> Result { + // Validate everything BEFORE mutating chain state: a failed sign + // must not advance the FSRT head (which is monotone), otherwise + // a malformed PetitionMeta could permanently block earlier slots. + if petition.slot < self.chain.t { + return Err(SignerError::SlotInPast { + slot: petition.slot, + head: self.chain.t, + }); + } + let chain_len = self.chain.chain_len(); + if petition.slot >= chain_len { + return Err(SignerError::SlotOutOfRange { + slot: petition.slot, + chain_len, + }); + } + + let class_tag_fr = Fr::from(petition.class_tag as u64); + let attr_vector_fr: Vec = self + .credentials + .attr_vector + .iter() + .map(|b| fr_from_be_bytes(b)) + .collect(); + let attr_at_idx = attr_vector_fr + .get(petition.class_index as usize) + .ok_or_else(|| { + SignerError::Invariant(format!( + "class_index {} out of range (attr_count {})", + petition.class_index, + attr_vector_fr.len() + )) + })?; + if *attr_at_idx != class_tag_fr { + return Err(SignerError::Invariant(format!( + "class binding mismatch: attr[{}] != class_tag", + petition.class_index + ))); + } + + // Validation passed; safe to advance. + self.chain.advance_to(petition.slot); + let v_slot = self.chain.v_at_current_slot(); + + let petition_id_fr = fr_from_be_bytes(&petition.petition_id); + let class_index_fr = Fr::from(petition.class_index as u64); + let identity_secret_fr = fr_from_be_bytes(&self.credentials.identity_secret); + let nullifier_fr = hash_nullifier( + v_slot, + petition_id_fr, + class_index_fr, + class_tag_fr, + identity_secret_fr, + ); + let identity_tag_fr = hash_identity_tag(v_slot, petition_id_fr); + + let (chain_siblings, chain_indices) = self.chain.merkle_path_for_current_slot(); + let ri_path = self + .ri + .merkle_path(self.credentials.ri_leaf_index) + .map_err(|e| SignerError::RiLookup(e.to_string()))?; + + let attr_version = self.chain.attr_version; + + let public = SignerPublicInputs { + r_root: fr_from_be_bytes(&petition.r_root), + petition_id: petition_id_fr, + predicate_hash: fr_from_be_bytes(&petition.predicate_hash), + class_index: class_index_fr, + class_tag: class_tag_fr, + slot: Fr::from(petition.slot as u64), + nullifier: nullifier_fr, + identity_tag: identity_tag_fr, + }; + let private = SignerPrivateInputs { + identity_secret: identity_secret_fr, + attr_vector: attr_vector_fr, + attr_version, + chain_root: self.chain.chain_root, + ri_path_siblings: ri_path + .siblings + .iter() + .map(|b| fr_from_be_bytes(b)) + .collect(), + ri_path_indices: ri_path.indices, + s_slot: self.chain.s_at(petition.slot), + chain_path_siblings: chain_siblings, + chain_path_indices: chain_indices, + salt: fr_from_be_bytes(&petition.salt), + predicate_def: petition.predicate_def.clone(), + }; + let proof_bytes = self + .proof_backend + .generate_signer_proof(&public, &private)?; + + Ok(SignerSubmission { + petition_id: petition.petition_id, + r_root: petition.r_root, + predicate_hash: petition.predicate_hash, + class_index: petition.class_index, + slot: petition.slot, + nullifier: fr_to_be_bytes(&nullifier_fr), + identity_tag: fr_to_be_bytes(&identity_tag_fr), + class_tag: petition.class_tag, + proof_bytes, + }) + } + + /// Journal a finalized slot; call after L1 finality. Writes the + /// post-signing state atomically to `journal_path` BEFORE mutating + /// in-memory state. + pub fn journal_finalized( + &mut self, + slot: u32, + journal_path: &std::path::Path, + ) -> Result { + self.chain.journal_finalized_signing(slot, journal_path) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + adapters::{ + in_memory_ri::InMemoryRi, + mock_proof::MockProofBackend, + }, + poseidon::hash_leaf, + types::ClassTag, + }; + + fn signer_factory() -> ( + Signer, + EnrollmentArtifact, + Vec, + ) { + let attrs = vec![ + Fr::from(1u64), + Fr::from(840u64), + Fr::from(0u64), + Fr::from(0u64), + ]; + let backend = MockProofBackend; + let ri = InMemoryRi::new(); + let (signer, artifact) = + Signer::enroll(backend, ri, attrs.clone(), 8, 0, 100, Some([0xa1u8; 32])); + (signer, artifact, attrs) + } + + #[test] + fn test_enroll_appends_to_ri_and_returns_chain_root() { + let (signer, artifact, attrs) = signer_factory(); + assert_eq!(artifact.attr_version, 0); + assert_eq!(signer.credentials.ri_leaf_index, 0); + assert_ne!(artifact.chain_root, [0u8; 32]); + let _ = attrs; + } + + fn meta_for( + petition_id: u64, + slot: u32, + class_index: u8, + class_tag: ClassTag, + ) -> PetitionMeta { + let mut id = [0u8; 32]; + id[24..].copy_from_slice(&petition_id.to_be_bytes()); + let mut r = [0u8; 32]; + r[24..].copy_from_slice(&77u64.to_be_bytes()); + let mut ph = [0u8; 32]; + ph[24..].copy_from_slice(&88u64.to_be_bytes()); + let mut s = [0u8; 32]; + s[24..].copy_from_slice(&33u64.to_be_bytes()); + PetitionMeta { + petition_id: id, + r_root: r, + predicate_hash: ph, + slot, + class_index, + class_tag, + predicate_def: dummy_predicate(class_index, class_tag), + salt: s, + ri_leaf_index: 0, + } + } + + fn dummy_predicate( + class_index: u8, + class_tag: ClassTag, + ) -> crate::predicate::PredicateDef { + use crate::predicate::{ + Op, + PredicateDef, + Tuple, + }; + let mut operand = [0u8; 32]; + operand[30..].copy_from_slice(&class_tag.to_be_bytes()); + PredicateDef { + tuples: vec![Tuple { + claim_index: class_index, + operand, + type_tag: crate::types::TypeTag::Int64, + comparator: crate::types::Comparator::Eq, + }], + ops: vec![Op { + code: crate::types::OpCode::PushTuple, + operand: 0, + }], + } + } + + #[test] + fn test_sign_emits_distinct_nullifiers_per_petition() { + let (mut signer, _, _) = signer_factory(); + let m1 = meta_for(1, 0, 1, 840); + let s1 = signer.sign(&m1).unwrap(); + let m2 = meta_for(2, 1, 1, 840); + let s2 = signer.sign(&m2).unwrap(); + assert_ne!(s1.nullifier, s2.nullifier); + assert_ne!(s1.identity_tag, s2.identity_tag); + } + + #[test] + fn test_sign_in_past_rejected() { + let (mut signer, _, _) = signer_factory(); + let m = meta_for(7, 3, 1, 840); + let _ = signer.sign(&m).unwrap(); + let tmpdir = tempfile::tempdir().unwrap(); + let journal_path = tmpdir.path().join("journal.bin"); + signer.journal_finalized(3, &journal_path).unwrap(); + let earlier = meta_for(8, 1, 1, 840); + let err = signer.sign(&earlier); + assert!(matches!(err, Err(SignerError::SlotInPast { .. }))); + } + + #[test] + fn test_sign_with_wrong_class_tag_rejected() { + let (mut signer, _, _) = signer_factory(); + let m = meta_for(1, 0, 1, 999); + let err = signer.sign(&m); + assert!(matches!(err, Err(SignerError::Invariant(_)))); + } + + #[test] + fn test_journal_finalized_advances_t() { + let (mut signer, _, _) = signer_factory(); + let m = meta_for(1, 0, 1, 840); + let _ = signer.sign(&m).unwrap(); + let pre = signer.chain.t; + let tmpdir = tempfile::tempdir().unwrap(); + let journal_path = tmpdir.path().join("journal.bin"); + signer.journal_finalized(0, &journal_path).unwrap(); + assert!(signer.chain.t > pre); + } + + #[test] + fn test_nullifier_and_identity_tag_distinct_under_same_inputs() { + let (mut signer, _, _) = signer_factory(); + let m = meta_for(1, 0, 1, 840); + let s = signer.sign(&m).unwrap(); + assert_ne!(s.nullifier, s.identity_tag); + } + + #[test] + fn test_leaf_derivation_matches_external_recompute() { + let (mut signer, _, _) = signer_factory(); + let m = meta_for(1, 0, 1, 840); + let s = signer.sign(&m).unwrap(); + let null_fr = fr_from_be_bytes(&s.nullifier); + let class_fr = Fr::from(s.class_tag as u64); + let leaf_external = hash_leaf(null_fr, class_fr); + let leaf_relayer = hash_leaf(null_fr, class_fr); + assert_eq!(leaf_external, leaf_relayer); + } +} diff --git a/pocs/civic-participation/resilient-civic-participation/src/signer/error.rs b/pocs/civic-participation/resilient-civic-participation/src/signer/error.rs new file mode 100644 index 0000000..df081c8 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/signer/error.rs @@ -0,0 +1,21 @@ +use thiserror::Error; + +use crate::error::ProofError; + +#[derive(Debug, Error)] +pub enum SignerError { + #[error("FSRT slot {slot} below current ratchet head {head}")] + SlotInPast { slot: u32, head: u32 }, + #[error("FSRT slot {slot} exceeds chain length {chain_len}")] + SlotOutOfRange { slot: u32, chain_len: u32 }, + #[error("RI lookup failed: {0}")] + RiLookup(String), + #[error("predicate failure: {0}")] + Predicate(#[from] crate::error::PredicateError), + #[error("proof: {0}")] + Proof(#[from] ProofError), + #[error("merkle: {0}")] + Merkle(#[from] crate::error::MerkleError), + #[error("invariant violated: {0}")] + Invariant(String), +} diff --git a/pocs/civic-participation/resilient-civic-participation/src/signer/mod.rs b/pocs/civic-participation/resilient-civic-participation/src/signer/mod.rs new file mode 100644 index 0000000..f623955 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/signer/mod.rs @@ -0,0 +1,5 @@ +pub mod error; +pub mod types; + +mod core; +pub use core::Signer; diff --git a/pocs/civic-participation/resilient-civic-participation/src/signer/types.rs b/pocs/civic-participation/resilient-civic-participation/src/signer/types.rs new file mode 100644 index 0000000..2dfc610 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/signer/types.rs @@ -0,0 +1,34 @@ +//! Signer-specific types: enrollment artifact and per-petition view. + +use serde::{ + Deserialize, + Serialize, +}; + +use crate::types::{ + Bytes32, + ClassTag, + PetitionId, +}; + +/// Enrollment artifact handed to the RI credential layer. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EnrollmentArtifact { + pub attr_hash: Bytes32, + pub chain_root: Bytes32, + pub attr_version: u32, +} + +/// Per-petition view the signer consumes. +#[derive(Debug, Clone)] +pub struct PetitionMeta { + pub petition_id: PetitionId, + pub r_root: Bytes32, + pub predicate_hash: Bytes32, + pub slot: u32, + pub class_index: u8, + pub class_tag: ClassTag, + pub predicate_def: crate::predicate::PredicateDef, + pub salt: Bytes32, + pub ri_leaf_index: u32, +} diff --git a/pocs/civic-participation/resilient-civic-participation/src/types.rs b/pocs/civic-participation/resilient-civic-participation/src/types.rs new file mode 100644 index 0000000..85dde6a --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/src/types.rs @@ -0,0 +1,475 @@ +//! Domain types shared across actors (SPEC byte and field-element layer). + +use ark_bn254::Fr; +use serde::{ + Deserialize, + Serialize, +}; + +pub type Address = [u8; 20]; +pub type Bytes32 = [u8; 32]; +pub type FieldBytes = Bytes32; +pub type PetitionId = Bytes32; + +/// 256-bit unsigned integer, big-endian. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct U256Be(pub Bytes32); + +impl U256Be { + pub const fn zero() -> Self { + Self([0u8; 32]) + } + pub fn from_u128(v: u128) -> Self { + let mut out = [0u8; 32]; + out[16..32].copy_from_slice(&v.to_be_bytes()); + Self(out) + } + pub fn from_u64(v: u64) -> Self { + let mut out = [0u8; 32]; + out[24..32].copy_from_slice(&v.to_be_bytes()); + Self(out) + } + pub fn as_bytes(&self) -> &Bytes32 { + &self.0 + } + pub fn into_bytes(self) -> Bytes32 { + self.0 + } + pub fn is_zero(&self) -> bool { + self.0 == [0u8; 32] + } + pub fn checked_add(self, rhs: Self) -> Option { + let mut out = [0u8; 32]; + let mut carry = 0u16; + for i in (0..32).rev() { + let s = self.0[i] as u16 + rhs.0[i] as u16 + carry; + out[i] = (s & 0xff) as u8; + carry = s >> 8; + } + if carry == 0 { Some(Self(out)) } else { None } + } +} + +/// Class tag (uint16) partitioning signatures into classes. +pub type ClassTag = u16; + +/// SPEC Lifecycle. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PetitionState { + Registered, + SigningOpen, + SigningClosed, + Cooldown, + DisputeWindow, + Resolved, + Unresolved, +} + +/// SPEC Batch Record. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum BatchState { + Active, + Repudiated, +} + +/// SPEC Predicate: per-attribute type tag. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[repr(u8)] +pub enum TypeTag { + Int64 = 0x01, + Hash = 0x02, + Bool = 0x03, +} + +impl TryFrom for TypeTag { + type Error = u8; + fn try_from(b: u8) -> Result { + match b { + 0x01 => Ok(Self::Int64), + 0x02 => Ok(Self::Hash), + 0x03 => Ok(Self::Bool), + other => Err(other), + } + } +} + +/// SPEC Predicate: comparator byte. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[repr(u8)] +pub enum Comparator { + Eq = 0x10, + Le = 0x11, + Ge = 0x12, +} + +impl TryFrom for Comparator { + type Error = u8; + fn try_from(b: u8) -> Result { + match b { + 0x10 => Ok(Self::Eq), + 0x11 => Ok(Self::Le), + 0x12 => Ok(Self::Ge), + other => Err(other), + } + } +} + +/// SPEC Predicate: postfix op code. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[repr(u8)] +pub enum OpCode { + PushTuple = 0x20, + And = 0x21, + Or = 0x22, + Not = 0x23, + Nop = 0xff, +} + +impl TryFrom for OpCode { + type Error = u8; + fn try_from(b: u8) -> Result { + match b { + 0x20 => Ok(Self::PushTuple), + 0x21 => Ok(Self::And), + 0x22 => Ok(Self::Or), + 0x23 => Ok(Self::Not), + 0xff => Ok(Self::Nop), + other => Err(other), + } + } +} + +/// SPEC Petition Record. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PetitionRecord { + pub petition_id: PetitionId, + pub slot: u32, + pub r_root: Bytes32, + pub predicate_def: Vec, + pub predicate_hash: Bytes32, + pub salt: Bytes32, + pub class_set: Vec, + pub class_thresholds: Vec, + pub class_index: u8, + pub close_at_block: u64, + pub bounty: U256Be, + pub alpha_at_registration: u64, + pub organizer: Address, + pub running_root: Bytes32, + pub identity_tag_set_root: Bytes32, + pub leaf_count: u64, + pub next_batch_index: u32, + pub resolution_proof: Vec, + pub b: bool, + pub b_per_class: Vec, + pub state: PetitionState, + pub registration_block: u64, +} + +/// SPEC Batch Record. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BatchRecord { + pub petition_id: PetitionId, + pub batch_index: u32, + pub batch_versioned_hash: Bytes32, + pub new_running_root: Bytes32, + pub new_identity_tag_set_root: Bytes32, + pub prior_running_root: Bytes32, + pub prior_identity_tag_set_root: Bytes32, + pub prior_leaf_count: u64, + pub new_leaf_count: u64, + pub relayer: Address, + pub submitted_at_block: u64, + pub state: BatchState, +} + +/// SPEC Global Registry State. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GlobalState { + pub s: u32, + pub alpha: u64, + pub alpha_min: u64, + pub alpha_max: u64, + pub srs_hash: Bytes32, + pub chain_id: u64, + pub n: u8, +} + +/// SPEC Blob Payload, decoded form (4 BLS12-381 field elements per record). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct RecordEntry { + pub nullifier: Bytes32, + pub identity_tag: Bytes32, + pub class_tag: ClassTag, +} + +/// Signer SNARK public inputs, ordered per SPEC Signer SNARK. +#[derive(Debug, Clone)] +pub struct SignerPublicInputs { + pub r_root: Fr, + pub petition_id: Fr, + pub predicate_hash: Fr, + pub class_index: Fr, + pub class_tag: Fr, + pub slot: Fr, + pub nullifier: Fr, + pub identity_tag: Fr, +} + +/// Signer SNARK private inputs (witness). +#[derive(Debug, Clone)] +pub struct SignerPrivateInputs { + /// Per-signer secret, CSPRNG-sampled at enrollment. Mixed into + /// `attr_hash` (pinning the RI leaf) and into `nullifier` (binding + /// per-petition signatures). Without this, an attacker who learned + /// `s_0` alone could enroll under the same RI leaf as the victim. + pub identity_secret: Fr, + pub attr_vector: Vec, + pub attr_version: u32, + pub chain_root: Fr, + pub ri_path_siblings: Vec, + pub ri_path_indices: Vec, + pub s_slot: Fr, + pub chain_path_siblings: Vec, + pub chain_path_indices: Vec, + pub salt: Fr, + /// Structured predicate def for the signer circuit's postfix evaluator. + pub predicate_def: crate::predicate::PredicateDef, +} + +/// Bundled signer SNARK plus the public tuple needed to enter a batch. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SignerSubmission { + pub petition_id: PetitionId, + pub r_root: Bytes32, + pub predicate_hash: Bytes32, + pub class_index: u8, + pub slot: u32, + pub nullifier: Bytes32, + pub identity_tag: Bytes32, + pub class_tag: ClassTag, + pub proof_bytes: Vec, +} + +/// Batch SNARK public inputs (SPEC section Batch SNARK). +#[derive(Debug, Clone)] +pub struct BatchPublicInputs { + pub petition_id: Fr, + pub r_root: Fr, + pub predicate_hash: Fr, + pub class_index: Fr, + pub slot: Fr, + pub batch_size: Fr, + pub prior_running_root: Fr, + pub new_running_root: Fr, + pub prior_identity_tag_set_root: Fr, + pub new_identity_tag_set_root: Fr, + pub prior_leaf_count: Fr, + pub new_leaf_count: Fr, + pub batch_versioned_hash: Fr, + /// Cross-field decompositions of the batch records per SPEC + /// constraint 8. Length = `BATCH_SIZE_MAX * FE_PER_RECORD` = 24. + /// `bls_fields[4*i + j]` is the `j`-th BLS12-381 field element of + /// record `i` per SPEC section Blob Payload. The contract verifies + /// each value via a KZG point-evaluation against `batch_versioned_hash`. + pub bls_fields: Vec, + /// Deploy-pinned hash of the signer SNARK's verification key. + /// Without this binding, a malicious relayer could supply their own + /// signer VK and prove arbitrary leaves. + pub signer_vk_hash: Fr, +} + +/// Resolution SNARK public inputs (SPEC section Resolution SNARK). +#[derive(Debug, Clone)] +pub struct ResolutionPublicInputs { + pub predicate_hash: Fr, + pub r_root: Fr, + pub running_root: Fr, + pub leaf_count: Fr, + pub class_set: Vec, + pub class_thresholds: Vec, + pub b: Fr, + pub b_per_class: Vec, + /// Petition's class_index. Binds the resolution proof to the + /// specific attribute slot the petition was registered with. + pub class_index: Fr, +} + +/// Per-leaf IMT membership witness in circuit form. +/// +/// `next_index` and `next_value` are the linked-list pointers of the +/// IMT leaf at the time the witness is captured. The resolution +/// circuit recomputes `imt_leaf = hash_imt_leaf(value, next_index, +/// next_value)` and verifies the path against `running_root`, so +/// these must reflect the *final* IMT state (post-all-inserts), not +/// the state at the time the leaf was first inserted. +#[derive(Debug, Clone)] +pub struct ImtMembershipFr { + pub leaf_hash: Fr, + pub leaf_index: u32, + pub next_index: u32, + pub next_value: Fr, + pub siblings: Vec, + pub indices: Vec, +} + +/// Resolution SNARK private inputs (witness). +#[derive(Debug, Clone)] +pub struct ResolutionPrivateInputs { + pub leaves: Vec, + pub imt_membership_paths: Vec, + pub witness_pairs: Vec<(Fr, Fr)>, +} + +/// Bundled batch SNARK plus the state it claims. +#[derive(Debug, Clone)] +pub struct BatchSubmission { + pub public_inputs: BatchPublicInputs, + pub records: Vec, + pub proof_bytes: Vec, +} + +/// Bundled resolution SNARK plus its public inputs. +#[derive(Debug, Clone)] +pub struct ResolutionSubmission { + pub public_inputs: ResolutionPublicInputs, + pub proof_bytes: Vec, +} + +/// SPEC Dispute: violation type byte. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[repr(u8)] +pub enum ViolationType { + ClassTagOutOfSet = 0x01, + IntraBatchDuplicateIdentityTag = 0x02, + LeafOrderingViolation = 0x03, +} + +impl TryFrom for ViolationType { + type Error = u8; + fn try_from(b: u8) -> Result { + match b { + 0x01 => Ok(Self::ClassTagOutOfSet), + 0x02 => Ok(Self::IntraBatchDuplicateIdentityTag), + 0x03 => Ok(Self::LeafOrderingViolation), + other => Err(other), + } + } +} + +/// KZG point-evaluation opening against `batch_versioned_hash`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KzgOpening { + pub field_element_index: u32, + pub claimed_value: Bytes32, + pub proof_bytes: Vec, +} + +/// SPEC Dispute envelope; record content is derived from `openings`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Dispute { + pub petition_id: PetitionId, + pub batch_index: u32, + pub violation_type: ViolationType, + pub position_i: u32, + pub position_j: Option, + pub openings: Vec, +} + +/// SPEC Off-Chain Signer State (840 bytes, journaled per advance). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SignerStateBytes { + pub s_curr: Bytes32, + pub t: u32, + pub caterpillar: [Bytes32; crate::FSRT_DEPTH], + pub chain_root: Bytes32, + pub attr_version: u32, +} + +/// Per-signer credential metadata. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SignerCredentials { + /// CSPRNG-sampled at enrollment; mixed into `attr_hash` and + /// `nullifier` (see hash_attr / hash_nullifier). + pub identity_secret: Bytes32, + pub attr_vector: Vec, + pub ri_leaf_index: u32, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_u256_be_addition() { + let a = U256Be::from_u64(100); + let b = U256Be::from_u64(50); + let c = a.checked_add(b).unwrap(); + assert_eq!(c, U256Be::from_u64(150)); + } + + #[test] + fn test_u256_be_overflow_returns_none() { + let max = U256Be([0xff; 32]); + let one = U256Be::from_u64(1); + assert!(max.checked_add(one).is_none()); + } + + #[test] + fn test_u256_be_is_zero() { + assert!(U256Be::zero().is_zero()); + assert!(!U256Be::from_u64(1).is_zero()); + } + + #[test] + fn test_type_tag_roundtrip() { + for t in [TypeTag::Int64, TypeTag::Hash, TypeTag::Bool] { + assert_eq!(TypeTag::try_from(t as u8), Ok(t)); + } + assert!(TypeTag::try_from(0x09).is_err()); + } + + #[test] + fn test_comparator_roundtrip() { + for t in [Comparator::Eq, Comparator::Le, Comparator::Ge] { + assert_eq!(Comparator::try_from(t as u8), Ok(t)); + } + assert!(Comparator::try_from(0xee).is_err()); + } + + #[test] + fn test_opcode_roundtrip() { + for op in [ + OpCode::PushTuple, + OpCode::And, + OpCode::Or, + OpCode::Not, + OpCode::Nop, + ] { + assert_eq!(OpCode::try_from(op as u8), Ok(op)); + } + assert!(OpCode::try_from(0x99).is_err()); + } + + #[test] + fn test_violation_type_roundtrip() { + for v in [ + ViolationType::ClassTagOutOfSet, + ViolationType::IntraBatchDuplicateIdentityTag, + ViolationType::LeafOrderingViolation, + ] { + assert_eq!(ViolationType::try_from(v as u8), Ok(v)); + } + assert!(ViolationType::try_from(0x99).is_err()); + } + + #[test] + fn test_signer_state_default_is_zeroed() { + let s = SignerStateBytes::default(); + assert_eq!(s.t, 0); + assert_eq!(s.s_curr, [0u8; 32]); + for level in &s.caterpillar { + assert_eq!(level, &[0u8; 32]); + } + } +} diff --git a/pocs/civic-participation/resilient-civic-participation/tests/anvil_harness.rs b/pocs/civic-participation/resilient-civic-participation/tests/anvil_harness.rs new file mode 100644 index 0000000..b945157 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/tests/anvil_harness.rs @@ -0,0 +1,159 @@ +//! Anvil harness: spawns anvil + `forge script Deploy.s.sol --broadcast`. + +use std::{ + path::PathBuf, + process::Command, +}; + +use alloy::{ + network::EthereumWallet, + node_bindings::{ + Anvil, + AnvilInstance, + }, + primitives::Address as AlloyAddress, + providers::{ + DynProvider, + Provider, + ProviderBuilder, + }, + signers::local::PrivateKeySigner, +}; +use resilient_civic_participation::{ + imt::IndexedMerkleTree, + poseidon::fr_to_be_bytes, +}; + +/// Anvil's first prefunded account. +const DEPLOYER_PK: &str = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + +pub struct AnvilDeployment { + /// Held so anvil stays alive; dropping it kills the process. + pub _anvil_guard: AnvilInstance, + pub endpoint: String, + pub deployer_addr: AlloyAddress, + pub provider: DynProvider, + pub bounty_token: AlloyAddress, + pub petition_registry: AlloyAddress, +} + +impl AnvilDeployment { + pub fn start_and_deploy(use_mock_verifier: bool) -> Self { + // Raise EIP-170 limit so the ~25KiB bb Honk verifier deploys. + let anvil = Anvil::new() + .arg("--hardfork") + .arg("cancun") + .arg("--code-size-limit") + .arg("65536") + .spawn(); + let endpoint = anvil.endpoint(); + + let signer: PrivateKeySigner = DEPLOYER_PK.parse().unwrap(); + let deployer_addr = signer.address(); + let wallet = EthereumWallet::from(signer); + + let provider = ProviderBuilder::new() + .with_simple_nonce_management() + .wallet(wallet) + .connect_http(anvil.endpoint_url()) + .erased(); + + let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let deployments_path = project_root.join("deployments.toml"); + let _deployments_guard = DeploymentsBackup::new(deployments_path.clone()); + + let use_mock_str = if use_mock_verifier { "true" } else { "false" }; + let empty_imt_root = { + let imt = IndexedMerkleTree::new(); + fr_to_be_bytes(&imt.root_fr()) + }; + let empty_imt_root_hex = format!("0x{}", hex::encode(empty_imt_root)); + + // Read signer VK hash from generate-verifiers.sh output. + let signer_vk_hash_path = project_root + .join("circuits") + .join("signer") + .join("target") + .join("vk_hash"); + let signer_vk_hash_bytes = std::fs::read(&signer_vk_hash_path).unwrap_or_else(|e| { + panic!( + "read signer vk_hash from {}: {e}. Run scripts/generate-verifiers.sh first.", + signer_vk_hash_path.display() + ) + }); + let signer_vk_hash_hex = format!("0x{}", hex::encode(&signer_vk_hash_bytes)); + + let out = Command::new("forge") + .args([ + "script", + "contracts/script/Deploy.s.sol:Deploy", + "--rpc-url", + &endpoint, + "--private-key", + DEPLOYER_PK, + "--broadcast", + "--disable-code-size-limit", + ]) + .env("USE_MOCK_VERIFIER", use_mock_str) + .env("GOVERNANCE", format!("{deployer_addr:#x}")) + .env("EMPTY_IMT_ROOT", &empty_imt_root_hex) + .env("PINNED_SIGNER_VK_HASH", &signer_vk_hash_hex) + .current_dir(&project_root) + .output() + .expect("spawn forge script"); + + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + if !out.status.success() { + panic!("forge script failed:\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}"); + } + let blob = format!("{stdout}\n{stderr}"); + + AnvilDeployment { + _anvil_guard: anvil, + endpoint, + deployer_addr, + provider, + bounty_token: parse_addr(&blob, "MockERC20:"), + petition_registry: parse_addr(&blob, "PetitionRegistry:"), + } + } +} + +/// RAII guard that restores `deployments.toml` on drop, including panic paths. +/// The forge script overwrites the file in place; we want every exit path +/// (Ok, Err, panic) to return it to its committed state. +struct DeploymentsBackup { + path: PathBuf, + original: String, +} + +impl DeploymentsBackup { + fn new(path: PathBuf) -> Self { + let original = std::fs::read_to_string(&path).expect("read deployments.toml"); + Self { path, original } + } +} + +impl Drop for DeploymentsBackup { + fn drop(&mut self) { + if let Err(e) = std::fs::write(&self.path, &self.original) { + eprintln!( + "DeploymentsBackup: failed to restore {}: {e}", + self.path.display() + ); + } + } +} + +fn parse_addr(blob: &str, label: &str) -> AlloyAddress { + for line in blob.lines() { + if let Some(rest) = line.trim().strip_prefix(label) + && let Ok(a) = rest.trim().parse::() + { + return a; + } + } + panic!("Could not parse address `{label}` from forge output:\n{blob}"); +} diff --git a/pocs/civic-participation/resilient-civic-participation/tests/common/mod.rs b/pocs/civic-participation/resilient-civic-participation/tests/common/mod.rs new file mode 100644 index 0000000..ae049cf --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/tests/common/mod.rs @@ -0,0 +1,364 @@ +//! Shared in-memory harness for integration tests that use `MockProofBackend`. + +use std::sync::{ + Arc, + Mutex, +}; + +use ark_bn254::Fr; + +use resilient_civic_participation::{ + adapters::{ + in_memory_blob::InMemoryBlobCarrier, + in_memory_ri::InMemoryRi, + mock_proof::MockProofBackend, + }, + clock::{ + BlockClock, + MockBlockClock, + }, + error::{ + BlobError, + MerkleError, + }, + organizer::{ + Organizer, + types::PetitionDraft, + }, + ports::{ + blob::BlobCarrier, + ri::{ + RiCredentialLayer, + RiPath, + }, + }, + predicate::{ + Op, + PredicateDef, + Tuple, + }, + registry::{ + PetitionRegistry, + types::{ + PetitionRegisteredEvent, + PetitionStateView, + }, + }, + relayer::{ + Relayer, + core::RelayerPetitionState, + types::PetitionView, + }, + signer::{ + Signer, + types::PetitionMeta, + }, + types::{ + Address, + BatchSubmission, + Bytes32, + ClassTag, + Comparator, + GlobalState, + KzgOpening, + OpCode, + SignerSubmission, + TypeTag, + U256Be, + }, +}; + +const CHAIN_ID: u64 = 31337; +const REGISTRY_ADDRESS: Address = [0xab; 20]; +pub const ATTR_CLASS: usize = 2; +const TEST_FSRT_LEN: u32 = 32; +const TEST_ALPHA: u64 = 1; +const TEST_ALPHA_MIN: u64 = 1; +const TEST_ALPHA_MAX: u64 = 1_000_000; + +#[derive(Clone)] +pub struct SharedRi { + inner: Arc>, +} + +impl SharedRi { + fn new() -> Self { + Self { + inner: Arc::new(Mutex::new(InMemoryRi::new())), + } + } +} + +impl RiCredentialLayer for SharedRi { + fn append_leaf(&mut self, attr_hash: Bytes32, posted_at_block: u64) -> u32 { + self.inner + .lock() + .expect("SharedRi poisoned") + .append_leaf(attr_hash, posted_at_block) + } + fn root(&self) -> Bytes32 { + self.inner.lock().expect("SharedRi poisoned").root() + } + fn merkle_path(&self, leaf_index: u32) -> Result { + self.inner + .lock() + .expect("SharedRi poisoned") + .merkle_path(leaf_index) + } + fn root_first_seen(&self, root: &Bytes32) -> Option { + self.inner + .lock() + .expect("SharedRi poisoned") + .root_first_seen(root) + } +} + +#[derive(Clone)] +pub struct SharedBlob { + inner: Arc>, +} + +impl SharedBlob { + fn new() -> Self { + Self { + inner: Arc::new(Mutex::new(InMemoryBlobCarrier::new())), + } + } +} + +impl BlobCarrier for SharedBlob { + fn publish( + &mut self, + records: &[resilient_civic_participation::types::RecordEntry], + ) -> Result { + self.inner + .lock() + .expect("SharedBlob poisoned") + .publish(records) + } + fn open( + &self, + batch_versioned_hash: &Bytes32, + field_element_index: u32, + ) -> Result { + self.inner + .lock() + .expect("SharedBlob poisoned") + .open(batch_versioned_hash, field_element_index) + } + fn verify( + &self, + batch_versioned_hash: &Bytes32, + opening: &KzgOpening, + ) -> Result<(), BlobError> { + self.inner + .lock() + .expect("SharedBlob poisoned") + .verify(batch_versioned_hash, opening) + } + fn fetch_records( + &self, + batch_versioned_hash: &Bytes32, + ) -> Result, BlobError> { + self.inner + .lock() + .expect("SharedBlob poisoned") + .fetch_records(batch_versioned_hash) + } +} + +pub struct SignerCtx { + pub signer: Signer, + pub class_tag: ClassTag, + pub ri_leaf_index: u32, +} + +pub struct Harness { + block_clock: Arc, + pub ri: SharedRi, + pub blob: SharedBlob, + pub registry: PetitionRegistry, +} + +impl Harness { + pub fn new() -> Self { + let clock = Arc::new(MockBlockClock::new(0)); + let ri = SharedRi::new(); + let blob = SharedBlob::new(); + let global = GlobalState { + s: 0, + alpha: TEST_ALPHA, + alpha_min: TEST_ALPHA_MIN, + alpha_max: TEST_ALPHA_MAX, + srs_hash: [0u8; 32], + chain_id: CHAIN_ID, + n: 4, + }; + let registry = PetitionRegistry::new( + REGISTRY_ADDRESS, + global, + clock.clone(), + MockProofBackend, + ri.clone(), + blob.clone(), + ); + Self { + block_clock: clock, + ri, + blob, + registry, + } + } + + pub fn block(&self) -> u64 { + self.block_clock.block_number() + } + pub fn advance_blocks(&self, n: u64) { + self.block_clock.advance(n); + } +} + +pub fn enroll_signer( + harness: &mut Harness, + age: u64, + class_tag: ClassTag, + seed: [u8; 32], +) -> SignerCtx { + let attrs = vec![ + Fr::from(age), + Fr::from(if age >= 18 { 1u64 } else { 0u64 }), + Fr::from(class_tag as u64), + Fr::from(20_000u64), + ]; + let (signer, _artifact) = Signer::enroll( + MockProofBackend, + harness.ri.clone(), + attrs, + TEST_FSRT_LEN, + 0, + harness.block(), + Some(seed), + ); + let ri_leaf_index = signer.credentials.ri_leaf_index; + SignerCtx { + signer, + class_tag, + ri_leaf_index, + } +} + +pub fn class_only_predicate(class_index: u8, class_tag: ClassTag) -> PredicateDef { + let mut operand = [0u8; 32]; + operand[30..].copy_from_slice(&class_tag.to_be_bytes()); + PredicateDef { + tuples: vec![Tuple { + claim_index: class_index, + operand, + type_tag: TypeTag::Int64, + comparator: Comparator::Eq, + }], + ops: vec![Op { + code: OpCode::PushTuple, + operand: 0, + }], + } +} + +pub struct RegisteredFixture { + pub event: PetitionRegisteredEvent, + pub view: PetitionStateView, +} + +#[allow(clippy::too_many_arguments)] +pub fn register_petition( + harness: &mut Harness, + organizer_addr: Address, + predicate: PredicateDef, + salt: Bytes32, + class_set: Vec, + class_thresholds: Vec, + class_index: u8, + bounty: U256Be, + signing_window_blocks: u64, +) -> RegisteredFixture { + let organizer = Organizer::new(organizer_addr); + let r_root = harness.ri.root(); + let now = harness.block(); + let close_at_block = now + signing_window_blocks; + let draft: PetitionDraft = organizer + .build_petition( + r_root, + predicate, + salt, + class_set, + class_thresholds, + class_index, + now, + close_at_block, + bounty, + ) + .expect("organizer draft"); + let (_ack, event) = harness.registry.register(draft).expect("registry register"); + let view = harness + .registry + .state_view(&event.petition_id) + .expect("state view"); + RegisteredFixture { event, view } +} + +pub fn signer_sign( + ctx: &mut SignerCtx, + petition_view: &PetitionStateView, + salt: Bytes32, + predicate_encoded: &[u8], +) -> SignerSubmission { + let predicate_def = + PredicateDef::decode(predicate_encoded).expect("decode predicate"); + let meta = PetitionMeta { + petition_id: petition_view.petition_id, + r_root: petition_view.r_root, + predicate_hash: petition_view.predicate_hash, + slot: petition_view.slot, + class_index: petition_view.class_index, + class_tag: ctx.class_tag, + predicate_def, + salt, + ri_leaf_index: ctx.ri_leaf_index, + }; + ctx.signer.sign(&meta).expect("signer sign") +} + +pub fn publish_one_batch( + harness: &mut Harness, + petition_view: &PetitionStateView, + relayer_addr: Address, + state: &mut RelayerPetitionState, + submissions: Vec, +) -> BatchSubmission { + let mut relayer = Relayer::new(relayer_addr, MockProofBackend, harness.blob.clone()); + let pv = PetitionView { + petition_id: petition_view.petition_id, + r_root: petition_view.r_root, + predicate_hash: petition_view.predicate_hash, + class_index: petition_view.class_index, + class_set: petition_view.class_set.clone(), + slot: petition_view.slot, + running_root: petition_view.running_root, + identity_tag_set_root: petition_view.identity_tag_set_root, + leaf_count: petition_view.leaf_count, + signer_vk_hash: [0u8; 32], + }; + let (batch, _positions) = relayer + .build_batch(&pv, state, submissions) + .expect("relayer build_batch"); + harness + .registry + .publish_batch(&petition_view.petition_id, relayer_addr, batch.clone()) + .expect("registry publish_batch"); + batch +} + +pub fn advance_past_ri_age_window(harness: &mut Harness) { + use resilient_civic_participation::MIN_R_AGE_BLOCKS; + harness.advance_blocks(MIN_R_AGE_BLOCKS + 1); +} diff --git a/pocs/civic-participation/resilient-civic-participation/tests/dispute_won_class_tag_out_of_set.rs b/pocs/civic-participation/resilient-civic-participation/tests/dispute_won_class_tag_out_of_set.rs new file mode 100644 index 0000000..e9473fa --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/tests/dispute_won_class_tag_out_of_set.rs @@ -0,0 +1,467 @@ +//! End-to-end dispute path against anvil with the mock SNARK verifier. +//! +//! Flow: register -> forge a 6-record batch where one record carries an +//! out-of-set class_tag -> publish via blob tx -> advance into +//! DisputeWindow -> Disputant builds an `0x01 ClassTagOutOfSet` envelope +//! -> submit on chain -> assert `BatchRepudiated` rolls running state +//! back to the empty IMT. +//! +//! The mock batch verifier accepts any proof bytes (it is deploy-gated +//! by `USE_MOCK_VERIFIER=true`), which lets the test publish a forged +//! batch the real SNARK + relayer would otherwise reject. The dispute +//! path is fully real: KZG openings are verified on chain, the +//! violation predicate is executed in Solidity, and rollback updates +//! the on-chain state. + +mod anvil_harness; + +use std::{ + path::PathBuf, + time::Instant, +}; + +use alloy::{ + primitives::{ + FixedBytes, + U256, + }, + providers::Provider, + sol, +}; +use ark_bn254::Fr; + +use anvil_harness::AnvilDeployment; +use resilient_civic_participation::{ + BATCH_SIZE_MAX, + adapters::{ + blob_4844::EIP4844BlobCarrier, + chain_registry::{ + ChainPetitionRegistry, + IPetitionRegistry, + }, + }, + blob::{ + FE_PER_RECORD, + canonical_eval_points, + record_to_bls_fields, + }, + disputant::{ + Disputant, + types::DisputeContext, + }, + imt::IndexedMerkleTree, + ports::{ + blob::BlobCarrier, + imt::ImtStore, + }, + poseidon::{ + derive_petition_id, + fr_from_be_bytes, + fr_to_be_bytes, + hash_leaf, + hash_predicate, + }, + predicate::{ + Op, + PredicateDef, + Tuple, + canonical_scalars, + }, + types::{ + BatchPublicInputs, + BatchSubmission, + Bytes32, + ClassTag, + Comparator, + KzgOpening, + OpCode, + RecordEntry, + TypeTag, + ViolationType, + }, +}; + +sol! { + #[sol(rpc)] + interface IMockERC20 { + function mint(address to, uint256 amount) external; + function approve(address spender, uint256 amount) external returns (bool); + } +} + +/// `attr[class_index] == class_a OR attr[class_index] == class_b`. Matches +/// the predicate shape the golden path uses; sums to two class operands so +/// the contract's class-binding taint walks through `Or`. +fn two_class_predicate( + class_index: u8, + class_a: ClassTag, + class_b: ClassTag, +) -> PredicateDef { + let mut operand_a = [0u8; 32]; + operand_a[30..].copy_from_slice(&class_a.to_be_bytes()); + let mut operand_b = [0u8; 32]; + operand_b[30..].copy_from_slice(&class_b.to_be_bytes()); + PredicateDef { + tuples: vec![ + Tuple { + claim_index: class_index, + operand: operand_a, + type_tag: TypeTag::Int64, + comparator: Comparator::Eq, + }, + Tuple { + claim_index: class_index, + operand: operand_b, + type_tag: TypeTag::Int64, + comparator: Comparator::Eq, + }, + ], + ops: vec![ + Op { + code: OpCode::PushTuple, + operand: 0, + }, + Op { + code: OpCode::PushTuple, + operand: 1, + }, + Op { + code: OpCode::Or, + operand: 0, + }, + ], + } +} + +/// Synthesize a record with caller-chosen seed and class_tag. Bypasses +/// `Signer`/`Relayer` (both refuse out-of-set tags), which is what lets +/// the test stage a batch the on-chain dispute path can repudiate. +fn forged_record(seed: u64, class_tag: ClassTag) -> RecordEntry { + let mut nullifier = [0u8; 32]; + nullifier[24..].copy_from_slice(&seed.to_be_bytes()); + nullifier[0] = 0; // keep within BN254 Fr range. + let mut identity_tag = [0u8; 32]; + identity_tag[24..].copy_from_slice(&(seed.wrapping_add(0xfeed)).to_be_bytes()); + identity_tag[0] = 0; + RecordEntry { + nullifier, + identity_tag, + class_tag, + } +} + +/// Build a `BatchSubmission` with arbitrary records, bypassing the +/// relayer's class_tag check. Records are sorted by canonical leaf +/// order before publish; IMTs are inserted in the same order. +#[allow(clippy::too_many_arguments)] +fn build_forged_batch( + petition_id: Bytes32, + r_root: Bytes32, + predicate_hash: Bytes32, + class_index: u8, + slot: u32, + empty_imt_root: Bytes32, + signer_vk_hash: Bytes32, + mut records: Vec, + blob_carrier: &mut EIP4844BlobCarrier, +) -> BatchSubmission { + assert_eq!( + records.len(), + BATCH_SIZE_MAX, + "on-chain `_preflightBatch` enforces batchSize == BATCH_SIZE_MAX", + ); + + records.sort_by_cached_key(|r| { + let leaf = + hash_leaf(fr_from_be_bytes(&r.nullifier), Fr::from(r.class_tag as u64)); + fr_to_be_bytes(&leaf) + }); + + let mut running_imt = IndexedMerkleTree::new(); + let mut id_imt = IndexedMerkleTree::new(); + for r in &records { + let leaf = + hash_leaf(fr_from_be_bytes(&r.nullifier), Fr::from(r.class_tag as u64)); + running_imt.insert(&fr_to_be_bytes(&leaf)).unwrap(); + id_imt.insert(&r.identity_tag).unwrap(); + } + let new_running_root = running_imt.root(); + let new_id_root = id_imt.root(); + + let batch_versioned_hash = blob_carrier.publish(&records).unwrap(); + + let fe_per_batch = BATCH_SIZE_MAX * FE_PER_RECORD; + let mut bls_fields: Vec = Vec::with_capacity(fe_per_batch); + bls_fields.extend(records.iter().flat_map(record_to_bls_fields)); + bls_fields.resize(fe_per_batch, Fr::from(0u64)); + + let public_inputs = BatchPublicInputs { + petition_id: fr_from_be_bytes(&petition_id), + r_root: fr_from_be_bytes(&r_root), + predicate_hash: fr_from_be_bytes(&predicate_hash), + class_index: Fr::from(class_index as u64), + slot: Fr::from(slot as u64), + batch_size: Fr::from(records.len() as u64), + prior_running_root: fr_from_be_bytes(&empty_imt_root), + new_running_root: fr_from_be_bytes(&new_running_root), + prior_identity_tag_set_root: fr_from_be_bytes(&empty_imt_root), + new_identity_tag_set_root: fr_from_be_bytes(&new_id_root), + prior_leaf_count: Fr::from(0u64), + new_leaf_count: Fr::from(records.len() as u64), + batch_versioned_hash: fr_from_be_bytes(&batch_versioned_hash), + bls_fields, + signer_vk_hash: fr_from_be_bytes(&signer_vk_hash), + }; + + BatchSubmission { + public_inputs, + records, + proof_bytes: Vec::new(), + } +} + +/// Pack a dispute envelope into the contract's `(openingsBlob, +/// proofsBlob)` layout. `openings` MUST be sorted by +/// `field_element_index`, with `positionI`'s four field elements first +/// followed by `positionJ`'s four (only the first four are used for +/// `ClassTagOutOfSet`). +fn pack_dispute_openings(openings: &[KzgOpening]) -> (Vec, Vec) { + let mut openings_blob = Vec::with_capacity(openings.len() * 32); + let mut proofs_blob = Vec::with_capacity(openings.len() * 48); + for o in openings { + openings_blob.extend_from_slice(&o.claimed_value); + proofs_blob.extend_from_slice(&o.proof_bytes[..48]); + } + (openings_blob, proofs_blob) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn dispute_won_class_tag_out_of_set_anvil_real() { + let test_start = Instant::now(); + + eprintln!("=== [1/10] Deploy anvil + contracts (mock verifier) ==="); + let dep = AnvilDeployment::start_and_deploy(true); + eprintln!(" anvil endpoint: {}", dep.endpoint); + eprintln!(" petition registry: {:#x}", dep.petition_registry); + eprintln!(" bounty token: {:#x}", dep.bounty_token); + + let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let chain = ChainPetitionRegistry::new(dep.provider.clone(), dep.petition_registry); + + let bounty = U256::from(1_000_000u64); + eprintln!("=== [2/10] Fund and approve bounty ({} units) ===", bounty); + IMockERC20::new(dep.bounty_token, &dep.provider) + .mint(dep.deployer_addr, bounty) + .send() + .await + .expect("mint") + .get_receipt() + .await + .unwrap(); + IMockERC20::new(dep.bounty_token, &dep.provider) + .approve(dep.petition_registry, bounty) + .send() + .await + .expect("approve") + .get_receipt() + .await + .unwrap(); + + let class_a: ClassTag = 100; + let class_b: ClassTag = 200; + let out_of_set_tag: ClassTag = 999; + let class_index = 2u8; + let class_set = vec![class_a, class_b]; + + eprintln!("=== [3/10] Build predicate and derive petition id ==="); + let pred = two_class_predicate(class_index, class_a, class_b); + let pred_encoded = pred.encode().expect("encode predicate"); + let canonical = canonical_scalars(&pred_encoded).unwrap(); + // Top byte zero so the Fr roundtrip `fr_to_be_bytes(fr_from_be_bytes(x))` + // is the identity. Otherwise the contract's stored bytes32 fields + // disagree with the publish path's reduced-then-re-encoded fields. + let mut salt = [0x77u8; 32]; + salt[0] = 0; + let mut r_root = [0xabu8; 32]; + r_root[0] = 0; + let predicate_hash_pre_id_fr = + hash_predicate(&canonical, Fr::from(0u64), fr_from_be_bytes(&salt)); + let predicate_hash_pre_id = fr_to_be_bytes(&predicate_hash_pre_id_fr); + + let current_block = dep.provider.get_block_number().await.unwrap(); + let close_at_block = current_block + 30; + + let petition_id = derive_petition_id( + 31337, + &dep.petition_registry.into(), + &dep.deployer_addr.into(), + 0, + &predicate_hash_pre_id, + close_at_block, + ); + let predicate_hash_fr = hash_predicate( + &canonical, + fr_from_be_bytes(&petition_id), + fr_from_be_bytes(&salt), + ); + let predicate_hash = fr_to_be_bytes(&predicate_hash_fr); + + eprintln!("=== [4/10] Register petition ==="); + let params = IPetitionRegistry::PetitionParams { + rRoot: FixedBytes(r_root), + predicateDef: pred_encoded.clone().into(), + salt: FixedBytes(salt), + classSet: class_set.clone(), + classThresholds: vec![1u64, 1u64], + classIndex: class_index, + closeAtBlock: close_at_block, + bounty, + }; + let returned_id = chain.register(params).await.expect("register on chain"); + assert_eq!(returned_id, petition_id); + eprintln!(" registered 0x{}", hex::encode(petition_id)); + + eprintln!("=== [5/10] Forge a 6-record batch with one out-of-set tag ==="); + let empty_imt_root = { + let imt = IndexedMerkleTree::new(); + fr_to_be_bytes(&imt.root_fr()) + }; + let signer_vk_hash_bytes = std::fs::read( + project_root + .join("circuits") + .join("signer") + .join("target") + .join("vk_hash"), + ) + .expect("read signer vk_hash"); + let mut signer_vk_hash: Bytes32 = [0u8; 32]; + signer_vk_hash.copy_from_slice(&signer_vk_hash_bytes); + + let mut records = vec![ + forged_record(1, class_a), + forged_record(2, class_b), + forged_record(3, class_a), + forged_record(4, class_b), + forged_record(5, class_a), + forged_record(6, out_of_set_tag), + ]; + // Pre-shuffle so canonical leaf sort is exercised, not preserved by accident. + records.swap(0, 5); + records.swap(1, 4); + + let mut blob_carrier = EIP4844BlobCarrier::new(); + let batch = build_forged_batch( + petition_id, + r_root, + predicate_hash, + class_index, + 0, + empty_imt_root, + signer_vk_hash, + records, + &mut blob_carrier, + ); + let bad_position = batch + .records + .iter() + .position(|r| r.class_tag == out_of_set_tag) + .expect("out-of-set record must be present after sort") + as u32; + eprintln!( + " out-of-set record landed at position {} (post canonical sort)", + bad_position + ); + + eprintln!("=== [6/10] Publish forged batch via blob tx ==="); + let bvh = fr_to_be_bytes(&batch.public_inputs.batch_versioned_hash); + let sidecar = blob_carrier.make_sidecar(&bvh).expect("make_sidecar"); + let eval_points = canonical_eval_points(); + let (commitment, proofs_concat, _ys) = blob_carrier + .commitment_and_per_point_proofs(&bvh, &eval_points) + .expect("commitment_and_per_point_proofs"); + let publish_start = Instant::now(); + chain + .publish_batch( + batch.public_inputs.clone(), + batch.proof_bytes, + commitment.to_vec(), + proofs_concat, + sidecar, + ) + .await + .expect("publishBatch with mock verifier"); + eprintln!(" publishBatch confirmed in {:?}", publish_start.elapsed()); + assert_eq!( + chain.get_batch_count(petition_id).await.unwrap(), + 1, + "exactly one batch must be published" + ); + + eprintln!("=== [7/10] Advance chain into DisputeWindow ==="); + let target = close_at_block + 600 + 1; + let now = dep.provider.get_block_number().await.unwrap(); + let to_mine = target.saturating_sub(now); + dep.provider + .raw_request::<_, ()>("anvil_mine".into(), (format!("0x{:x}", to_mine),)) + .await + .expect("anvil_mine"); + + eprintln!("=== [8/10] Disputant builds ClassTagOutOfSet envelope ==="); + let disputant = Disputant::new(blob_carrier.clone()); + let dispute_ctx = DisputeContext { + petition_id, + batch_versioned_hash: bvh, + batch_index: 0, + class_set: class_set.clone(), + }; + let dispute = disputant + .build_class_tag_out_of_set(&dispute_ctx, bad_position) + .expect("build dispute"); + assert_eq!(dispute.violation_type, ViolationType::ClassTagOutOfSet); + assert_eq!(dispute.openings.len(), 4); + + eprintln!("=== [9/10] Submit dispute on chain ==="); + let (openings_blob, proofs_blob) = pack_dispute_openings(&dispute.openings); + let dispute_start = Instant::now(); + let event = chain + .dispute( + petition_id, + dispute.batch_index, + dispute.position_i, + dispute.position_j.unwrap_or(0), + dispute.violation_type as u8, + commitment.to_vec(), + openings_blob, + proofs_blob, + ) + .await + .expect("dispute on chain"); + eprintln!(" dispute confirmed in {:?}", dispute_start.elapsed()); + + eprintln!("=== [10/10] Verify rollback to empty IMT ==="); + assert_eq!(event.petitionId, FixedBytes(petition_id)); + assert_eq!(event.batchIndex, 0); + assert_eq!( + event.newRunningRoot, + FixedBytes(empty_imt_root), + "running root must roll back to empty IMT (no active predecessor)", + ); + assert_eq!( + event.newIdentityTagSetRoot, + FixedBytes(empty_imt_root), + "identity-tag set root must roll back to empty IMT", + ); + assert_eq!(event.newLeafCount, 0, "leaf count must roll back to 0"); + // The repudiated batch stays in storage (`getBatchCount` is unaffected); + // only its `state` flips to Repudiated. + assert_eq!( + chain.get_batch_count(petition_id).await.unwrap(), + 1, + "repudiation does not pop the batch array", + ); + + eprintln!( + "=== Dispute-won path complete in {:?} ===", + test_start.elapsed() + ); +} diff --git a/pocs/civic-participation/resilient-civic-participation/tests/duplicate_nullifier_across_batches_rejected.rs b/pocs/civic-participation/resilient-civic-participation/tests/duplicate_nullifier_across_batches_rejected.rs new file mode 100644 index 0000000..e2de717 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/tests/duplicate_nullifier_across_batches_rejected.rs @@ -0,0 +1,74 @@ +//! Cross-batch duplicate-nullifier resubmission must be rejected by the registry. + +mod common; + +use common::*; +use resilient_civic_participation::{ + adapters::mock_proof::MockProofBackend, + relayer::{ + Relayer, + core::RelayerPetitionState, + types::PetitionView, + }, + types::U256Be, +}; + +#[test] +fn duplicate_nullifier_across_batches_rejected() { + let mut harness = Harness::new(); + + let class_tag = 826u16; + let mut signer = enroll_signer(&mut harness, 30, class_tag, [0xa1u8; 32]); + advance_past_ri_age_window(&mut harness); + + let predicate = class_only_predicate(ATTR_CLASS as u8, class_tag); + let predicate_encoded = predicate.encode().unwrap(); + let mut salt = [0u8; 32]; + salt[31] = 0x77; + let fixture = register_petition( + &mut harness, + [0xc0; 20], + predicate, + salt, + vec![class_tag], + vec![1], + ATTR_CLASS as u8, + U256Be::from_u64(1_000_000), + 100, + ); + + let s1 = signer_sign(&mut signer, &fixture.view, salt, &predicate_encoded); + let mut state = RelayerPetitionState::new(); + let _ = publish_one_batch( + &mut harness, + &fixture.view, + [0xa1; 20], + &mut state, + vec![s1], + ); + + // Resubmit the same signer; the relayer's IMT insert detects the duplicate. + let view_after = harness + .registry + .state_view(&fixture.event.petition_id) + .unwrap(); + let s2 = signer_sign(&mut signer, &view_after, salt, &predicate_encoded); + let mut relayer = Relayer::new([0xa1; 20], MockProofBackend, harness.blob.clone()); + let pv = PetitionView { + petition_id: view_after.petition_id, + r_root: view_after.r_root, + predicate_hash: view_after.predicate_hash, + class_index: view_after.class_index, + class_set: view_after.class_set.clone(), + slot: view_after.slot, + running_root: view_after.running_root, + identity_tag_set_root: view_after.identity_tag_set_root, + leaf_count: view_after.leaf_count, + signer_vk_hash: [0u8; 32], + }; + let result = relayer.build_batch(&pv, &mut state, vec![s2]); + assert!( + result.is_err(), + "expected duplicate-nullifier batch to be rejected by the relayer's IMT insert" + ); +} diff --git a/pocs/civic-participation/resilient-civic-participation/tests/golden_path.rs b/pocs/civic-participation/resilient-civic-participation/tests/golden_path.rs new file mode 100644 index 0000000..a5b0923 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/tests/golden_path.rs @@ -0,0 +1,549 @@ +//! End-to-end golden path against anvil with real verifiers, blobs, and Honk proofs. +//! +//! Full lifecycle: register -> 6 signers sign -> relayer publishes one +//! batch -> anvil advances past `closeAtBlock + COOLDOWN_BLOCKS` -> +//! resolver reconstructs leaves, builds resolution SNARK, submits. + +mod anvil_harness; + +use std::{ + path::PathBuf, + sync::{ + Arc, + Mutex, + }, + time::Instant, +}; + +use alloy::{ + primitives::{ + FixedBytes, + U256, + keccak256, + }, + providers::Provider, + sol, +}; +use ark_bn254::Fr; + +use anvil_harness::AnvilDeployment; +use resilient_civic_participation::{ + adapters::{ + bb_prover::BBProver, + blob_4844::EIP4844BlobCarrier, + chain_registry::{ + ChainPetitionRegistry, + IPetitionRegistry, + }, + in_memory_ri::InMemoryRi, + }, + error::MerkleError, + imt::IndexedMerkleTree, + ports::ri::{ + RiCredentialLayer, + RiPath, + }, + poseidon::{ + fr_from_be_bytes, + fr_to_be_bytes, + hash_predicate, + }, + predicate::{ + Op, + PredicateDef, + Tuple, + canonical_scalars, + }, + relayer::{ + Relayer, + core::RelayerPetitionState, + types::PetitionView, + }, + resolver::{ + Resolver, + types::ResolverView, + }, + signer::{ + Signer, + types::PetitionMeta, + }, + types::{ + Bytes32, + ClassTag, + Comparator, + OpCode, + SignerSubmission, + TypeTag, + }, +}; + +sol! { + #[sol(rpc)] + interface IMockERC20 { + function mint(address to, uint256 amount) external; + function approve(address spender, uint256 amount) external returns (bool); + function balanceOf(address account) external view returns (uint256); + } +} + +/// Shared `InMemoryRi` so each signer's RI port observes the same tree. +#[derive(Clone)] +struct SharedRi { + inner: Arc>, +} + +impl SharedRi { + fn new() -> Self { + Self { + inner: Arc::new(Mutex::new(InMemoryRi::new())), + } + } +} + +impl RiCredentialLayer for SharedRi { + fn append_leaf(&mut self, attr_hash: Bytes32, posted_at_block: u64) -> u32 { + self.inner + .lock() + .unwrap() + .append_leaf(attr_hash, posted_at_block) + } + fn root(&self) -> Bytes32 { + self.inner.lock().unwrap().root() + } + fn merkle_path(&self, leaf_index: u32) -> Result { + self.inner.lock().unwrap().merkle_path(leaf_index) + } + fn root_first_seen(&self, root: &Bytes32) -> Option { + self.inner.lock().unwrap().root_first_seen(root) + } +} + +/// `attr[class_index] == class_a OR attr[class_index] == class_b`. +fn two_class_predicate( + class_index: u8, + class_a: ClassTag, + class_b: ClassTag, +) -> PredicateDef { + let mut operand_a = [0u8; 32]; + operand_a[30..].copy_from_slice(&class_a.to_be_bytes()); + let mut operand_b = [0u8; 32]; + operand_b[30..].copy_from_slice(&class_b.to_be_bytes()); + PredicateDef { + tuples: vec![ + Tuple { + claim_index: class_index, + operand: operand_a, + type_tag: TypeTag::Int64, + comparator: Comparator::Eq, + }, + Tuple { + claim_index: class_index, + operand: operand_b, + type_tag: TypeTag::Int64, + comparator: Comparator::Eq, + }, + ], + ops: vec![ + Op { + code: OpCode::PushTuple, + operand: 0, + }, + Op { + code: OpCode::PushTuple, + operand: 1, + }, + Op { + code: OpCode::Or, + operand: 0, + }, + ], + } +} + +/// Off-chain mirror of `PetitionRegistry._derivePetitionId`. +fn derive_petition_id_offchain( + chain_id: u64, + registry: alloy::primitives::Address, + msg_sender: alloy::primitives::Address, + s_at_reg: u32, + predicate_hash_pre_id: Bytes32, + close_at_block: u64, +) -> Bytes32 { + let domain = keccak256(b"RCP/petition_id/v1"); + let mut packed = Vec::::with_capacity(32 + 8 + 20 + 20 + 4 + 32 + 8); + packed.extend_from_slice(domain.as_slice()); + packed.extend_from_slice(&chain_id.to_be_bytes()); + packed.extend_from_slice(registry.as_slice()); + packed.extend_from_slice(msg_sender.as_slice()); + packed.extend_from_slice(&s_at_reg.to_be_bytes()); + packed.extend_from_slice(&predicate_hash_pre_id); + packed.extend_from_slice(&close_at_block.to_be_bytes()); + let h = keccak256(packed); + let mut out: [u8; 32] = h.into(); + // Matches contract: top byte masked. + out[0] = 0; + out +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn golden_path_anvil_real() { + let test_start = Instant::now(); + + eprintln!("=== [1/13] Deploy anvil + contracts ==="); + let use_mock_verifier = std::env::var("USE_MOCK_VERIFIER") + .map(|s| s.eq_ignore_ascii_case("true") || s == "1") + .unwrap_or(true); + let dep = AnvilDeployment::start_and_deploy(use_mock_verifier); + eprintln!(" anvil endpoint: {}", dep.endpoint); + eprintln!(" petition registry: {:#x}", dep.petition_registry); + eprintln!(" bounty token: {:#x}", dep.bounty_token); + eprintln!(" deployer: {:#x}", dep.deployer_addr); + + let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let prover_root = project_root.clone(); + + eprintln!("=== [2/13] Ensure signer VK ==="); + let vk_start = Instant::now(); + BBProver::new(prover_root.clone()) + .ensure_signer_vk() + .expect("ensure signer VK"); + eprintln!(" signer VK ready in {:?}", vk_start.elapsed()); + + let chain = ChainPetitionRegistry::new(dep.provider.clone(), dep.petition_registry); + + let bounty = U256::from(1_000_000_000u64); + eprintln!("=== [3/13] Fund and approve bounty ({} units) ===", bounty); + IMockERC20::new(dep.bounty_token, &dep.provider) + .mint(dep.deployer_addr, bounty) + .send() + .await + .expect("mint") + .get_receipt() + .await + .unwrap(); + eprintln!(" minted {} to deployer", bounty); + IMockERC20::new(dep.bounty_token, &dep.provider) + .approve(dep.petition_registry, bounty) + .send() + .await + .expect("approve") + .get_receipt() + .await + .unwrap(); + eprintln!(" approved registry to spend {} from deployer", bounty); + + let ri = SharedRi::new(); + let class_a: ClassTag = 826; + let class_b: ClassTag = 840; + let class_index = 2u8; + eprintln!( + "=== [4/13] Enroll 6 signers (classes {} x3, {} x3) ===", + class_a, class_b + ); + let mut signers: Vec> = Vec::with_capacity(6); + let mut class_tags: Vec = Vec::with_capacity(6); + for i in 0..6u8 { + let class_tag = if i < 3 { class_a } else { class_b }; + let attrs = vec![ + Fr::from(25u64), + Fr::from(1u64), + Fr::from(class_tag as u64), + Fr::from(20_000u64), + ]; + let mut seed = [0u8; 32]; + seed[31] = i + 1; + let (signer, _artifact) = Signer::enroll( + BBProver::new(prover_root.clone()), + ri.clone(), + attrs, + 32, + 0, + 100, + Some(seed), + ); + eprintln!( + " enrolled signer {}/6 (class {}, ri_leaf_index {})", + i + 1, + class_tag, + signer.credentials.ri_leaf_index + ); + signers.push(signer); + class_tags.push(class_tag); + } + eprintln!(" RI root after enrollment: 0x{}", hex::encode(ri.root())); + + eprintln!("=== [5/13] Build predicate and derive petition id ==="); + let pred = two_class_predicate(class_index, class_a, class_b); + let pred_encoded = pred.encode().expect("encode predicate"); + let canonical = canonical_scalars(&pred_encoded).unwrap(); + let salt = [0x55u8; 32]; + eprintln!(" predicate encoded: {} bytes", pred_encoded.len()); + + let predicate_hash_pre_id_fr = + hash_predicate(&canonical, Fr::from(0u64), fr_from_be_bytes(&salt)); + let predicate_hash_pre_id = fr_to_be_bytes(&predicate_hash_pre_id_fr); + + let current_block = dep.provider.get_block_number().await.unwrap(); + let close_at_block = current_block + 30; + eprintln!(" current block: {}", current_block); + eprintln!(" close_at_block: {}", close_at_block); + + let petition_id = derive_petition_id_offchain( + 31337, + dep.petition_registry, + dep.deployer_addr, + 0, + predicate_hash_pre_id, + close_at_block, + ); + let predicate_hash_fr = hash_predicate( + &canonical, + fr_from_be_bytes(&petition_id), + fr_from_be_bytes(&salt), + ); + let predicate_hash = fr_to_be_bytes(&predicate_hash_fr); + eprintln!(" predicate_hash: 0x{}", hex::encode(predicate_hash)); + + let r_root = ri.root(); + let params = IPetitionRegistry::PetitionParams { + rRoot: FixedBytes(r_root), + predicateDef: pred_encoded.clone().into(), + salt: FixedBytes(salt), + classSet: vec![class_a, class_b], + classThresholds: vec![3u64, 3u64], + classIndex: class_index, + closeAtBlock: close_at_block, + bounty, + }; + + eprintln!("=== [6/13] Register petition on chain ==="); + let register_start = Instant::now(); + let returned_id = chain + .register(params.clone()) + .await + .expect("register on chain"); + assert_eq!( + returned_id, petition_id, + "off-chain petition_id derivation must match contract" + ); + eprintln!( + " registered petition_id 0x{} in {:?}", + hex::encode(petition_id), + register_start.elapsed() + ); + eprintln!(" bounty escrowed: {}", bounty); + + eprintln!("=== [7/13] Generate signer proofs (6 total) ==="); + let signing_start = Instant::now(); + let mut submissions: Vec = Vec::with_capacity(6); + for (i, signer) in signers.iter_mut().enumerate() { + let meta = PetitionMeta { + petition_id, + r_root, + predicate_hash, + slot: 0, + class_index, + class_tag: class_tags[i], + predicate_def: pred.clone(), + salt, + ri_leaf_index: signer.credentials.ri_leaf_index, + }; + eprintln!(" signer {}/6 (class {}): proving...", i + 1, class_tags[i]); + let proof_start = Instant::now(); + let sub = signer.sign(&meta).expect("signer.sign"); + eprintln!( + " signer {}/6: proof generated in {:?} ({} bytes)", + i + 1, + proof_start.elapsed(), + sub.proof_bytes.len() + ); + submissions.push(sub); + } + eprintln!(" all 6 signer proofs in {:?}", signing_start.elapsed()); + + let empty_imt_root = { + let imt = IndexedMerkleTree::new(); + fr_to_be_bytes(&imt.root_fr()) + }; + // Read the signer VK hash that the on-chain registry was pinned to + // at deploy time; the batch SNARK's signer_vk_hash public input + // must match it bit-for-bit. + let signer_vk_hash_bytes = std::fs::read( + prover_root + .join("circuits") + .join("signer") + .join("target") + .join("vk_hash"), + ) + .expect("read signer vk_hash"); + let mut signer_vk_hash: [u8; 32] = [0u8; 32]; + signer_vk_hash.copy_from_slice(&signer_vk_hash_bytes); + let petition_view = PetitionView { + petition_id, + r_root, + predicate_hash, + class_index, + class_set: vec![class_a, class_b], + slot: 0, + running_root: empty_imt_root, + identity_tag_set_root: empty_imt_root, + leaf_count: 0, + signer_vk_hash, + }; + let blob_carrier = EIP4844BlobCarrier::new(); + let mut relayer_state = RelayerPetitionState::new(); + let mut relayer = Relayer::new( + dep.deployer_addr.into_array(), + BBProver::new(prover_root.clone()), + blob_carrier.clone(), + ); + + eprintln!("=== [8/13] Build recursive batch proof (multi-minute) ==="); + let batch_start = Instant::now(); + let (batch, _positions) = relayer + .build_batch(&petition_view, &mut relayer_state, submissions) + .expect("relayer.build_batch"); + eprintln!( + " batch proof ready in {:?} ({} bytes)", + batch_start.elapsed(), + batch.proof_bytes.len() + ); + eprintln!( + " new_running_root: 0x{}", + hex::encode(fr_to_be_bytes(&batch.public_inputs.new_running_root)) + ); + eprintln!( + " batch_versioned_hash: 0x{}", + hex::encode(fr_to_be_bytes(&batch.public_inputs.batch_versioned_hash)) + ); + eprintln!( + " new_leaf_count: {}", + batch.public_inputs.new_leaf_count + ); + + eprintln!("=== [9/13] Build EIP-4844 sidecar and KZG openings ==="); + let batch_versioned_hash_be = + fr_to_be_bytes(&batch.public_inputs.batch_versioned_hash); + let sidecar = blob_carrier + .make_sidecar(&batch_versioned_hash_be) + .expect("make sidecar"); + let eval_points = resilient_civic_participation::blob::canonical_eval_points(); + let (commitment, proofs_concat, ys) = blob_carrier + .commitment_and_per_point_proofs(&batch_versioned_hash_be, &eval_points) + .expect("commitment_and_per_point_proofs"); + eprintln!(" KZG commitment: {} bytes", commitment.len()); + eprintln!( + " KZG proofs concat: {} bytes across {} eval points", + proofs_concat.len(), + eval_points.len() + ); + for (k, fe) in batch.public_inputs.bls_fields.iter().enumerate() { + let expected = fr_to_be_bytes(fe); + assert_eq!( + ys[k], expected, + "kzg opening y_{k} disagrees with SNARK bls_fields[{k}]" + ); + } + eprintln!(" KZG openings consistent with SNARK bls_fields"); + + eprintln!("=== [10/13] Publish batch on chain ==="); + let publish_start = Instant::now(); + chain + .publish_batch( + batch.public_inputs.clone(), + batch.proof_bytes, + commitment.to_vec(), + proofs_concat, + sidecar, + ) + .await + .expect("publishBatch on chain"); + eprintln!(" publishBatch confirmed in {:?}", publish_start.elapsed()); + + let count = chain + .get_batch_count(petition_id) + .await + .expect("getBatchCount"); + assert_eq!(count, 1, "exactly one batch must be published"); + eprintln!(" batchCount = {}", count); + + // Advance past `closeAtBlock + COOLDOWN_BLOCKS` so the petition is + // in `DisputeWindow` (the only state from which `resolve` is valid). + // COOLDOWN_BLOCKS = 600 in PetitionRegistry.sol. + eprintln!("=== [11/13] Advance chain into DisputeWindow ==="); + let now = dep.provider.get_block_number().await.unwrap(); + let target = close_at_block + 600 + 1; + let to_mine = target.saturating_sub(now); + eprintln!( + " current block: {} | target: {} | mining {} blocks", + now, target, to_mine + ); + let mine_hex = format!("0x{:x}", to_mine); + dep.provider + .raw_request::<_, ()>("anvil_mine".into(), (mine_hex,)) + .await + .expect("anvil_mine"); + let post_mine = dep.provider.get_block_number().await.unwrap(); + eprintln!(" block after mine: {}", post_mine); + + // Resolver reconstructs the leaf set from the published blob, + // builds the resolution SNARK, and submits it. + let resolver_view = ResolverView { + petition_id, + r_root, + predicate_hash, + running_root: fr_to_be_bytes(&batch.public_inputs.new_running_root), + leaf_count: 6, + class_set: vec![class_a, class_b], + class_thresholds: vec![3u64, 3u64], + class_index, + active_batch_versioned_hashes: vec![batch_versioned_hash_be], + }; + let resolver = + Resolver::new(BBProver::new(prover_root.clone()), blob_carrier.clone()); + eprintln!("=== [12/13] Build resolution proof ==="); + let resolution_start = Instant::now(); + let resolution = resolver.resolve(&resolver_view).expect("resolver.resolve"); + eprintln!( + " resolution proof ready in {:?} ({} bytes)", + resolution_start.elapsed(), + resolution.proof_bytes.len() + ); + + eprintln!("=== [13/13] Submit resolution and verify bounty payout ==="); + let pre_balance = IMockERC20::new(dep.bounty_token, &dep.provider) + .balanceOf(dep.deployer_addr) + .call() + .await + .expect("balanceOf pre-resolve"); + eprintln!(" deployer balance pre-resolve: {}", pre_balance); + assert_eq!( + pre_balance, + U256::ZERO, + "deployer should hold no bounty token while petition is escrowed" + ); + + let resolve_start = Instant::now(); + chain + .resolve( + petition_id, + resolution.public_inputs.clone(), + resolution.proof_bytes, + ) + .await + .expect("resolve on chain"); + eprintln!(" resolve confirmed in {:?}", resolve_start.elapsed()); + + let post_balance = IMockERC20::new(dep.bounty_token, &dep.provider) + .balanceOf(dep.deployer_addr) + .call() + .await + .expect("balanceOf post-resolve"); + eprintln!(" deployer balance post-resolve: {}", post_balance); + assert_eq!( + post_balance, bounty, + "bounty must transfer to resolve caller on success" + ); + + eprintln!("=== Golden path complete in {:?} ===", test_start.elapsed()); +} From 54e316f58164f2b5e18c81e03bd3250d49c24945 Mon Sep 17 00:00:00 2001 From: rymnc <43716372+rymnc@users.noreply.github.com> Date: Fri, 22 May 2026 00:29:34 +0200 Subject: [PATCH 2/5] fix: oskar comments --- .../resilient-civic-participation/SPEC.md | 34 +++++++++++++------ .../contracts/src/PetitionRegistry.sol | 1 + .../contracts/test/PetitionRegistry.t.sol | 19 ++++++++--- .../src/registry/core.rs | 8 +++++ .../tests/golden_path.rs | 13 ++++--- 5 files changed, 56 insertions(+), 19 deletions(-) diff --git a/pocs/civic-participation/resilient-civic-participation/SPEC.md b/pocs/civic-participation/resilient-civic-participation/SPEC.md index d2c9ed1..d439eee 100644 --- a/pocs/civic-participation/resilient-civic-participation/SPEC.md +++ b/pocs/civic-participation/resilient-civic-participation/SPEC.md @@ -12,15 +12,17 @@ iptf_approach: "https://github.com/ethereum/iptf-map/blob/master/approaches/appr ## Problem Statement -Civic petitions need signers to prove a stated eligibility criterion without revealing their identity or other petitions they have signed. The outcome must remain verifiable from a durable record after the hosting platform goes offline. Sybil resistance comes from an external credential layer. +Civic petitions need signers to prove a stated eligibility criterion without revealing their identity or other petitions they have signed. The outcome must remain verifiable from L1 plus blob payloads during EIP-4844 retention, or from a voluntary blob archive after retention. Sybil resistance comes from an external credential layer. -A credentialed petition system, composed with the ResilientIdentity (RI) credential layer, anchors per-petition state in an on-chain Indexed Merkle Tree (IMT) and publishes per-signature data on EIP-4844 blob carriers. After the dispute window closes, one resolution SNARK settles the outcome from chain state alone. Each signer maintains a Forward-Secure Ratchet Tree (FSRT) chain that advances past each signed slot, overwriting prior seed material. +A credentialed petition system, composed with the ResilientIdentity (RI) credential layer, anchors per-petition state in an on-chain Indexed Merkle Tree (IMT) and publishes per-signature data on EIP-4844 blob carriers. After the dispute window closes, one resolution SNARK settles the outcome over the on-chain record and the blob payloads of active batches. Each signer maintains a Forward-Secure Ratchet Tree (FSRT) chain that advances past each signed slot, overwriting prior seed material. + +This specification covers petition-style threshold-per-class outcomes: one signature per signer, per-class threshold tallying with regulated visibility. Single-choice, ranked, weighted, and event-count-bounded outcome shapes listed in the umbrella `REQUIREMENTS.md` are out of scope for this approach. ### Constraints -- **Privacy.** Cross-process and within-process unlinkability across the public record. Signer identity stays hidden, and no attribute value beyond the predicate's boolean outcome is exposed. -- **Regulatory.** Outcome publicly verifiable from durable record. Per-class threshold breakdowns come from the predicate's class-binding clause and the Resolver's class-counted outcome bits. -- **Operational.** After enrollment, signers generate proofs from local state and public on-chain/RI data, without further RI issuer interaction. Outcome reconstructible from chain state once the dispute window closes. +- **Privacy.** Cross-process and within-process unlinkability across the public record. Signer identity stays hidden. The per-record `class_tag` is public outcome metadata that supports per-class threshold accounting and regulator visibility; no other attribute value is exposed. +- **Regulatory.** Outcome publicly verifiable from L1 plus blob payloads (or a voluntary blob archive once retention expires). Per-class threshold breakdowns come from the predicate's class-binding clause and the Resolver's class-counted outcome bits. +- **Operational.** After enrollment, signers generate proofs from local state and public on-chain/RI data, without further RI issuer interaction. Outcome reconstructible from L1 plus blob payloads during EIP-4844 retention, or from a voluntary blob archive once the dispute window closes. - **Trust.** RI provides Sybil resistance. Only signers hold FSRT seed material. The signer's submission path assumes at least one reachable honest relay. ### System Overview @@ -63,7 +65,7 @@ Rejected alternatives: | `SigningOpen` | `SigningClosed` | first transaction at block `>= close_at_block` | | `SigningClosed` | `Cooldown` | atomic with `SigningClosed` entry | | `Cooldown` | `DisputeWindow` | first transaction at block `>= close_at_block + 2h` | -| `DisputeWindow` | `Resolved` | valid resolution SNARK submission | +| `DisputeWindow` | `Resolved` | valid resolution SNARK submission at block `>= close_at_block + 14 days` | | `DisputeWindow` | `Unresolved` | `markUnresolved(petition_id)` at block `>= close_at_block + 14 days`; replaces `running_root` with the tombstone marker | The Registry MUST enforce the current `state` at every entry point. @@ -94,6 +96,8 @@ The signing window MUST satisfy `close_at_block - registration_block <= 11.5 day 4. Signer MUST build the signer SNARK and MUST submit it with `(nullifier, identity_tag, class_tag)` to a relayer. 5. After L1 finality of the carrying batch, signer MUST overwrite `s_curr` past `s_slot`, advance the caterpillar frontier, and set `t <- slot + 1`. The signer MUST journal this transition to fsync'd storage before the signing counts as complete. +`class_tag` is exposed as a public input on the signer SNARK and is published per-record in the blob payload. This is intentional public outcome metadata. The cost is reflected in the signer-level unlinkability bound (see [Guarantees](#guarantees)), where `k` is restricted to RI signers whose `attr[class_index]` matches each petition's published `class_tag`. + #### Batch Publication 1. Relayer MUST collect signer SNARKs targeting `(petition_id, R, predicate_hash, class_index, slot)` and extract `(nullifier_i, identity_tag_i, class_tag_i)` per position. @@ -104,7 +108,7 @@ The signing window MUST satisfy `close_at_block - registration_block <= 11.5 day #### Dispute 1. Disputant submits `dispute(petition_id, batch_index, position_i, position_j, violation_type, opening_proofs)`. `position_j` is `None` for violation `0x01` and `Some(j)` for `0x02` and `0x03`. The record content at each position MUST be derived by the Registry from `opening_proofs` rather than submitted separately. -2. Registry MUST validate openings via the `0x0A` precompile against `batch_versioned_hash`, MUST derive the record content for `position_i` (and `position_j` where applicable) from the openings, MUST apply the violation predicate against that derived content, MUST set the BatchRecord state at `batch_index` to `Repudiated` together with every BatchRecord at index `> batch_index` whose state is still `Active` (those records' `prior_running_root` is no longer canonical), MUST advance `next_batch_index` to `batch_index`, MUST roll back `running_root`, `identity_tag_set_root`, and `leaf_count` to the values held by the immediately preceding active batch (or the initial empty-IMT state if no such predecessor exists), and MUST emit `BatchRepudiated`. +2. Registry MUST validate openings via the `0x0A` precompile against `batch_versioned_hash`, MUST derive the record content for `position_i` (and `position_j` where applicable) from the openings, MUST apply the violation predicate against that derived content, MUST set the BatchRecord state at `batch_index` to `Repudiated`, MUST set every BatchRecord at index `> batch_index` whose state is `Active` to `Repudiated` (those records' `prior_running_root` is no longer canonical), MUST advance `next_batch_index` to `batch_index`, MUST roll back `running_root`, `identity_tag_set_root`, and `leaf_count` to the values held by the immediately preceding active batch (or the initial empty-IMT state if no such predecessor exists), and MUST emit `BatchRepudiated`. Violation types: @@ -118,7 +122,9 @@ Violation types: 1. Resolver MUST read `(running_root, leaf_count, R, predicate_hash, class_set, class_thresholds, class_index)` and MUST reconstruct `L = {leaf_1, ..., leaf_{leaf_count}}` from blobs of active batches. 2. For each `c in class_set`, `count[c] = |{leaf in L : class_tag(leaf) = c}|`; `b_per_class[i] = (count[class_set[i]] >= class_thresholds[i])`; `b = AND_i b_per_class[i]`. -3. Resolver MUST build and submit the resolution SNARK; Registry MUST validate and emit `PetitionResolved` and `BountyPaid`. First valid submission claims the bounty. +3. At block `>= close_at_block + 14 days`, Resolver MUST submit the resolution SNARK; Registry MUST reject earlier submissions. Registry MUST validate the proof and MUST emit `PetitionResolved` and `BountyPaid`. First valid submission claims the bounty. + +For batches whose `submitted_at_block` precedes resolution by more than 4096 epochs, the EIP-4844 blob is no longer retained on the consensus layer and Resolver MUST obtain its payload from a voluntary blob archive. The batch SNARK commitments on L1 still bind blob contents to `batch_versioned_hash`, so any archive copy is verifiable against the on-chain record. After `close_at_block + 14 days` with no valid resolution, any party MAY call `markUnresolved(petition_id)`. The Registry MUST refund the bounty (less a gas rebate to the caller, capped at 1% of the total bounty so a caller cannot starve the Organizer of their refund) to the Organizer, MUST replace `running_root` with the tombstone marker, MUST transition the petition state to `Unresolved`, and MUST emit `PetitionUnresolved`. This call is only permitted when the petition's state is `DisputeWindow`; the 14-day timer makes any earlier state unreachable. @@ -213,7 +219,13 @@ GlobalState: Minimum-bounty calibration: `N_expected = 10 * sum(class_thresholds)`; `B_min = alpha * N_expected * predicate_op_count`. The Registry MUST keep `alpha_min <= alpha <= alpha_max`; updates bind petitions registered after the update. -Petition identifier: `petition_id = keccak256(DOMAIN_PETITION, chain_id, registry_address, organizer, S_at_registration, predicate_hash_pre_id, close_at_block)`, where `predicate_hash_pre_id` sets `petition_id = 0`; `predicate_hash` is recomputed and stored once `petition_id` is assigned. The tombstone marker is the BN254 scalar `0x0000...0001`. +Petition identifier derivation proceeds in three steps: + +1. `predicate_hash_pre_id = Poseidon1(DOMAIN_PRED, canonical_predicate_def, 0, salt)`, computed with the literal scalar `0` as the placeholder `petition_id`. +2. `petition_id = keccak256(DOMAIN_PETITION, chain_id, registry_address, organizer, S_at_registration, predicate_hash_pre_id, close_at_block)`. +3. The final `predicate_hash = Poseidon1(DOMAIN_PRED, canonical_predicate_def, petition_id, salt)` is recomputed with the now-known `petition_id` and stored on the petition record. + +The tombstone marker is the BN254 scalar `0x0000...0001`. #### Blob Payload @@ -334,6 +346,8 @@ UltraHonk; recursive. `BATCH_SIZE_MAX = 100`. 7. `new_leaf_count = prior_leaf_count + batch_size`. 8. `batch_versioned_hash` opens at canonical evaluation points to BLS12-381 field elements decoding under [Blob Payload](#blob-payload) to `(nullifier_i, identity_tag_i, class_tag_i)` for `i in [0, batch_size)`; positions in `[batch_size, BATCH_SIZE_MAX)` decode to `(0, 0, 0)`. Cross-field binding: each in-circuit BN254 scalar has a big-endian 32-byte decomposition, byte-range-checked, equal to the corresponding BLS12-381 field element. +**Blob binding mechanism.** The binding between the BN254 batch SNARK and the BLS12-381 KZG commitment in `batch_versioned_hash` is split across two trust domains. Inside the circuit, constraint 8 establishes byte-level equality between BN254 record scalars and the `bls_fields` public-input array of BLS12-381 field elements. On L1, during `publishBatch`, the Registry calls the `0x0A` KZG point-evaluation precompile once per `bls_fields` entry against `batch_versioned_hash` at canonical evaluation points, closing the binding from the BLS12-381 commitment side. No BLS12-381 KZG verification happens inside the SNARK; the curve crossing is resolved by exposing the BLS12-381 field elements as public inputs and verifying them against the blob commitment on-chain. + ### Resolution SNARK UltraHonk; zero-knowledge. @@ -373,7 +387,7 @@ Out of scope: network transport anonymity beyond what Tor or an equivalent provi ### Guarantees - **Per-petition forward secrecy.** An adversary holding post-signing runtime state, `identity_secret`, `attr_vector`, the RI Merkle path, and the full blob and L1 archives recovers `v_{k'}`, `s_{k'}`, or any value computationally non-trivial in `v_{k'}` (including the slot-`k'` nullifier and identity tag) for any slot `k' < t` with advantage at most `(t - k') * eps_sponge`, under the Poseidon1-sponge PRG assumption. -- **Signer-level unlinkability.** For petitions `X1`, `X2` and records `r_1 in batch_{X1}`, `r_2 in batch_{X2}`, the adversary's advantage in deciding "same signer" exceeds `1/k - negl` only when it holds at least one of `v_{slot(X1)}` or `v_{slot(X2)}`, where `k` is the cardinality of Signers in `R` whose `attr_vector` satisfies both predicates and matches each petition's `class_tag`. +- **Signer-level unlinkability.** For petitions `X1`, `X2` and records `r_1 in batch_{X1}`, `r_2 in batch_{X2}`, the adversary's advantage in deciding "same signer" exceeds `1/k - negl` only when it holds at least one of `v_{slot(X1)}` or `v_{slot(X2)}`. `k` is the cardinality of Signers in `R` whose `attr_vector` satisfies both predicates and whose `attr[class_index]` matches each petition's published `class_tag`. The public per-record `class_tag` is what restricts `k` to class-matched signers rather than all signers matching both predicates. - **One signature per RI leaf per petition.** For petition `X` and RI leaf `L_RI`, at most one record in `running_root` derives from `L_RI` after batching and dispute resolution. - **Outcome verifiability.** A verifier holding L1 chain state, the SRS identified by `srs_hash`, and the Poseidon1 parameter set re-verifies the resolution SNARK, confirming `b` and `b_per_class`. - **In-window dispute soundness.** A batch's contribution to `running_root` is removed only when the disputant produces valid KZG openings against `batch_versioned_hash` and evidence satisfying one of the enumerated violation predicates. diff --git a/pocs/civic-participation/resilient-civic-participation/contracts/src/PetitionRegistry.sol b/pocs/civic-participation/resilient-civic-participation/contracts/src/PetitionRegistry.sol index f9d5f3e..83f9b51 100644 --- a/pocs/civic-participation/resilient-civic-participation/contracts/src/PetitionRegistry.sol +++ b/pocs/civic-participation/resilient-civic-participation/contracts/src/PetitionRegistry.sol @@ -644,6 +644,7 @@ contract PetitionRegistry is IPetitionRegistry { PetitionRecord storage rec = petitions[petitionId]; _advanceStateOnRead(rec); if (rec.state != PetitionState.DisputeWindow) revert InvalidState(); + if (block.number < uint256(rec.closeAtBlock) + RESOLUTION_DEADLINE_BLOCKS) revert TooEarly(); if (rec.bPerClass.length != 0) revert AlreadyResolved(); if (!resolutionVerifier.verify(resolutionProof, _resolutionPublicInputs(rec, pi))) revert ProofRejected(); diff --git a/pocs/civic-participation/resilient-civic-participation/contracts/test/PetitionRegistry.t.sol b/pocs/civic-participation/resilient-civic-participation/contracts/test/PetitionRegistry.t.sol index 83c5925..94f6e01 100644 --- a/pocs/civic-participation/resilient-civic-participation/contracts/test/PetitionRegistry.t.sol +++ b/pocs/civic-participation/resilient-civic-participation/contracts/test/PetitionRegistry.t.sol @@ -412,7 +412,7 @@ contract PetitionRegistryTest is Test { function test_Resolve_Succeeds() public { (bytes32 petitionId, IPetitionRegistry.PetitionParams memory p) = _registerDefault(); _publishBatch(petitionId, p); - vm.roll(p.closeAtBlock + COOLDOWN_BLOCKS + 1); + vm.roll(p.closeAtBlock + RESOLUTION_DEADLINE_BLOCKS); IPetitionRegistry.ResolutionPublicInputs memory pi = _resolutionPi(true); uint256 callerPre = token.balanceOf(resolver_); @@ -442,6 +442,17 @@ contract PetitionRegistryTest is Test { registry.resolve(petitionId, pi, hex"01"); } + function test_Resolve_RevertsBeforeResolutionDeadline() public { + (bytes32 petitionId, IPetitionRegistry.PetitionParams memory p) = _registerDefault(); + _publishBatch(petitionId, p); + // In DisputeWindow but before the resolution deadline. + vm.roll(p.closeAtBlock + RESOLUTION_DEADLINE_BLOCKS - 1); + IPetitionRegistry.ResolutionPublicInputs memory pi = _resolutionPi(true); + vm.prank(resolver_); + vm.expectRevert(PetitionRegistry.TooEarly.selector); + registry.resolve(petitionId, pi, hex"01"); + } + /// CRIT-1 regression: after the cleanup, runningRoot is sourced from /// rec.* and the caller cannot substitute it. Any staleness in the /// verifier's view of running_root surfaces from the verifier as @@ -451,7 +462,7 @@ contract PetitionRegistryTest is Test { function test_Resolve_RevertsOnBPerClassLengthMismatch() public { (bytes32 petitionId, IPetitionRegistry.PetitionParams memory p) = _registerDefault(); _publishBatch(petitionId, p); - vm.roll(p.closeAtBlock + COOLDOWN_BLOCKS + 1); + vm.roll(p.closeAtBlock + RESOLUTION_DEADLINE_BLOCKS); // Registered class_set has length 2; submit a length-3 outcome vector. bool[] memory bpc = new bool[](3); bpc[0] = true; @@ -467,7 +478,7 @@ contract PetitionRegistryTest is Test { function test_Resolve_RevertsOnProofRejected() public { (bytes32 petitionId, IPetitionRegistry.PetitionParams memory p) = _registerDefault(); _publishBatch(petitionId, p); - vm.roll(p.closeAtBlock + COOLDOWN_BLOCKS + 1); + vm.roll(p.closeAtBlock + RESOLUTION_DEADLINE_BLOCKS); resolutionVerifier.setResult(false); IPetitionRegistry.ResolutionPublicInputs memory pi = _resolutionPi(true); vm.prank(resolver_); @@ -478,7 +489,7 @@ contract PetitionRegistryTest is Test { function test_Resolve_RevertsOnSecondResolve() public { (bytes32 petitionId, IPetitionRegistry.PetitionParams memory p) = _registerDefault(); _publishBatch(petitionId, p); - vm.roll(p.closeAtBlock + COOLDOWN_BLOCKS + 1); + vm.roll(p.closeAtBlock + RESOLUTION_DEADLINE_BLOCKS); IPetitionRegistry.ResolutionPublicInputs memory pi = _resolutionPi(true); vm.prank(resolver_); registry.resolve(petitionId, pi, hex"01"); diff --git a/pocs/civic-participation/resilient-civic-participation/src/registry/core.rs b/pocs/civic-participation/resilient-civic-participation/src/registry/core.rs index bf6835f..31c3eb7 100644 --- a/pocs/civic-participation/resilient-civic-participation/src/registry/core.rs +++ b/pocs/civic-participation/resilient-civic-participation/src/registry/core.rs @@ -754,6 +754,14 @@ where if state != PetitionState::DisputeWindow { return Err(RegistryError::BadState(state, "resolve")); } + let now = self.now(); + let close_at_block = self.petition(petition_id)?.record.close_at_block; + let deadline = close_at_block + .checked_add(RESOLUTION_DEADLINE_BLOCKS) + .ok_or(RegistryError::BadState(state, "resolve"))?; + if now < deadline { + return Err(RegistryError::BadState(state, "resolve")); + } // Verify before taking a mut borrow. self.proof_backend .verify_resolution_proof(&submission.proof_bytes, &submission.public_inputs) diff --git a/pocs/civic-participation/resilient-civic-participation/tests/golden_path.rs b/pocs/civic-participation/resilient-civic-participation/tests/golden_path.rs index a5b0923..030cb64 100644 --- a/pocs/civic-participation/resilient-civic-participation/tests/golden_path.rs +++ b/pocs/civic-participation/resilient-civic-participation/tests/golden_path.rs @@ -467,12 +467,15 @@ async fn golden_path_anvil_real() { assert_eq!(count, 1, "exactly one batch must be published"); eprintln!(" batchCount = {}", count); - // Advance past `closeAtBlock + COOLDOWN_BLOCKS` so the petition is - // in `DisputeWindow` (the only state from which `resolve` is valid). - // COOLDOWN_BLOCKS = 600 in PetitionRegistry.sol. - eprintln!("=== [11/13] Advance chain into DisputeWindow ==="); + // Advance past `closeAtBlock + RESOLUTION_DEADLINE_BLOCKS` so the + // petition is in `DisputeWindow` AND the resolution deadline has + // elapsed. After SPEC line 65 / line 121, `resolve` is gated on + // `block.number >= closeAtBlock + RESOLUTION_DEADLINE_BLOCKS` to + // prevent races against in-flight disputes. + // RESOLUTION_DEADLINE_BLOCKS = 100_800 in PetitionRegistry.sol. + eprintln!("=== [11/13] Advance chain past resolution deadline ==="); let now = dep.provider.get_block_number().await.unwrap(); - let target = close_at_block + 600 + 1; + let target = close_at_block + 100_800; let to_mine = target.saturating_sub(now); eprintln!( " current block: {} | target: {} | mining {} blocks", From e7105cd23f2b5eca03065d4e3fc3f0ab095a13ee Mon Sep 17 00:00:00 2001 From: rymnc <43716372+rymnc@users.noreply.github.com> Date: Mon, 25 May 2026 06:51:01 +0200 Subject: [PATCH 3/5] fix: oskar review p2 --- .../resilient-civic-participation/SPEC.md | 13 +- .../contracts/src/PetitionRegistry.sol | 185 +++++++++++++----- .../contracts/test/PetitionRegistry.t.sol | 103 +++++++++- .../src/registry/core.rs | 14 +- .../src/registry/types.rs | 1 - .../src/types.rs | 1 - 6 files changed, 253 insertions(+), 64 deletions(-) diff --git a/pocs/civic-participation/resilient-civic-participation/SPEC.md b/pocs/civic-participation/resilient-civic-participation/SPEC.md index d439eee..b1f5e76 100644 --- a/pocs/civic-participation/resilient-civic-participation/SPEC.md +++ b/pocs/civic-participation/resilient-civic-participation/SPEC.md @@ -66,7 +66,7 @@ Rejected alternatives: | `SigningClosed` | `Cooldown` | atomic with `SigningClosed` entry | | `Cooldown` | `DisputeWindow` | first transaction at block `>= close_at_block + 2h` | | `DisputeWindow` | `Resolved` | valid resolution SNARK submission at block `>= close_at_block + 14 days` | -| `DisputeWindow` | `Unresolved` | `markUnresolved(petition_id)` at block `>= close_at_block + 14 days`; replaces `running_root` with the tombstone marker | +| `DisputeWindow` | `Unresolved` | `markUnresolved(petition_id)` at block `>= close_at_block + 14 days + 1 hour grace`; replaces `running_root` with the tombstone marker | The Registry MUST enforce the current `state` at every entry point. @@ -84,7 +84,7 @@ The Registry MUST enforce the current `state` at every entry point. 1. Organizer constructs `(R, predicate_def, salt, class_set, class_thresholds, class_index, close_at_block)` and calls `register(petition_data, B)`. 2. Registry MUST structurally validate the predicate and class-binding clause, MUST recompute and assert `predicate_hash`, MUST derive `petition_id`, MUST assign `slot = S` then increment `S`, MUST snapshot `alpha_at_registration`, and MUST assert `B >= alpha_at_registration * N_expected * predicate_op_count`. -3. Registry MUST initialise `running_root`, `identity_tag_set_root`, `leaf_count`, `next_batch_index` and MUST emit `PetitionRegistered`. +3. Registry MUST initialise `running_root`, `identity_tag_set_root`, `leaf_count` and MUST emit `PetitionRegistered`. The signing window MUST satisfy `close_at_block - registration_block <= 11.5 days * BLOCKS_PER_DAY`. Organizer MUST select an `R` that has been published on RI for at least 30 days. Each `class_thresholds[i]` MUST be at least 1; a zero threshold would collapse the resolution SNARK's per-class predicate to `true` regardless of participation and would zero the bounty floor. @@ -107,8 +107,10 @@ The signing window MUST satisfy `close_at_block - registration_block <= 11.5 day #### Dispute -1. Disputant submits `dispute(petition_id, batch_index, position_i, position_j, violation_type, opening_proofs)`. `position_j` is `None` for violation `0x01` and `Some(j)` for `0x02` and `0x03`. The record content at each position MUST be derived by the Registry from `opening_proofs` rather than submitted separately. -2. Registry MUST validate openings via the `0x0A` precompile against `batch_versioned_hash`, MUST derive the record content for `position_i` (and `position_j` where applicable) from the openings, MUST apply the violation predicate against that derived content, MUST set the BatchRecord state at `batch_index` to `Repudiated`, MUST set every BatchRecord at index `> batch_index` whose state is `Active` to `Repudiated` (those records' `prior_running_root` is no longer canonical), MUST advance `next_batch_index` to `batch_index`, MUST roll back `running_root`, `identity_tag_set_root`, and `leaf_count` to the values held by the immediately preceding active batch (or the initial empty-IMT state if no such predecessor exists), and MUST emit `BatchRepudiated`. +1. Disputant submits `dispute(petition_id, batch_index, position_i, position_j, violation_type, opening_proofs)` at block `< close_at_block + 14 days`. `position_j` is `None` for violation `0x01` and `Some(j)` for `0x02` and `0x03`. The record content at each position MUST be derived by the Registry from `opening_proofs` rather than submitted separately. Registry MUST reject dispute submissions at block `>= close_at_block + 14 days`; the dispute window upper bound matches the block at which `resolve` opens, so a late dispute cannot invalidate a Resolver's prior-state binding mid-flight. +2. Registry MUST validate openings via the `0x0A` precompile against `batch_versioned_hash`, MUST derive the record content for `position_i` (and `position_j` where applicable) from the openings, MUST apply the violation predicate against that derived content, MUST set the BatchRecord state at `batch_index` to `Repudiated`, MUST set every BatchRecord at index `> batch_index` whose state is `Active` to `Repudiated` (those records' `prior_running_root` is no longer canonical), MUST roll back `running_root`, `identity_tag_set_root`, and `leaf_count` to the values held by the immediately preceding active batch (or the initial empty-IMT state if no such predecessor exists), and MUST emit `BatchRepudiated`. + +`batch_index` in `BatchPublished` events and in subsequent `dispute` calls is the storage position of the BatchRecord. Storage positions are assigned monotonically by append and never decrease, even across rollbacks. Repudiated BatchRecords remain at their original storage positions and are rejected by the `Active`-state precondition on `dispute`. Violation types: @@ -126,7 +128,7 @@ Violation types: For batches whose `submitted_at_block` precedes resolution by more than 4096 epochs, the EIP-4844 blob is no longer retained on the consensus layer and Resolver MUST obtain its payload from a voluntary blob archive. The batch SNARK commitments on L1 still bind blob contents to `batch_versioned_hash`, so any archive copy is verifiable against the on-chain record. -After `close_at_block + 14 days` with no valid resolution, any party MAY call `markUnresolved(petition_id)`. The Registry MUST refund the bounty (less a gas rebate to the caller, capped at 1% of the total bounty so a caller cannot starve the Organizer of their refund) to the Organizer, MUST replace `running_root` with the tombstone marker, MUST transition the petition state to `Unresolved`, and MUST emit `PetitionUnresolved`. This call is only permitted when the petition's state is `DisputeWindow`; the 14-day timer makes any earlier state unreachable. +After `close_at_block + 14 days + 1 hour` with no valid resolution, any party MAY call `markUnresolved(petition_id)`. The Registry MUST refund the bounty (less a gas rebate to the caller, capped at 1% of the total bounty so a caller cannot starve the Organizer of their refund) to the Organizer, MUST replace `running_root` with the tombstone marker, MUST transition the petition state to `Unresolved`, and MUST emit `PetitionUnresolved`. This call is only permitted when the petition's state is `DisputeWindow`; the 14-day timer makes any earlier state unreachable. The 1-hour grace after the resolution deadline gives a Resolver exclusive access to the bounty so a `markUnresolved` caller cannot tombstone the petition in the same block where a valid resolution first becomes possible. ### Data Structures @@ -179,7 +181,6 @@ PetitionRecord: running_root bytes32 identity_tag_set_root bytes32 leaf_count uint64 - next_batch_index uint32 resolution_proof bytes b bool b_per_class bool[] diff --git a/pocs/civic-participation/resilient-civic-participation/contracts/src/PetitionRegistry.sol b/pocs/civic-participation/resilient-civic-participation/contracts/src/PetitionRegistry.sol index 83f9b51..448e173 100644 --- a/pocs/civic-participation/resilient-civic-participation/contracts/src/PetitionRegistry.sol +++ b/pocs/civic-participation/resilient-civic-participation/contracts/src/PetitionRegistry.sol @@ -38,7 +38,6 @@ contract PetitionRegistry is IPetitionRegistry { bytes32 runningRoot; bytes32 identityTagSetRoot; uint64 leafCount; - uint32 nextBatchIndex; bool b; bool[] bPerClass; PetitionState state; @@ -77,6 +76,11 @@ contract PetitionRegistry is IPetitionRegistry { uint64 public constant COOLDOWN_BLOCKS = 600; /// 12-second block assumption. 14 days. uint64 public constant RESOLUTION_DEADLINE_BLOCKS = 100_800; + /// 12-second block assumption. 1 hour. Gives a Resolver exclusive + /// access to the bounty starting at `closeAtBlock + RESOLUTION_DEADLINE_BLOCKS` + /// before `markUnresolved` becomes callable, removing the same-block + /// race between `resolve` and `markUnresolved`. + uint64 public constant MARK_UNRESOLVED_GRACE_BLOCKS = 300; /// 12-second block assumption. 11.5 days. uint64 public constant MAX_SIGNING_WINDOW_BLOCKS = 82_800; uint32 public constant BATCH_SIZE_MAX = 6; @@ -114,6 +118,7 @@ contract PetitionRegistry is IPetitionRegistry { error BlobHashMismatch(); error AlreadyResolved(); error TooEarly(); + error DisputeWindowClosed(); error AlphaOutOfBounds(); error PaymentFailed(); error ViolationFalse(); @@ -154,11 +159,19 @@ contract PetitionRegistry is IPetitionRegistry { constructor(InitArgs memory args) { // Reject zero-address and EOA verifiers/token. - if (address(args.batchVerifier) == address(0)) revert VerifierAddressInvalid(); - if (address(args.resolutionVerifier) == address(0)) revert VerifierAddressInvalid(); + if (address(args.batchVerifier) == address(0)) { + revert VerifierAddressInvalid(); + } + if (address(args.resolutionVerifier) == address(0)) { + revert VerifierAddressInvalid(); + } if (args.bountyToken == address(0)) revert VerifierAddressInvalid(); - if (address(args.batchVerifier).code.length == 0) revert VerifierAddressInvalid(); - if (address(args.resolutionVerifier).code.length == 0) revert VerifierAddressInvalid(); + if (address(args.batchVerifier).code.length == 0) { + revert VerifierAddressInvalid(); + } + if (address(args.resolutionVerifier).code.length == 0) { + revert VerifierAddressInvalid(); + } if (args.bountyToken.code.length == 0) revert VerifierAddressInvalid(); batchVerifier = args.batchVerifier; @@ -230,15 +243,21 @@ contract PetitionRegistry is IPetitionRegistry { function _validateParams(PetitionParams calldata params) internal view { if (params.closeAtBlock <= block.number) revert InvalidPetition(); - if (params.closeAtBlock - block.number > MAX_SIGNING_WINDOW_BLOCKS) revert SigningWindowTooLong(); + if (params.closeAtBlock - block.number > MAX_SIGNING_WINDOW_BLOCKS) { + revert SigningWindowTooLong(); + } if (params.classIndex >= attrCount) revert InvalidPetition(); uint256 n = params.classSet.length; - if (n == 0 || n != params.classThresholds.length) revert ClassSetInvalid(); + if (n == 0 || n != params.classThresholds.length) { + revert ClassSetInvalid(); + } // Bound class_set size at the resolution circuit's CLASS_MAX. if (n > 16) revert ClassSetInvalid(); for (uint256 i = 1; i < n; i++) { - if (params.classSet[i] <= params.classSet[i - 1]) revert ClassSetInvalid(); + if (params.classSet[i] <= params.classSet[i - 1]) { + revert ClassSetInvalid(); + } } uint256 sumThresholds = 0; for (uint256 i = 0; i < n; i++) { @@ -533,7 +552,9 @@ contract PetitionRegistry is IPetitionRegistry { bytes calldata kzgProofs ) external { _preflightBatch(pi); - if (!batchVerifier.verify(batchProof, _batchPublicInputs(pi))) revert ProofRejected(); + if (!batchVerifier.verify(batchProof, _batchPublicInputs(pi))) { + revert ProofRejected(); + } _verifyConstraint8Openings(pi, kzgCommitment, kzgProofs); _commitBatch(pi); } @@ -542,29 +563,75 @@ contract PetitionRegistry is IPetitionRegistry { // so the canonical eval point at index `k` is `omega^{br(k, 12)}` // where `omega` is the primitive 4096th root of unity in BLS12-381 function _kzgEvalPoint(uint256 k) internal pure returns (bytes32) { - if (k == 0) return bytes32(uint256(0x0000000000000000000000000000000000000000000000000000000000000001)); - if (k == 1) return bytes32(uint256(0x73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000000)); - if (k == 2) return bytes32(uint256(0x00000000000000008d51ccce760304d0ec030002760300000001000000000000)); - if (k == 3) return bytes32(uint256(0x73eda753299d7d47a5e80b39939ed33467baa40089fb5bfefffeffff00000001)); - if (k == 4) return bytes32(uint256(0x345766f603fa66e78c0625cd70d77ce2b38b21c28713b7007228fd3397743f7a)); - if (k == 5) return bytes32(uint256(0x3f96405d25a31660a733b23a98ca5b22a032824078eaa4fe8dd702cb688bc087)); - if (k == 6) return bytes32(uint256(0x1333b22e5ce11044babc5affca86bf658e74903694b04fd86037fe81ae99502e)); - if (k == 7) return bytes32(uint256(0x60b9f524ccbc6d03787d7d083f1b189fc54913cc6b4e0c269fc8017d5166afd3)); - if (k == 8) return bytes32(uint256(0x20b1ce9140267af9dd1c0af834cec32c17beb312f20b6f7653ea61d87742bcce)); - if (k == 9) return bytes32(uint256(0x533bd8c1e977024e561dcd0fd4d314d93bfef0f00df2ec88ac159e2688bd4333)); - if (k == 10) return bytes32(uint256(0x4f2c596e753e4fcc6e92a9c460afca4a1ef4e672ebc1e1bb95df4b360411fe73)); - if (k == 11) return bytes32(uint256(0x24c14de4b45f2d7bc4a72e43a8f20dbb34c8bd90143c7a436a20b4c8fbee018e)); - if (k == 12) return bytes32(uint256(0x1edc919ec91f38ac5ccd4631f16edba4967a6b6cfb0faca4807b811a823f728d)); - if (k == 13) return bytes32(uint256(0x551115b4607e449bd66c91d61832fc60bd43389604eeaf5a7f847ee47dc08d74)); - if (k == 14) return bytes32(uint256(0x38c7f2dd7e0c63fccabf643eda8951f257bc96af334c36bca1abb31fb37786b9)); - if (k == 15) return bytes32(uint256(0x3b25b475ab91194b687a73c92f188612fc010d53ccb225425e544cdf4c887948)); - if (k == 16) return bytes32(uint256(0x50e0903a157988bab4bcd40e22f55448bf6e88fb4c38fb8a360c60997369df4e)); - if (k == 17) return bytes32(uint256(0x230d17191423f48d7e7d03f9e6ac83bc944f1b07b3c56074c9f39f658c9620b3)); - if (k == 18) return bytes32(uint256(0x65f6c5837cb5fca206050b5832d1099726bc7f62d13a6e1c3ec50c9031a36ca3)); - if (k == 19) return bytes32(uint256(0x0df6e1cface780a62d34ccafd6d0ce6e2d0124a02ec3ede2c13af36ece5c935e)); - if (k == 20) return bytes32(uint256(0x2c7e0457c83a7d9c5aea51f540eb0c04963dc46688b5e11768cc0c58459f155b)); - if (k == 21) return bytes32(uint256(0x476fa2fb6162ffabd84f8612c8b6cc00bd7fdf9c77487ae79733f3a6ba60eaa6)); - if (k == 22) return bytes32(uint256(0x5303da18a9d30564a8f0cfd2438f018c01e943612401899720d4ed194fccfeb9)); + if (k == 0) { + return bytes32(uint256(0x0000000000000000000000000000000000000000000000000000000000000001)); + } + if (k == 1) { + return bytes32(uint256(0x73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000000)); + } + if (k == 2) { + return bytes32(uint256(0x00000000000000008d51ccce760304d0ec030002760300000001000000000000)); + } + if (k == 3) { + return bytes32(uint256(0x73eda753299d7d47a5e80b39939ed33467baa40089fb5bfefffeffff00000001)); + } + if (k == 4) { + return bytes32(uint256(0x345766f603fa66e78c0625cd70d77ce2b38b21c28713b7007228fd3397743f7a)); + } + if (k == 5) { + return bytes32(uint256(0x3f96405d25a31660a733b23a98ca5b22a032824078eaa4fe8dd702cb688bc087)); + } + if (k == 6) { + return bytes32(uint256(0x1333b22e5ce11044babc5affca86bf658e74903694b04fd86037fe81ae99502e)); + } + if (k == 7) { + return bytes32(uint256(0x60b9f524ccbc6d03787d7d083f1b189fc54913cc6b4e0c269fc8017d5166afd3)); + } + if (k == 8) { + return bytes32(uint256(0x20b1ce9140267af9dd1c0af834cec32c17beb312f20b6f7653ea61d87742bcce)); + } + if (k == 9) { + return bytes32(uint256(0x533bd8c1e977024e561dcd0fd4d314d93bfef0f00df2ec88ac159e2688bd4333)); + } + if (k == 10) { + return bytes32(uint256(0x4f2c596e753e4fcc6e92a9c460afca4a1ef4e672ebc1e1bb95df4b360411fe73)); + } + if (k == 11) { + return bytes32(uint256(0x24c14de4b45f2d7bc4a72e43a8f20dbb34c8bd90143c7a436a20b4c8fbee018e)); + } + if (k == 12) { + return bytes32(uint256(0x1edc919ec91f38ac5ccd4631f16edba4967a6b6cfb0faca4807b811a823f728d)); + } + if (k == 13) { + return bytes32(uint256(0x551115b4607e449bd66c91d61832fc60bd43389604eeaf5a7f847ee47dc08d74)); + } + if (k == 14) { + return bytes32(uint256(0x38c7f2dd7e0c63fccabf643eda8951f257bc96af334c36bca1abb31fb37786b9)); + } + if (k == 15) { + return bytes32(uint256(0x3b25b475ab91194b687a73c92f188612fc010d53ccb225425e544cdf4c887948)); + } + if (k == 16) { + return bytes32(uint256(0x50e0903a157988bab4bcd40e22f55448bf6e88fb4c38fb8a360c60997369df4e)); + } + if (k == 17) { + return bytes32(uint256(0x230d17191423f48d7e7d03f9e6ac83bc944f1b07b3c56074c9f39f658c9620b3)); + } + if (k == 18) { + return bytes32(uint256(0x65f6c5837cb5fca206050b5832d1099726bc7f62d13a6e1c3ec50c9031a36ca3)); + } + if (k == 19) { + return bytes32(uint256(0x0df6e1cface780a62d34ccafd6d0ce6e2d0124a02ec3ede2c13af36ece5c935e)); + } + if (k == 20) { + return bytes32(uint256(0x2c7e0457c83a7d9c5aea51f540eb0c04963dc46688b5e11768cc0c58459f155b)); + } + if (k == 21) { + return bytes32(uint256(0x476fa2fb6162ffabd84f8612c8b6cc00bd7fdf9c77487ae79733f3a6ba60eaa6)); + } + if (k == 22) { + return bytes32(uint256(0x5303da18a9d30564a8f0cfd2438f018c01e943612401899720d4ed194fccfeb9)); + } return bytes32(uint256(0x20e9cd3a7fca77e38a490835c612d67951d460a1dbfcd267df2b12e5b0330148)); } @@ -590,16 +657,24 @@ contract PetitionRegistry is IPetitionRegistry { // batch circuit additionally enforces `batch_size == BATCH_SIZE_MAX` // (see circuits/batch/src/main.nr); production removes the circuit-side // equality and accepts the full SPEC range here. - if (pi.batchSize == 0 || pi.batchSize > BATCH_SIZE_MAX) revert BatchSizeOutOfRange(); - if (pi.newLeafCount != pi.priorLeafCount + pi.batchSize) revert InvalidBatch(); - if (pi.signerVkHash != pinnedSignerVkHash) revert SignerVkHashMismatch(); + if (pi.batchSize == 0 || pi.batchSize > BATCH_SIZE_MAX) { + revert BatchSizeOutOfRange(); + } + if (pi.newLeafCount != pi.priorLeafCount + pi.batchSize) { + revert InvalidBatch(); + } + if (pi.signerVkHash != pinnedSignerVkHash) { + revert SignerVkHashMismatch(); + } _checkPriorState(rec, pi); if (blobhash(0) != pi.batchVersionedHash) revert BlobHashMismatch(); } function _checkPriorState(PetitionRecord storage rec, BatchPublicInputs calldata pi) internal view { if (rec.runningRoot != pi.priorRunningRoot) revert PriorStateMismatch(); - if (rec.identityTagSetRoot != pi.priorIdentityTagSetRoot) revert PriorStateMismatch(); + if (rec.identityTagSetRoot != pi.priorIdentityTagSetRoot) { + revert PriorStateMismatch(); + } if (rec.leafCount != pi.priorLeafCount) revert PriorStateMismatch(); if (rec.rRoot != pi.rRoot) revert PriorStateMismatch(); if (rec.predicateHash != pi.predicateHash) revert PriorStateMismatch(); @@ -609,7 +684,7 @@ contract PetitionRegistry is IPetitionRegistry { function _commitBatch(BatchPublicInputs calldata pi) internal { PetitionRecord storage rec = petitions[pi.petitionId]; - uint32 batchIndex = rec.nextBatchIndex; + uint32 batchIndex = uint32(batches[pi.petitionId].length); batches[pi.petitionId].push( BatchRecord({ batchVersionedHash: pi.batchVersionedHash, @@ -627,7 +702,6 @@ contract PetitionRegistry is IPetitionRegistry { rec.runningRoot = pi.newRunningRoot; rec.identityTagSetRoot = pi.newIdentityTagSetRoot; rec.leafCount = pi.newLeafCount; - rec.nextBatchIndex = batchIndex + 1; emit BatchPublished( pi.petitionId, batchIndex, @@ -671,7 +745,9 @@ contract PetitionRegistry is IPetitionRegistry { PetitionRecord storage rec = petitions[petitionId]; _advanceStateOnRead(rec); if (rec.state != PetitionState.DisputeWindow) revert InvalidState(); - if (block.number < uint256(rec.closeAtBlock) + RESOLUTION_DEADLINE_BLOCKS) revert TooEarly(); + if (block.number < uint256(rec.closeAtBlock) + RESOLUTION_DEADLINE_BLOCKS + MARK_UNRESOLVED_GRACE_BLOCKS) { + revert TooEarly(); + } uint256 rebateCap = (rec.bounty * uint256(GAS_REBATE_BPS)) / 10_000; uint256 estimatedGasCost = MARK_UNRESOLVED_GAS_ESTIMATE * tx.gasprice; @@ -715,7 +791,9 @@ contract PetitionRegistry is IPetitionRegistry { DisputeParams memory dp = DisputeParams(petitionId, batchIndex, positionI, positionJ, violationType); _preflightDispute(dp, kzgCommitment); _verifyOpenings(dp, kzgCommitment, openingsBlob, proofsBlob); - if (!_applyViolationPredicate(dp, openingsBlob)) revert ViolationFalse(); + if (!_applyViolationPredicate(dp, openingsBlob)) { + revert ViolationFalse(); + } _cascadeRepudiation(dp); } @@ -724,7 +802,15 @@ contract PetitionRegistry is IPetitionRegistry { _advanceStateOnRead(rec); // SPEC line 107: dispute only during DisputeWindow. if (rec.state != PetitionState.DisputeWindow) revert InvalidState(); - if (dp.batchIndex >= batches[dp.petitionId].length) revert InvalidBatch(); + // Dispute window upper bound: disputes close when resolution opens + // so a late dispute cannot invalidate a Resolver's prior-state + // binding mid-flight. + if (block.number >= uint256(rec.closeAtBlock) + RESOLUTION_DEADLINE_BLOCKS) { + revert DisputeWindowClosed(); + } + if (dp.batchIndex >= batches[dp.petitionId].length) { + revert InvalidBatch(); + } BatchRecord storage bat = batches[dp.petitionId][dp.batchIndex]; if (bat.state != BatchState.Active) revert InvalidBatch(); if (kzgCommitment.length != 48) revert InvalidBatch(); @@ -742,7 +828,9 @@ contract PetitionRegistry is IPetitionRegistry { bytes32 sha = sha256(kzgCommitment); bytes32 reconstructedVh = (sha & ~bytes32(uint256(0xff) << 248)) | bytes32(uint256(0x01) << 248); - if (reconstructedVh != bat.batchVersionedHash) revert BlobHashMismatch(); + if (reconstructedVh != bat.batchVersionedHash) { + revert BlobHashMismatch(); + } } /// y-only openings; z is derived from positions inside the contract. @@ -823,14 +911,15 @@ contract PetitionRegistry is IPetitionRegistry { rec.runningRoot = newRunning; rec.identityTagSetRoot = newIdtag; rec.leafCount = newLeafCount; - rec.nextBatchIndex = dp.batchIndex; emit BatchRepudiated(dp.petitionId, dp.batchIndex, newRunning, newIdtag, newLeafCount); } // ---------- Governance ---------- function updateAlpha(uint64 newAlpha) external onlyGovernance { - if (newAlpha < alphaMin || newAlpha > alphaMax) revert AlphaOutOfBounds(); + if (newAlpha < alphaMin || newAlpha > alphaMax) { + revert AlphaOutOfBounds(); + } emit AlphaUpdated(alpha, newAlpha); alpha = newAlpha; } @@ -994,12 +1083,16 @@ contract PetitionRegistry is IPetitionRegistry { function _safeTransfer(address token, address to, uint256 amount) internal { (bool ok, bytes memory ret) = token.call(abi.encodeWithSignature("transfer(address,uint256)", to, amount)); - if (!ok || (ret.length != 0 && !abi.decode(ret, (bool)))) revert PaymentFailed(); + if (!ok || (ret.length != 0 && !abi.decode(ret, (bool)))) { + revert PaymentFailed(); + } } function _safeTransferFrom(address token, address from, address to, uint256 amount) internal { (bool ok, bytes memory ret) = token.call(abi.encodeWithSignature("transferFrom(address,address,uint256)", from, to, amount)); - if (!ok || (ret.length != 0 && !abi.decode(ret, (bool)))) revert PaymentFailed(); + if (!ok || (ret.length != 0 && !abi.decode(ret, (bool)))) { + revert PaymentFailed(); + } } } diff --git a/pocs/civic-participation/resilient-civic-participation/contracts/test/PetitionRegistry.t.sol b/pocs/civic-participation/resilient-civic-participation/contracts/test/PetitionRegistry.t.sol index 94f6e01..d16f860 100644 --- a/pocs/civic-participation/resilient-civic-participation/contracts/test/PetitionRegistry.t.sol +++ b/pocs/civic-participation/resilient-civic-participation/contracts/test/PetitionRegistry.t.sol @@ -31,6 +31,7 @@ contract PetitionRegistryTest is Test { uint64 internal constant BATCH_SIZE_MAX = 6; uint64 internal constant COOLDOWN_BLOCKS = 600; uint64 internal constant RESOLUTION_DEADLINE_BLOCKS = 100_800; + uint64 internal constant MARK_UNRESOLVED_GRACE_BLOCKS = 300; uint64 internal constant MAX_SIGNING_WINDOW_BLOCKS = 82_800; function setUp() public { @@ -502,7 +503,7 @@ contract PetitionRegistryTest is Test { function test_MarkUnresolved_Succeeds() public { (bytes32 petitionId, IPetitionRegistry.PetitionParams memory p) = _registerDefault(); _publishBatch(petitionId, p); - vm.roll(p.closeAtBlock + RESOLUTION_DEADLINE_BLOCKS + 1); + vm.roll(p.closeAtBlock + RESOLUTION_DEADLINE_BLOCKS + MARK_UNRESOLVED_GRACE_BLOCKS); uint256 organizerPre = token.balanceOf(organizer); uint256 callerPre = token.balanceOf(resolver_); @@ -545,6 +546,106 @@ contract PetitionRegistryTest is Test { registry.markUnresolved(petitionId); } + function test_MarkUnresolved_RevertsInGraceWindow() public { + (bytes32 petitionId, IPetitionRegistry.PetitionParams memory p) = _registerDefault(); + _publishBatch(petitionId, p); + + // At the resolution deadline: resolve opens, markUnresolved is + // still gated for the duration of the grace window. + vm.roll(p.closeAtBlock + RESOLUTION_DEADLINE_BLOCKS); + vm.prank(resolver_); + vm.expectRevert(PetitionRegistry.TooEarly.selector); + registry.markUnresolved(petitionId); + + // Resolve succeeds at the same block, confirming Resolvers get + // exclusive access during the grace window. + IPetitionRegistry.ResolutionPublicInputs memory rpi = _resolutionPi(true); + vm.prank(resolver_); + registry.resolve(petitionId, rpi, hex"01"); + } + + function test_MarkUnresolved_RevertsOneBlockBeforeGraceEnds() public { + (bytes32 petitionId, IPetitionRegistry.PetitionParams memory p) = _registerDefault(); + _publishBatch(petitionId, p); + + // Last block where markUnresolved is still gated. + vm.roll(p.closeAtBlock + RESOLUTION_DEADLINE_BLOCKS + MARK_UNRESOLVED_GRACE_BLOCKS - 1); + vm.prank(resolver_); + vm.expectRevert(PetitionRegistry.TooEarly.selector); + registry.markUnresolved(petitionId); + } + + function test_Dispute_CascadesAndPreservesStorageLength() public { + (bytes32 petitionId, IPetitionRegistry.PetitionParams memory p) = _registerDefault(); + (bytes memory kzgCommit, bytes memory openings, bytes memory proofs) = _publishDisputableBatch(petitionId, p); + + vm.roll(p.closeAtBlock + COOLDOWN_BLOCKS); + + vm.expectEmit(true, true, false, true, address(registry)); + emit IPetitionRegistry.BatchRepudiated(petitionId, 0, EMPTY_IMT_ROOT, EMPTY_IMT_ROOT, 0); + + registry.dispute(petitionId, 0, 0, 0, 0x01, kzgCommit, openings, proofs); + + // Storage length preserved; the repudiated batch stays at its + // original position. State rolls back to the empty-IMT baseline + // because there is no predecessor. + assertEq(registry.getBatchCount(petitionId), 1, "storage truncated after dispute"); + PetitionRegistry.BatchRecord memory bat = registry.getBatch(petitionId, 0); + assertEq(uint8(bat.state), uint8(IPetitionRegistry.BatchState.Repudiated), "state != Repudiated"); + PetitionRegistry.PetitionRecord memory rec = registry.getPetition(petitionId); + assertEq(rec.runningRoot, EMPTY_IMT_ROOT, "runningRoot not rolled back"); + assertEq(rec.leafCount, 0, "leafCount not rolled back"); + } + + function test_Dispute_SucceedsAtUpperBoundMinusOne() public { + (bytes32 petitionId, IPetitionRegistry.PetitionParams memory p) = _registerDefault(); + (bytes memory kzgCommit, bytes memory openings, bytes memory proofs) = _publishDisputableBatch(petitionId, p); + + // Last block where dispute is valid. + vm.roll(p.closeAtBlock + RESOLUTION_DEADLINE_BLOCKS - 1); + registry.dispute(petitionId, 0, 0, 0, 0x01, kzgCommit, openings, proofs); + + PetitionRegistry.BatchRecord memory bat = registry.getBatch(petitionId, 0); + assertEq(uint8(bat.state), uint8(IPetitionRegistry.BatchState.Repudiated), "state != Repudiated"); + } + + function test_Dispute_RevertsAtUpperBound() public { + (bytes32 petitionId, IPetitionRegistry.PetitionParams memory p) = _registerDefault(); + (bytes memory kzgCommit, bytes memory openings, bytes memory proofs) = _publishDisputableBatch(petitionId, p); + + // First block where dispute is closed; resolve opens at the same block. + vm.roll(p.closeAtBlock + RESOLUTION_DEADLINE_BLOCKS); + vm.expectRevert(PetitionRegistry.DisputeWindowClosed.selector); + registry.dispute(petitionId, 0, 0, 0, 0x01, kzgCommit, openings, proofs); + } + + /// Helper. Publishes one batch with a vh derived from sha256(kzgCommit) + /// so a follow-up dispute against batch 0 can pass the reconstructedVh + /// check. Returns the kzgCommit, all-zero openings, and all-zero proofs + /// shaped for a 0x01 (class-tag-out-of-set) dispute where the decoded + /// class_tag is 0 (not in classSet [826, 840]). + function _publishDisputableBatch(bytes32 petitionId, IPetitionRegistry.PetitionParams memory p) + internal + returns (bytes memory kzgCommit, bytes memory openings, bytes memory proofs) + { + kzgCommit = new bytes(48); + bytes32 vhBase = sha256(kzgCommit); + bytes32 vh = (vhBase & ~bytes32(uint256(0xff) << 248)) | bytes32(uint256(0x01) << 248); + + bytes32 newRoot = bytes32(uint256(0xabc1)); + bytes32 newIdtag = bytes32(uint256(0xabc2)); + bytes32[] memory blobs = new bytes32[](1); + blobs[0] = vh; + vm.blobhashes(blobs); + + IPetitionRegistry.BatchPublicInputs memory pi = _batchPi(petitionId, p, newRoot, newIdtag, vh); + vm.prank(relayer); + registry.publishBatch(pi, hex"00", new bytes(48), new bytes(48 * 24)); + + openings = new bytes(4 * 32); + proofs = new bytes(4 * 48); + } + function test_UpdateAlpha_Succeeds() public { vm.expectEmit(false, false, false, true, address(registry)); emit IPetitionRegistry.AlphaUpdated(1, 50); diff --git a/pocs/civic-participation/resilient-civic-participation/src/registry/core.rs b/pocs/civic-participation/resilient-civic-participation/src/registry/core.rs index 31c3eb7..efbcfae 100644 --- a/pocs/civic-participation/resilient-civic-participation/src/registry/core.rs +++ b/pocs/civic-participation/resilient-civic-participation/src/registry/core.rs @@ -192,7 +192,6 @@ where running_root: entry.record.running_root, identity_tag_set_root: entry.record.identity_tag_set_root, leaf_count: entry.record.leaf_count, - next_batch_index: entry.record.next_batch_index, }) } @@ -384,7 +383,6 @@ where running_root: self.empty_imt_root, identity_tag_set_root: self.empty_imt_root, leaf_count: 0, - next_batch_index: 0, resolution_proof: Vec::new(), b: false, b_per_class: Vec::new(), @@ -563,15 +561,14 @@ where entry.identity_tag_imt = scratch_identity_tag_imt; let new_rr_be = claimed_new_rr_be; let new_idt_be = claimed_new_idt_be; - let batch_index = entry.record.next_batch_index; - entry.record.running_root = new_rr_be; - entry.record.identity_tag_set_root = new_idt_be; - entry.record.leaf_count = new_lc_u; - entry.record.next_batch_index = batch_index.checked_add(1).ok_or_else(|| { + let batch_index = u32::try_from(entry.batches.len()).map_err(|_| { RegistryError::BadBatchProof(ProofError::Verification( - "next_batch_index overflow".into(), + "batch index overflow".into(), )) })?; + entry.record.running_root = new_rr_be; + entry.record.identity_tag_set_root = new_idt_be; + entry.record.leaf_count = new_lc_u; entry.batches.push(InternalBatch { record: BatchRecord { @@ -730,7 +727,6 @@ where entry.record.running_root = rb_rr; entry.record.identity_tag_set_root = rb_idt; entry.record.leaf_count = rb_lc; - entry.record.next_batch_index = dispute.batch_index; entry.running_imt = scratch_running_imt; entry.identity_tag_imt = scratch_identity_tag_imt; diff --git a/pocs/civic-participation/resilient-civic-participation/src/registry/types.rs b/pocs/civic-participation/resilient-civic-participation/src/registry/types.rs index fc792ce..f5865c6 100644 --- a/pocs/civic-participation/resilient-civic-participation/src/registry/types.rs +++ b/pocs/civic-participation/resilient-civic-participation/src/registry/types.rs @@ -113,5 +113,4 @@ pub struct PetitionStateView { pub running_root: Bytes32, pub identity_tag_set_root: Bytes32, pub leaf_count: u64, - pub next_batch_index: u32, } diff --git a/pocs/civic-participation/resilient-civic-participation/src/types.rs b/pocs/civic-participation/resilient-civic-participation/src/types.rs index 85dde6a..681ba89 100644 --- a/pocs/civic-participation/resilient-civic-participation/src/types.rs +++ b/pocs/civic-participation/resilient-civic-participation/src/types.rs @@ -158,7 +158,6 @@ pub struct PetitionRecord { pub running_root: Bytes32, pub identity_tag_set_root: Bytes32, pub leaf_count: u64, - pub next_batch_index: u32, pub resolution_proof: Vec, pub b: bool, pub b_per_class: Vec, From 625dfa443f1cb5c04647c63eae7b0e2d37578f59 Mon Sep 17 00:00:00 2001 From: rymnc <43716372+rymnc@users.noreply.github.com> Date: Mon, 25 May 2026 13:54:11 +0200 Subject: [PATCH 4/5] fix: address comment --- .../resilient-civic-participation/src/lib.rs | 5 + .../src/registry/core.rs | 9 ++ .../src/registry/error.rs | 4 + .../tests/lifecycle_boundaries.rs | 103 ++++++++++++++++++ 4 files changed, 121 insertions(+) create mode 100644 pocs/civic-participation/resilient-civic-participation/tests/lifecycle_boundaries.rs diff --git a/pocs/civic-participation/resilient-civic-participation/src/lib.rs b/pocs/civic-participation/resilient-civic-participation/src/lib.rs index 1669675..79e9984 100644 --- a/pocs/civic-participation/resilient-civic-participation/src/lib.rs +++ b/pocs/civic-participation/resilient-civic-participation/src/lib.rs @@ -45,6 +45,11 @@ pub const BLOCKS_PER_DAY: u64 = 24 * 60 * 60 / 12; pub const MAX_SIGNING_WINDOW_BLOCKS: u64 = 11 * BLOCKS_PER_DAY + BLOCKS_PER_DAY / 2; pub const COOLDOWN_BLOCKS: u64 = 2 * 60 * 60 / 12; pub const RESOLUTION_DEADLINE_BLOCKS: u64 = 14 * BLOCKS_PER_DAY; +/// 1-hour grace after `RESOLUTION_DEADLINE_BLOCKS` during which only `resolve` +/// is callable; `mark_unresolved` becomes callable at +/// `close_at_block + RESOLUTION_DEADLINE_BLOCKS + MARK_UNRESOLVED_GRACE_BLOCKS`. +/// Must match `MARK_UNRESOLVED_GRACE_BLOCKS` in `PetitionRegistry.sol`. +pub const MARK_UNRESOLVED_GRACE_BLOCKS: u64 = 300; pub const MIN_R_AGE_BLOCKS: u64 = 30 * BLOCKS_PER_DAY; pub const IMT_DEPTH: usize = 24; pub const RESOLUTION_CLASS_MAX: usize = 16; diff --git a/pocs/civic-participation/resilient-civic-participation/src/registry/core.rs b/pocs/civic-participation/resilient-civic-participation/src/registry/core.rs index efbcfae..80a9154 100644 --- a/pocs/civic-participation/resilient-civic-participation/src/registry/core.rs +++ b/pocs/civic-participation/resilient-civic-participation/src/registry/core.rs @@ -12,6 +12,7 @@ use crate::{ BATCH_SIZE_MAX, COOLDOWN_BLOCKS, FSRT_SLOT_COUNT, + MARK_UNRESOLVED_GRACE_BLOCKS, MIN_R_AGE_BLOCKS, RESOLUTION_DEADLINE_BLOCKS, blob::compute_batch_versioned_hash, @@ -606,6 +607,13 @@ where if state != PetitionState::DisputeWindow { return Err(RegistryError::BadState(state, "dispute")); } + let close_at_block = self.petition(&dispute.petition_id)?.record.close_at_block; + let dispute_close = close_at_block + .checked_add(RESOLUTION_DEADLINE_BLOCKS) + .ok_or(RegistryError::BadState(state, "dispute"))?; + if self.now() >= dispute_close { + return Err(RegistryError::DisputeWindowClosed); + } // Pre-fetch `batch_versioned_hash` without a mut borrow so verification runs uncontended. let vh = { let entry = self.petition(&dispute.petition_id)?; @@ -829,6 +837,7 @@ where .record .close_at_block .checked_add(RESOLUTION_DEADLINE_BLOCKS) + .and_then(|d| d.checked_add(MARK_UNRESOLVED_GRACE_BLOCKS)) .ok_or(RegistryError::BadState(state, "mark_unresolved"))?; if now < deadline { return Err(RegistryError::BadState(state, "mark_unresolved")); diff --git a/pocs/civic-participation/resilient-civic-participation/src/registry/error.rs b/pocs/civic-participation/resilient-civic-participation/src/registry/error.rs index 8ddb67d..b1741b2 100644 --- a/pocs/civic-participation/resilient-civic-participation/src/registry/error.rs +++ b/pocs/civic-participation/resilient-civic-participation/src/registry/error.rs @@ -69,6 +69,10 @@ pub enum RegistryError { DisputeUnknownBatch(u32), #[error("dispute references already-repudiated batch_index {0}")] DisputeBatchAlreadyRepudiated(u32), + #[error( + "dispute window closed once resolution opens at close_at_block + RESOLUTION_DEADLINE_BLOCKS" + )] + DisputeWindowClosed, #[error("imt error: {0}")] Imt(#[from] ImtError), #[error("blob error: {0}")] diff --git a/pocs/civic-participation/resilient-civic-participation/tests/lifecycle_boundaries.rs b/pocs/civic-participation/resilient-civic-participation/tests/lifecycle_boundaries.rs new file mode 100644 index 0000000..fd12512 --- /dev/null +++ b/pocs/civic-participation/resilient-civic-participation/tests/lifecycle_boundaries.rs @@ -0,0 +1,103 @@ +//! Regression tests for the dispute-window upper bound and the +//! `mark_unresolved` grace period; the Rust state machine must mirror +//! `PetitionRegistry.sol` so that `mark_unresolved` cannot tombstone +//! a petition in the same block where a valid `resolve` first becomes +//! callable. + +mod common; + +use common::*; +use resilient_civic_participation::{ + MARK_UNRESOLVED_GRACE_BLOCKS, + RESOLUTION_DEADLINE_BLOCKS, + registry::error::RegistryError, + types::{ + Dispute, + U256Be, + ViolationType, + }, +}; + +const SIGNING_WINDOW: u64 = 100; + +fn register_for_lifecycle(harness: &mut Harness) -> (RegisteredFixture, u64) { + let class_tag = 826u16; + let _signer = enroll_signer(harness, 30, class_tag, [0xa1u8; 32]); + advance_past_ri_age_window(harness); + let predicate = class_only_predicate(ATTR_CLASS as u8, class_tag); + let mut salt = [0u8; 32]; + salt[31] = 0x42; + let close_at_block = harness.block() + SIGNING_WINDOW; + let fixture = register_petition( + harness, + [0xc0; 20], + predicate, + salt, + vec![class_tag], + vec![1], + ATTR_CLASS as u8, + U256Be::from_u64(1_000_000), + SIGNING_WINDOW, + ); + (fixture, close_at_block) +} + +#[test] +fn dispute_rejected_once_resolution_opens() { + let mut harness = Harness::new(); + let (fixture, close_at_block) = register_for_lifecycle(&mut harness); + + // Jump to the first block where resolve is callable; dispute MUST be closed. + let now_at = harness.block(); + harness.advance_blocks(close_at_block + RESOLUTION_DEADLINE_BLOCKS - now_at); + + let dispute = Dispute { + petition_id: fixture.event.petition_id, + batch_index: 0, + violation_type: ViolationType::ClassTagOutOfSet, + position_i: 0, + position_j: None, + openings: vec![], + }; + let err = harness + .registry + .dispute(dispute) + .expect_err("dispute past resolution-open must fail"); + assert!( + matches!(err, RegistryError::DisputeWindowClosed), + "expected DisputeWindowClosed, got {err:?}" + ); +} + +#[test] +fn mark_unresolved_requires_grace_after_resolution_deadline() { + let mut harness = Harness::new(); + let (fixture, close_at_block) = register_for_lifecycle(&mut harness); + let petition_id = fixture.event.petition_id; + let caller = [0xbe; 20]; + + // At `close_at_block + RESOLUTION_DEADLINE_BLOCKS` (resolve opens): + // mark_unresolved MUST still be blocked. + let now_at = harness.block(); + harness.advance_blocks(close_at_block + RESOLUTION_DEADLINE_BLOCKS - now_at); + let err = harness + .registry + .mark_unresolved(&petition_id, caller, 0) + .expect_err("mark_unresolved at resolve-open block must fail"); + assert!(matches!(err, RegistryError::BadState(_, _))); + + // One block before the grace expires; still blocked. + harness.advance_blocks(MARK_UNRESOLVED_GRACE_BLOCKS - 1); + let err = harness + .registry + .mark_unresolved(&petition_id, caller, 0) + .expect_err("mark_unresolved one block before grace must fail"); + assert!(matches!(err, RegistryError::BadState(_, _))); + + // Grace expires; mark_unresolved succeeds. + harness.advance_blocks(1); + harness + .registry + .mark_unresolved(&petition_id, caller, 0) + .expect("mark_unresolved at grace boundary must succeed"); +} From 771b210cad682b0a20c7ececc38e42d827616961 Mon Sep 17 00:00:00 2001 From: rymnc <43716372+rymnc@users.noreply.github.com> Date: Wed, 27 May 2026 15:20:25 +0200 Subject: [PATCH 5/5] fix: dispute clearing --- .../resilient-civic-participation/SPEC.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/pocs/civic-participation/resilient-civic-participation/SPEC.md b/pocs/civic-participation/resilient-civic-participation/SPEC.md index b1f5e76..1e64255 100644 --- a/pocs/civic-participation/resilient-civic-participation/SPEC.md +++ b/pocs/civic-participation/resilient-civic-participation/SPEC.md @@ -94,7 +94,7 @@ The signing window MUST satisfy `close_at_block - registration_block <= 11.5 day 2. `(v_slot, _) = Poseidon1Sponge.absorb(DOMAIN_FSRT_PRG, s_slot).squeeze(2)`; `class_tag = attr[class_index]`. 3. `nullifier = Poseidon1(DOMAIN_NULLIFIER, v_slot, petition_id, class_index, class_tag, identity_secret)`; `identity_tag = Poseidon1(DOMAIN_IDTAG, v_slot, petition_id)`. 4. Signer MUST build the signer SNARK and MUST submit it with `(nullifier, identity_tag, class_tag)` to a relayer. -5. After L1 finality of the carrying batch, signer MUST overwrite `s_curr` past `s_slot`, advance the caterpillar frontier, and set `t <- slot + 1`. The signer MUST journal this transition to fsync'd storage before the signing counts as complete. +5. After L1 finality of the carrying batch, signer MUST advance the caterpillar frontier and set `t <- slot + 1`, and MAY advance `s_curr` past `s_slot` for use in signing other petitions. Signer MUST retain `s_slot` and the Merkle path from `v_slot` to `chain_root` in `pending_retention` (see [Off-Chain Signer State](#off-chain-signer-state)) until block `close_at_block + 14 days`; at or after that block, the carrying batch is no longer rollbackable and the signer MUST overwrite the retained `s_slot` and Merkle path. Retention is required because a later in-window dispute against an earlier batch cascades into repudiation of every subsequent active batch (see [Dispute](#dispute)), and the signer needs `s_slot` plus the Merkle path to rebuild the signer SNARK for resubmission. Worst-case retention is bounded by 11.5 days (maximum signing window) + 14 days (dispute window) = 25.5 days from signing. The signer MUST journal each transition to fsync'd storage before the corresponding signing counts as complete. `class_tag` is exposed as a public input on the signer SNARK and is published per-record in the blob payload. This is intentional public outcome metadata. The cost is reflected in the signer-level unlinkability bound (see [Guarantees](#guarantees)), where `k` is restricted to RI signers whose `attr[class_index]` matches each petition's published `class_tag`. @@ -246,7 +246,7 @@ Cross-field binding: Batch SNARK constraint 8. Blob retention: 4096 epochs (EIP- #### Off-Chain Signer State -A signer MUST maintain the following local state (840B total): +A signer MUST maintain the following local state (840B fixed plus ~812B per in-flight petition in `pending_retention`): | Field | Size | Role | |-------|------|------| @@ -255,6 +255,7 @@ A signer MUST maintain the following local state (840B total): | `caterpillar` | 768B | Right-sibling frontier toward leaf `t` | | `chain_root` | 32B | Bound in `attr_hash` | | `attr_version` | 4B | Bound in `attr_hash` | +| `pending_retention` | ~812B per entry | Per in-flight petition: `(petition_id, slot, s_slot, merkle_path_to_chain_root[24], retain_until_block)`; each entry held until `retain_until_block = close_at_block + 14 days` then overwritten | `caterpillar` stores 24 BN254 scalars (32-byte big-endian); empty levels hold the empty-subtree Poseidon1 hash; frontier update is `O(log N)` per chain advance [Szydlo]. `attr_version` starts at `0` and increments on each re-enrollment posting a new `attr_hash` leaf. @@ -307,7 +308,7 @@ Implementations MUST embed these constants at compile time. The Noir circuits (` ### FSRT Chain -Depth-24 Poseidon1 Merkle tree over `N = 2^24` per-slot values from `(v_i, s_{i+1}) = Poseidon1Sponge.absorb(DOMAIN_FSRT_PRG, s_i).squeeze(2)` for `i in [0, N)`. `chain_root` binds into `attr_hash` at RI enrollment. After each finalised signing at slot `k`, the signer MUST, in order, derive `(_, s_{k+1}) = Poseidon1Sponge.absorb(DOMAIN_FSRT_PRG, s_k).squeeze(2)`, set `s_curr <- s_{k+1}` (overwriting `s_k` in place), call `caterpillar.advance(k)`, and set `t <- k + 1`; the transition MUST be journaled to fsync'd storage before it counts as final. `t` is monotone; the global slot counter `S` is bounded by `N - 1 = 2^24 - 1`. +Depth-24 Poseidon1 Merkle tree over `N = 2^24` per-slot values from `(v_i, s_{i+1}) = Poseidon1Sponge.absorb(DOMAIN_FSRT_PRG, s_i).squeeze(2)` for `i in [0, N)`. `chain_root` binds into `attr_hash` at RI enrollment. After each finalised signing at slot `k` for petition `X`, the signer MUST, in order: copy `(s_k, merkle_path_k_to_chain_root)` into `pending_retention` with retain-until block `close_at_block(X) + 14 days`; derive `(_, s_{k+1}) = Poseidon1Sponge.absorb(DOMAIN_FSRT_PRG, s_k).squeeze(2)`; set `s_curr <- s_{k+1}` (overwriting `s_k` in the main chain head while leaving the retained copy intact); call `caterpillar.advance(k)`; and set `t <- k + 1`. The transition MUST be journaled to fsync'd storage before it counts as final. At or after `close_at_block(X) + 14 days`, the signer MUST overwrite the retained `(s_k, merkle_path)` entry. `t` is monotone; the global slot counter `S` is bounded by `N - 1 = 2^24 - 1`. ### Signer SNARK @@ -376,18 +377,18 @@ Adversary capabilities: - Passive observation of L1 indefinitely, the blob carrier during the EIP-4844 retention window, and any voluntary blob archive thereafter. - Compelled key disclosure of any non-signer party (Organizer, Relayer, Resolver, Disputant, archiver). -- Compelled key disclosure or device compromise of a signer, yielding `(identity_secret, attr_vector, RI Merkle path, s_curr, t, caterpillar, chain_root)` before or after the ratchet for slot `s_slot`. +- Compelled key disclosure or device compromise of a signer, yielding `(identity_secret, attr_vector, RI Merkle path, s_curr, t, caterpillar, chain_root, pending_retention)` before or after the ratchet for slot `s_slot`. - Cross-petition correlation against any observable, including predicate-match intersections. - Sybil enrolment of multiple RI identities. - Absence of an honest disputant; published batches may go unchallenged for the duration of the dispute window. Honest-party assumptions: Poseidon1 sponge security and UltraHonk soundness; EIP-4844 blob commitment binding; L1 censorship-resistant inclusion and finality; permissionless Relayer entry such that Signers can resubmit on Relayer-side censorship; Sybil resistance from RI. -Out of scope: network transport anonymity beyond what Tor or an equivalent provides; real-time device compromise before the chain advances past `s_slot`; forensic recovery of overwritten storage on commodity media without `TRIM`. +Out of scope: network transport anonymity beyond what Tor or an equivalent provides; real-time device compromise during the `pending_retention` window for `s_slot`; forensic recovery of overwritten storage on commodity media without `TRIM`. ### Guarantees -- **Per-petition forward secrecy.** An adversary holding post-signing runtime state, `identity_secret`, `attr_vector`, the RI Merkle path, and the full blob and L1 archives recovers `v_{k'}`, `s_{k'}`, or any value computationally non-trivial in `v_{k'}` (including the slot-`k'` nullifier and identity tag) for any slot `k' < t` with advantage at most `(t - k') * eps_sponge`, under the Poseidon1-sponge PRG assumption. +- **Per-petition forward secrecy.** An adversary holding post-signing runtime state, `identity_secret`, `attr_vector`, the RI Merkle path, `pending_retention`, and the full blob and L1 archives recovers `v_{k'}`, `s_{k'}`, or any value computationally non-trivial in `v_{k'}` (including the slot-`k'` nullifier and identity tag) for any slot `k' < t` whose `pending_retention` entry has already been overwritten with advantage at most `(t - k') * eps_sponge`, under the Poseidon1-sponge PRG assumption. Slots with live `pending_retention` entries are recovered directly from that buffer. - **Signer-level unlinkability.** For petitions `X1`, `X2` and records `r_1 in batch_{X1}`, `r_2 in batch_{X2}`, the adversary's advantage in deciding "same signer" exceeds `1/k - negl` only when it holds at least one of `v_{slot(X1)}` or `v_{slot(X2)}`. `k` is the cardinality of Signers in `R` whose `attr_vector` satisfies both predicates and whose `attr[class_index]` matches each petition's published `class_tag`. The public per-record `class_tag` is what restricts `k` to class-matched signers rather than all signers matching both predicates. - **One signature per RI leaf per petition.** For petition `X` and RI leaf `L_RI`, at most one record in `running_root` derives from `L_RI` after batching and dispute resolution. - **Outcome verifiability.** A verifier holding L1 chain state, the SRS identified by `srs_hash`, and the Poseidon1 parameter set re-verifies the resolution SNARK, confirming `b` and `b_per_class`.