From 4029d496f94058c11b18cfe18ccff97cce5b9af8 Mon Sep 17 00:00:00 2001 From: ctrlc03 <93448202+ctrlc03@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:33:02 +0100 Subject: [PATCH 01/15] feat(crisp)!: encode one weight per option in each ballot Each ballot writes the weight of option o to message coefficient o, so the decrypted tally holds per-option totals. - circuits: decode each option weight from its coefficient, range-check it against the plaintext modulus, and bound the weight sum by the slot's voting power; regenerate fold key hashes and verifiers for both presets - CRISPProgram: read the plaintext modulus t from the round's BFV parameters; refuse credits >= t; limit CONSTANT rounds to (t - 1) / credits inputs; size the CUSTOM divisor from getPastTotalSupply; decodeTally returns the first numOptions coefficients - SDK: remove getMaxVoteValue, toBinary and getScaledBalance; add getPlaintextModulus and checkVoteWeights - server, crisp-utils: decode per-option totals; read the stored divisor for every CUSTOM round; the local census carries the round credits - dashboard: read tallies through CRISPProgram.decodeTally BREAKING CHANGE: deployed CRISPProgram instances and verifiers do not accept the new ballots. Redeploy CRISPProgram with the regenerated verifiers and release the SDK, client, server and crisp-utils together. CUSTOM-credit rounds need a votes token that implements getPastTotalSupply. --- agent/flow-trace/00_INDEX.md | 9 +- agent/flow-trace/04_DKG_AND_COMPUTATION.md | 75 ++- agent/invariants/01_PROTOCOL_ONCHAIN.md | 5 +- agent/invariants/02_CRYPTO_CIRCUITS.md | 43 +- docs/pages/CRISP/introduction.mdx | 16 +- docs/pages/CRISP/running-e3.mdx | 52 +- .../build/tutorials/custom-zk-circuits.mdx | 7 +- examples/CRISP/circuits/bin/crisp/src/main.nr | 5 +- .../circuits/bin/crisp_onchain/src/main.nr | 5 +- examples/CRISP/circuits/bin/fold/src/main.nr | 4 +- .../circuits/bin/fold_onchain/src/main.nr | 4 +- examples/CRISP/circuits/lib/src/constants.nr | 6 - examples/CRISP/circuits/lib/src/utils.nr | 363 +++++++------- examples/CRISP/crates/crisp-utils/src/lib.rs | 147 +++--- examples/CRISP/crates/evm_helpers/src/lib.rs | 35 +- .../contracts/CRISPProgram.sol | 327 ++++++++----- .../contracts/Mocks/MockInterfold.sol | 32 +- .../contracts/Mocks/MockVotesToken.sol | 7 +- .../contracts/Mocks/MockVotingToken.sol | 11 +- .../contracts/SelfRegistry.sol | 6 +- .../contracts/interfaces/IVotesToken.sol | 20 +- .../verifiers/CRISPOnchainVerifier.sol | 6 +- .../contracts/verifiers/CRISPVerifier.sol | 6 +- .../crisp-contracts/tests/census-mode.test.ts | 150 +++++- .../tests/crisp.contracts.test.ts | 5 +- .../tests/crisp.journal.test.ts | 3 +- .../tests/input-availability-flow.test.ts | 50 +- .../tests/interfold-binding.test.ts | 7 +- .../tests/onchain-census.test.ts | 61 +-- .../tests/self-registry.test.ts | 17 +- .../tests/tally.decoding.test.ts | 86 +++- examples/CRISP/packages/crisp-sdk/README.md | 34 +- .../CRISP/packages/crisp-sdk/src/constants.ts | 4 +- .../CRISP/packages/crisp-sdk/src/encoding.ts | 110 ++--- .../CRISP/packages/crisp-sdk/src/index.ts | 10 +- .../CRISP/packages/crisp-sdk/src/utils.ts | 37 +- examples/CRISP/packages/crisp-sdk/src/vote.ts | 13 +- .../packages/crisp-sdk/tests/vote.test.ts | 104 ++-- examples/CRISP/server/src/cli/commands.rs | 7 +- examples/CRISP/server/src/server/indexer.rs | 310 ++++++------ examples/CRISP/server/src/server/models.rs | 9 +- .../CRISP/server/src/server/routes/rounds.rs | 7 +- .../src/server/token_holders/etherscan.rs | 450 ++++-------------- packages/interfold-dashboard/src/lib/adapt.ts | 28 +- packages/interfold-dashboard/src/lib/e3.ts | 108 ++--- 45 files changed, 1387 insertions(+), 1414 deletions(-) diff --git a/agent/flow-trace/00_INDEX.md b/agent/flow-trace/00_INDEX.md index 43c452b0d8..ff58c13bbc 100644 --- a/agent/flow-trace/00_INDEX.md +++ b/agent/flow-trace/00_INDEX.md @@ -410,13 +410,14 @@ Findings in the ballot path of the reference app (`examples/CRISP`), not the pro | Finding | Status | Implemented behavior | | ---------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Ballot rules read only zero padding** | Fixed | `check_coefficient_values_with_balance` and `check_coefficient_zero` indexed `k1` as if it were `MAX_MSG_NON_ZERO_COEFFS` wide, while the circuit receives the full reversed BFV degree. Both read padding, so every vote passed any balance bound and a mask could carry an arbitrary payload. `ballot_layout` now derives the real offset. | -| **Coefficients outside the ballot are unconstrained** | Fixed | The whole plaintext is bound: binary inside an option segment, zero everywhere else, and zero everywhere for a mask. An unconstrained coefficient survives to the decrypted tally, so it was both a way to corrupt a slot and a channel a coercer could read a receipt from. | +| **Ballot rules read only zero padding** | Fixed | `check_coefficient_values_with_balance` and `check_coefficient_zero` indexed `k1` as if it were `MAX_MSG_NON_ZERO_COEFFS` wide, while the circuit receives the full reversed BFV degree. Both read padding, so every vote passed any balance bound and a mask could carry an arbitrary payload. Both now read option `o` at `k1[D - 1 - o]` of the full-degree polynomial. | +| **Coefficients outside the ballot are unconstrained** | Fixed | The whole plaintext is bound: one weight in `[0, t)` for each option, zero everywhere else, and zero everywhere for a mask. An unconstrained coefficient survives to the decrypted tally, so it was both a way to corrupt a slot and a channel a coercer could read a receipt from. | | **Re-votes are accepted and never tallied** | Fixed | The witness published `previous + new` while the circuit committed to `new`. One generator now publishes exactly what the circuit commits to on every branch, and the SDK exposes one commitment for the ballot digest. | | **Occupied-slot masks cannot be published** | Fixed | The client built the digest from the ballot commitment while the contract rebuilt it from the stored one, which differ for a mask over an occupied slot. `PreparedBallot.ctCommitment` is now the published ciphertext's commitment on every branch. | | **A valid input can freeze a slot** | Fixed | Publishing a real proof beside unusable bytes advanced the slot head to a commitment only the submitter could open, so nobody could mask that slot — and a slot that cannot be masked is one where every later input is provably its owner voting again. Inputs now name the entry they extend and the guest follows the chain. | | **A mask could erase the vote in its slot** | Fixed | The ballot circuits checked `published = ballot + addend` at one Fiat-Shamir point derived from three `pack` commitments, and `pack` commitments have second openings. A prover chose a parent opening that satisfied the one equation while it published its mask ballot alone. `verify_slot_update` checks the relation at every coefficient, and the fold key hashes and generated verifiers bind the new circuits. A deployed `CRISPProgram` keeps its old verifier until it is redeployed. | -| **Tally coefficients can wrap at the plaintext modulus** | Accepted | The committee decrypts each tally coefficient modulo `t`, and a coefficient counts the ballots that set that bit. `t` or more such ballots in one round decode a wrong total with every proof valid: 100 at insecure-512, 17 million at secure-8192. `CRISPProgram` does not limit the slots of a round; `decodeTally` and the CRISP docs state the bound. | +| **Tally coefficients can wrap at the plaintext modulus** | Fixed | Coefficient `o` of the tally is the total weight on option `o`, decrypted modulo `t`. `CRISPProgram.validate` reads `t` from the round's BFV parameters and keeps every total below it. A CONSTANT round refuses `credits >= t` and accepts at most `min((t - 1) / credits, MAX_INPUTS_PER_ROUND)` inputs. A CUSTOM round needs a divisor of at least `getPastTotalSupply(snapshot) / t + 1`. A census-tree round also relies on the server's leaf balances, and a CUSTOM round on its token. | +| **Masks can fill the input limit** | Accepted | A mask needs no voter signature, and `CRISPProgram` cannot tell a mask from a vote. Thus any account can send masks to eligible slots until the round reaches `inputLimit`, and every later input reverts with `InputLimitReached`. Each mask still needs a valid proof, an availability attestation, and a `publishInput` transaction. A CONSTANT round with large `credits` has the smallest limit. | | **Relay limits held per server instance** | Accepted | Every instance relays from the one availability-signer key and counts only the relays in its own ledger, so several relaying instances can each send up to the limits for the same slot and round. `CRISPProgram` does not count relays. The CRISP setup docs tell an operator who needs one limit for the key to let one instance relay, to send all client requests to it, and to move the relay only when no round is open. | | **A relay ledger adopted mid-round missed earlier relays** | Fixed | A round whose input window opened before the local relay ledger started takes the wallet path until it closes, because the ledger cannot hold its earlier relays. The ledger start is a durable marker that restarts and pruning keep. | | **A dropped ballot was reported as cast** | Partial | Clients reported success at commitment, but an earlier sibling on the same parent takes the slot. `POST /voting/selection` reports whether the input is selected, and the CRISP client shows each stage, marks the round as voted only at `counted`, and offers a new proof before the commitment deadline. The governance apps do not run the check. | @@ -620,7 +621,7 @@ rows: a `Resolved` row describes this repository's remediation, not an auditor's | ZEN2-16 | **Fee and voting-power calculations use floor division (Zenith #16)** | Info | Documented | Pricing sums the weighted phase terms before one division, so each request rounds down once rather than per phase, and the margin rounds down again. The bound is per request, in raw fee-token units, and is not claimed to be below one unit. The treasury share rounds down into the committee pool, and the remainder joins the allocation for slot `e3Id % activeCount`, so allocations still sum to the whole pool. Pair-count divisions are exact. Behavior is unchanged: no rounding direction, unit, or remainder policy was altered, and each request repeats a positive randomness fee, so splitting a request is not profitable. | | ZEN2-17 | **Large payloads and repeated key events increase processing costs (Zenith #17)** | Info | Documented | Publication interfaces accept dynamic data without wrapper-level caps, and committee-key publication caps a candidate at 512 KiB and a chunk at 90 KiB but does not record already emitted chunks, so a member whose status is not `None` can re-emit valid chunks while the round stays in `KeyPublished`. Senders pay the gas and observers pay the log processing. Required verification still gates acceptance: a failure reverts the publication call, so no input finalizes and no ciphertext is partly accepted. Consumers already select one candidate per publisher and bound retained assembly state under the public-key invariant. Behavior is unchanged. | | ZEN2-18 | **Unvalidated ciphertext funds unusable inputs (Zenith #18)** | Low | Resolved | `validate_input_ciphertext` runs at intake, after proof validation and before any attestation, job persistence, or funded commitment. It builds the BFV tables from the E3's on-chain `paramSet`, checks the derived config id against `e3CryptoConfigIds`, and rejects bytes whose recomputed SAFE commitment does not equal `encryptedVoteCommitment`. Validation slots bound the added processor work. Votes, updates, and masks take the identical path; the publication worker is unchanged, so an already committed input still reaches DA finalization. Intake skips the check for an input that Ethereum already committed (`stage_input`), because the first attestation for that input followed it. | -| ZEN2-19 | **Coordinator voting-power scaling disagrees with the contract (Zenith #19)** | Info | Resolved | The divisor is read from the round through `votingPowerDivisorOf` for an initialized ONCHAIN round. Where off-chain derivation remains, an explicit override is selected before any metadata call, and the default is `U256::from(10).checked_pow(decimals - 1)` in 256-bit arithmetic: 1 for decimals 0-1, derived for 2-78, and a named error above that instead of the previous `u128` overflow. A reverting `decimals()` is distinguished from an RPC failure, which never silently becomes divisor 1. Follow-up: a failed stored-divisor read no longer blocks registration. The round is registered and votable at once, only holder discovery is deferred, and the deferral is durable (`discovery_pending`): a retry pass re-reads the divisor while the round is votable and runs the same discovery once it answers, so a transient RPC failure costs mask cover for a while rather than the round or its census. Coverage pins the decision and the durable debt, not a live listener with a failing provider. | +| ZEN2-19 | **Coordinator voting-power scaling disagrees with the contract (Zenith #19)** | Info | Resolved | `CRISPProgram.validate` records the divisor of every CUSTOM-credit round once: the requested value, or `getPastTotalSupply(snapshot) / t + 1` when the request names 0. The coordinator reads it through `votingPowerDivisorOf` for every CUSTOM-credit round, whatever its census mode, and derives nothing from token metadata. A CONSTANT-credit round has no divisor. Follow-up: for an ONCHAIN round, a failed stored-divisor read no longer blocks registration. The round is registered and votable at once, only holder discovery is deferred, and the deferral is durable (`discovery_pending`): a retry pass re-reads the divisor while the round is votable and runs the same discovery once it answers, so a transient RPC failure costs mask cover for a while rather than the round or its census. Coverage pins the decision and the durable debt, not a live listener with a failing provider. | | ZEN2-20 | **Credited rewards bypass later expulsion (Zenith #20)** | Medium | Resolved | Committee allocations stay keyed by operator in `E3RefundManager._operatorEntitlements` instead of being credited to recipient-keyed balances at settlement. `claimReward`, `claimHeldSuccessReward`, `claimSlashedFunds`, and the permissionless `claimOperator*` entry points all re-check `pendingExpulsions` and `excluded` before paying the frozen recipient, so a proposal opened after completion holds the allocation and an executed expulsion forfeits and reallocates it. Two operators sharing one recipient keep independent entitlements. Balances credited before the upgrade stay claimable through the legacy ledgers; mainnet has processed no E3, so those ledgers are empty at upgrade. Follow-up: `_heldSlashFrom` records each held slash share's penalty target, and an expulsion re-shares each bucket without that target only, so a penalty never returns to its target and non-expelling penalties stay order-independent (one appended mapping, `__gap` 43→42). `claimReward` emits one `RewardClaimed` covering the legacy ledger and the operator-held escrow. | | ZEN2-21 | **Failed requests release another request's quota reservation (Zenith #21)** | Info | Resolved | A global admission reservation is held by an RAII guard, so a failed request returns its own reservation and cannot release one belonging to another request. Follow-up: the reservation is committed inside admission, in the step that writes the job, so a request cancelled during a later await cannot release quota for admitted work. | | ZEN2-22 | **Concurrent status refresh overwrites durable progress (Zenith #22)** | Low | Resolved | A status refresh no longer writes back over a job that a worker advanced concurrently; publication progress is preserved. | diff --git a/agent/flow-trace/04_DKG_AND_COMPUTATION.md b/agent/flow-trace/04_DKG_AND_COMPUTATION.md index f53a34a408..c755905779 100644 --- a/agent/flow-trace/04_DKG_AND_COMPUTATION.md +++ b/agent/flow-trace/04_DKG_AND_COMPUTATION.md @@ -2134,17 +2134,52 @@ server that lost its database, or one of several instances, can report `not_inde Closing the gap entirely would need the guest to tell a replace from an add, which means publishing that distinction — the thing the whole design exists to hide. -Capacity: `TREE_DEPTH = 20` gives 2^20 entries, against a physical ceiling of roughly three writes -per block at the secure preset — append-only is not capacity-bound. +Capacity: `TREE_DEPTH = 20` gives 2^20 entries. Every input, whether a vote, an update, or a mask, +appends one entry, and `_verifyInputProof` refuses an input once the round holds `inputLimit` +entries (`InputLimitReached`), on both `publishInput` and `validateInputProof`. The limit is at most +`MAX_INPUTS_PER_ROUND` (100,000). Every input adds at most one fresh ciphertext to the sum that the +committee decrypts. Every secure parameter set must keep that decryption correct for at least +100,000 additions (`SEARCH_Z` is 100,000 for secure-8192). The insecure-512 test preset is sized +for 1,024 additions. A CUSTOM round on it, or a CONSTANT round with zero credits, can accept more +inputs than that. + +A mask needs no voter signature, and the contract cannot tell a mask from a vote, so it cannot count +votes alone. Thus any account can send masks to eligible slots until the round reaches `inputLimit`, +and every later input reverts. Each mask still needs a valid proof, an availability attestation, and +a `publishInput` transaction. A CONSTANT round with large `credits` has the smallest limit, so it is +the cheapest round to fill. **Plaintext modulus bound.** The committee decrypts each tally coefficient modulo the plaintext -modulus `t` of the round's BFV parameters: 100 for insecure-512 and 17,000,000 for secure-8192. Every -ballot coefficient is 0 or 1 and the tally adds one ballot per selected slot, so a coefficient -counts the ballots that set that bit, and the decoded count is exact only while fewer than `t` -ballots set it. `CRISPProgram` does not enforce the bound, and the input tree (`2^20` entries) does -not prevent a round past it. At secure-8192 a wrong count needs 17 million ballots in one round; at -insecure-512 it needs 100, so a round on that preset with 100 or more voters for one option can -decode a wrong result with every proof valid. +modulus `t` of the round's BFV parameters: 100 for insecure-512 and 17,000,000 for secure-8192. A +ballot holds one weight in `[0, t)` for each option, so the decrypted tally holds per-option totals: +coefficient `o` is the total weight on option `o`, exact only while it is below `t`. +`CRISPProgram.validate` decodes `t` from `e3ProgramParams` (the `BfvParameters` tuple that +`encode_bfv_params` and `encodeBfvParams` write) and sizes the round so that no total reaches it: + +- CONSTANT credits: `credits >= t` reverts with `CreditsExceedPlaintextModulus`. A ballot's weights + sum to at most `credits`, so the input limit is `(t - 1) / credits`, capped at + `MAX_INPUTS_PER_ROUND`. Zero credits carry no weight and take `MAX_INPUTS_PER_ROUND`. No divisor + is recorded. +- CUSTOM credits: the round records the token snapshot and a divisor of at least + `getPastTotalSupply(snapshot) / t + 1`. A requested 0 takes that minimum, and a smaller request + reverts with `VotingPowerDivisorBelowMinimum`. A slot's weight bound is + `getPastVotes(slot, snapshot) / divisor`, and the votes of all accounts sum to at most the total + supply, so the bounds of all slots sum to less than `t`. A token that does not answer + `getPastTotalSupply` reverts with `CustomCreditsRequireVotesToken`. + +An ONCHAIN round takes each slot's bound from the contract, so the bound holds without trusting the +coordinator. A census-tree round takes it from the census leaf, so it also relies on the coordinator +writing `credits` (CONSTANT) or the scaled voting power (CUSTOM) into each leaf, the same trust the +census already carries. The server reads CUSTOM leaf balances at a timepoint that it derives from +the request timestamp. For a timestamp-clock token, that timepoint is the contract's snapshot. For a +block-number clock, the server takes the last block at or before the request timestamp minus one. On +a chain with several blocks per second, that block can be earlier than the contract's snapshot. A +CUSTOM round also relies on the token: at the snapshot, `getPastVotes` over all accounts must not +sum above `getPastTotalSupply`. ERC20Votes meets this. `BondedVotes` with an escrow votes source +does not guarantee it. Its vesting-locked term reads the current locks and the present wallet +balance, not a checkpointed history. After a slash leaves a lock larger than its bond, that term can +count FOLD that another account also counts at the snapshot. The bound still holds while that +overlap is at most the FOLD that carries no vote at the snapshot. A round where _every_ entry is unusable fails at the output commitment, because the processor's empty ciphertext does not deserialize. That is only reachable when no honest input exists, and is @@ -2186,8 +2221,8 @@ derived from the commitments accepted a mask that published its ballot alone: th second opening of the parent commitment that satisfied the single equation. A per-coefficient linear relation, aligned across three ciphertexts that pack with the same `BIT_CT`, proves the same statement for the committed coefficients under any opening that keeps the carriers, so the circuit -needs no `pack_checked` digit asserts. At secure-8192 the `crisp` circuit is 1,844,049 gates and -`crisp_onchain` 1,824,326, under the `2^21` browser ceiling. With the checked helper on the three +needs no `pack_checked` digit asserts. At secure-8192 the `crisp` circuit is 1,750,827 gates and +`crisp_onchain` 1,731,104, under the `2^21` browser ceiling. With the checked helper on the three commitments, the secure `crisp` circuit measured 2,520,034 gates. The circuit returns `sum_ct_commitment` on every path, so the public inputs, the stored commitment, @@ -2196,9 +2231,15 @@ them apart would mean distinguishing a fresh BFV ciphertext from a sum, which th hides. The SDK has one code path for all three, and `CrispSDK.prepareBallot` makes the same `state/previous-ciphertext` request either way, so the request pattern says nothing either. -The plaintext is fully constrained on both branches. `check_coefficient_values_with_balance` binds -every coefficient of `k1`: those inside an option segment must be binary, and every coefficient -outside the ballot region must be zero. `check_coefficient_zero` requires the whole polynomial to be -zero for a mask. Both read the payload at `k1[D - MAX_MSG_NON_ZERO_COEFFS ..]`, because the witness -generator reverses the message over the full BFV degree — the ballot occupies the **last** 50 -coefficients, and the options appear back to front. +The plaintext is fully constrained on both branches. The witness generator reverses the message over +the full BFV degree, so option `o` is `k1[D - 1 - o]`. `check_coefficient_values_with_balance` +requires every coefficient outside the first `num_options` message positions to be zero and decodes +each option weight with `decode_weight`. The prover hints the weight `v` and a quotient `r`. +`check_weight` range-checks the coefficient, `v`, and `r` to `[0, t)` and checks +`k1 + t * r == Q_MOD_T * v`. With every value below `t`, the equation holds over the integers, so +`v` is the value that the coefficient decrypts to. The fold binds this `k1` to the plaintext of +`user_data_encryption_ct0` through `k1_commitment`. A packed commitment has one opening only while +every coefficient fits its slot. `user_data_encryption_ct0` range-checks its `k1` to `[0, t)`, and +the ballot circuit bounds every coefficient of its own `k1`. The weights must sum to at most the +slot's bound, and with two options at most one weight is nonzero. `check_coefficient_zero` requires +the whole polynomial to be zero for a mask. diff --git a/agent/invariants/01_PROTOCOL_ONCHAIN.md b/agent/invariants/01_PROTOCOL_ONCHAIN.md index 8ec99e2b63..283fad0021 100644 --- a/agent/invariants/01_PROTOCOL_ONCHAIN.md +++ b/agent/invariants/01_PROTOCOL_ONCHAIN.md @@ -49,7 +49,10 @@ every section. original behaviour) or an escrow adapter (only locked FOLD votes, so holders must lock to participate while operators keep weight by bonding). Reading the denominator off the escrow instead would omit the bonded half and let participation exceed 100%. Summed voting power must - never exceed total supply. — `BondedVotes.sol`; `flow-trace/02` + never exceed total supply. **Gap:** under an escrow source, the vesting-locked term reads the + current locks and the present wallet balance. After a slash leaves a lock larger than its bond, a + past answer can count FOLD that another account also counts at that timepoint. The CUSTOM-credit + divisor of a CRISP round relies on this sum. — `BondedVotes.sol`; `flow-trace/02`; `flow-trace/04` - **Bonded delegation moves weight; it never copies it.** An owner's bonded weight is its bonded FOLD, plus its vesting-locked FOLD under an escrow source. At every timepoint it counts at the owner or at exactly one delegate. Two functions write the links, each at the token's clock in one diff --git a/agent/invariants/02_CRYPTO_CIRCUITS.md b/agent/invariants/02_CRYPTO_CIRCUITS.md index bd247c83f3..84ba903880 100644 --- a/agent/invariants/02_CRYPTO_CIRCUITS.md +++ b/agent/invariants/02_CRYPTO_CIRCUITS.md @@ -192,8 +192,8 @@ every section. rebuild the matching verifier artifacts before deploying a protocol-version-7 node. — `flow-trace/04`; `scripts/build-circuits.ts` - fhe.rs v0.4.1 passes plaintext-scaled ballot coefficients as non-centered residues. Both CRISP - vote circuits must check `Q_MOD_T`, rather than `Q_MOD_T_CENTERED`, against those coefficients. — - `examples/CRISP/circuits/bin/{crisp,crisp_onchain}/src/main.nr` + vote circuits decode each ballot weight against `Q_MOD_T`, rather than `Q_MOD_T_CENTERED`. — + `examples/CRISP/circuits/lib/src/utils.nr` - The C3 and user-data-encryption `k1` witnesses use non-centered residues in `[0, t - 1]`. Their Noir equations and asymmetric quotient bounds must match the Rust witnesses. — `circuits/lib/src/core/dkg/share_encryption.nr`; `crates/zk-helpers/src/circuits/` @@ -570,21 +570,32 @@ every section. a change to either ballot circuit must regenerate those constants (`pnpm compute:vk-hash`, both presets) and the generated verifiers; a deployed `CRISPProgram` keeps the circuit its verifier was built from. — `flow-trace/04` -- **A CRISP tally coefficient is exact only below the plaintext modulus.** Every ballot coefficient - is 0 or 1, and the tally adds one ballot per selected slot, so each decrypted coefficient counts - the ballots that set that bit of that option. The committee decrypts it modulo the plaintext - modulus `t` of the round's BFV parameters: 100 at insecure-512 and 17,000,000 at secure-8192. - Voting power does not change the bound, because a ballot adds at most 1 to each coefficient. A - tally format that is not one bit per coefficient per ballot needs a new bound. **Gap:** - `CRISPProgram` does not limit the slots of a round. When `t` or more ballots in one round set the - same bit, `decodeTally` reads the residue and the count is wrong with every proof valid. That - takes 100 ballots at insecure-512 and 17 million at secure-8192. — `flow-trace/04` +- **CRISPProgram keeps every option total below the plaintext modulus.** A ballot holds one weight + for each option, so coefficient `o` of the decrypted tally is the total weight on option `o`, and + the committee decrypts it modulo the plaintext modulus `t` of the round's BFV parameters. + `CRISPProgram.validate` must read `t` from `e3ProgramParams`, not from a constant, and size the + round so that no total reaches `t`. A CONSTANT-credit round must refuse `credits >= t` and accept + at most `(t - 1) / credits` inputs, capped at `MAX_INPUTS_PER_ROUND`. A CUSTOM-credit round must + record a divisor of at least `getPastTotalSupply(snapshot) / t + 1`, which bounds the totals only + while the past votes of all accounts sum to at most the past supply. ERC20Votes meets this. + `BondedVotes` with an escrow votes source does not guarantee it (`flow-trace/04`). Every input + counts toward the limit, including updates and masks, on both `publishInput` and + `validateInputProof`. A mask needs no signature, so any account can fill the limit. A census-tree + round also relies on the coordinator writing `credits` or the scaled voting power at the snapshot + into each leaf. A change to the tally format, the credit modes, or the parameter sets needs a new + bound. **Gap:** the server derives its census timepoint from the request timestamp. For a token + with a block-number clock, on a chain with several blocks per second, the census block can precede + the snapshot. — `flow-trace/04` - **CRISP constrains every coefficient of the ballot plaintext, at the real BFV degree.** The - witness generator reverses the message over the full degree, so the payload starts at - `D - MAX_MSG_NON_ZERO_COEFFS + (MAX_MSG_NON_ZERO_COEFFS mod num_options)` with the options back to - front; `crisp_lib::utils::ballot_layout` derives that offset, both checkers use it, and no code - may hand-code it. Each coefficient inside an option segment encodes one bit as 0 or `q_mod_t`, - everything outside the ballot region must be zero, and a mask's plaintext must be zero everywhere. + witness generator reverses the message over the full degree, so option `o` is `k1[D - 1 - o]`. + `check_coefficient_values_with_balance` must decode each option weight from that coefficient, + require every other coefficient to be zero, and bound the sum of the weights by the slot's bound. + A mask's plaintext must be zero everywhere. `check_weight` must range-check the option + coefficient, the hinted weight, and the quotient to `[0, t)`. Then `k1 + t * r == Q_MOD_T * v` + holds over the integers, and `v` is the value that the coefficient decrypts to. The bound on the + coefficient also gives the packed `k1_commitment` one opening. Thus the fold's equality check + binds this `k1` to the plaintext that `user_data_encryption_ct0` encrypts. Without that bound, a + prover can open the commitment to shifted option coefficients that decode to other weights. Indexing as if the polynomial were the message width makes both checks read only padding: every vote passes any balance bound, and a mask — which needs no signature and may be written to any eligible slot — can carry an arbitrary payload into someone else's ballot. Tests must build `k1` diff --git a/docs/pages/CRISP/introduction.mdx b/docs/pages/CRISP/introduction.mdx index 9aac958753..e83c94b5a2 100644 --- a/docs/pages/CRISP/introduction.mdx +++ b/docs/pages/CRISP/introduction.mdx @@ -102,14 +102,14 @@ The table shows which CRISP component fills each part of an E3. The requested and when the encrypted output is published. The [program contract guide](/build/e3-program/program-contract) explains the `IE3Program` interface. -| Function | Caller | What it does | -| --------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `validate` | Interfold contract, at the request | Decodes the round parameters and starts an input Merkle tree of depth 20. A round must have 2 to 10 options. On Avail deployments, it also checks the input window timing. | -| `setMerkleRoot` | Owner (the CRISP server key) | Stores the census Merkle root for a round that uses a census tree. | -| `publishInput` | Voter wallet or server relay | Verifies the Noir proof and the availability signature of the server. Reserves the input leaf and emits `InputCommitted`. | -| `finalizeInput` | Any account (usually the server) | Verifies the data-availability receipt for the committed ciphertext hash. Emits `InputPublished`. | -| `verify` | Interfold contract, at the output | Reverts with `InputAvailabilityPending` while an input waits for finalization. Checks the OpenVM receipt against the parameters hash and the input root. | -| `decodeTally` | Anyone (view) | Decodes the plaintext output into one count per option. | +| Function | Caller | What it does | +| --------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `validate` | Interfold contract, at the request | Decodes the round parameters and starts an input Merkle tree of depth 20. A round must have 2 to 10 options. It sets the input limits of the round from the plaintext modulus `t` (see [Decode the tally](/CRISP/running-e3#decode-the-tally)). On Avail deployments, it also checks the input window timing. | +| `setMerkleRoot` | Owner (the CRISP server key) | Stores the census Merkle root for a round that uses a census tree. | +| `publishInput` | Voter wallet or server relay | Verifies the Noir proof and the availability signature of the server. Reserves the input leaf and emits `InputCommitted`. | +| `finalizeInput` | Any account (usually the server) | Verifies the data-availability receipt for the committed ciphertext hash. Emits `InputPublished`. | +| `verify` | Interfold contract, at the output | Reverts with `InputAvailabilityPending` while an input waits for finalization. Checks the OpenVM receipt against the parameters hash and the input root. | +| `decodeTally` | Anyone (view) | Decodes the plaintext output into the total weight of each option. | The round parameters include a census mode. The census mode selects who can vote and which Noir verifier `publishInput` uses: diff --git a/docs/pages/CRISP/running-e3.mdx b/docs/pages/CRISP/running-e3.mdx index 834fb738ab..e0e9d92d45 100644 --- a/docs/pages/CRISP/running-e3.mdx +++ b/docs/pages/CRISP/running-e3.mdx @@ -261,17 +261,51 @@ its receipt. ## Decode the tally The plaintext output is a list of polynomial coefficients. The first 50 coefficients -(`MAX_MSG_NON_ZERO_COEFFS`) carry the result. Each option has `floor(50 / numOptions)` coefficients -that hold a binary number, with the most significant coefficient first. Homomorphic addition adds -the coefficients of all ballots, so each segment decodes to the total for its option. With 2 -options, each option has 25 coefficients, so one ballot can give an option at most `2^25 - 1`. +(`MAX_MSG_NON_ZERO_COEFFS`) carry the result. A ballot has one coefficient for each option, so the +decrypted tally holds one total for each option. Coefficient `o` is the total weight on option `o`. +The other coefficients are zero. The committee decrypts each coefficient modulo the plaintext modulus `t` of the parameter set: 100 -for `insecure-512` and 17,000,000 for `secure-8192`. Each ballot sets a coefficient to 0 or 1, so a -coefficient counts the ballots that set that bit. The total is exact only while fewer than `t` -ballots in the round set the same bit. `CRISPProgram` does not enforce this limit, and a round past -it decodes a total that is too low, with every proof valid. Each eligible address has one slot, so -use `insecure-512` only for rounds with fewer than 100 eligible addresses. +for `insecure-512` and 17,000,000 for `secure-8192`. A total is correct only while it is less than +`t`. `CRISPProgram.validate` reads `t` from the BFV parameters of the request. Then it sets limits +for the credit mode of the round: + +- With `CONSTANT` credits, each voter has the same voting power, `credits`. +- With `CUSTOM` credits, the voting power of a voter is `getPastVotes(voter, snapshot)` divided by + the voting-power divisor of the round. + +The limits keep each total less than `t`: + +| Credit mode | Limits | +| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `CONSTANT` | `credits` must be less than `t`. The round accepts at most `(t - 1) / credits` inputs, rounded down, and not more than `MAX_INPUTS_PER_ROUND` (100,000). | +| `CUSTOM` | The divisor must be at least `getPastTotalSupply(snapshot) / t + 1`. A requested divisor of 0 selects this minimum. The round accepts at most `MAX_INPUTS_PER_ROUND` inputs. | + +Every input counts toward the limit. This includes a new vote that replaces an earlier vote, and a +mask. When a round has its maximum number of inputs, `publishInput` reverts with +`InputLimitReached`. The CLI requests `CONSTANT` credits of 1. Thus, an `insecure-512` round accepts +99 inputs, and a `secure-8192` round accepts 100,000 inputs. + +A mask does not need a signature from the voter. Thus, any account can send masks to eligible slots +until the round has its maximum number of inputs. After that, the round accepts no more votes. A +round with a small limit costs less to fill in this way. A `CONSTANT` round with large `credits` has +a small limit. + +`CRISPProgram.validate` rejects a request with these errors: + +| Error | Condition | +| -------------------------------- | --------------------------------------------------------------------------------------------- | +| `CreditsExceedPlaintextModulus` | A `CONSTANT` round has `credits` of `t` or more. | +| `VotingPowerDivisorBelowMinimum` | A `CUSTOM` round requests a divisor that is less than `getPastTotalSupply(snapshot) / t + 1`. | +| `CustomCreditsRequireVotesToken` | A `CUSTOM` round names a token that does not answer `getPastTotalSupply`. | + +In a census tree round, the census leaf of each voter holds the voting power of that voter. The +CRISP server writes `credits` for a `CONSTANT` round. For a `CUSTOM` round, it writes +`getPastVotes(voter, snapshot) / divisor`. The limits hold only when the census holds these values. + +The `CUSTOM` limit also relies on the token. At the snapshot, the sum of the voting power of all +accounts must not be more than `getPastTotalSupply(snapshot)`. An ERC20Votes token satisfies this +condition. Three decoders give the same result (`packages/crisp-contracts/tests/tally.decoding.test.ts`): diff --git a/docs/pages/build/tutorials/custom-zk-circuits.mdx b/docs/pages/build/tutorials/custom-zk-circuits.mdx index 9de6ecf16a..6a9c91dce3 100644 --- a/docs/pages/build/tutorials/custom-zk-circuits.mdx +++ b/docs/pages/build/tutorials/custom-zk-circuits.mdx @@ -153,9 +153,10 @@ fn main( ``` The input `k1` is the plaintext polynomial scaled to `[q * m]_t` (`k1 = [q*m]_t` in -`crates/zk-helpers/src/circuits/threshold/user_data_encryption/computation.rs`). CRISP uses the same -test for each vote bit in `check_coefficient_values_with_balance`. The two commitment functions are -the ones that `user_data_encryption` uses, so equal inputs give equal outputs. +`crates/zk-helpers/src/circuits/threshold/user_data_encryption/computation.rs`). A CRISP ballot +holds one weight per option. For each option, `check_coefficient_values_with_balance` proves that +the coefficient in `k1` is a weight in `[0, t)` scaled the same way. The two commitment functions +are the ones that `user_data_encryption` uses, so equal inputs give equal outputs. Check the circuit: diff --git a/examples/CRISP/circuits/bin/crisp/src/main.nr b/examples/CRISP/circuits/bin/crisp/src/main.nr index 41b71c25d9..4086bae29e 100644 --- a/examples/CRISP/circuits/bin/crisp/src/main.nr +++ b/examples/CRISP/circuits/bin/crisp/src/main.nr @@ -9,7 +9,7 @@ use crisp_lib::ecdsa::{address_to_field, derive_address, digest_from_halves, val use crisp_lib::merkle_tree::get_merkle_root; use crisp_lib::utils::{check_coefficient_values_with_balance, check_coefficient_zero}; use interfold_lib::configs::default::threshold::{ - L, N, Q_MOD_T, QIS, USER_DATA_ENCRYPTION_BIT_CT, USER_DATA_ENCRYPTION_BIT_K, + L, N, QIS, USER_DATA_ENCRYPTION_BIT_CT, USER_DATA_ENCRYPTION_BIT_K, }; use interfold_lib::math::{ commitments::{compute_single_polynomial_commitment, DS_USER_DATA_ENCRYPTION_COMMITMENT}, @@ -166,8 +166,7 @@ fn main( // the slot decrypts to, and cannot carry a payload a coercer could read back out of the // tally // - No signature is required, which is what lets a third party mask a slot at all - let valid_vote = - check_coefficient_values_with_balance(k1, Q_MOD_T, balance, num_options); + let valid_vote = check_coefficient_values_with_balance(k1, balance, num_options); // The digest is a public input, so the prover cannot select the message that the signature // covers. The contract computes the digest from the round, the slot, and this ballot, which diff --git a/examples/CRISP/circuits/bin/crisp_onchain/src/main.nr b/examples/CRISP/circuits/bin/crisp_onchain/src/main.nr index 83915bc19f..3a6c3e71d8 100644 --- a/examples/CRISP/circuits/bin/crisp_onchain/src/main.nr +++ b/examples/CRISP/circuits/bin/crisp_onchain/src/main.nr @@ -8,7 +8,7 @@ use crisp_lib::ciphertext_addition::verify_slot_update; use crisp_lib::ecdsa::{address_to_field, derive_address, digest_from_halves, validate_signature}; use crisp_lib::utils::{check_coefficient_values_with_balance, check_coefficient_zero}; use interfold_lib::configs::default::threshold::{ - L, N, Q_MOD_T, QIS, USER_DATA_ENCRYPTION_BIT_CT, USER_DATA_ENCRYPTION_BIT_K, + L, N, QIS, USER_DATA_ENCRYPTION_BIT_CT, USER_DATA_ENCRYPTION_BIT_K, }; use interfold_lib::math::{ commitments::{compute_single_polynomial_commitment, DS_USER_DATA_ENCRYPTION_COMMITMENT}, @@ -155,8 +155,7 @@ fn main( // the slot decrypts to, and cannot carry a payload a coercer could read back out of the // tally // - No signature is required, which is what lets a third party mask a slot at all - let valid_vote = - check_coefficient_values_with_balance(k1, Q_MOD_T, voting_power, num_options); + let valid_vote = check_coefficient_values_with_balance(k1, voting_power, num_options); // The digest is a public input, so the prover cannot select the message that the signature // covers. The contract computes the digest from the round, the slot, and this ballot, which diff --git a/examples/CRISP/circuits/bin/fold/src/main.nr b/examples/CRISP/circuits/bin/fold/src/main.nr index 6e79f48ea0..611ac8ea0d 100644 --- a/examples/CRISP/circuits/bin/fold/src/main.nr +++ b/examples/CRISP/circuits/bin/fold/src/main.nr @@ -11,9 +11,9 @@ use interfold_lib::math::commitments::compute_vk_hash; /// `examples/CRISP` after changing ct0 / ct1 / user_data_encryption / crisp (or the lib preset). /// Insecure: `lib::configs::default` uses `insecure::*`; secure: uses `secure::*`. pub global CRISP_FOLD_EXPECTED_KEY_HASH_INSECURE: Field = - 0x2fc96ba17cae24fd303ab18098cbc5357e0e7b7649b68e03fdaf156aea15c64c; + 0x0fb783a0f9843410cc8252d269ec225a349870415444cbb2263284af2819e1cc; pub global CRISP_FOLD_EXPECTED_KEY_HASH_SECURE: Field = - 0x14191420bb98d5d273bab6c3744c419f1fdce8fe1140a5dcf8f3e5502bc03a6e; + 0x21762277d29d63c5947d473c545e2caaf26441747f77923558c1f60495037448; fn main( // User Data Encryption Section. diff --git a/examples/CRISP/circuits/bin/fold_onchain/src/main.nr b/examples/CRISP/circuits/bin/fold_onchain/src/main.nr index bf09421b3c..d0305726db 100644 --- a/examples/CRISP/circuits/bin/fold_onchain/src/main.nr +++ b/examples/CRISP/circuits/bin/fold_onchain/src/main.nr @@ -11,9 +11,9 @@ use interfold_lib::math::commitments::compute_vk_hash; /// `examples/CRISP` after changing ct0 / ct1 / user_data_encryption / crisp_onchain (or the lib /// preset). Insecure: `lib::configs::default` uses `insecure::*`; secure: uses `secure::*`. pub global CRISP_ONCHAIN_FOLD_EXPECTED_KEY_HASH_INSECURE: Field = - 0x28d68708fed060619ea78086f56c85f95fc23d3545e2b11b1fc28a7c9bae91ab; + 0x090d43b8bbd50e0840fd14a5918216321b0fc5dc5696da80da7cca2b22614696; pub global CRISP_ONCHAIN_FOLD_EXPECTED_KEY_HASH_SECURE: Field = - 0x1d9548e6929c5d6ad03b99760ac2bb8c3aa9d0f2fe7c1b774e2da8cbe6ce07bd; + 0x19d907fc728bd5c51dfa27ff251c71fa8bdeb8a6f709ffff4092238ffb4027e7; fn main( // User Data Encryption Section. diff --git a/examples/CRISP/circuits/lib/src/constants.nr b/examples/CRISP/circuits/lib/src/constants.nr index 0bf70b01ff..e8dfe816b0 100644 --- a/examples/CRISP/circuits/lib/src/constants.nr +++ b/examples/CRISP/circuits/lib/src/constants.nr @@ -4,13 +4,7 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. -use interfold_lib::configs::default::MAX_MSG_NON_ZERO_COEFFS; - /// Maximum number of options for a vote. /// We need this value because we need a compile-time bound value /// for looping. pub global MAX_OPTIONS: u32 = 10; - -/// The widest option segment, in binary coefficients. A ballot has at least two options, so one -/// option holds at most half of the message region. -pub global MAX_VOTE_BITS: u32 = MAX_MSG_NON_ZERO_COEFFS / 2; diff --git a/examples/CRISP/circuits/lib/src/utils.nr b/examples/CRISP/circuits/lib/src/utils.nr index 96a3865d40..d71f67af4a 100644 --- a/examples/CRISP/circuits/lib/src/utils.nr +++ b/examples/CRISP/circuits/lib/src/utils.nr @@ -5,156 +5,155 @@ // or FITNESS FOR A PARTICULAR PURPOSE. use interfold_lib::configs::default::MAX_MSG_NON_ZERO_COEFFS; +use interfold_lib::configs::default::threshold::{ + PLAINTEXT_MODULUS, Q_INVERSE_MOD_T, Q_MOD_T, USER_DATA_ENCRYPTION_BIT_K, +}; +use interfold_lib::math::helpers::assert_in_range; +use interfold_lib::math::modulo::unconstrained_U128::__compute_mod_reduction; use interfold_lib::math::polynomial::Polynomial; -use crate::constants::{MAX_OPTIONS, MAX_VOTE_BITS}; +use crate::constants::MAX_OPTIONS; -/// Where the ballot sits inside `k1`, and how wide one option is. +/// Checks the option count of a round, and that the ballot layout fits the compiled degree. /// -/// `k1` reaches this circuit as the whole BFV plaintext polynomial, of degree `D`, reversed: -/// `k1[i]` is coefficient `D - 1 - i` of the message. The SDK writes the ballot into the first -/// `MAX_MSG_NON_ZERO_COEFFS` message coefficients and pads the rest with zeros, so after the -/// reversal the ballot occupies the **last** `MAX_MSG_NON_ZERO_COEFFS` coefficients of `k1` and the -/// options appear back to front. -/// -/// The layout inside `k1` is: +/// A ballot holds one coefficient per option: message coefficient `o` is the weight on option `o`, +/// so the decrypted tally holds one total per option. `k1` reaches this circuit as the whole BFV +/// plaintext polynomial, of degree `D`, reversed, so option `o` is `k1[D - 1 - o]`: /// /// ```text -/// [ ------- zero prefix ------- | pad | option n-1 | ... | option 1 | option 0 ] -/// 0 base D +/// [ ------------------ zero ------------------ | option n-1 | ... | option 1 | option 0 ] +/// 0 D /// ``` /// -/// `pad` is the tail of the message region that no option uses, because the SDK divides -/// `MAX_MSG_NON_ZERO_COEFFS` between the options and zero-fills the remainder. The reversal moves -/// that tail to the front of the ballot region, which is why it counts towards the zero prefix. -/// -/// Each option holds `segment_size` binary coefficients, least significant first. +/// The committee publishes the first `MAX_MSG_NON_ZERO_COEFFS` message coefficients as the tally, +/// so every option must sit inside that region. `D` and both bounds are compile-time values, so the +/// last two assertions are decided when the circuit is built. /// /// # Arguments /// * `num_options` - Number of voting options -/// -/// # Returns -/// The first coefficient of option `num_options - 1`, and the width of one option. -fn ballot_layout(num_options: u32) -> (u32, u32) { +fn check_option_count(num_options: u32) { assert(num_options > 1); - assert(num_options as u64 <= MAX_OPTIONS as u64); - // The ballot cannot be laid out in a polynomial too short to hold the message region. `D` is - // the compiled BFV degree, so this is decided when the circuit is built. - assert(D >= MAX_MSG_NON_ZERO_COEFFS); - - let segment_size: u32 = MAX_MSG_NON_ZERO_COEFFS / num_options; - let pad: u32 = MAX_MSG_NON_ZERO_COEFFS - (segment_size * num_options); - - (D - MAX_MSG_NON_ZERO_COEFFS + pad, segment_size) + assert(num_options <= MAX_OPTIONS); + assert(MAX_OPTIONS <= MAX_MSG_NON_ZERO_COEFFS); + assert(MAX_MSG_NON_ZERO_COEFFS <= D); } -/// Checks that every coefficient before the ballot region is zero. +/// Reads the weight that one option coefficient of `k1` carries. /// -/// The ballot only occupies the coefficients the options are laid out in. Everything before them -/// must be zero, or a submitter could attach a payload that the vote rules never look at. Such a -/// payload survives homomorphic addition and is decrypted with the tally, so it is both a way to -/// corrupt a slot and a channel a coercer could read a receipt out of. +/// The witness generator scales each message coefficient `m` to the non-centered residue +/// `k1 = (Q_MOD_T * m) mod t`, so the weight is the `v` in `[0, t)` with +/// `k1 + t * r == Q_MOD_T * v` for a quotient `r` in `[0, t)`. The prover supplies `v` and `r`, +/// and [`check_weight`] constrains them. /// /// # Arguments -/// * `k1` - The plaintext polynomial encoding the vote -/// * `base` - The first coefficient of the ballot region, from [`ballot_layout`] +/// * `coefficient` - The `k1` coefficient of one option /// /// # Returns -/// Returns `true` if every coefficient below `base` is zero, `false` otherwise. -fn check_prefix_zero(k1: Polynomial, base: u32) -> bool { - let mut valid = true; +/// The weight. +fn decode_weight(coefficient: Field) -> Field { + // Safety: `check_weight` constrains both values. + let (weight, quotient) = unsafe { decode_weight_hint(coefficient) }; + check_weight(coefficient, weight, quotient); - for i in 0..D { - if i < base { - valid = valid & (k1.coefficients[i] == 0); - } - } + weight +} - valid +/// Checks that `weight` is the value the committee decrypts `coefficient` to. +/// +/// The coefficient, the weight and the quotient must all be in `[0, t)`. +/// +/// The bound on the coefficient makes this `k1` the plaintext that `user_data_encryption_ct0` +/// encrypts. That circuit range-checks its `k1` to `[0, t)`, and the fold only checks that the two +/// `k1_commitment` values are equal. Plain packing gives a commitment one opening only while every +/// coefficient fits its slot. Every other coefficient of this `k1` must be zero, so this bound +/// keeps all of them in their slots. +/// +/// With every value below `t`, neither side of the equation reaches `t * t`, so the field +/// equation holds over the integers. `Q_MOD_T` is invertible modulo `t`, so one weight solves it. +/// +/// # Arguments +/// * `coefficient` - The `k1` coefficient of one option +/// * `weight` - The weight the coefficient carries +/// * `quotient` - The quotient `r` of `Q_MOD_T * weight` by `t` +fn check_weight(coefficient: Field, weight: Field, quotient: Field) { + assert_in_range::(coefficient, PLAINTEXT_MODULUS); + assert_in_range::(weight, PLAINTEXT_MODULUS); + assert_in_range::(quotient, PLAINTEXT_MODULUS); + assert(coefficient + PLAINTEXT_MODULUS * quotient == Q_MOD_T * weight); +} + +/// Computes the weight and the quotient that [`check_weight`] checks. +unconstrained fn decode_weight_hint(coefficient: Field) -> (Field, Field) { + let (_, weight) = __compute_mod_reduction(coefficient * Q_INVERSE_MOD_T, PLAINTEXT_MODULUS); + let (quotient, _) = __compute_mod_reduction(Q_MOD_T * weight, PLAINTEXT_MODULUS); + + (weight, quotient) } -/// Validates vote coefficients for n-choice voting. +/// Validates a ballot against the voter's balance. /// -/// Constrains the whole plaintext, not only the option segments: coefficients outside the ballot -/// region must be zero, and every coefficient inside it must be a binary digit. +/// Constrains the whole plaintext, not only the options: every coefficient outside the options of +/// the round must be zero, and every option coefficient is decoded to its weight. /// /// Rules: -/// - 2 options: Mutually exclusive (only one can be non-zero) -/// - 3+ options: Can split votes, but total must not exceed balance +/// - 2 options: Mutually exclusive (at most one weight is non-zero) +/// - 3+ options: Weights can be split between options +/// In both cases the weights must sum to at most the balance. /// /// # Arguments /// * `k1` - The plaintext polynomial encoding the vote -/// * `q_mod_t` - The non-centered plaintext scale modulo the plaintext modulus /// * `balance` - The voter's balance/voting power /// * `num_options` - Number of voting options pub fn check_coefficient_values_with_balance( k1: Polynomial, - q_mod_t: Field, balance: Field, num_options: u32, ) -> bool { - let (base, segment_size) = ballot_layout::(num_options); + check_option_count::(num_options); - let mut valid = check_prefix_zero(k1, base); - - let mut sums: [u64; MAX_OPTIONS] = [0; MAX_OPTIONS]; - let mut non_zero_count: u64 = 0; - let mut total_votes: u64 = 0; - - // Process each option - for opt_idx in 0..MAX_OPTIONS { - if (opt_idx as u64) < (num_options as u64) { - // Because of the reversal, option `opt_idx` sits at segment `num_options - 1 - opt_idx`. - let circuit_segment: u32 = num_options - 1 - opt_idx; - let start_idx: u32 = base + (circuit_segment * segment_size); - - let mut sum: u64 = 0; - - // Bits are reversed (LSB first) - for bit_pos in 0..MAX_VOTE_BITS { - if (bit_pos as u64) < (segment_size as u64) { - let idx: u32 = start_idx + bit_pos; - let coeff = k1.coefficients[idx]; + let mut valid = true; - // Binary constraint: coeff must be 0 or q_mod_t - valid = valid & (0 == coeff * (q_mod_t - coeff)); + // Everything below the option coefficients is outside the ballot. A payload there survives + // homomorphic addition and is decrypted with the tally, so it would both corrupt the slot and + // give a coercer a channel to read a receipt out of. + for i in 0..(D - MAX_OPTIONS) { + valid = valid & (k1.coefficients[i] == 0); + } - let bit: u64 = if coeff == 0 { 0 } else { 1 }; - sum += bit * (1 << (bit_pos as u64)); - } - } + let mut total: Field = 0; + let mut non_zero_count: u32 = 0; - sums[opt_idx] = sum; - total_votes += sum; + for option in 0..MAX_OPTIONS { + let coefficient = k1.coefficients[D - 1 - option]; + let weight = decode_weight(coefficient); - if sum != 0 { + if option < num_options { + total += weight; + if weight != 0 { non_zero_count += 1; } + } else { + // An option the round does not have carries nothing. + valid = valid & (coefficient == 0); } } - // Voting rules based on number of options + // With two options the ballot picks one side, so its only non-zero weight is the total. if num_options == 2 { valid = valid & (non_zero_count <= 1); - - for opt_idx in 0..MAX_OPTIONS { - if ((opt_idx as u64) < (num_options as u64)) & (sums[opt_idx] != 0) { - valid = valid & (sums[opt_idx] <= (balance as u64)); - } - } - } else { - valid = valid & (total_votes <= (balance as u64)); } - valid + // At most `MAX_OPTIONS` weights below `t` fit in a u64, so this cast is exact. Truncating the + // balance can only lower the bound. + valid & ((total as u64) <= (balance as u64)) } /// Checks that the plaintext carries no ballot at all. /// -/// Every coefficient of `k1` must be zero, not only the option segments. A mask needs no signature, -/// so anyone may write one to any eligible slot; a mask that could carry a payload outside the -/// segments would let anyone corrupt a slot they cannot vote in, and would give a coercer a channel -/// to mark a ballot with. Checking the whole polynomial closes both. +/// Every coefficient of `k1` must be zero, not only the option coefficients. A mask needs no +/// signature, so anyone may write one to any eligible slot; a mask that could carry a payload +/// outside the options would let anyone corrupt a slot they cannot vote in, and would give a +/// coercer a channel to mark a ballot with. Checking the whole polynomial closes both. /// /// `num_options` is still taken, and still range-checked, so a caller cannot reach this function /// with a round configuration the vote rules would refuse. @@ -167,7 +166,7 @@ pub fn check_coefficient_values_with_balance( /// Returns `true` if every coefficient is zero, `false` otherwise. /// pub fn check_coefficient_zero(k1: Polynomial, num_options: u32) -> bool { - let (_base, _segment_size) = ballot_layout::(num_options); + check_option_count::(num_options); let mut valid = true; @@ -182,44 +181,33 @@ pub fn check_coefficient_zero(k1: Polynomial, num_options: u32) - // Tests // --------------------------------------------------------------------------- // -// Every test builds `k1` the way the witness generator does — SDK message layout, reversed over the -// full BFV degree — rather than at the width of the message region. The two only agree when the -// degree equals `MAX_MSG_NON_ZERO_COEFFS`, which no preset uses, so testing at the message width -// hid the layout the circuit actually receives. - -/// The BFV degree of the `insecure-512` preset. Large enough that the padding between the top of -/// the message region and the degree is most of the polynomial, which is the region the old index -/// arithmetic read from. +// Every test builds `k1` the way the witness generator does — SDK message layout, scaled by +// `Q_MOD_T` modulo `t`, reversed over the full BFV degree — and takes `t` and `Q_MOD_T` from the +// compiled preset, so the suite holds for any plaintext modulus of at least 100. + +/// The BFV degree of the `insecure-512` preset. The option coefficients sit at the top of the +/// reversed polynomial, so a degree well above `MAX_OPTIONS` checks that the layout is read from +/// the real degree. global TEST_DEGREE: u32 = 512; -/// Builds `k1` for a ballot: `encodeVote` from `@crisp-e3/sdk`, followed by the reversal the -/// witness generator applies. +/// The `k1` coefficient the witness generator produces for one message coefficient. +fn scale(m: u64) -> Field { + (((Q_MOD_T as u64) * m) % (PLAINTEXT_MODULUS as u64)) as Field +} + +/// Builds `k1` for a ballot: `encodeVote` from `@crisp-e3/sdk` writes the weight of option `o` to +/// message coefficient `o`, and the witness generator scales every coefficient and reverses the +/// polynomial over the full degree. /// /// # Arguments -/// * `values` - One value per option, in option order; entries past `num_options` are ignored +/// * `weights` - One weight per option, in option order; entries past `num_options` are ignored /// * `num_options` - Number of voting options -/// * `one` - The coefficient a set bit encodes to, which is `q_mod_t` -fn encode_ballot( - values: [u64; MAX_OPTIONS], - num_options: u32, - one: Field, -) -> Polynomial { - let segment_size: u32 = MAX_MSG_NON_ZERO_COEFFS / num_options; +fn encode_ballot(weights: [u64; MAX_OPTIONS], num_options: u32) -> Polynomial { let mut coefficients: [Field; D] = [0; D]; - for opt in 0..MAX_OPTIONS { - if opt < num_options { - let start: u32 = opt * segment_size; - - for i in 0..MAX_VOTE_BITS { - if i < segment_size { - // The SDK writes each segment most significant bit first. - let bit_pos: u32 = segment_size - 1 - i; - let bit: u64 = (values[opt] >> (bit_pos as u64)) & 1; - - coefficients[D - 1 - (start + i)] = if bit == 1 { one } else { 0 }; - } - } + for option in 0..MAX_OPTIONS { + if option < num_options { + coefficients[D - 1 - option] = scale(weights[option]); } } @@ -228,110 +216,123 @@ fn encode_ballot( #[test] fn test_2_options_vote_first() { - let pol = encode_ballot::([5, 0, 0, 0, 0, 0, 0, 0, 0, 0], 2, 1); - assert(check_coefficient_values_with_balance(pol, 1, 100, 2)); -} - -#[test] -fn test_non_centered_plaintext_scale() { - let pol = encode_ballot::([5, 0, 0, 0, 0, 0, 0, 0, 0, 0], 2, 93); - assert(check_coefficient_values_with_balance(pol, 93, 100, 2)); - assert(!check_coefficient_values_with_balance(pol, -7, 100, 2)); + let pol = encode_ballot::([5, 0, 0, 0, 0, 0, 0, 0, 0, 0], 2); + assert(check_coefficient_values_with_balance(pol, 10, 2)); } #[test] fn test_2_options_vote_second() { - let pol = encode_ballot::([0, 3, 0, 0, 0, 0, 0, 0, 0, 0], 2, 1); - assert(check_coefficient_values_with_balance(pol, 1, 100, 2)); + let pol = encode_ballot::([0, 3, 0, 0, 0, 0, 0, 0, 0, 0], 2); + assert(check_coefficient_values_with_balance(pol, 10, 2)); } #[test] fn test_2_options_both_voted_fails() { - let pol = encode_ballot::([1, 1, 0, 0, 0, 0, 0, 0, 0, 0], 2, 1); - assert(!check_coefficient_values_with_balance(pol, 1, 100, 2)); + let pol = encode_ballot::([1, 1, 0, 0, 0, 0, 0, 0, 0, 0], 2); + assert(!check_coefficient_values_with_balance(pol, 10, 2)); } #[test] fn test_2_options_exceeds_balance_fails() { - let pol = encode_ballot::([10, 0, 0, 0, 0, 0, 0, 0, 0, 0], 2, 1); - assert(!check_coefficient_values_with_balance(pol, 1, 5, 2)); + let pol = encode_ballot::([10, 0, 0, 0, 0, 0, 0, 0, 0, 0], 2); + assert(!check_coefficient_values_with_balance(pol, 5, 2)); } #[test] fn test_2_options_at_balance_passes() { - let pol = encode_ballot::([5, 0, 0, 0, 0, 0, 0, 0, 0, 0], 2, 1); - assert(check_coefficient_values_with_balance(pol, 1, 5, 2)); + let pol = encode_ballot::([5, 0, 0, 0, 0, 0, 0, 0, 0, 0], 2); + assert(check_coefficient_values_with_balance(pol, 5, 2)); } -/// The top bit of an option segment. The old index arithmetic read the zero padding instead, so a -/// ballot worth `2**24` passed against any balance. +/// The largest weight a coefficient can carry is `t - 1`. It decodes to exactly that value, so a +/// balance one below it refuses the ballot. #[test] -fn test_2_options_top_bit_is_bounded() { - let pol = encode_ballot::([16777216, 0, 0, 0, 0, 0, 0, 0, 0, 0], 2, 1); - assert(!check_coefficient_values_with_balance(pol, 1, 1, 2)); - assert(check_coefficient_values_with_balance(pol, 1, 16777216, 2)); +fn test_largest_weight_decodes_exactly() { + let largest = (PLAINTEXT_MODULUS as u64) - 1; + let pol = encode_ballot::([largest, 0, 0, 0, 0, 0, 0, 0, 0, 0], 2); + + assert(check_coefficient_values_with_balance(pol, largest as Field, 2)); + assert(!check_coefficient_values_with_balance(pol, (largest - 1) as Field, 2)); +} + +/// The prover supplies the weight and the quotient. This pair solves the decode equation for the +/// coefficient `Q_MOD_T - 2 * t`, which is below zero. A coefficient outside `[0, t)` could open +/// `k1_commitment` to a polynomial other than the encrypted plaintext, so it is refused. +#[test(should_fail)] +fn test_coefficient_below_zero_is_refused() { + check_weight(Q_MOD_T - 2 * PLAINTEXT_MODULUS, 1, 2); +} + +/// The prover supplies the weight. A weight the coefficient does not carry is refused, also with +/// the quotient that belongs to that weight. +#[test(should_fail)] +fn test_weight_the_coefficient_does_not_carry_is_refused() { + let quotient = ((Q_MOD_T as u64) * 6) / (PLAINTEXT_MODULUS as u64); + + check_weight(scale(5), 6, quotient as Field); } // --- 3+ Options (Split Voting) --- #[test] fn test_3_options_split_vote() { - let pol = encode_ballot::([40, 0, 60, 0, 0, 0, 0, 0, 0, 0], 3, 1); - assert(check_coefficient_values_with_balance(pol, 1, 100, 3)); + let pol = encode_ballot::([40, 0, 60, 0, 0, 0, 0, 0, 0, 0], 3); + assert(check_coefficient_values_with_balance(pol, 100, 3)); } #[test] fn test_3_options_vote_all_three() { - let pol = encode_ballot::([30, 30, 30, 0, 0, 0, 0, 0, 0, 0], 3, 1); - assert(check_coefficient_values_with_balance(pol, 1, 100, 3)); + let pol = encode_ballot::([30, 30, 30, 0, 0, 0, 0, 0, 0, 0], 3); + assert(check_coefficient_values_with_balance(pol, 100, 3)); } #[test] fn test_3_options_total_exceeds_balance_fails() { - let pol = encode_ballot::([60, 60, 0, 0, 0, 0, 0, 0, 0, 0], 3, 1); - assert(!check_coefficient_values_with_balance(pol, 1, 100, 3)); + let pol = encode_ballot::([60, 60, 0, 0, 0, 0, 0, 0, 0, 0], 3); + assert(!check_coefficient_values_with_balance(pol, 100, 3)); } #[test] fn test_5_options_single_vote() { - let pol = encode_ballot::([0, 0, 50, 0, 0, 0, 0, 0, 0, 0], 5, 1); - assert(check_coefficient_values_with_balance(pol, 1, 100, 5)); + let pol = encode_ballot::([0, 0, 50, 0, 0, 0, 0, 0, 0, 0], 5); + assert(check_coefficient_values_with_balance(pol, 100, 5)); } +/// The last option of the largest round sits at the bottom of the option region, next to the +/// coefficients that must be zero. #[test] -fn test_zero_vote_passes() { - let pol = encode_ballot::([0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 3, 1); - assert(check_coefficient_values_with_balance(pol, 1, 100, 3)); +fn test_last_option_of_largest_round_counts() { + let pol = encode_ballot::([0, 0, 0, 0, 0, 0, 0, 0, 0, 7], MAX_OPTIONS); + + assert(check_coefficient_values_with_balance(pol, 7, MAX_OPTIONS)); + assert(!check_coefficient_values_with_balance(pol, 6, MAX_OPTIONS)); } #[test] -fn test_invalid_coefficient_fails() { - let mut pol: Polynomial = encode_ballot([1, 0, 0, 0, 0, 0, 0, 0, 0, 0], 2, 1); - // Option 0 occupies the top segment, so its least significant coefficient is the last one. - pol.coefficients[TEST_DEGREE - 1] = 2; - - assert(!check_coefficient_values_with_balance(pol, 1, 100, 2)); +fn test_zero_vote_passes() { + let pol = encode_ballot::([0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 3); + assert(check_coefficient_values_with_balance(pol, 100, 3)); } -/// Coefficients the ballot layout does not use must be zero. Before the layout was corrected they -/// were never read, which let any submitter attach a payload to a slot. +/// Coefficients below the option region must be zero, or a submitter could attach a payload the +/// vote rules never look at. #[test] fn test_payload_outside_ballot_region_fails() { - let mut pol: Polynomial = encode_ballot([1, 0, 0, 0, 0, 0, 0, 0, 0, 0], 2, 1); + let mut pol: Polynomial = encode_ballot([1, 0, 0, 0, 0, 0, 0, 0, 0, 0], 2); pol.coefficients[0] = 1; - assert(!check_coefficient_values_with_balance(pol, 1, 100, 2)); + assert(!check_coefficient_values_with_balance(pol, 10, 2)); } -/// With three options the SDK leaves two message coefficients unused, which the reversal puts at -/// the bottom of the ballot region. They belong to no option, so they must be zero. +/// An option the round does not have must carry nothing, although its coefficient sits inside the +/// option region. #[test] -fn test_unused_message_coefficient_fails() { - let mut pol: Polynomial = encode_ballot([1, 0, 0, 0, 0, 0, 0, 0, 0, 0], 3, 1); - // MAX_MSG_NON_ZERO_COEFFS is 50 and three segments of 16 leave two coefficients over. - pol.coefficients[TEST_DEGREE - MAX_MSG_NON_ZERO_COEFFS] = 1; +fn test_unused_option_coefficient_fails() { + let mut pol: Polynomial = encode_ballot([1, 0, 0, 0, 0, 0, 0, 0, 0, 0], 3); + // Option 3, the first one a three-option round does not have. + pol.coefficients[TEST_DEGREE - 1 - 3] = scale(1); - assert(!check_coefficient_values_with_balance(pol, 1, 100, 3)); + assert(!check_coefficient_values_with_balance(pol, 10, 3)); } #[test] @@ -342,12 +343,12 @@ fn test_check_coefficient_zero_passes() { #[test] fn test_check_coefficient_zero_fails() { - let pol = encode_ballot::([1, 0, 0, 0, 0, 0, 0, 0, 0, 0], 3, 1); + let pol = encode_ballot::([1, 0, 0, 0, 0, 0, 0, 0, 0, 0], 3); assert(!check_coefficient_zero(pol, 3)); } /// A mask must be zero everywhere, not only where the vote rules look. This coefficient is outside -/// every option segment, and it used to pass. +/// every option. #[test] fn test_check_coefficient_zero_outside_ballot_region_fails() { let mut pol: Polynomial = Polynomial { coefficients: [0; TEST_DEGREE] }; diff --git a/examples/CRISP/crates/crisp-utils/src/lib.rs b/examples/CRISP/crates/crisp-utils/src/lib.rs index e79dc533ae..22f85e79a0 100644 --- a/examples/CRISP/crates/crisp-utils/src/lib.rs +++ b/examples/CRISP/crates/crisp-utils/src/lib.rs @@ -8,9 +8,10 @@ use e3_bfv_client::decode_bytes_to_vec_u64; use eyre::Result; use num_bigint::BigUint; -/// Number of polynomial coefficients used for the vote payload (must match `@crisp-e3/sdk` / circuits). +/// Number of leading polynomial coefficients that carry the tally (must match `@crisp-e3/sdk` / circuits). /// -/// Splits evenly across options: `segment_size = MAX_MSG_NON_ZERO_COEFFS / num_choices` (e.g. 2 → 25 bits each). +/// Coefficient `o` holds option `o`'s total, so a round has at most this many options. The remaining +/// coefficients up to the BFV polynomial degree are zero padding. pub const MAX_MSG_NON_ZERO_COEFFS: usize = 50; /// Represents decoded vote counts from a tally @@ -20,35 +21,37 @@ pub struct VoteCounts { pub no: BigUint, } -/// Decode an FHE-encrypted tally result into vote counts for each choice. +/// Decode an FHE-encrypted tally result into the total for each choice. /// -/// # Encoding scheme +/// # Layout /// -/// Only the first [`MAX_MSG_NON_ZERO_COEFFS`] coefficients carry the vote; the rest are zero padding -/// to the BFV polynomial degree. With `n` choices, each choice uses -/// `segment_size = floor(MAX_MSG_NON_ZERO_COEFFS / n)` binary coefficients (MSB at the start of the segment). -/// -/// |-- choice 0 --|-- choice 1 --| ... | unused in msg region |-- degree padding --| -/// -/// Homomorphic addition is coefficient-wise, so summed ciphertexts yield per-coefficient sums and this -/// decode recovers each option’s total. +/// Coefficient `o` of the decrypted polynomial is the total weight on option `o`, for +/// `o < num_choices`. Homomorphic addition is coefficient-wise, so the sum of the ballots holds the sum +/// of each option's weights in that option's coefficient. Coefficients at or after `num_choices` are +/// ignored. /// /// # Arguments /// /// * `tally_bytes` - Raw bytes from the FHE decryption, encoding u64 values -/// in little-endian format (8 bytes per coefficient). -/// * `num_choices` - Number of voting options (must match what was used to encode). +/// in little-endian format (8 bytes per coefficient). At least [`MAX_MSG_NON_ZERO_COEFFS`] +/// coefficients are required. +/// * `num_choices` - Number of voting options, from 1 to [`MAX_MSG_NON_ZERO_COEFFS`]. /// /// # Returns /// /// A `Vec` of length `num_choices`, where `results[i]` is the /// total vote weight for choice `i`. -/// pub fn decode_tally(tally_bytes: &[u8], num_choices: usize) -> Result> { if num_choices == 0 { return Err(eyre::eyre!("Number of choices must be positive")); } + if num_choices > MAX_MSG_NON_ZERO_COEFFS { + return Err(eyre::eyre!( + "Number of choices ({num_choices}) exceeds MAX_MSG_NON_ZERO_COEFFS ({MAX_MSG_NON_ZERO_COEFFS})" + )); + } + let values = decode_bytes_to_vec_u64(tally_bytes)?; if values.len() < MAX_MSG_NON_ZERO_COEFFS { @@ -59,94 +62,58 @@ pub fn decode_tally(tally_bytes: &[u8], num_choices: usize) -> Result Vec { - assert!(vote.len() >= 2); - assert!(degree >= MAX_MSG_NON_ZERO_COEFFS); - - let n = vote.len(); - let segment_size = MAX_MSG_NON_ZERO_COEFFS / n; - let max_val = (1u128 << segment_size) - 1; - - let mut out = vec![0u64; degree]; - let mut idx = 0; - - for &value in vote { - assert!( - (value as u128) <= max_val, - "value {value} exceeds max for segment_size {segment_size}" - ); - let bits = format!("{value:b}"); - let bin_len = bits.len(); - for i in 0..segment_size { - let offset = segment_size.saturating_sub(bin_len); - out[idx] = if i < offset { - 0 - } else { - u64::from(bits.as_bytes()[i - offset] - b'0') - }; - idx += 1; - } - } - - while idx < MAX_MSG_NON_ZERO_COEFFS { - out[idx] = 0; - idx += 1; - } - - out + fn coeffs_to_le_bytes(coeffs: &[u64]) -> Vec { + coeffs.iter().flat_map(|c| c.to_le_bytes()).collect() } - fn coeffs_to_le_bytes(coeffs: &[u64]) -> Vec { - let mut bytes = Vec::with_capacity(coeffs.len() * 8); - for c in coeffs { - bytes.extend_from_slice(&c.to_le_bytes()); - } - bytes + /// A tally polynomial of `degree` coefficients with `totals` in the leading coefficients and + /// `noise` in every coefficient from `totals.len()` through the message region and the padding. + fn tally_bytes(totals: &[u64], noise: u64, degree: usize) -> Vec { + let mut coeffs = vec![noise; degree]; + coeffs[..totals.len()].copy_from_slice(totals); + coeffs_to_le_bytes(&coeffs) } #[test] - fn test_decode_tally_matches_sdk_layout() { - let degree = 512; - let coeffs = encode_vote_like_sdk(&[10_000_000u64, 30_000_000u64], degree); - let bytes = coeffs_to_le_bytes(&coeffs); - let result = decode_tally(&bytes, 2).unwrap(); - - assert_eq!(result[0], BigUint::from(10_000_000u64)); - assert_eq!(result[1], BigUint::from(30_000_000u64)); + fn decode_tally_reads_coefficient_per_option_and_ignores_the_rest() { + let totals = [3u64, 0, 17_000_000, u64::MAX]; + let bytes = tally_bytes(&totals, 99, 512); + let result = decode_tally(&bytes, totals.len()).unwrap(); + + let expected: Vec = totals.iter().map(|&t| BigUint::from(t)).collect(); + assert_eq!(result, expected); } #[test] - fn test_decode_tally_wrong_num_options_differs() { - let degree = 512; - let coeffs = encode_vote_like_sdk(&[10_000_000u64, 30_000_000u64], degree); - let bytes = coeffs_to_le_bytes(&coeffs); - let result = decode_tally(&bytes, 3).unwrap(); - - assert_ne!(result[0], BigUint::from(10_000_000u64)); - assert_ne!(result[1], BigUint::from(30_000_000u64)); + fn decode_tally_accepts_exactly_max_choices_at_minimum_length() { + let totals: Vec = (1..=MAX_MSG_NON_ZERO_COEFFS as u64).collect(); + let bytes = coeffs_to_le_bytes(&totals); + let result = decode_tally(&bytes, MAX_MSG_NON_ZERO_COEFFS).unwrap(); + + assert_eq!(result.len(), MAX_MSG_NON_ZERO_COEFFS); + assert_eq!( + result[MAX_MSG_NON_ZERO_COEFFS - 1], + BigUint::from(MAX_MSG_NON_ZERO_COEFFS as u64) + ); + } + + #[test] + fn decode_tally_rejects_invalid_arguments() { + let bytes = tally_bytes(&[1, 2], 0, 512); + assert!(decode_tally(&bytes, 0).is_err()); + assert!(decode_tally(&bytes, MAX_MSG_NON_ZERO_COEFFS + 1).is_err()); + + let short = coeffs_to_le_bytes(&vec![0u64; MAX_MSG_NON_ZERO_COEFFS - 1]); + assert!(decode_tally(&short, 2).is_err()); } } diff --git a/examples/CRISP/crates/evm_helpers/src/lib.rs b/examples/CRISP/crates/evm_helpers/src/lib.rs index ecc985197b..f7b52af743 100644 --- a/examples/CRISP/crates/evm_helpers/src/lib.rs +++ b/examples/CRISP/crates/evm_helpers/src/lib.rs @@ -83,11 +83,9 @@ sol! { function pendingInputCount(uint256 e3Id) external view returns (uint40); function inputCommitmentDeadline(uint256 e3Id) external view returns (uint256); /// The divisor `CRISPProgram` resolved and stored when the round was requested. - /// Zero for a round that is not ONCHAIN, and for a round it never initialized. + /// Non-zero for every CUSTOM-credit round, zero for a CONSTANT-credit round and for a + /// round it never initialized. function votingPowerDivisorOf(uint256 e3Id) external view returns (uint256); - /// The census mode recorded at validation. Declared as a uint8 because that is how - /// Solidity encodes the enum, so no enum declaration has to be mirrored here. - function censusModeOf(uint256 e3Id) external view returns (uint8); function verify( uint256 e3Id, bytes32 ciphertextOutputHash, @@ -133,12 +131,6 @@ sol! { ); } -/// The `CensusMode.ONCHAIN` discriminant, as `CRISPProgram` declares the enum. -/// -/// The third variant, after `TOKEN` and `BY_REQUESTER`. A round of any other mode holds no -/// voting-power divisor. -const CENSUS_MODE_ONCHAIN: u8 = 2; - /// Why a `publishInput` dry run failed. /// /// The two kinds blame different parties, and the relay maps them to different HTTP answers: a @@ -659,26 +651,19 @@ impl CRISPContract { Ok(contract.pendingInputCount(e3_id).call().await?.to::()) } - /// The voting-power divisor `CRISPProgram` stored for an ONCHAIN round. + /// The voting-power divisor `CRISPProgram` stored for a CUSTOM-credit round. /// /// The authority on the divisor. The contract resolves it once, in the transaction that - /// requests the E3, and every input is then scaled by exactly this value. Reading it removes - /// the whole off-chain derivation, so the coordinator cannot disagree with the chain over an - /// optional `decimals()` call or over the width of an intermediate value. + /// requests the E3, and every voter's power is then scaled by exactly this value. Reading it + /// keeps the coordinator from deriving a second value that could disagree with the chain. + /// + /// Call it only for a CUSTOM-credit round, whatever its census mode. A CONSTANT-credit round + /// stores no divisor. /// - /// `Ok(None)` when the round is not ONCHAIN, or when the contract holds no divisor for it. - /// Both are legitimate answers rather than failures, and neither one is authoritative. - pub async fn onchain_voting_power_divisor(&self, e3_id: U256) -> Result> { + /// `Ok(None)` when the contract holds no divisor for the round, which is not authoritative. + pub async fn stored_voting_power_divisor(&self, e3_id: U256) -> Result> { let contract = CRISPProgram::new(self.contract_address, self.provider.as_ref()); - // The mode is checked first. The contract records a divisor for ONCHAIN rounds only, and - // applying one to a Merkle round would scale the census by a factor the tally does not - // read back. - let census_mode = contract.censusModeOf(e3_id).call().await?; - if census_mode != CENSUS_MODE_ONCHAIN { - return Ok(None); - } - let divisor = contract.votingPowerDivisorOf(e3_id).call().await?; if divisor.is_zero() { return Ok(None); diff --git a/examples/CRISP/packages/crisp-contracts/contracts/CRISPProgram.sol b/examples/CRISP/packages/crisp-contracts/contracts/CRISPProgram.sol index 87bbfc942f..e7e5f36208 100644 --- a/examples/CRISP/packages/crisp-contracts/contracts/CRISPProgram.sol +++ b/examples/CRISP/packages/crisp-contracts/contracts/CRISPProgram.sol @@ -30,6 +30,20 @@ interface IInterfoldRegistryView { function ciphernodeRegistry() external view returns (ICiphernodeRegistry); } +/// @notice The BFV parameter blob that Interfold passes to `validate` as `e3ProgramParams`. +/// @dev Mirrors the encoding of one `abi.encode` tuple `(degree, plaintext_modulus, moduli, +/// error1_variance)`. Every encoder must keep this field order: `encode_bfv_params` in +/// `crates/fhe-params/src/encoding.rs`, and `encodeBfvParams` in both +/// `packages/interfold-contracts/scripts/protocol/values.ts` and `packages/interfold-sdk/src/utils.ts`. +/// `CRISPProgram.validate` reads `plaintextModulus` only; the other fields are decoded to keep the +/// layout checked. +struct BfvParameters { + uint256 degree; + uint256 plaintextModulus; + uint256[] moduli; + string error1Variance; +} + contract CRISPProgram is IE3Program, IE3ProgramDataAvailability, IERC165, Ownable, EIP712 { using InternalLazyIMT for LazyIMTData; @@ -96,8 +110,8 @@ contract CRISPProgram is IE3Program, IE3ProgramDataAvailability, IERC165, Ownabl /// commitment, not who submits it, so anyone who observes a committed input can resubmit the /// identical calldata: the proof still verifies and {_processVote} appends again. The tally /// does not change — the replay names the same parent as the original, which is no longer the - /// head, so the Secure Process drops it — but the tree is fixed-depth, so enough replays reach - /// capacity and every later input reverts, denying the round. + /// head, so the Secure Process drops it — but every append counts toward `inputLimit`, so + /// enough replays reach the limit and every later input reverts, denying the round. /// /// Keyed by the leaf rather than the proof because the leaf is exactly what an append adds. /// Two genuinely distinct inputs differ in bytes, commitment, slot or parent, so they differ @@ -115,7 +129,8 @@ contract CRISPProgram is IE3Program, IE3ProgramDataAvailability, IERC165, Ownabl uint256 numOptions; CreditMode creditMode; CensusMode censusMode; - /// @notice The token that voting power is read from. Only used by `CensusMode.ONCHAIN`. + /// @notice The token that voting power is read from. Used by `CensusMode.ONCHAIN` and by every + /// `CreditMode.CUSTOM` round. address token; /// @notice The smallest voting power that may cast an input. Only used by /// `CensusMode.ONCHAIN`, where eligibility is checked per input instead of by a census. @@ -123,11 +138,21 @@ contract CRISPProgram is IE3Program, IE3ProgramDataAvailability, IERC165, Ownabl /// @notice Credits given to each eligible voter under `CreditMode.CONSTANT`. uint256 credits; /// @notice The timepoint that voting power is read at, in the ERC-6372 clock units of the - /// token. Recorded when the round is requested, so it is the same for every input. + /// token. Recorded when the round is requested, so it is the same for every input. Set for + /// `CensusMode.ONCHAIN` rounds and for `CreditMode.CUSTOM` rounds, where the divisor is sized + /// against the total supply at this timepoint. Zero otherwise. uint48 snapshot; - /// @notice Divides raw token voting power into the units the ballot is encoded in. Only used - /// by `CensusMode.ONCHAIN`. Never zero for such a round. + /// @notice Divides raw token voting power into the units the ballot is encoded in. Never + /// zero for a `CreditMode.CUSTOM` round, and zero for a `CreditMode.CONSTANT` round, where no + /// scaling happens. At least `getPastTotalSupply(snapshot) / plaintextModulus + 1`, so the sum + /// of all scaled voting power stays below the plaintext modulus. uint256 votingPowerDivisor; + /// @notice The most inputs the round accepts. Every input (vote, update, mask) counts. + /// @dev `MAX_INPUTS_PER_ROUND` for a `CreditMode.CUSTOM` round. For a `CreditMode.CONSTANT` + /// round with non-zero credits it is lowered so that `credits` times the input count stays + /// below the plaintext modulus. A mask needs no signature, so any account can use up the limit. + /// The contract cannot tell a mask from a vote, so it cannot count votes alone. + uint256 inputLimit; } // Constants @@ -137,18 +162,23 @@ contract CRISPProgram is IE3Program, IE3ProgramDataAvailability, IERC165, Ownabl uint8 public constant TREE_DEPTH = 20; /// @notice Minimum time available to create new input commitments after a worst-case key setup. uint256 public constant MIN_VOTING_DURATION = 1 hours; - /// @notice Number of leading plaintext coefficients that carry the vote payload. + /// @notice Number of leading plaintext coefficients that carry the tally. /// @dev Must stay aligned with `@crisp-e3/sdk` and `crisp_utils` (`MAX_MSG_NON_ZERO_COEFFS`). - /// The remaining coefficients up to the BFV degree are zero padding. + /// Coefficient `o` for `o < numOptions` is the total on option `o`; the rest of these + /// coefficients, and the coefficients up to the BFV degree, are zero padding. uint256 constant MAX_MSG_NON_ZERO_COEFFS = 50; /// @notice Maximum number of vote options a round may configure. /// @dev Bounded by the Noir circuit, which asserts `num_options <= MAX_OPTIONS` /// (`circuits/lib/src/constants.nr`). A round above this accepts no ballot, because every /// vote proof fails. Must stay aligned with the SDK constant of the same name. uint256 constant MAX_VOTE_OPTIONS = 10; - /// @notice Largest `decimals` a divisor can be derived from: `10 ** 77` is the last power of ten - /// that fits in a uint256. - uint8 constant MAX_DERIVABLE_DECIMALS = 78; + /// @notice The most inputs any round accepts. + /// @dev Every input (vote, update, mask) adds one fresh ciphertext to a slot's chain, so the + /// input count bounds the fresh ciphertexts that the tally sums. The BFV parameters keep the + /// decryption correct for a fixed number of such additions (`SEARCH_Z` in + /// `crates/fhe-params/src/constants.rs`). Every secure parameter set that CRISP runs on must + /// allow at least this many. The insecure-512 test preset allows fewer. + uint256 public constant MAX_INPUTS_PER_ROUND = 100_000; // State variables IInterfold public interfold; IOpenVmReceiptVerifier public openVmVerifier; @@ -215,9 +245,23 @@ contract CRISPProgram is IE3Program, IE3ProgramDataAvailability, IERC165, Ownabl error SlotNotEligible(); error InvalidCensusMode(); - /// @notice A token reports more decimals than a divisor can be derived from. - /// @dev `10 ** (decimals - 1)` must fit in a uint256. Pass an explicit divisor for such a token. - error UnsupportedTokenDecimals(uint8 decimals); + /// @notice A `CreditMode.CONSTANT` round grants credits that do not fit in the BFV plaintext + /// modulus. + /// @dev One ballot of `credits` or more could wrap an option total, so the round is refused. + error CreditsExceedPlaintextModulus(uint256 credits, uint256 plaintextModulus); + + /// @notice The requested voting-power divisor would let the scaled voting power sum to the + /// plaintext modulus or more. + /// @dev `minimum` is `getPastTotalSupply(snapshot) / plaintextModulus + 1`. + error VotingPowerDivisorBelowMinimum(uint256 divisor, uint256 minimum); + + /// @notice A `CreditMode.CUSTOM` round needs a votes token that answers `getPastTotalSupply`. + /// @dev The divisor is sized against the total supply at the snapshot, so a round without such + /// a token cannot be sized and is refused in the request transaction. + error CustomCreditsRequireVotesToken(); + + /// @notice The round already holds as many inputs as it accepts. + error InputLimitReached(uint256 e3Id, uint256 limit); /// @notice An ONCHAIN round's floor is below one ballot unit. /// @dev `minVotingPower` must be at least the divisor, so every slot that clears the floor @@ -405,10 +449,12 @@ contract CRISPProgram is IE3Program, IE3ProgramDataAvailability, IERC165, Ownabl numberOfVotes = round.votes.numberOfLeaves; } - /// @notice The divisor applied to raw token voting power for a `CensusMode.ONCHAIN` round. - /// @dev A client must divide by exactly this before proving, because the contract passes the - /// scaled value to the circuit as public input 4 and the proof is checked against it. Zero for - /// rounds that are not ONCHAIN, where no scaling happens. + /// @notice The divisor applied to raw token voting power in a `CreditMode.CUSTOM` round. + /// @dev A client must divide by exactly this before proving. For an ONCHAIN round the contract + /// passes the scaled value to the circuit as public input 4 and the proof is checked against + /// it; for a TOKEN round the coordinator scales every census leaf by it. Never zero for a + /// `CreditMode.CUSTOM` round, whatever the census mode. Zero for a `CreditMode.CONSTANT` round, + /// where no scaling happens. /// @param e3Id The E3 to look up. /// @return The divisor recorded at validation. function votingPowerDivisorOf(uint256 e3Id) external view returns (uint256) { @@ -460,9 +506,11 @@ contract CRISPProgram is IE3Program, IE3ProgramDataAvailability, IERC165, Ownabl // this check the owner can create parallel CRISP round state for another program's E3. _requireAssignedE3(e3Id); - // Delegated to its own frame rather than scoped inline: `validate` is close enough to the - // stack limit that holding the six decoded values alongside the parameters exceeds it. - _initRound(e3Id, customParams); + // The plaintext modulus bounds every option total of the round. It comes from the registered + // BFV parameters, so the contract sizes the round against the ciphertext arithmetic that will + // actually run. Delegated to its own frame rather than scoped inline: `validate` is close + // enough to the stack limit that holding the decoded values alongside the parameters exceeds it. + _initRound(e3Id, customParams, abi.decode(e3ProgramParams, (BfvParameters)).plaintextModulus); _validateInputTiming(e3Id); e3Data[e3Id].paramsHash = keccak256(e3ProgramParams); @@ -542,7 +590,9 @@ contract CRISPProgram is IE3Program, IE3ProgramDataAvailability, IERC165, Ownabl /// instead of a bare panic. /// @param e3Id The E3 being configured. /// @param customParams The ABI-encoded round configuration. - function _initRound(uint256 e3Id, bytes calldata customParams) internal { + /// @param plaintextModulus The BFV plaintext modulus `t` of the round. Every option total is + /// kept below it. + function _initRound(uint256 e3Id, bytes calldata customParams, uint256 plaintextModulus) internal { ( address token, uint256 minVotingPower, @@ -550,7 +600,7 @@ contract CRISPProgram is IE3Program, IE3ProgramDataAvailability, IERC165, Ownabl CreditMode creditMode, uint256 credits, uint256 rawCensusMode, - uint256 votingPowerDivisor + uint256 requestedDivisor ) = abi.decode(customParams, (address, uint256, uint256, CreditMode, uint256, uint256, uint256)); // The circuit asserts `num_options <= MAX_OPTIONS`, so a round configured above it accepts no @@ -589,49 +639,130 @@ contract CRISPProgram is IE3Program, IE3ProgramDataAvailability, IERC165, Ownabl round.minVotingPower = minVotingPower; round.credits = credits; - // The snapshot is taken here rather than supplied by the requester. This function runs in the - // transaction that requests the E3, so `clock() - 1` is the last finalized timepoint of the - // round, and a requester cannot name a timepoint that suits it. Recording it once also makes - // every input of the round read the same electorate. - if (CensusMode(rawCensusMode) == CensusMode.ONCHAIN) { - // Checked before any call is attempted. A call to an address with no code succeeds and - // returns nothing, so `clock()` fails while decoding the empty return data rather than - // inside the call — and a decode failure is not what `try/catch` is there to catch. An EOA - // would otherwise be refused by a bare panic instead of a named error. - if (token.code.length == 0) revert CensusModeRequiresToken(); - - uint48 snapshot = _previousTimepoint(token); - - // Probe the exact call every input will make. `_previousTimepoint` swallows a missing - // `clock()` and falls back to block numbers, which is right for a token that predates - // ERC-6372 but also lets an address that is not a votes token pass validation — and then - // every `publishInput` reverts inside `getPastVotes`, after the fee is paid. + _initCredits(round, requestedDivisor, plaintextModulus); + } + + /// @notice Record how the round weighs a ballot and how many inputs it accepts, so every + /// option total stays below the plaintext modulus. + /// @dev A decrypted coefficient is exact only below `t`, the plaintext modulus. The tally holds + /// one total per option, so the bound is on the sum of all weights in the round: + /// + /// - CONSTANT credits: each input carries at most `credits` weight, so `(t - 1) / credits` + /// inputs keep the sum below `t`. No divisor is recorded. + /// - CUSTOM credits: the scaled voting power of every holder sums to at most + /// `totalSupply / divisor`, so a divisor of at least `totalSupply / t + 1` keeps the sum below + /// `t`. The supply is read at the snapshot, the timepoint every voting-power read uses. This + /// relies on the token: its past votes over all accounts must not sum above its past supply, + /// which ERC20Votes guarantees. + /// + /// The snapshot is taken here rather than supplied by the requester. This function runs in the + /// transaction that requests the E3, so `clock() - 1` is the last finalized timepoint of the + /// round, and a requester cannot name a timepoint that suits it. Recording it once also makes + /// every input of the round read the same electorate. + /// @param round The round being configured. Its census mode, credit mode, token, credits and + /// floor are already recorded. + /// @param requestedDivisor The divisor the requester asked for, or zero for the minimum. Ignored + /// by a CONSTANT round. + /// @param plaintextModulus The BFV plaintext modulus `t`. + function _initCredits(RoundData storage round, uint256 requestedDivisor, uint256 plaintextModulus) internal { + bool onchain = round.censusMode == CensusMode.ONCHAIN; + + if (round.creditMode == CreditMode.CONSTANT) { + round.inputLimit = _constantCreditsInputLimit(round.credits, plaintextModulus); + // A CONSTANT round hands the circuit `credits`, but ONCHAIN still reads eligibility from + // the token at the snapshot. + if (onchain) round.snapshot = _tokenSnapshot(round.token, true); + return; + } + + uint48 snapshot = _tokenSnapshot(round.token, onchain); + uint256 divisor = _customCreditsDivisor(round.token, snapshot, requestedDivisor, plaintextModulus); + + // Where the floor and the divisor meet, the floor is raw and the bound is scaled, so they only + // agree when the floor is worth at least one ballot unit. Requiring it here means every slot + // that passes `_eligibility` carries weight, and it costs nothing: this reverts in the + // transaction that requests the E3, not per input after the fee is paid. It also keeps masks + // working — they run the same eligibility check as real votes, so a slot that scaled to zero + // could not be masked without revealing which inputs were masks. A Merkle round takes its + // floor from the census, so the check applies to ONCHAIN only. + if (onchain && round.minVotingPower < divisor) revert MinVotingPowerBelowScale(); + + round.snapshot = snapshot; + round.votingPowerDivisor = divisor; + round.inputLimit = MAX_INPUTS_PER_ROUND; + } + + /// @notice The number of inputs a CONSTANT-credit round accepts. + /// @dev Zero credits carry no weight into the tally, so only the global limit applies. Otherwise + /// `credits` times the limit stays at most `t - 1`, below the plaintext modulus. + /// @param credits The credits every voter carries. + /// @param plaintextModulus The BFV plaintext modulus `t`. + /// @return The input limit of the round. + function _constantCreditsInputLimit(uint256 credits, uint256 plaintextModulus) internal pure returns (uint256) { + if (credits == 0) return MAX_INPUTS_PER_ROUND; + // One ballot of `t` credits or more already wraps an option total on its own. + if (credits >= plaintextModulus) revert CreditsExceedPlaintextModulus(credits, plaintextModulus); + + uint256 limit = (plaintextModulus - 1) / credits; + return limit < MAX_INPUTS_PER_ROUND ? limit : MAX_INPUTS_PER_ROUND; + } + + /// @notice Resolve the snapshot of a round that reads voting power from a token. + /// @dev Checked before any call is attempted. A call to an address with no code succeeds and + /// returns nothing, so `clock()` fails while decoding the empty return data rather than inside + /// the call — and a decode failure is not what `try/catch` is there to catch. An EOA would + /// otherwise be refused by a bare panic instead of a named error. + /// + /// An ONCHAIN round also probes the exact call every input will make. `_previousTimepoint` + /// swallows a missing `clock()` and falls back to block numbers, which is right for a token that + /// predates ERC-6372 but also lets an address that is not a votes token pass validation — and + /// then every `publishInput` reverts inside `getPastVotes`, after the fee is paid. + /// @param token The token voting power is read from. + /// @param onchain Whether the round is `CensusMode.ONCHAIN`, which names its own errors. + /// @return The last finalized timepoint of the token. + function _tokenSnapshot(address token, bool onchain) internal view returns (uint48) { + if (token.code.length == 0) { + if (onchain) revert CensusModeRequiresToken(); + revert CustomCreditsRequireVotesToken(); + } + + uint48 snapshot = _previousTimepoint(token); + + if (onchain) { try IVotesToken(token).getPastVotes(address(0), snapshot) returns (uint256) {} catch { revert CensusModeRequiresToken(); } + } + + return snapshot; + } - round.snapshot = snapshot; - - // Derived only after the code check above. `decimals()` on a codeless address returns empty - // data, and the failure happens while decoding rather than inside the call, which `try` does - // not catch — deriving any earlier would refuse an EOA with a bare panic instead of the - // named error the check above raises. - uint256 divisor = votingPowerDivisor == 0 ? _defaultVotingPowerDivisor(token) : votingPowerDivisor; - - // Only CUSTOM credits take the circuit bound from scaled power; a CONSTANT round hands the - // circuit `credits` and never reads the scaled value, so the floor and the divisor have - // nothing to agree about there. - // - // Where they do meet, the floor is raw and the bound is scaled, so they only agree when the - // floor is worth at least one ballot unit. Requiring it here means every slot that passes - // `_eligibility` carries weight, and it costs nothing: this reverts in the transaction that - // requests the E3, not per input after the fee is paid. It also keeps masks working — they - // run the same eligibility check as real votes, so a slot that scaled to zero could not be - // masked without revealing which inputs were masks. - if (creditMode == CreditMode.CUSTOM && minVotingPower < divisor) revert MinVotingPowerBelowScale(); - - round.votingPowerDivisor = divisor; + /// @notice The divisor of a CUSTOM-credit round: the requested one, or the minimum. + /// @dev The minimum is `totalSupply / t + 1`. The scaled power of all holders sums to at most + /// `totalSupply / divisor`, which is below `t` exactly when the divisor exceeds + /// `totalSupply / t`. Runs after the code check in {_tokenSnapshot}: `getPastTotalSupply` on a + /// codeless address fails while decoding, which `try` does not catch. + /// @param token The votes token. + /// @param snapshot The timepoint the supply is read at. + /// @param requestedDivisor The requested divisor, or zero to take the minimum. + /// @param plaintextModulus The BFV plaintext modulus `t`. + /// @return divisor The divisor to record, never zero. + function _customCreditsDivisor( + address token, + uint48 snapshot, + uint256 requestedDivisor, + uint256 plaintextModulus + ) internal view returns (uint256 divisor) { + uint256 supply; + try IVotesToken(token).getPastTotalSupply(snapshot) returns (uint256 pastSupply) { + supply = pastSupply; + } catch { + revert CustomCreditsRequireVotesToken(); } + + uint256 minimum = supply / plaintextModulus + 1; + divisor = requestedDivisor == 0 ? minimum : requestedDivisor; + if (divisor < minimum) revert VotingPowerDivisorBelowMinimum(divisor, minimum); } /// @inheritdoc IE3Program @@ -787,6 +918,11 @@ contract CRISPProgram is IE3Program, IE3ProgramDataAvailability, IERC165, Ownabl bytes32 encryptedVoteHash, uint40 parentIndexPlusOne ) internal view { + // Checked on every proof path, so `validateInputProof` cannot accept an input that + // `publishInput` rejects. Every leaf the tree holds is an input, pending or finalized. + uint256 limit = e3Data[e3Id].inputLimit; + if (e3Data[e3Id].votes.numberOfLeaves >= limit) revert InputLimitReached(e3Id, limit); + // A zero content hash matches an Avail padding leaf. Refuse it on every proof path so that // no committed input can later finalize against data that no party published, and so that // `validateInputProof` cannot accept a statement that `publishInput` rejects. @@ -851,7 +987,8 @@ contract CRISPProgram is IE3Program, IE3ProgramDataAvailability, IERC165, Ownabl /// address are the whole difference between the two paths. /// @param e3Id The E3 the input belongs to. /// @param slotAddress The slot the input is written to. - /// @return eligibility The Merkle root of the census, or the voting power of the slot. + /// @return eligibility The Merkle root of the census, or the voting power of the slot in the + /// units the circuit bounds the ballot by. /// @return verifier The verifier that matches the circuit of this round. function _eligibility(uint256 e3Id, address slotAddress) internal view returns (bytes32 eligibility, IHonkVerifier verifier) { RoundData storage round = e3Data[e3Id]; @@ -870,38 +1007,15 @@ contract CRISPProgram is IE3Program, IE3ProgramDataAvailability, IERC165, Ownabl uint256 threshold = round.minVotingPower == 0 ? 1 : round.minVotingPower; if (rawPower < threshold) revert SlotNotEligible(); - // Scaled only for the circuit. It enforces `vote <= voting_power`, and the BFV encoding caps - // each choice at `2**(100/numOptions) - 1` — about 8.6e9 for three options. Raw power from an - // 18-decimal token is ~1e18 per token, so handing it over unscaled would put every holder - // above the cap and collapse token weighting into a flat ceiling. Dividing mirrors what the - // coordinator does when it builds a Merkle census (`balance / 10**(decimals - 1)`), so both - // census families encode ballots in the same units and a tally decodes the same way. - uint256 power = rawPower / round.votingPowerDivisor; - // Eligibility comes from the power at the snapshot. The weight the circuit enforces comes from - // the credit mode, so a CONSTANT round gives every eligible slot the same credits. - return (bytes32(round.creditMode == CreditMode.CONSTANT ? round.credits : power), onchainHonkVerifier); - } - - /// @notice The divisor to apply to raw voting power when the requester does not name one. - /// @dev Mirrors the coordinator's census scaling (`balance / 10**(decimals - 1)`), so an ONCHAIN - /// round and a Merkle round over the same token encode ballots in identical units. `decimals()` - /// is optional on an ERC20, so a token without it is left unscaled rather than rejected — a - /// requester that needs scaling for such a token passes an explicit divisor. - /// @param token The token voting power is read from. - /// @return The divisor, never zero. - function _defaultVotingPowerDivisor(address token) internal view returns (uint256) { - try IVotesToken(token).decimals() returns (uint8 dec) { - // `10 ** 78` does not fit in a uint256, and the exponentiation happens in the success body - // of the `try`, where a revert is NOT caught — an absurd `decimals` would surface as a bare - // arithmetic panic instead of a named error, which is the failure mode the code check above - // exists to avoid. Refused explicitly; such a token can still be used by naming a divisor. - if (dec > MAX_DERIVABLE_DECIMALS) revert UnsupportedTokenDecimals(dec); - - return dec > 1 ? 10 ** (uint256(dec) - 1) : 1; - } catch { - return 1; - } + // the credit mode: a CONSTANT round gives every eligible slot the same credits, unscaled. + if (round.creditMode == CreditMode.CONSTANT) return (bytes32(round.credits), onchainHonkVerifier); + + // Scaled only for the circuit, which enforces `vote <= voting_power` in ballot units. Raw + // power from an 18-decimal token is ~1e18 per token, far above the plaintext modulus. The + // divisor, at least `totalSupply / t + 1` since `validate`, keeps the sum of all scaled voting + // power below `t`, which keeps every option total exact. + return (bytes32(rawPower / round.votingPowerDivisor), onchainHonkVerifier); } /// @notice The last finalized timepoint of a token, in its ERC-6372 clock units. @@ -918,12 +1032,14 @@ contract CRISPProgram is IE3Program, IE3ProgramDataAvailability, IERC165, Ownabl } /// @notice Decode the tally from the plaintext output - /// @dev Each coefficient counts the selected slots whose ballot sets that bit, modulo the - /// plaintext modulus that the committee decrypts under: 100 for insecure-512 and 17,000,000 for - /// secure-8192. The count is exact only while fewer ballots than the modulus set the same bit in - /// one round. This contract does not enforce that bound. + /// @dev The decrypted polynomial holds one coefficient per option, so the decrypted tally holds + /// per-option totals: coefficient `o` is the total weight on option `o`. The total is exact + /// because `validate` keeps it below the plaintext modulus `t` that the committee decrypts + /// under. A CUSTOM-credit round bounds it through the divisor, which keeps the sum of all + /// scaled voting power below `t`. A CONSTANT-credit round bounds it through the input limit, + /// which keeps `credits` times the input count below `t`. /// @param e3Id The E3 program ID - /// @return votes - an array of vote counts for each option + /// @return votes - an array with the total weight of each option function decodeTally(uint256 e3Id) public view returns (uint256[] memory votes) { E3 memory e3 = interfold.getE3(e3Id); @@ -941,23 +1057,10 @@ contract CRISPProgram is IE3Program, IE3ProgramDataAvailability, IERC165, Ownabl // the polynomial is zero padding and must not be read. if (tally.length < MAX_MSG_NON_ZERO_COEFFS) revert InvalidTallyLength(); - uint256 segmentSize = MAX_MSG_NON_ZERO_COEFFS / numOptions; - // More options than payload coefficients leaves nothing to decode. - if (segmentSize == 0) return new uint256[](0); - votes = new uint256[](numOptions); for (uint256 optIdx = 0; optIdx < numOptions; optIdx++) { - uint256 segmentStart = optIdx * segmentSize; - uint256 value = 0; - - // Each segment holds the count in binary, most significant coefficient first. - for (uint256 i = 0; i < segmentSize; i++) { - uint256 weight = 2 ** (segmentSize - 1 - i); - value += uint256(tally[segmentStart + i]) * weight; - } - - votes[optIdx] = value; + votes[optIdx] = tally[optIdx]; } return votes; diff --git a/examples/CRISP/packages/crisp-contracts/contracts/Mocks/MockInterfold.sol b/examples/CRISP/packages/crisp-contracts/contracts/Mocks/MockInterfold.sol index ee52d52526..d733a3e566 100644 --- a/examples/CRISP/packages/crisp-contracts/contracts/Mocks/MockInterfold.sol +++ b/examples/CRISP/packages/crisp-contracts/contracts/Mocks/MockInterfold.sol @@ -11,6 +11,7 @@ import { IE3Program } from "@interfold/contracts/contracts/interfaces/IE3Program import { IDecryptionVerifier } from "@interfold/contracts/contracts/interfaces/IDecryptionVerifier.sol"; import { IPkVerifier } from "@interfold/contracts/contracts/interfaces/IPkVerifier.sol"; import { ICiphernodeRegistry } from "@interfold/contracts/contracts/interfaces/ICiphernodeRegistry.sol"; +import { BfvParameters } from "../CRISPProgram.sol"; contract MockInterfold { bytes32 public constant ENCRYPTION_SCHEME_ID = keccak256("fhe.rs:BFV"); @@ -24,6 +25,33 @@ contract MockInterfold { /// @dev Defaults to the value the timing tests relied on before it was settable. uint256 public mockComputeWindow = 100; + /// @notice The BFV parameter blob that every request passes to `validate` as `e3ProgramParams`. + /// @dev Decoded by `CRISPProgram` as a {BfvParameters}, so it must be the encoding Interfold + /// hands a program. Insecure-512 (degree 512, plaintext modulus 100), the preset the deploy + /// scripts and tests run against. A test that needs another plaintext modulus passes `validate` + /// the blob from {bfvParamsWithPlaintextModulus}. + bytes public e3ProgramParams; + + constructor() { + uint256[] memory moduli = new uint256[](2); + moduli[0] = 0xffffee001; + moduli[1] = 0xffffc4001; + e3ProgramParams = abi.encode(BfvParameters({ degree: 512, plaintextModulus: 100, moduli: moduli, error1Variance: "3" })); + } + + /// @notice The plaintext modulus of {e3ProgramParams}, which every round is sized against. + function plaintextModulus() external view returns (uint256) { + return abi.decode(e3ProgramParams, (BfvParameters)).plaintextModulus; + } + + /// @notice {e3ProgramParams} with another plaintext modulus, encoded by the struct `validate` + /// decodes. + function bfvParamsWithPlaintextModulus(uint256 modulus) external view returns (bytes memory) { + BfvParameters memory params = abi.decode(e3ProgramParams, (BfvParameters)); + params.plaintextModulus = modulus; + return abi.encode(params); + } + uint256 public nextE3Id; mapping(uint256 => E3) public e3s; @@ -90,7 +118,7 @@ contract MockInterfold { ciphertextCommitment: bytes32(0) }); - IE3Program(program).validate(nextE3Id, 0, bytes(""), bytes(""), params); + IE3Program(program).validate(nextE3Id, 0, e3ProgramParams, bytes(""), params); nextE3Id++; numOptions; // silence unused-parameter warning; the count travels inside `params` @@ -116,7 +144,7 @@ contract MockInterfold { ciphertextCommitment: bytes32(0) }); - IE3Program(program).validate(nextE3Id, 0, bytes(""), bytes(""), abi.encode(address(0), nextE3Id, numOptions, 0, 0, 0, 0)); + IE3Program(program).validate(nextE3Id, 0, e3ProgramParams, bytes(""), abi.encode(address(0), nextE3Id, numOptions, 0, 0, 0, 0)); nextE3Id++; } diff --git a/examples/CRISP/packages/crisp-contracts/contracts/Mocks/MockVotesToken.sol b/examples/CRISP/packages/crisp-contracts/contracts/Mocks/MockVotesToken.sol index 063fa09731..065d459401 100644 --- a/examples/CRISP/packages/crisp-contracts/contracts/Mocks/MockVotesToken.sol +++ b/examples/CRISP/packages/crisp-contracts/contracts/Mocks/MockVotesToken.sol @@ -11,9 +11,10 @@ import { ERC20Votes } from "@openzeppelin/contracts/token/ERC20/extensions/ERC20 import { Nonces } from "@openzeppelin/contracts/utils/Nonces.sol"; /// @title MockVotesToken -/// @notice An ERC20Votes token for exercising `CensusMode.ONCHAIN`. -/// @dev `MockVotingToken` is a plain ERC20 with no `getPastVotes`, which makes it the negative -/// case for the token probe in `CRISPProgram._initRound`. This one is the positive case. +/// @notice An ERC20Votes token for exercising `CensusMode.ONCHAIN` and `CreditMode.CUSTOM` rounds. +/// @dev `MockVotingToken` is a plain ERC20 with no `getPastVotes` or `getPastTotalSupply`, which +/// makes it the negative case for the token probes in `CRISPProgram._initRound`. This one is the +/// positive case. /// /// Uses the timestamp clock, matching `InterfoldToken`. A round records its snapshot in whatever /// units the token reports, so a mock on the default block-number clock would exercise a diff --git a/examples/CRISP/packages/crisp-contracts/contracts/Mocks/MockVotingToken.sol b/examples/CRISP/packages/crisp-contracts/contracts/Mocks/MockVotingToken.sol index 17693f2bf6..c999b30c1d 100644 --- a/examples/CRISP/packages/crisp-contracts/contracts/Mocks/MockVotingToken.sol +++ b/examples/CRISP/packages/crisp-contracts/contracts/Mocks/MockVotingToken.sol @@ -9,11 +9,10 @@ import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; /// @title MockVotingToken /// @notice A mock voting token for testing purposes -/// @dev Public mint function that allows to keep balances to 1e9. -/// @dev by default CRISP server will scale down voting power by 1e18/2 -/// @dev in this case leaving everyone with a balance of 1 to vote yes or no +/// @dev Public mint grants 1e9 base units to an account that holds none, and caps every balance at +/// `MAX_BALANCE`. A plain ERC20 without checkpoints: it cannot back a round that reads voting +/// power at a snapshot (`CensusMode.ONCHAIN` or `CreditMode.CUSTOM`). Use `MockVotesToken` there. contract MockVotingToken is ERC20 { - // half of 10e18 uint256 public constant MAX_BALANCE = 1e9; constructor() ERC20("Mock Voting Token", "MVT") { @@ -27,8 +26,4 @@ contract MockVotingToken is ERC20 { } _mint(to, 1e9); } - - function decimals() public pure override returns (uint8) { - return 18; - } } diff --git a/examples/CRISP/packages/crisp-contracts/contracts/SelfRegistry.sol b/examples/CRISP/packages/crisp-contracts/contracts/SelfRegistry.sol index 72dea0d33b..48657b9e13 100644 --- a/examples/CRISP/packages/crisp-contracts/contracts/SelfRegistry.sol +++ b/examples/CRISP/packages/crisp-contracts/contracts/SelfRegistry.sol @@ -8,9 +8,9 @@ pragma solidity >=0.8.27; /// @notice An open self-registration census for `CensusMode.ONCHAIN` rounds. /// @dev Anyone may register themselves, once, and registration is permanent. The contract answers /// the `IVotesToken` surface `CRISPProgram._eligibility` reads: `getPastVotes` returns 1 for a -/// registered account and 0 otherwise. It has no `decimals()`, so the voting-power divisor -/// derives to 1, and no `clock()`, so the round snapshot falls back to block numbers — both -/// fallbacks `CRISPProgram` already implements. +/// registered account and 0 otherwise. It has no `clock()`, so the round snapshot falls back to +/// block numbers, which `CRISPProgram` already implements. It has no `getPastTotalSupply`, so it +/// serves `CreditMode.CONSTANT` rounds only, the round shape this census is meant for. /// /// `getPastVotes` deliberately ignores the timepoint. An honest checkpointed answer would fix the /// electorate at the round's snapshot, which is taken in the transaction that requests the round — diff --git a/examples/CRISP/packages/crisp-contracts/contracts/interfaces/IVotesToken.sol b/examples/CRISP/packages/crisp-contracts/contracts/interfaces/IVotesToken.sol index 972202d9d2..e16c21d003 100644 --- a/examples/CRISP/packages/crisp-contracts/contracts/interfaces/IVotesToken.sol +++ b/examples/CRISP/packages/crisp-contracts/contracts/interfaces/IVotesToken.sol @@ -5,9 +5,12 @@ // or FITNESS FOR A PARTICULAR PURPOSE. pragma solidity >=0.8.27; -/// @notice The subset of an ERC20Votes token that `CensusMode.ONCHAIN` reads. -/// @dev Required, not optional. A round that names a token which cannot answer this is rejected -/// at request time, because every input would otherwise revert here after the fee is paid. +/// @notice The subset of an ERC20Votes token that CRISP reads. +/// @dev `getPastVotes` is read by `CensusMode.ONCHAIN` eligibility, once per input. +/// `getPastTotalSupply` is read by every `CreditMode.CUSTOM` round when it is requested, to size +/// the voting-power divisor. Both are required. A round that names a token which cannot answer +/// the call it needs is rejected at request time, because every input would otherwise revert after +/// the fee is paid. interface IVotesToken { /// @notice The voting power of an account at a past timepoint. /// @param account The account to read. @@ -15,9 +18,10 @@ interface IVotesToken { /// @return The voting power at that timepoint. function getPastVotes(address account, uint256 timepoint) external view returns (uint256); - /// @notice The token's decimals, used to derive the default voting-power divisor. - /// @dev Optional: a token without it falls back to a divisor of 1, so the probe must not be - /// treated as a requirement the way `getPastVotes` is. - /// @return The number of decimals. - function decimals() external view returns (uint8); + /// @notice The total supply of voting units at a past timepoint. + /// @dev CRISP assumes it bounds the sum of every account's voting power at the same timepoint. + /// ERC20Votes guarantees this. + /// @param timepoint The timepoint, in the ERC-6372 clock units of the token. + /// @return The total supply at that timepoint. + function getPastTotalSupply(uint256 timepoint) external view returns (uint256); } diff --git a/examples/CRISP/packages/crisp-contracts/contracts/verifiers/CRISPOnchainVerifier.sol b/examples/CRISP/packages/crisp-contracts/contracts/verifiers/CRISPOnchainVerifier.sol index 3a348c1123..f1ee93ff53 100644 --- a/examples/CRISP/packages/crisp-contracts/contracts/verifiers/CRISPOnchainVerifier.sol +++ b/examples/CRISP/packages/crisp-contracts/contracts/verifiers/CRISPOnchainVerifier.sol @@ -8,7 +8,7 @@ pragma solidity >=0.8.21; uint256 constant N = 2097152; uint256 constant LOG_N = 21; uint256 constant NUMBER_OF_PUBLIC_INPUTS = 17; -uint256 constant VK_HASH = 0x2bb762e5d4085e12682ccc0c471359cc4fbcb9a873a5252262a61a7a39e10e28; +uint256 constant VK_HASH = 0x087b347c0c36c9c435e29eb9becb2e349021edc54119c90ef05789e529958e5e; library HonkVerificationKey { function loadVerificationKey() internal pure returns (Honk.VerificationKey memory) { Honk.VerificationKey memory vk = Honk.VerificationKey({ @@ -20,8 +20,8 @@ library HonkVerificationKey { y: uint256(0x01137e39f6b1ec6101fad7a4472102bfe11e7534e55e58109734c4b927efe6a6) }), qr: Honk.G1Point({ - x: uint256(0x203afec06a2fd3df8a65dd36f9279bbeba70264daa389db9d364dde80748e8bd), - y: uint256(0x133c32c14b514b2163ef5f27a85cf78ed9459c3c708021f065e883c30d05f0f9) + x: uint256(0x0d47561eecc2d9f53b0600e6485b8bf75c413c89ea96c23feecc7f32f0de886e), + y: uint256(0x1c25a5ac9c2a80715f717333c0e5038ce0da41a21422d43c8fa3d3894d8de47d) }), qo: Honk.G1Point({ x: uint256(0x18424976826978ddb710bfdc21c64a296884c27b0d8dadf6ac7681445dde8d8c), diff --git a/examples/CRISP/packages/crisp-contracts/contracts/verifiers/CRISPVerifier.sol b/examples/CRISP/packages/crisp-contracts/contracts/verifiers/CRISPVerifier.sol index 0e86b961bc..17d15c22cc 100644 --- a/examples/CRISP/packages/crisp-contracts/contracts/verifiers/CRISPVerifier.sol +++ b/examples/CRISP/packages/crisp-contracts/contracts/verifiers/CRISPVerifier.sol @@ -8,7 +8,7 @@ pragma solidity >=0.8.21; uint256 constant N = 2097152; uint256 constant LOG_N = 21; uint256 constant NUMBER_OF_PUBLIC_INPUTS = 17; -uint256 constant VK_HASH = 0x08f6c91eee84ac56d47e539b22f4ee2446144c876555dbb32174313fabf78610; +uint256 constant VK_HASH = 0x0737a10b7c3ebd432c2017ce7f11bfafb6a42283a876ec1095571bb8e825f5ba; library HonkVerificationKey { function loadVerificationKey() internal pure returns (Honk.VerificationKey memory) { Honk.VerificationKey memory vk = Honk.VerificationKey({ @@ -20,8 +20,8 @@ library HonkVerificationKey { y: uint256(0x01137e39f6b1ec6101fad7a4472102bfe11e7534e55e58109734c4b927efe6a6) }), qr: Honk.G1Point({ - x: uint256(0x142a06a1868f732012de3cdd373d319d96655a1ed39bf30679ca2d307774ad3a), - y: uint256(0x2004812309b18f67c74ba9c23b07784562ced1a0521bba59d63fbbdcc4c7da40) + x: uint256(0x0496945b93204262e8cb0efa8f57db0eb9ba1ca06919091b7179b8956cc100bf), + y: uint256(0x0353d0480d2c24fd6b2a70097a3f8119a7a9bca70a001bcba6bd9c8db0cea016) }), qo: Honk.G1Point({ x: uint256(0x18424976826978ddb710bfdc21c64a296884c27b0d8dadf6ac7681445dde8d8c), diff --git a/examples/CRISP/packages/crisp-contracts/tests/census-mode.test.ts b/examples/CRISP/packages/crisp-contracts/tests/census-mode.test.ts index 778dff9ecc..24cf45d28c 100644 --- a/examples/CRISP/packages/crisp-contracts/tests/census-mode.test.ts +++ b/examples/CRISP/packages/crisp-contracts/tests/census-mode.test.ts @@ -5,8 +5,8 @@ // or FITNESS FOR A PARTICULAR PURPOSE. import { expect } from 'chai' -import { deployCRISPProgram, ethers } from './utils' -import type { CRISPProgram } from '../types' +import { deployCRISPProgram, deployMockInterfold, ethers } from './utils' +import type { CRISPProgram, MockInterfold } from '../types' const CONSTANT = 0 const CUSTOM = 1 @@ -23,31 +23,41 @@ const ONCHAIN = 2 describe('CRISPProgram census mode', function () { this.timeout(120000) + let mockInterfold: MockInterfold let crispProgram: CRISPProgram let owner: string + /// The BFV parameter blob Interfold hands `validate`, and the plaintext modulus inside it. + let bfvParams: string + let t: bigint const encode = ( creditMode: number, censusMode?: number, numOptions = 2, - opts: { token?: string; credits?: number; divisor?: number; minVotingPower?: bigint } = {}, + opts: { token?: string; credits?: bigint; divisor?: bigint; minVotingPower?: bigint } = {}, ) => { const types = ['address', 'uint256', 'uint256', 'uint256', 'uint256'] - const values: unknown[] = [opts.token ?? ethers.ZeroAddress, opts.minVotingPower ?? 0n, numOptions, creditMode, opts.credits ?? 1] + const values: unknown[] = [opts.token ?? ethers.ZeroAddress, opts.minVotingPower ?? 0n, numOptions, creditMode, opts.credits ?? 1n] if (censusMode !== undefined) { types.push('uint256', 'uint256') - // 0 means "derive the divisor from the token's decimals". - values.push(censusMode, opts.divisor ?? 0) + // 0 means "use the smallest divisor that keeps the round below the plaintext modulus". + values.push(censusMode, opts.divisor ?? 0n) } return ethers.AbiCoder.defaultAbiCoder().encode(types, values) } - const validate = (e3Id: number, params: string) => crispProgram.validate(e3Id, 0, '0x', '0x', params) + const validate = (e3Id: number, params: string) => crispProgram.validate(e3Id, 0, bfvParams, '0x', params) + + /// The smallest divisor that keeps the sum of every scaled balance below `t`. + const minimumDivisor = (supply: bigint, modulus = t) => supply / modulus + 1n beforeEach(async () => { - crispProgram = await deployCRISPProgram() + mockInterfold = await deployMockInterfold() + crispProgram = await deployCRISPProgram({ mockInterfold }) owner = (await ethers.getSigners())[0].address expect(await crispProgram.owner()).to.equal(owner, 'validate is owner-callable in these tests') + bfvParams = await mockInterfold.e3ProgramParams() + t = await mockInterfold.plaintextModulus() }) /// Required, not optional. A caller that omits it must fail loudly rather than silently receive @@ -74,15 +84,10 @@ describe('CRISPProgram census mode', function () { await expect(validate(4, encode(CUSTOM, BY_REQUESTER))).to.be.revertedWithCustomError(crispProgram, 'CensusModeRequiresConstantCredits') }) - it('still allows custom credits with token discovery', async () => { - await validate(5, encode(CUSTOM, TOKEN)) - expect(await crispProgram.censusModeOf(5)).to.equal(TOKEN) - }) - it('keeps census root publication owner-only', async () => { const [, availabilitySigner] = await ethers.getSigners() const program = await deployCRISPProgram({ inputAvailabilitySigner: availabilitySigner.address }) - await program.validate(7, 0, '0x', '0x', encode(CUSTOM, TOKEN)) + await program.validate(7, 0, bfvParams, '0x', encode(CONSTANT, TOKEN)) await expect(program.connect(availabilitySigner).setMerkleRoot(7, 123)) .to.be.revertedWithCustomError(program, 'OwnableUnauthorizedAccount') @@ -98,6 +103,104 @@ describe('CRISPProgram census mode', function () { await expect(validate(6, encode(CONSTANT, 3))).to.be.revertedWithCustomError(crispProgram, 'InvalidCensusMode') }) + /// The tally holds one total per option and a total is exact only below `t`. A CONSTANT round + /// carries `credits` per input, so one ballot of `t` credits or more could wrap a total by itself. + describe('constant credits and the plaintext modulus', () => { + it('rejects credits at or above the plaintext modulus and accepts the largest below it', async () => { + await expect(validate(30, encode(CONSTANT, TOKEN, 2, { credits: t }))) + .to.be.revertedWithCustomError(crispProgram, 'CreditsExceedPlaintextModulus') + .withArgs(t, t) + + await validate(31, encode(CONSTANT, TOKEN, 2, { credits: t - 1n })) + expect(await crispProgram.censusModeOf(31)).to.equal(TOKEN) + }) + + it('sizes the round against the plaintext modulus of the registered parameters', async () => { + // The same credits are refused under the default modulus and accepted under a larger one, + // so the modulus is read from the blob and not fixed in the contract. + const credits = t + const wider = t * 1000n + await expect(validate(32, encode(CONSTANT, TOKEN, 2, { credits }))).to.be.revertedWithCustomError( + crispProgram, + 'CreditsExceedPlaintextModulus', + ) + + const widerParams = await mockInterfold.bfvParamsWithPlaintextModulus(wider) + await crispProgram.validate(33, 0, widerParams, '0x', encode(CONSTANT, TOKEN, 2, { credits })) + expect(await crispProgram.censusModeOf(33)).to.equal(TOKEN) + }) + + it('records no divisor, whatever the request names', async () => { + await validate(34, encode(CONSTANT, TOKEN, 2, { divisor: 7n })) + + expect(await crispProgram.votingPowerDivisorOf(34)).to.equal(0n) + }) + }) + + /// CUSTOM credits weight each ballot by token voting power. The sum of every holder's scaled + /// power must stay below `t`, which holds exactly when the divisor exceeds `supply / t`. + describe('custom credits divisor', () => { + const deployVotes = async () => { + const votes = await ethers.deployContract('MockVotesToken') + await votes.waitForDeployment() + return votes + } + + it('rejects TOKEN with custom credits and no votes token', async () => { + // No code at the address: refused before any call, by a named error. + await expect(validate(10, encode(CUSTOM, TOKEN))).to.be.revertedWithCustomError(crispProgram, 'CustomCreditsRequireVotesToken') + + // Code, but no `getPastTotalSupply`: refused by the call itself. + const plain = await ethers.deployContract('MockVotingToken') + await expect(validate(11, encode(CUSTOM, TOKEN, 2, { token: await plain.getAddress() }))).to.be.revertedWithCustomError( + crispProgram, + 'CustomCreditsRequireVotesToken', + ) + }) + + it('rejects a divisor below the minimum and accepts exactly the minimum', async () => { + const votes = await deployVotes() + const token = await votes.getAddress() + // The supply is a multiple of `t`, so a divisor of `supply / t` lets the scaled power sum to + // exactly `t` — one past what a coefficient holds. The minimum is strictly above it. + const minimum = minimumDivisor(await votes.totalSupply()) + + await expect(validate(12, encode(CUSTOM, TOKEN, 2, { token, divisor: minimum - 1n }))) + .to.be.revertedWithCustomError(crispProgram, 'VotingPowerDivisorBelowMinimum') + .withArgs(minimum - 1n, minimum) + + await validate(13, encode(CUSTOM, TOKEN, 2, { token, divisor: minimum })) + expect(await crispProgram.votingPowerDivisorOf(13)).to.equal(minimum) + }) + + it('resolves a requested divisor of zero to exactly the minimum', async () => { + const votes = await deployVotes() + + await validate(14, encode(CUSTOM, TOKEN, 2, { token: await votes.getAddress() })) + + expect(await crispProgram.votingPowerDivisorOf(14)).to.equal(minimumDivisor(await votes.totalSupply())) + }) + + it('keeps an explicit divisor above the minimum', async () => { + const votes = await deployVotes() + const larger = minimumDivisor(await votes.totalSupply()) * 2n + + await validate(15, encode(CUSTOM, TOKEN, 2, { token: await votes.getAddress(), divisor: larger })) + + expect(await crispProgram.votingPowerDivisorOf(15)).to.equal(larger) + }) + + it('sizes the minimum against the plaintext modulus of the registered parameters', async () => { + const votes = await deployVotes() + const wider = t * 1000n + const widerParams = await mockInterfold.bfvParamsWithPlaintextModulus(wider) + + await crispProgram.validate(16, 0, widerParams, '0x', encode(CUSTOM, TOKEN, 2, { token: await votes.getAddress() })) + + expect(await crispProgram.votingPowerDivisorOf(16)).to.equal(minimumDivisor(await votes.totalSupply(), wider)) + }) + }) + /// ONCHAIN reads every voter's power from the token, one input at a time. A round that names no /// token, or names something that cannot answer `getPastVotes`, accepts no ballot at all — so it /// is refused in the request transaction rather than after the fee is paid. @@ -139,16 +242,17 @@ describe('CRISPProgram census mode', function () { const votes = await ethers.deployContract('MockVotesToken') await votes.waitForDeployment() const token = await votes.getAddress() + const divisor = minimumDivisor(await votes.totalSupply()) - // Derived divisor is 10 ** (18 - 1); a floor under that admits sub-unit voters. - await expect(validate(40, encode(CUSTOM, ONCHAIN, 2, { token, minVotingPower: 10n ** 17n - 1n }))).to.be.revertedWithCustomError( + // A floor under the divisor admits sub-unit voters. + await expect(validate(40, encode(CUSTOM, ONCHAIN, 2, { token, minVotingPower: divisor - 1n }))).to.be.revertedWithCustomError( crispProgram, 'MinVotingPowerBelowScale', ) // Exactly one ballot unit is enough. - await validate(41, encode(CUSTOM, ONCHAIN, 2, { token, minVotingPower: 10n ** 17n })) - expect(await crispProgram.votingPowerDivisorOf(41)).to.equal(10n ** 17n) + await validate(41, encode(CUSTOM, ONCHAIN, 2, { token, minVotingPower: divisor })) + expect(await crispProgram.votingPowerDivisorOf(41)).to.equal(divisor) }) it('rejects ONCHAIN with constant credits of zero', async () => { @@ -156,25 +260,27 @@ describe('CRISPProgram census mode', function () { const votes = await ethers.deployContract('MockVotesToken') await expect( - validate(22, encode(CONSTANT, ONCHAIN, 2, { token: await votes.getAddress(), credits: 0 })), + validate(22, encode(CONSTANT, ONCHAIN, 2, { token: await votes.getAddress(), credits: 0n })), ).to.be.revertedWithCustomError(crispProgram, 'InvalidCredits') }) it('accepts ONCHAIN with a votes token', async () => { const votes = await ethers.deployContract('MockVotesToken') + const divisor = minimumDivisor(await votes.totalSupply()) // CUSTOM credits take the bound from scaled power, so the floor must be worth a ballot unit. - await validate(23, encode(CUSTOM, ONCHAIN, 2, { token: await votes.getAddress(), minVotingPower: 10n ** 17n })) + await validate(23, encode(CUSTOM, ONCHAIN, 2, { token: await votes.getAddress(), minVotingPower: divisor })) expect(await crispProgram.censusModeOf(23)).to.equal(ONCHAIN) }) - it('allows constant credits when the allowance is non-zero', async () => { + it('records no divisor for constant credits', async () => { const votes = await ethers.deployContract('MockVotesToken') - await validate(24, encode(CONSTANT, ONCHAIN, 2, { token: await votes.getAddress(), credits: 5 })) + await validate(24, encode(CONSTANT, ONCHAIN, 2, { token: await votes.getAddress(), credits: 5n, divisor: 7n })) expect(await crispProgram.censusModeOf(24)).to.equal(ONCHAIN) + expect(await crispProgram.votingPowerDivisorOf(24)).to.equal(0n) }) }) diff --git a/examples/CRISP/packages/crisp-contracts/tests/crisp.contracts.test.ts b/examples/CRISP/packages/crisp-contracts/tests/crisp.contracts.test.ts index adc02c941b..d123cd67ef 100644 --- a/examples/CRISP/packages/crisp-contracts/tests/crisp.contracts.test.ts +++ b/examples/CRISP/packages/crisp-contracts/tests/crisp.contracts.test.ts @@ -255,8 +255,6 @@ describe('CRISP Contracts', function () { describe('get round data', () => { // The dynamic input tree has a minimum depth of one (InternalLazyIMT.Z_1). const EMPTY_TREE_ROOT = 14744269619966411208579211824598458697587494354926760081771325075741142829156n - // MockInterfold calls validate with empty e3ProgramParams - const EMPTY_PARAMS_HASH = ethers.keccak256('0x') it('should return empty data for an e3 which was not initialized', async () => { const e3Id = await mockInterfold.nextE3Id() @@ -278,7 +276,8 @@ describe('CRISP Contracts', function () { const [merkleRoot, paramsHash, numOptions, creditMode, inputRoot, numberOfVotes] = await crispProgram.getRoundData(e3Id) expect(merkleRoot).to.equal(0n) - expect(paramsHash).to.equal(EMPTY_PARAMS_HASH) + // The program hashes the BFV parameter blob that MockInterfold passes to `validate`. + expect(paramsHash).to.equal(ethers.keccak256(await mockInterfold.e3ProgramParams())) expect(numOptions).to.equal(2n) // CreditMode.CONSTANT expect(creditMode).to.equal(0n) diff --git a/examples/CRISP/packages/crisp-contracts/tests/crisp.journal.test.ts b/examples/CRISP/packages/crisp-contracts/tests/crisp.journal.test.ts index 0596ebd081..c8e625215c 100644 --- a/examples/CRISP/packages/crisp-contracts/tests/crisp.journal.test.ts +++ b/examples/CRISP/packages/crisp-contracts/tests/crisp.journal.test.ts @@ -10,12 +10,13 @@ describe('CRISP journal', () => { it('should match the journal returned by the OpenVM guest', async () => { const ciphertextHash = ethers.hexlify(Uint8Array.from({ length: 32 }, (_, index) => index)) const ciphertextCommitment = ethers.hexlify(Uint8Array.from({ length: 32 }, (_, index) => index + 32)) - const paramsHash = ethers.keccak256('0x') const inputRoot = '0x2098f5fb9e239eab3ceac3f27b81e481dc3124d55ffed523a839ee8446b64864' const committeePublicKey = `0x${'33'.repeat(32)}` const encryptionSchemeId = ethers.keccak256(ethers.toUtf8Bytes('fhe.rs:BFV')) const mockInterfold = await deployMockInterfold() + // The program hashes the BFV parameter blob that Interfold passes to `validate`. + const paramsHash = ethers.keccak256(await mockInterfold.e3ProgramParams()) await mockInterfold.setCommitteePublicKey(committeePublicKey) const chainId = (await ethers.provider.getNetwork()).chainId const journal = ethers.concat([ diff --git a/examples/CRISP/packages/crisp-contracts/tests/input-availability-flow.test.ts b/examples/CRISP/packages/crisp-contracts/tests/input-availability-flow.test.ts index 581036bcf5..316c5b2b9b 100644 --- a/examples/CRISP/packages/crisp-contracts/tests/input-availability-flow.test.ts +++ b/examples/CRISP/packages/crisp-contracts/tests/input-availability-flow.test.ts @@ -23,7 +23,8 @@ const FINALIZATION_WINDOW = 30 const PRODUCTION_FINALIZATION_WINDOW = 10_800 describe('CRISP input availability flow', function () { - async function openRound() { + /// Opens a round with the mock's default parameters, or with the given `customParams`. + async function openRound(customParams?: string) { const mockInterfold = await deployMockInterfold() const mockHonk = (await deployContract('MockHonkVerifier')) as unknown as HonkVerifier const program = await deployCRISPProgram({ @@ -37,7 +38,10 @@ describe('CRISP input availability flow', function () { const end = start + 3_700 await (await mockInterfold.setInputWindow(start, end)).wait() const e3Id = await mockInterfold.nextE3Id() - await (await mockInterfold.request(await program.getAddress())).wait() + const programAddress = await program.getAddress() + await ( + await (customParams ? mockInterfold.requestWithParams(programAddress, 2, customParams) : mockInterfold.request(programAddress)) + ).wait() await (await mockInterfold.setCommitteePublicKey(ethers.id('committee-key'))).wait() await (await program.setMerkleRoot(e3Id, 1)).wait() await increaseTimeTo(start) @@ -398,4 +402,46 @@ describe('CRISP input availability flow', function () { .to.be.revertedWithCustomError(program, 'InputWindowTooShort') .withArgs(e3Id, FINALIZATION_WINDOW, FINALIZATION_WINDOW + 1) }) + + /// Every input adds one ciphertext to a slot's chain, and a CONSTANT round carries `credits` per + /// input, so its input count is capped to keep their sum below the plaintext modulus. Both entry + /// points must refuse alike: a service that pre-checks with `validateInputProof` must not see an + /// input accepted that `publishInput` then rejects. + it('accepts inputs up to the round limit and rejects the next from both entry points', async function () { + const t = await (await deployMockInterfold()).plaintextModulus() + // The largest input count that keeps `credits * count` below `t` is 2 for these credits. + const credits = (t - 1n) / 2n + const limit = (t - 1n) / credits + const params = abiCoder.encode( + ['address', 'uint256', 'uint256', 'uint256', 'uint256', 'uint256', 'uint256'], + [ethers.ZeroAddress, 0n, 2, 0, credits, 0, 0], + ) + const { program, e3Id } = await openRound(params) + + for (let i = 0; i < Number(limit); i++) { + const accepted = await input(program, e3Id, `within-limit-${i}`) + expect( + await program.validateInputProof( + e3Id, + '0x01', + accepted.slotAddress, + accepted.encryptedVoteCommitment, + accepted.encryptedVoteHash, + 0, + ), + ).to.equal(true) + await expect(program.publishInput(e3Id, accepted.commitmentPayload)).to.emit(program, 'InputCommitted') + } + expect((await program.getRoundData(e3Id)).numberOfVotes).to.equal(limit) + + const refused = await input(program, e3Id, 'over-limit') + await expect( + program.validateInputProof(e3Id, '0x01', refused.slotAddress, refused.encryptedVoteCommitment, refused.encryptedVoteHash, 0), + ) + .to.be.revertedWithCustomError(program, 'InputLimitReached') + .withArgs(e3Id, limit) + await expect(program.publishInput(e3Id, refused.commitmentPayload)) + .to.be.revertedWithCustomError(program, 'InputLimitReached') + .withArgs(e3Id, limit) + }) }) diff --git a/examples/CRISP/packages/crisp-contracts/tests/interfold-binding.test.ts b/examples/CRISP/packages/crisp-contracts/tests/interfold-binding.test.ts index b23879f8a4..93ead92125 100644 --- a/examples/CRISP/packages/crisp-contracts/tests/interfold-binding.test.ts +++ b/examples/CRISP/packages/crisp-contracts/tests/interfold-binding.test.ts @@ -43,24 +43,25 @@ describe('CRISP Interfold binding', function () { ['address', 'uint256', 'uint256', 'uint256', 'uint256', 'uint256', 'uint256'], [ethers.ZeroAddress, 0n, 2, 0, 1, 0, 0], ) + const bfvParams = await mockInterfold.e3ProgramParams() // Interfold reports E3 1 as assigned to a different program. Bind the exact ID under test, // so an implementation that reads another E3 record cannot pass. await (await mockInterfold.setE3ProgramFor(1, otherProgram.address)).wait() - await expect(program.validate(1, 0, '0x', '0x', params)) + await expect(program.validate(1, 0, bfvParams, '0x', params)) .to.be.revertedWithCustomError(program, 'E3NotAssignedToProgram') .withArgs(1) // A different E3 is assigned to this program. E3 1 is still not, so reading the wrong // record would wrongly succeed here. await (await mockInterfold.setE3ProgramFor(2, await program.getAddress())).wait() - await expect(program.validate(1, 0, '0x', '0x', params)) + await expect(program.validate(1, 0, bfvParams, '0x', params)) .to.be.revertedWithCustomError(program, 'E3NotAssignedToProgram') .withArgs(1) // The same E3, once Interfold assigns it to this program, initializes normally. await (await mockInterfold.setE3ProgramFor(1, await program.getAddress())).wait() - await (await program.validate(1, 0, '0x', '0x', params)).wait() + await (await program.validate(1, 0, bfvParams, '0x', params)).wait() expect((await program.getRoundData(1)).numOptions).to.equal(2) }) }) diff --git a/examples/CRISP/packages/crisp-contracts/tests/onchain-census.test.ts b/examples/CRISP/packages/crisp-contracts/tests/onchain-census.test.ts index 0188a2d473..403fe1c494 100644 --- a/examples/CRISP/packages/crisp-contracts/tests/onchain-census.test.ts +++ b/examples/CRISP/packages/crisp-contracts/tests/onchain-census.test.ts @@ -80,12 +80,11 @@ describe('CRISP on-chain census', function () { creditMode: number credits: bigint censusMode: number - /// 0 means "derive the divisor from the token's decimals". - votingPowerDivisor?: bigint }) => ethers.AbiCoder.defaultAbiCoder().encode( ['address', 'uint256', 'uint256', 'uint256', 'uint256', 'uint256', 'uint256'], - [opts.token, opts.minVotingPower, opts.numOptions, opts.creditMode, opts.credits, opts.censusMode, opts.votingPowerDivisor ?? 0n], + // A divisor of 0 asks for the smallest one that keeps the round below the plaintext modulus. + [opts.token, opts.minVotingPower, opts.numOptions, opts.creditMode, opts.credits, opts.censusMode, 0n], ) before(async function () { @@ -106,6 +105,8 @@ describe('CRISP on-chain census', function () { await ethers.provider.send('evm_mine', []) e3Id = await mockInterfold.nextE3Id() + // The smallest divisor `validate` accepts. The floor must be worth at least one ballot unit. + const minimumDivisor = (await token.totalSupply()) / (await mockInterfold.plaintextModulus()) + 1n // CUSTOM credits, so the weight the circuit enforces is the token balance itself rather than a // flat per-voter allowance. That is what makes this exercise the token read. const requestTx = await mockInterfold.requestWithParams( @@ -113,7 +114,7 @@ describe('CRISP on-chain census', function () { numOptions, encodeParams({ token: await token.getAddress(), - minVotingPower: 10n ** 17n, + minVotingPower: minimumDivisor, numOptions, creditMode: CUSTOM, credits: 1n, @@ -134,9 +135,12 @@ describe('CRISP on-chain census', function () { // prover has to use the same value. Read the divisor from the round rather than recomputing // it, which also pins the getter clients depend on. const divisor = await crispProgram.votingPowerDivisorOf(e3Id) - expect(divisor, 'derived from the token decimals: 10 ** (18 - 1)').to.equal(10n ** 17n) + expect(divisor, 'the minimum: supply / t + 1').to.equal(minimumDivisor) votingPower = rawPower / divisor + // The voter holds most of the supply, so the scaled power carries the weight the ballot proves + // and stays below the plaintext modulus. + expect(votingPower, 'voter must carry the ballot weight').to.be.greaterThanOrEqual(BigInt(Math.max(...vote))) voteProof = await buildOnchainProof(votingPower) }) @@ -215,15 +219,17 @@ describe('CRISP on-chain census', function () { }) /// The contract reads the power from the token rather than trusting the ballot. A proof built - /// for a different power therefore fails, which is what stops a voter inflating their own weight. + /// for a different power therefore fails, which is what stops a voter choosing their own weight. + /// The mismatched power stays below the plaintext modulus, so the ballot is valid in every other + /// way. /// - /// The inflated ballot goes first, while the slot is empty. After the honest ballot the slot would - /// already hold a vote, so the inflated ballot would also mismatch on `prev_ct_commitment` and + /// The mismatched ballot goes first, while the slot is empty. After the honest ballot the slot would + /// already hold a vote, so the mismatched ballot would also mismatch on `prev_ct_commitment` and /// `is_first_vote`: it would still revert, but not for the reason under test. it('publishes an ONCHAIN ballot and rejects one that proves a power the token does not report', async function () { - const inflated = await buildOnchainProof(votingPower * 2n) - await (await mockInterfold.setCommitteePublicKey(inflated.publicInputs[8])).wait() - await expect(publishAvailableInput(crispProgram, e3Id, encodeSolidityProof(inflated))).to.be.revert(ethers) + const mismatched = await buildOnchainProof(votingPower - 1n) + await (await mockInterfold.setCommitteePublicKey(mismatched.publicInputs[8])).wait() + await expect(publishAvailableInput(crispProgram, e3Id, encodeSolidityProof(mismatched))).to.be.revert(ethers) // Positive control in the same round and the same slot: the honest power publishes. The only // difference between the two ballots is the power, so the revert above is attributable to it. @@ -231,39 +237,6 @@ describe('CRISP on-chain census', function () { await publishAvailableInput(crispProgram, e3Id, encodeSolidityProof(voteProof)) }) - /// A requester that needs different precision names its own divisor; 0 means "derive it". - it('honours an explicit divisor', async function () { - const id = await mockInterfold.nextE3Id() - await ( - await mockInterfold.requestWithParams( - await crispProgram.getAddress(), - numOptions, - encodeParams({ - token: await token.getAddress(), - // A coarser divisor demands a proportionally higher floor: the round is refused unless - // clearing it is worth at least one ballot unit. - minVotingPower: 10n ** 18n, - numOptions, - creditMode: CUSTOM, - credits: 1n, - censusMode: ONCHAIN, - votingPowerDivisor: 10n ** 18n, - }), - ) - ).wait() - - expect(await crispProgram.votingPowerDivisorOf(id)).to.equal(10n ** 18n) - }) - - /// Only ONCHAIN scales. A Merkle round records no divisor, because its bound comes from the - /// census leaf the coordinator has already scaled. - it('records no divisor for a non-ONCHAIN round', async function () { - const id = await mockInterfold.nextE3Id() - await (await mockInterfold.request(await crispProgram.getAddress())).wait() - - expect(await crispProgram.votingPowerDivisorOf(id)).to.equal(0n) - }) - /// The check the shared-verifier substitution can never make: the two verifiers are not /// interchangeable. If the constructor arguments were ever swapped, ONCHAIN ballots would be /// checked by the Merkle verifier and this would be the test that noticed. diff --git a/examples/CRISP/packages/crisp-contracts/tests/self-registry.test.ts b/examples/CRISP/packages/crisp-contracts/tests/self-registry.test.ts index 4596dfe60b..e7a836ba56 100644 --- a/examples/CRISP/packages/crisp-contracts/tests/self-registry.test.ts +++ b/examples/CRISP/packages/crisp-contracts/tests/self-registry.test.ts @@ -5,7 +5,7 @@ // or FITNESS FOR A PARTICULAR PURPOSE. import { expect } from 'chai' -import { deployContract, deployCRISPProgram, ethers } from './utils' +import { deployContract, deployCRISPProgram, deployMockInterfold, ethers } from './utils' import type { CRISPProgram } from '../types' const CONSTANT = 0 @@ -77,11 +77,13 @@ describe('SelfRegistry', function () { await expect(registry.connect(accounts[2]).register()).to.emit(registry, 'Registered').withArgs(accounts[2].address, 1) }) - /// The registry has no `decimals()` and no `clock()`, so `CRISPProgram.validate` must accept it - /// through both fallbacks: a divisor of 1 and a block-number snapshot. This is the round shape - /// the registry is meant for — CONSTANT credits of 1 and a floor of 1, one registrant one vote. + /// The registry has no `clock()`, so `CRISPProgram.validate` must accept it through the + /// block-number snapshot fallback. This is the round shape the registry is meant for — CONSTANT + /// credits of 1 and a floor of 1, one registrant one vote. Constant credits are never scaled, so + /// the round records no divisor and a slot's voting power is the credits themselves. it('validates as the token of an ONCHAIN round', async () => { - const crispProgram: CRISPProgram = await deployCRISPProgram() + const mockInterfold = await deployMockInterfold() + const crispProgram: CRISPProgram = await deployCRISPProgram({ mockInterfold }) const registryAddress = await registry.getAddress() const params = ethers.AbiCoder.defaultAbiCoder().encode( @@ -89,11 +91,10 @@ describe('SelfRegistry', function () { [registryAddress, 1n, 2, CONSTANT, 1n, ONCHAIN, 0n], ) - await (await crispProgram.validate(1, 0, '0x', '0x', params)).wait() + await (await crispProgram.validate(1, 0, await mockInterfold.e3ProgramParams(), '0x', params)).wait() expect(await crispProgram.censusModeOf(1)).to.equal(ONCHAIN) - // No `decimals()` on the registry, so the divisor derives to 1. - expect(await crispProgram.votingPowerDivisorOf(1)).to.equal(1n) + expect(await crispProgram.votingPowerDivisorOf(1)).to.equal(0n) // CONSTANT credits: every eligible slot weighs the configured 1. expect(await crispProgram.votingPowerOf(1, accounts[0].address)).to.equal(1n) }) diff --git a/examples/CRISP/packages/crisp-contracts/tests/tally.decoding.test.ts b/examples/CRISP/packages/crisp-contracts/tests/tally.decoding.test.ts index 926d8edc27..b9d21aa94c 100644 --- a/examples/CRISP/packages/crisp-contracts/tests/tally.decoding.test.ts +++ b/examples/CRISP/packages/crisp-contracts/tests/tally.decoding.test.ts @@ -7,12 +7,14 @@ /** * Cross-layer tally decoding tests. * - * A round produces `plaintextOutput` as the full decrypted BFV polynomial: one - * little-endian uint64 per coefficient, `degree` coefficients long (Rust - * `encode_vec_u64_to_bytes` in `crates/bfv-client`). Three decoders read that - * blob — the SDK (`decodeTally`), the CRISP server (`crisp_utils::decode_tally`) - * and the contract (`CRISPProgram.decodeTally`). These tests build the blob with - * the real SDK encoder and assert all readers agree. + * A ballot stores one integer per option: coefficient `o` of the message polynomial is the weight + * on option `o`. BFV adds ballots coefficient by coefficient, so a round produces `plaintextOutput` + * as the full decrypted polynomial with the total of option `o` at coefficient `o`: one + * little-endian uint64 per coefficient, `degree` coefficients long (Rust `encode_vec_u64_to_bytes` + * in `crates/bfv-client`). Three decoders read that blob: the SDK (`decodeTally`), the CRISP server + * (`crisp_utils::decode_tally`) and the contract (`CRISPProgram.decodeTally`), which the interfold + * dashboard calls. These tests build the blob with the real SDK encoder and assert the SDK and the + * contract agree. */ import { encodeVote, decodeTally, MAX_MSG_NON_ZERO_COEFFS, MAX_VOTE_OPTIONS } from '@crisp-e3/sdk' @@ -45,10 +47,13 @@ describe('Tally decoding (SDK vs CRISPProgram)', function () { let mockInterfold: MockInterfold let crispProgram: CRISPProgram + /// The plaintext modulus of the parameters the program under test validates against. + let t: bigint before(async function () { mockInterfold = await deployMockInterfold() crispProgram = await deployCRISPProgram({ mockInterfold }) + t = await mockInterfold.plaintextModulus() }) /** Publish a plaintext output for a fresh E3 and read back the on-chain tally. */ @@ -62,18 +67,19 @@ describe('Tally decoding (SDK vs CRISPProgram)', function () { } describe('encoder layout', function () { - it('should place the payload in the first MAX_MSG_NON_ZERO_COEFFS coefficients and pad the rest', function () { - const coefficients = encodeVote([12345, 6789]) + it('should place the weight of option o at coefficient o and zero everything else', function () { + const coefficients = encodeVote([5, 0, 7]) + expect(coefficients.slice(0, 3)).to.deep.equal([5, 0, 7]) // The encoder emits the full polynomial, not just the message region. expect(coefficients.length).to.be.greaterThan(MAX_MSG_NON_ZERO_COEFFS) - expect(coefficients.slice(MAX_MSG_NON_ZERO_COEFFS).every((c) => c === 0)).to.equal(true) + expect(coefficients.slice(3).every((c) => c === 0)).to.equal(true) }) }) describe('contract agreement', function () { it('should decode a single encoded ballot the same way the SDK does', async function () { - const vote = [10000000, 30000000] + const vote = [Number(t / 4n), Number(t / 2n)] const coefficients = encodeVote(vote) const onChain = await decodeOnChain(coefficients, 2) @@ -84,27 +90,29 @@ describe('Tally decoding (SDK vs CRISPProgram)', function () { }) it('should decode an aggregated round the same way the SDK does', async function () { + // Ballots on different options, with every option total below `t`. + const unit = Number(t / 10n) const ballots = [ - [100, 0], - [0, 250], - [0, 250], - [75, 0], + [unit, 0], + [0, 2 * unit], + [0, 2 * unit], + [3 * unit, 0], ] const coefficients = aggregateBallots(ballots.map((ballot) => encodeVote(ballot))) const onChain = await decodeOnChain(coefficients, 2) const offChain = decodeTally(packCoefficients(coefficients), 2) - expect(offChain).to.deep.equal([175n, 500n]) + expect(offChain).to.deep.equal([BigInt(4 * unit), BigInt(4 * unit)]) expect(Array.from(onChain)).to.deep.equal(offChain) }) - it('should decode a three-option round the same way the SDK does', async function () { + it('should sum a three-option round whose ballots spread weight over several options', async function () { const coefficients = aggregateBallots( [ - [3, 0, 0], - [0, 4, 0], - [0, 0, 5], + [2, 3, 4], + [1, 1, 1], + [0, 0, 0], ].map((ballot) => encodeVote(ballot)), ) @@ -114,6 +122,46 @@ describe('Tally decoding (SDK vs CRISPProgram)', function () { expect(offChain).to.deep.equal([3n, 4n, 5n]) expect(Array.from(onChain)).to.deep.equal(offChain) }) + + // `validate` keeps every option total below `t`, so `t - 1` is the largest total a round can + // hold, and the one a decoder is most likely to get wrong. + it('should decode a total of t - 1 exactly', async function () { + const first = Number(t / 2n) + const second = Number(t) - 1 - first + const coefficients = aggregateBallots([encodeVote([first, 0]), encodeVote([second, 0])]) + + const onChain = await decodeOnChain(coefficients, 2) + const offChain = decodeTally(packCoefficients(coefficients), 2) + + expect(offChain).to.deep.equal([t - 1n, 0n]) + expect(Array.from(onChain)).to.deep.equal(offChain) + }) + + it('should read only the first numOptions coefficients', async function () { + const coefficients = encodeVote([4, 6]) + coefficients[2] = 99 + + const onChain = await decodeOnChain(coefficients, 2) + const offChain = decodeTally(packCoefficients(coefficients), 2) + + expect(offChain).to.deep.equal([4n, 6n]) + expect(Array.from(onChain)).to.deep.equal(offChain) + }) + }) + + describe('payload length', function () { + // The payload lives in the first MAX_MSG_NON_ZERO_COEFFS coefficients, so a shorter output is + // not a tally. + it('should reject a plaintext output shorter than the payload in both decoders', async function () { + const tooShort = new Array(MAX_MSG_NON_ZERO_COEFFS - 1).fill(0) + const e3Id = await mockInterfold.nextE3Id() + + await mockInterfold.requestWithOptions(await crispProgram.getAddress(), 2) + await mockInterfold.setPlaintextOutput(packCoefficients(tooShort)) + + await expect(crispProgram.decodeTally(e3Id)).to.be.revertedWithCustomError(crispProgram, 'InvalidTallyLength') + expect(() => decodeTally(packCoefficients(tooShort), 2)).to.throw() + }) }) describe('option count bounds', function () { diff --git a/examples/CRISP/packages/crisp-sdk/README.md b/examples/CRISP/packages/crisp-sdk/README.md index 9ea5530044..48bdda87ff 100644 --- a/examples/CRISP/packages/crisp-sdk/README.md +++ b/examples/CRISP/packages/crisp-sdk/README.md @@ -62,7 +62,7 @@ const sdk = new CrispSDK(serverUrl) // Generate a vote proof (automatically fetches previous ciphertext if needed) const voteProof = await sdk.generateVoteProof({ e3Id: 1n, - vote: { yes: 100n, no: 0n }, + vote: [100, 0], // one weight per option publicKey: publicKeyBytes, signature: '0x...', messageHash: '0x...', @@ -108,7 +108,7 @@ const merkleLeaves = await getTreeData(serverUrl, e3Id) import { generateVoteProof } from '@crisp-e3/sdk' const proof = await generateVoteProof({ - vote: { yes: 100n, no: 0n }, + vote: [100, 0], // one weight per option publicKey: publicKeyBytes, signature: '0x...', messageHash: '0x...', @@ -141,15 +141,27 @@ import { verifyProof } from '@crisp-e3/sdk' const isValid = await verifyProof(proof) ``` -#### Decode Tally +#### Ballot Encoding and Decode Tally + +A ballot stores one integer per option: coefficient `o` of the message polynomial is the weight on +option `o`, and every other coefficient up to the BFV degree is zero. BFV adds ballots coefficient +by coefficient, so the decrypted tally holds one total per option in the same positions. Each weight +is a non-negative integer below the BFV plaintext modulus `t` of the active preset, and a round has +2 to `MAX_VOTE_OPTIONS` options. `CRISPProgram` keeps every option total below `t`. ```typescript -import { decodeTally } from '@crisp-e3/sdk' +import { encodeVote, decodeTally } from '@crisp-e3/sdk' + +const coefficients = encodeVote([3, 0, 7]) // coefficient 0 = 3, coefficient 2 = 7, the rest 0 const tally = decodeTally(tallyBytes, numOptions) -// Returns: bigint[] — one total per option +// Returns: bigint[] — the first numOptions coefficients, one total per option ``` +`decodeTally` reads the first `numOptions` of the `MAX_MSG_NON_ZERO_COEFFS` published coefficients. +`CRISPProgram.decodeTally` (Solidity) and `crisp_utils::decode_tally` (Rust) read the same layout. +The interfold dashboard calls `CRISPProgram.decodeTally`. + #### Cryptographic Utilities ```typescript @@ -250,8 +262,13 @@ const { stage, retryOffered } = getSubmissionStage({ - `generateMaskVoteProof(maskVoteProofInputs: MaskVoteProofInputs): Promise` - Generate a mask vote proof (low-level) - `verifyProof(proof: ProofData): Promise` - Verify a proof locally +- `encodeVote(vote: Vote): number[]` - Encode one weight per option into message coefficients, + padded to the BFV degree. Rejects a weight that is not a non-negative integer below the plaintext + modulus - `decodeTally(tallyBytes: string | number[] | bigint[], numChoices: number): TallyResult` - Decode - an encoded tally into one total per option + the first `numChoices` coefficients of a tally into one total per option +- `validateVote(vote: Vote, balance: bigint): void` - Check the weights against the plaintext + modulus and the voting-power rules; throws on an invalid vote - `generatePublicKey(): Uint8Array` - Generate a random public key - `encryptVote(vote: Vote, publicKey: Uint8Array): Uint8Array` - Encrypt a vote - `encodeSolidityProof(proof: ProofData): Hex` - Encode proof for Solidity contract @@ -280,7 +297,8 @@ const { stage, retryOffered } = getSubmissionStage({ - `MERKLE_TREE_MAX_DEPTH` - Maximum depth of the merkle tree - `SIGNATURE_MESSAGE` - Message used for signature verification -- `MAXIMUM_VOTE_VALUE` - Maximum allowed vote value +- `MAX_VOTE_OPTIONS` - Maximum number of options in a round +- `MAX_MSG_NON_ZERO_COEFFS` - Number of plaintext coefficients published as the tally - `SIGNATURE_MESSAGE_HASH` - Hash of the signature message ### Types @@ -288,6 +306,6 @@ const { stage, retryOffered } = getSubmissionStage({ - `RoundDetails` - Round details type - `RoundDetailsResponse` - Server response type for round details - `TokenDetails` - Token details type -- `Vote` - Vote type with `yes` and `no` bigint fields +- `Vote` - One non-negative integer weight per option, indexed by option - `MaskVoteProofInputs` - Inputs for mask vote proof generation - `VoteProofInputs` - Inputs for vote proof generation diff --git a/examples/CRISP/packages/crisp-sdk/src/constants.ts b/examples/CRISP/packages/crisp-sdk/src/constants.ts index f139788d7d..39dd98ead0 100644 --- a/examples/CRISP/packages/crisp-sdk/src/constants.ts +++ b/examples/CRISP/packages/crisp-sdk/src/constants.ts @@ -32,8 +32,8 @@ export const CRISP_SERVER_CHAIN_BLOCK_AT_TIMESTAMP_ENDPOINT = 'chain/block-at-ti export const MERKLE_TREE_MAX_DEPTH = 20 // static, hardcoded in the circuit. // @note Must stay aligned with CRISP circuits / threshold message layout (Rust & Noir MAX_MSG_NON_ZERO_COEFFS). -// Vote payload uses only the first MAX_MSG_NON_ZERO_COEFFS polynomial coeffs, split evenly across options -// (e.g. 2 options → 25 binary coeffs each within those 50). +// The committee publishes this many plaintext coefficients as the tally. Option `o` is coefficient `o`, +// so a round can have at most this many options; the rest of the polynomial is zero padding. export const MAX_MSG_NON_ZERO_COEFFS = 50 // Hard limit on the maximum number of vote options supported. export const MAX_VOTE_OPTIONS = 10 diff --git a/examples/CRISP/packages/crisp-sdk/src/encoding.ts b/examples/CRISP/packages/crisp-sdk/src/encoding.ts index 73afca4a43..408e8dbff6 100644 --- a/examples/CRISP/packages/crisp-sdk/src/encoding.ts +++ b/examples/CRISP/packages/crisp-sdk/src/encoding.ts @@ -7,16 +7,15 @@ /** * Vote encoding and BFV encryption for the CRISP voting protocol. * - * Encodes vote choices (numbers per option) into polynomial coefficient arrays - * suitable for BFV homomorphic encryption. Each choice is represented as a - * segment of binary digits within the first MAX_MSG_NON_ZERO_COEFFS coeffs, then - * zero-padded to the BFV polynomial degree. Supports - * encoding, encryption, decryption, and tally decoding. + * A ballot stores one integer per option: coefficient `o` of the message polynomial is the + * weight on option `o`, and every other coefficient up to the BFV degree is zero. BFV adds + * ballots coefficient by coefficient, so the decrypted tally holds one total per option in the + * same positions. Supports encoding, encryption, decryption, and tally decoding. */ import { ZKInputsGenerator } from '@crisp-e3/zk-inputs' import { registeredPreset, type CircuitPreset } from './circuits' -import { toBinary, numberArrayToBigInt64Array, decodeBytesToBigInts, getMaxVoteValue } from './utils' +import { numberArrayToBigInt64Array, decodeBytesToBigInts } from './utils' import { MAX_MSG_NON_ZERO_COEFFS, MAX_VOTE_OPTIONS } from './constants' import { hexToBytes } from 'viem' import type { Hex } from 'viem' @@ -50,15 +49,40 @@ export const getZkInputsGenerator = () => { return _zkInputsGenerator } +/** + * Returns the BFV plaintext modulus `t` of the active preset. A choice weight, and every option + * total of a round, must stay below it. + */ +export const getPlaintextModulus = (): bigint => BigInt(getZkInputsGenerator().getBFVParams().plaintextModulus) + +/** + * Checks every choice weight against the plaintext modulus `t`. A weight at or above `t` wraps + * around in the plaintext ring and no longer adds as an integer. + * + * @param vote - Weight per option + * @param plaintextModulus - The plaintext modulus `t` of the active preset + * @throws If a weight is not a non-negative safe integer below `t` + */ +export const checkVoteWeights = (vote: Vote, plaintextModulus: bigint): void => { + vote.forEach((value, choiceIdx) => { + if (!Number.isSafeInteger(value) || value < 0 || BigInt(value) >= plaintextModulus) { + throw new Error(`Vote value for choice ${choiceIdx} must be a non-negative integer below the plaintext modulus (${plaintextModulus})`) + } + }) +} + /** * Encodes vote choices into a polynomial coefficient array for BFV encryption. - * Each choice occupies floor(MAX_MSG_NON_ZERO_COEFFS / n) binary coefficients; - * remaining slots in the first MAX_MSG_NON_ZERO_COEFFS coeffs are zero; then - * the vector is padded to the BFV degree. + * Coefficient `o` is the weight on option `o`; every other coefficient, up to the BFV degree, is zero. + * + * Decoded by `decodeTally`, and by the other tally decoders that share this layout: + * `CRISPProgram.decodeTally` (Solidity) and `crisp_utils::decode_tally` (Rust). * - * @param vote - Array of numeric values per choice (e.g. [10, 5] for 2 options) - * @returns Array of 0s and 1s representing coefficients - * @throws If vote has fewer than 2 choices, any value exceeds max for its segment, or degree is too small + * @param vote - Weight per option, for example [10, 0] for 2 options. Each weight is a non-negative + * safe integer below the BFV plaintext modulus. + * @returns The coefficients, `degree` entries long + * @throws If vote has fewer than 2 or more than MAX_VOTE_OPTIONS choices, any weight is not a + * non-negative safe integer below the plaintext modulus, or the BFV degree is too small */ export const encodeVote = (vote: Vote): number[] => { const numChoices = vote.length @@ -73,38 +97,16 @@ export const encodeVote = (vote: Vote): number[] => { throw new Error(`Number of choices (${numChoices}) exceeds MAX_VOTE_OPTIONS (${MAX_VOTE_OPTIONS})`) } - const bfvParams = getZkInputsGenerator().getBFVParams() - const degree = bfvParams.degree + const { degree, plaintextModulus } = getZkInputsGenerator().getBFVParams() as { degree: number; plaintextModulus: bigint } if (degree < MAX_MSG_NON_ZERO_COEFFS) { throw new Error(`BFV degree (${degree}) must be at least MAX_MSG_NON_ZERO_COEFFS (${MAX_MSG_NON_ZERO_COEFFS})`) } - const segmentSize = Math.floor(MAX_MSG_NON_ZERO_COEFFS / numChoices) - const maxValue = getMaxVoteValue(numChoices) - const voteArray: number[] = [] + checkVoteWeights(vote, plaintextModulus) + const voteArray: number[] = new Array(degree).fill(0) for (let choiceIdx = 0; choiceIdx < numChoices; choiceIdx += 1) { - const value = vote[choiceIdx] - - if (value > maxValue) { - throw new Error(`Vote value for choice ${choiceIdx} exceeds maximum (${maxValue})`) - } - - const binary = toBinary(value).split('') - - for (let i = 0; i < segmentSize; i += 1) { - const offset = segmentSize - binary.length - voteArray.push(i < offset ? 0 : parseInt(binary[i - offset], 10)) - } - } - - const msgCoeffsUsed = segmentSize * numChoices - for (let i = msgCoeffsUsed; i < MAX_MSG_NON_ZERO_COEFFS; i += 1) { - voteArray.push(0) - } - - for (let i = 0; i < degree - MAX_MSG_NON_ZERO_COEFFS; i += 1) { - voteArray.push(0) + voteArray[choiceIdx] = vote[choiceIdx] } return voteArray @@ -125,23 +127,23 @@ export const encryptVote = (vote: Vote, publicKey: Uint8Array): Uint8Array => { /** * Decodes raw tally bytes (or coefficients) into a total per choice. - * Expects the same segment layout as used in encodeVote. + * Expects the layout `encodeVote` produces: coefficient `o` holds the total weight on option `o`. * - * Mirrors `crisp_utils::decode_tally` (Rust) and `CRISPProgram.decodeTally` (Solidity): - * only the first MAX_MSG_NON_ZERO_COEFFS coefficients carry the payload, split into - * `floor(MAX_MSG_NON_ZERO_COEFFS / numChoices)` binary coefficients per choice, MSB first. + * Same layout as `CRISPProgram.decodeTally` (Solidity) and `crisp_utils::decode_tally` (Rust). + * Only the first MAX_MSG_NON_ZERO_COEFFS coefficients are published as the tally; the first + * `numChoices` of them are the totals and the rest are ignored. * * @param tallyBytes - Hex string, or the polynomial coefficients from tally/decryption * @param numChoices - Number of vote options: an integer from 2 to MAX_VOTE_OPTIONS - * @returns One total per choice + * @returns One total per choice, as bigint because each coefficient is a uint64 word * @throws If numChoices is outside 2..MAX_VOTE_OPTIONS or not an integer, or there are fewer - * coefficients than the payload region + * coefficients than MAX_MSG_NON_ZERO_COEFFS */ export const decodeTally = (tallyBytes: string | number[] | bigint[], numChoices: number): TallyResult => { // `CRISPProgram.validate` rejects a round outside 2..MAX_VOTE_OPTIONS, and `encodeVote` refuses // to encode fewer than two choices, so no tally in that range can exist. `Number.isInteger` also - // screens out NaN, Infinity, and fractions: a fractional count silently returns `ceil(numChoices)` - // segments, and NaN passes both bound checks to return an empty tally. + // screens out NaN, Infinity, and fractions: a fractional count would slice a fractional number of + // coefficients, and NaN passes both bound checks to return an empty tally. if (!Number.isInteger(numChoices) || numChoices < 2) { throw new Error(`Number of choices (${numChoices}) must be an integer of at least 2`) } @@ -164,21 +166,7 @@ export const decodeTally = (tallyBytes: string | number[] | bigint[], numChoices throw new Error(`decoded coefficient count (${coefficients.length}) is less than MAX_MSG_NON_ZERO_COEFFS (${MAX_MSG_NON_ZERO_COEFFS})`) } - const segmentSize = Math.floor(MAX_MSG_NON_ZERO_COEFFS / numChoices) - const results: TallyResult = [] - - for (let choiceIdx = 0; choiceIdx < numChoices; choiceIdx++) { - const segmentStart = choiceIdx * segmentSize - - let value = 0n - for (let i = 0; i < segmentSize; i++) { - value += coefficients[segmentStart + i] << BigInt(segmentSize - 1 - i) - } - - results.push(value) - } - - return results + return coefficients.slice(0, numChoices) } /** diff --git a/examples/CRISP/packages/crisp-sdk/src/index.ts b/examples/CRISP/packages/crisp-sdk/src/index.ts index 0b505e2e7f..f648331a1e 100644 --- a/examples/CRISP/packages/crisp-sdk/src/index.ts +++ b/examples/CRISP/packages/crisp-sdk/src/index.ts @@ -9,15 +9,7 @@ export * from './token' export * from './state' export * from './api' export { MAX_MSG_NON_ZERO_COEFFS, MAX_VOTE_OPTIONS, MERKLE_TREE_MAX_DEPTH, SIGNATURE_MESSAGE, SIGNATURE_MESSAGE_HASH } from './constants' -export { - hashLeaf, - generateMerkleProof, - generateMerkleTree, - getAddressFromSignature, - getMaxVoteValue, - getZeroVote, - getScaledBalance, -} from './utils' +export { hashLeaf, generateMerkleProof, generateMerkleTree, getAddressFromSignature, getZeroVote } from './utils' export { encodeVote, decodeTally, diff --git a/examples/CRISP/packages/crisp-sdk/src/utils.ts b/examples/CRISP/packages/crisp-sdk/src/utils.ts index 6eca5ab527..55d03b21c5 100644 --- a/examples/CRISP/packages/crisp-sdk/src/utils.ts +++ b/examples/CRISP/packages/crisp-sdk/src/utils.ts @@ -7,7 +7,7 @@ import { poseidon2 } from 'poseidon-lite' import { LeanIMT } from '@zk-kit/lean-imt' import type { MerkleProof } from './types' -import { MAX_MSG_NON_ZERO_COEFFS, MERKLE_TREE_MAX_DEPTH, SIGNATURE_MESSAGE_HASH } from './constants' +import { MERKLE_TREE_MAX_DEPTH, SIGNATURE_MESSAGE_HASH } from './constants' import { publicKeyToAddress } from 'viem/utils' import { hexToBytes, recoverPublicKey } from 'viem' @@ -78,19 +78,6 @@ export const generateMerkleProof = (balance: bigint, address: string, leaves: bi } } -/** - * Convert a number to its binary representation - * @param number The number to convert to binary - * @returns The binary representation of the number as a string - */ -export const toBinary = (number: number): string => { - if (number < 0) { - throw new Error('Value cannot be negative') - } - - return number.toString(2) -} - /** * Given a signature, extract the signature components for the Noir signature verification circuit. * @param signature The signature to extract the components from. @@ -133,16 +120,6 @@ export const getAddressFromSignature = async (signature: `0x${string}`, messageH return publicKeyToAddress(publicKey) } -/** - * Get the maximum vote value for a given number of choices. - * @param numChoices Number of choices. - * @returns Maximum value per choice. - */ -export const getMaxVoteValue = (numChoices: number): number => { - const segmentSize = Math.floor(MAX_MSG_NON_ZERO_COEFFS / numChoices) - return 2 ** segmentSize - 1 -} - /** * Get a zero vote with the given number of choices. * @param numChoices Number of choices. @@ -195,15 +172,3 @@ export const proofToFields = (proof: Uint8Array): string[] => { } return fields } - -/** - * Scale down the raw balance to 1 decimal precision - * @param balance - The raw balance (with all tokens decimals) - * @param decimals - The decimals of the token - * @returns The balance as a .1 precision scaled value - */ -export const getScaledBalance = (balance: bigint, decimals: bigint): bigint => { - const precision = decimals > 1n ? decimals - 1n : 0n - - return balance / 10n ** precision -} diff --git a/examples/CRISP/packages/crisp-sdk/src/vote.ts b/examples/CRISP/packages/crisp-sdk/src/vote.ts index 78d4d02f35..4b5467d95a 100644 --- a/examples/CRISP/packages/crisp-sdk/src/vote.ts +++ b/examples/CRISP/packages/crisp-sdk/src/vote.ts @@ -5,7 +5,8 @@ // or FITNESS FOR A PARTICULAR PURPOSE. import { type Vote, type CensusVariant, type PrepareBallotInputs, type PreparedBallot, ProofData } from './types' -import { getMaxVoteValue, proofToFields } from './utils' +import { proofToFields } from './utils' +import { checkVoteWeights, getPlaintextModulus } from './encoding' import { attachSignatureImpl, prepareCircuitInputsImpl } from './circuitInputs' import { MASK_SIGNATURE } from './constants' export { encodeVote, encryptVote, decodeTally, decryptVote, generateBFVKeys } from './encoding' @@ -257,16 +258,8 @@ export const generateProof = async (circuitInputs: any, censusMode: CensusVarian */ export const validateVote = (vote: Vote, balance: bigint): void => { const numChoices = vote.length - const maxValue = getMaxVoteValue(numChoices) - for (let i = 0; i < vote.length; i++) { - if (vote[i] < 0) { - throw new Error(`Invalid vote: choice ${i} is negative`) - } - if (vote[i] > maxValue) { - throw new Error(`Invalid vote: choice ${i} exceeds maximum encodable value`) - } - } + checkVoteWeights(vote, getPlaintextModulus()) if (numChoices === 2) { // Binary: mutually exclusive diff --git a/examples/CRISP/packages/crisp-sdk/tests/vote.test.ts b/examples/CRISP/packages/crisp-sdk/tests/vote.test.ts index 2515e403d9..21f74cbc06 100644 --- a/examples/CRISP/packages/crisp-sdk/tests/vote.test.ts +++ b/examples/CRISP/packages/crisp-sdk/tests/vote.test.ts @@ -6,7 +6,8 @@ import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll, vi } from 'vitest' import { Vote } from '../src/types' import { MAX_MSG_NON_ZERO_COEFFS, MAX_VOTE_OPTIONS, SIGNATURE_MESSAGE_HASH, SIGNATURE_MESSAGE } from '../src/constants' -import { decodeTally, verifyProof, encodeVote, generateBFVKeys, encryptVote, decryptVote, destroyBBApi } from '../src/vote' +import { decodeTally, verifyProof, encodeVote, generateBFVKeys, encryptVote, decryptVote, validateVote, destroyBBApi } from '../src/vote' +import { getZkInputsGenerator } from '../src/encoding' import { publicKeyToAddress, sign, signMessage } from 'viem/accounts' import { Hex, concat, keccak256, numberToHex, recoverPublicKey } from 'viem' import { CRISP_SERVER_URL, ECDSA_PRIVATE_KEY, SLOT_ADDRESS } from './constants' @@ -75,27 +76,25 @@ describe('Vote', () => { }) describe('decodeTally', () => { - it('Should decode an encoded tally into its decimal representation', () => { - const expected: Vote = [10000000, 30000000] - const encoded = encodeVote(expected) - const decoded = decodeTally(encoded, 2) - - expect(decoded[0]).toBe(BigInt(expected[0])) - expect(decoded[1]).toBe(BigInt(expected[1])) + it('Should return the leading coefficients and ignore the rest of the payload region', () => { + const coefficients = new Array(MAX_MSG_NON_ZERO_COEFFS).fill(0n) + coefficients[0] = 7n + coefficients[1] = 0n + coefficients[2] = 42n + coefficients[3] = 99n + + expect(decodeTally(coefficients, 3)).toEqual([7n, 0n, 42n]) }) it('Should decode totals above Number.MAX_SAFE_INTEGER without losing precision', () => { - // After aggregation a coefficient is a ballot count, not a bit. This models 2**30 - // ballots landing on the top coefficient of option 0 and one on its bottom coefficient, - // giving a total that a double cannot represent exactly. - const coefficients = new Array(MAX_MSG_NON_ZERO_COEFFS).fill(0) - coefficients[0] = 2 ** 30 - coefficients[MAX_MSG_NON_ZERO_COEFFS / 2 - 1] = 1 + const total = (1n << 60n) + 1n + const coefficients = new Array(MAX_MSG_NON_ZERO_COEFFS).fill(0n) + coefficients[1] = total const decoded = decodeTally(coefficients, 2) - expect(decoded[0]).toBe((1n << 54n) + 1n) - expect(decoded[0] > BigInt(Number.MAX_SAFE_INTEGER)).toBe(true) + expect(decoded[1]).toBe(total) + expect(decoded[1] > BigInt(Number.MAX_SAFE_INTEGER)).toBe(true) }) it('Should reject a tally shorter than the payload region', () => { @@ -118,8 +117,8 @@ describe('Vote', () => { it('Should reject a non-integer number of choices', () => { const coefficients = new Array(MAX_MSG_NON_ZERO_COEFFS).fill(0) - // A fraction silently decoded `ceil(numChoices)` segments; NaN passed both bound - // checks and returned an empty tally. + // A fraction would slice a fractional number of coefficients; NaN passes both bound + // checks and would return an empty tally. expect(() => decodeTally(coefficients, 2.5)).toThrow('must be an integer of at least 2') expect(() => decodeTally(coefficients, Number.NaN)).toThrow('must be an integer of at least 2') expect(() => decodeTally(coefficients, Number.POSITIVE_INFINITY)).toThrow('must be an integer of at least 2') @@ -147,58 +146,43 @@ describe('Vote', () => { expect(decodeTally(encoded, MAX_VOTE_OPTIONS)).toEqual(new Array(MAX_VOTE_OPTIONS).fill(1n)) }) - it('Should encode votes correctly with 2 choices', () => { - const encoded = encodeVote([10, 2]) - const decoded = decodeTally(encoded, 2) - - expect(decoded[0]).toBe(10n) - expect(decoded[1]).toBe(2n) - }) - - it('Should encode zero votes correctly', () => { - const encoded = encodeVote([0, 5]) - const decoded = decodeTally(encoded, 2) + it('Should place the weight of option o at coefficient o and zero elsewhere', () => { + const { degree } = getZkInputsGenerator().getBFVParams() + const encoded = encodeVote([3, 0, 7]) - expect(decoded[0]).toBe(0n) - expect(decoded[1]).toBe(5n) + expect(encoded).toHaveLength(degree) + expect(encoded.slice(0, 3)).toEqual([3, 0, 7]) + expect(encoded.slice(3).every((c) => c === 0)).toBe(true) }) - it('Should only contain binary digits (0 or 1)', () => { - const encoded = encodeVote([255, 128]) + it('Should round-trip through decodeTally, including the largest weight below the plaintext modulus', () => { + const t = getZkInputsGenerator().getBFVParams().plaintextModulus as bigint + const largest = Number(t - 1n) + const weights: Vote = [largest, 0, 1, 2, largest] - expect(Array.from(encoded).every((b) => b === 0 || b === 1)).toBe(true) + expect(decodeTally(encodeVote(weights), weights.length)).toEqual(weights.map(BigInt)) }) - it('Should encode votes correctly with 3 choices', () => { - const encoded = encodeVote([10, 2, 3]) - const decoded = decodeTally(encoded, 3) + it('Should reject a weight that is not a non-negative safe integer below the plaintext modulus', () => { + const t = getZkInputsGenerator().getBFVParams().plaintextModulus as bigint - expect(decoded[0]).toBe(10n) - expect(decoded[1]).toBe(2n) - expect(decoded[2]).toBe(3n) + expect(() => encodeVote([0, Number(t)])).toThrow( + `Vote value for choice 1 must be a non-negative integer below the plaintext modulus (${t})`, + ) + expect(() => encodeVote([-1, 0])).toThrow('Vote value for choice 0') + expect(() => encodeVote([0, 1.5])).toThrow('Vote value for choice 1') + expect(() => encodeVote([Number.NaN, 0])).toThrow('Vote value for choice 0') + expect(() => encodeVote([0, Number.MAX_SAFE_INTEGER + 1])).toThrow('Vote value for choice 1') }) + }) - it('Should encode votes correctly with 5 choices', () => { - const encoded = encodeVote([100, 50, 25, 10, 5]) - const decoded = decodeTally(encoded, 5) - - expect(decoded[0]).toBe(100n) - expect(decoded[1]).toBe(50n) - expect(decoded[2]).toBe(25n) - expect(decoded[3]).toBe(10n) - expect(decoded[4]).toBe(5n) - }) - - it('Should zero-pad unused slots in the first MAX_MSG_NON_ZERO_COEFFS coeffs for 3 choices', () => { - const encoded = encodeVote([1, 1, 1]) - const decoded = decodeTally(encoded, 3) - - expect(decoded[0]).toBe(1n) - expect(decoded[1]).toBe(1n) - expect(decoded[2]).toBe(1n) + describe('validateVote', () => { + it('Should reject a choice at or above the plaintext modulus and accept the largest weight below it', () => { + const t = getZkInputsGenerator().getBFVParams().plaintextModulus as bigint + const balance = t * 2n - const segmentSize = Math.floor(MAX_MSG_NON_ZERO_COEFFS / 3) - expect(encoded.slice(segmentSize * 3, MAX_MSG_NON_ZERO_COEFFS).every((b) => b === 0)).toBe(true) + expect(() => validateVote([Number(t - 1n), 0], balance)).not.toThrow() + expect(() => validateVote([0, Number(t)], balance)).toThrow('Vote value for choice 1') }) }) diff --git a/examples/CRISP/server/src/cli/commands.rs b/examples/CRISP/server/src/cli/commands.rs index 05d88e55f3..6372427fd1 100644 --- a/examples/CRISP/server/src/cli/commands.rs +++ b/examples/CRISP/server/src/cli/commands.rs @@ -279,10 +279,9 @@ pub async fn initialize_crisp_round( // `SelfRegistry`, whose power is 1 or 0. BY_REQUESTER is not offered: the requester here is // this CLI's EOA, which cannot answer `getCensus`. let census_mode = U256::from(if onchain { 2u64 } else { 0u64 }); - // Seventh field: the ONCHAIN voting-power divisor. Zero here for two reasons — a TOKEN round - // never reads it, and zero is also the "derive it from the token's decimals" sentinel. It is - // not optional: `_initRound` decodes exactly seven fields, so a six-field encoding reverts the - // request with empty data rather than defaulting to anything. + // Seventh field: the requested voting-power divisor. Constant credits ignore it, so zero here. + // It is not optional: `_initRound` decodes exactly seven fields, so a six-field encoding + // reverts the request with empty data rather than defaulting to anything. let voting_power_divisor = U256::from(0); let custom_params_bytes = Bytes::from( ( diff --git a/examples/CRISP/server/src/server/indexer.rs b/examples/CRISP/server/src/server/indexer.rs index f2f6134644..ce5aafcf35 100644 --- a/examples/CRISP/server/src/server/indexer.rs +++ b/examples/CRISP/server/src/server/indexer.rs @@ -71,33 +71,20 @@ fn stage_ends_input_retrieval(stage: &E3Stage) -> bool { ) } -/// The divisor the coordinator must scale raw voting power by, or `None` to derive a default. +/// Read the divisor the contract stored for a CUSTOM-credit round, retrying a failed read. /// -/// Three rules, in order: -/// -/// - A Merkle round gets `None`. The contract records a divisor for ONCHAIN rounds only, because -/// a Merkle round takes its bound from the census leaf. Scaling the census by a factor that -/// `_tallyScale()` does not read back would give a wrong tally with nothing to show it. -/// - The value the contract stored wins. `CRISPProgram` resolved it once at request time, and -/// every input is scaled by exactly it, so it is the authority. -/// - The requested field is the fallback, for a round whose stored value could not be read. A -/// zero or unparseable field means the round named no divisor, and the default is derived from -/// the token as the contract did. -/// Read the divisor the contract stored for a round, retrying a failed read. -/// -/// `CRISPProgram` resolves and stores this value at request time. Deriving it again from -/// `decimals()` can produce a different number, so a transient RPC failure must not silently -/// become a coordinator that scales balances in units the contract does not use. Retry with -/// backoff; on exhaustion return `None`, which is safe only when the round declared its own -/// divisor and is refused by the caller when it did not. +/// `CRISPProgram` resolves and stores this value at request time and scales every voter's power by +/// exactly it. A transient RPC failure must not leave the coordinator scaling balances in other +/// units, so the read retries with backoff. On exhaustion it returns `None`, which +/// `resolve_divisor_override` answers from the requested field when the round named one. async fn read_stored_divisor( crisp: &CRISPContract, e3_id: U256, label: &str, ) -> Option { - match call_with_retry("onchain_voting_power_divisor", &[], || async { + match call_with_retry("stored_voting_power_divisor", &[], || async { crisp - .onchain_voting_power_divisor(e3_id) + .stored_voting_power_divisor(e3_id) .await .map_err(|error| anyhow::anyhow!(error.to_string())) }) @@ -124,8 +111,10 @@ async fn read_stored_divisor( /// round: the contract reads power per input, so the only cost is mask cover. /// /// One function for both callers: the `E3Requested` handler and the retry pass for a round that -/// was registered without a census because its divisor could not be read. `divisor_override` is -/// honoured only for an on-chain census, mirroring the contract. +/// was registered without a census because its divisor could not be read. `divisor` is the +/// divisor of a CUSTOM-credit round, whatever its census mode. A CONSTANT-credit round has none. +/// A CUSTOM-credit round without one fails discovery: the contract scales every voter by its stored +/// divisor, so a census in any other unit would not match the tally. async fn discover_holders( e3_id: &str, custom_params: &CustomParams, @@ -133,7 +122,7 @@ async fn discover_holders( token_address: Address, snapshot_timepoint: u64, balance_threshold: &BigUint, - divisor_override: Option, + divisor: Option, ) -> eyre::Result> { Ok(if custom_params.census_mode == CensusMode::ByRequester { let credits_str = match custom_params.credit_mode { @@ -184,7 +173,18 @@ async fn discover_holders( e3_id, CONFIG.chain_id ); - get_mock_token_holders() + // Each leaf is the slot's weight bound. A CONSTANT round sizes its input limit from + // `credits`, so every leaf carries exactly that, as on every other chain. A CUSTOM round + // gets 1 per account, and ten accounts stay below the plaintext modulus of every preset. + let balance = match custom_params.credit_mode { + CreditMode::Constant => custom_params + .credits + .as_deref() + .expect("credits must be set for Constant mode"), + CreditMode::Custom => "1", + }; + + get_mock_token_holders(balance) } else { info!( "[e3_id={}] Using Etherscan API for network (chain_id: {})", @@ -215,6 +215,15 @@ async fn discover_holders( .context("Etherscan token-holder discovery failed")? } CreditMode::Custom => { + let divisor = divisor.ok_or_else(|| { + eyre::eyre!( + "[e3_id={}] No voting-power divisor is known for this CUSTOM-credit \ + round, so the census cannot be built in the units the contract \ + reads back.", + e3_id + ) + })?; + etherscan_client .get_token_holders_with_voting_power( token_address, @@ -227,13 +236,7 @@ async fn discover_holders( e ) })?, - // Honoured only for an on-chain census, mirroring the contract: - // `_initRound` records the divisor for ONCHAIN rounds and ignores - // the field otherwise, because a Merkle round's bound comes from - // the census leaf. Applying it there would scale the census by one - // factor while `_tallyScale()` reads the results back assuming - // another, and the tally would be wrong with nothing to show it. - divisor_override, + divisor, ) .await .context("Etherscan token-holder discovery failed")? @@ -255,10 +258,11 @@ fn order_token_holders(holders: &mut [TokenHolder]) { /// What `E3Requested` does with a round once the divisor sources have answered. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RegistrationPlan { - /// Register the round and run holder discovery with this divisor override. + /// Register the round and run holder discovery with this divisor. A CUSTOM-credit TOKEN round + /// with `None` fails in discovery, as a Merkle round must not be built in unknown units. Discover(Option), /// Register the round but skip holder discovery: an on-chain round with no known divisor - /// is fully votable, and deriving a divisor would build the census in the wrong units. + /// is fully votable, and building the census in unknown units would serve wrong balances. RegisterWithoutDiscovery, } @@ -268,26 +272,39 @@ enum RegistrationPlan { /// Registration itself never depends on the divisor. The live listener logs a handler error /// and moves on, so an `Err` from this handler does not defer anything: it drops the round /// from coordinator metadata until the next backfill replays the event. Only discovery is -/// divisor-dependent, so only discovery is skipped. +/// divisor-dependent. An on-chain round is still votable without a census, so only its discovery +/// is skipped. A Merkle round has no votes without a census, so its discovery fails. fn plan_registration( stored_divisor: Option, requested_divisor: &str, - is_onchain_census: bool, + credit_mode: CreditMode, + census_mode: CensusMode, ) -> RegistrationPlan { - let divisor_override = - resolve_divisor_override(stored_divisor, requested_divisor, is_onchain_census); - if is_onchain_census && divisor_override.is_none() { + let divisor = resolve_divisor_override(stored_divisor, requested_divisor, credit_mode); + if credit_mode == CreditMode::Custom && census_mode == CensusMode::Onchain && divisor.is_none() + { return RegistrationPlan::RegisterWithoutDiscovery; } - RegistrationPlan::Discover(divisor_override) + RegistrationPlan::Discover(divisor) } +/// The divisor the coordinator scales voting power by, or `None` when none is known. +/// +/// Three rules, in order: +/// +/// - A CONSTANT-credit round gets `None`. The contract stores no divisor for it, and the census +/// carries the constant credits. +/// - For a CUSTOM-credit round, whatever its census mode, the stored value wins. `CRISPProgram` +/// resolved it once at request time, and every voter is scaled by exactly it. +/// - The requested field is the fallback, for a round whose stored value could not be read. The +/// contract stores a non-zero request verbatim. A zero or unparseable field means the round +/// asked for the minimum divisor, which only the contract can compute. fn resolve_divisor_override( stored: Option, requested: &str, - is_onchain_census: bool, + credit_mode: CreditMode, ) -> Option { - if !is_onchain_census { + if credit_mode != CreditMode::Custom { return None; } @@ -350,10 +367,9 @@ pub async fn register_e3_requested( .map_err(|e| eyre::eyre!("{}", e))?; // Use sol_data types instead of primitives - // Seven fields. The seventh is the ONCHAIN voting-power divisor: the contract - // scales raw token power by it before handing the value to the circuit, so a - // six-field decode would fail outright on every round requested after that field - // was added. + // Seven fields. The seventh is the requested voting-power divisor: the contract + // stores a non-zero request for a CUSTOM-credit round, and computes the minimum + // divisor for a zero request. type CustomParamsTuple = ( sol_data::Address, sol_data::Uint<256>, @@ -449,36 +465,37 @@ pub async fn register_e3_requested( } // The divisor the contract resolved and stored when the round was requested. - // `CRISPProgram` scales every input by exactly this value, so reading it removes - // the need to derive anything: the coordinator cannot disagree with the chain - // about an optional `decimals()` call or about a default the chain refused. + // `CRISPProgram` scales every voter's power by exactly this value for a + // CUSTOM-credit round, TOKEN and ONCHAIN alike, so the coordinator reads it + // instead of computing its own. // - // A failed read is not fatal when the round declared a divisor: that value is what - // the contract stored. It is only dangerous when the round declared none, because - // the fallback then derives a divisor from `decimals()`, which can disagree with - // the value the contract resolved at request time. Retry before accepting that. - let stored_divisor = if is_onchain_census { + // A failed read is not fatal when the round requested a divisor: the contract + // stores a non-zero request verbatim. A zero request leaves the coordinator with + // no value to fall back on, because only the contract can compute the minimum. + // Retry before accepting that. + let stored_divisor = if custom_params.credit_mode == CreditMode::Custom { read_stored_divisor(&crisp, event.e3Id, &e3_id).await } else { None }; - // Neither source producing a divisor for an on-chain round must not block - // registration: see `plan_registration`. Deriving one from `decimals()` can - // disagree with what the contract stored, which would build the census in units - // the tally does not read back, so discovery is skipped instead. + // An on-chain round with no known divisor must not block registration: see + // `plan_registration`. Its census would be built in units the tally does not + // read back, so discovery is skipped instead. let plan = plan_registration( stored_divisor, &custom_params.voting_power_divisor, - is_onchain_census, + custom_params.credit_mode, + custom_params.census_mode, ); - let (divisor_override, divisor_unavailable) = match plan { + let (divisor, divisor_unavailable) = match plan { RegistrationPlan::Discover(divisor) => (divisor, false), RegistrationPlan::RegisterWithoutDiscovery => { warn!( "[e3_id={}] The stored voting-power divisor is unavailable and the \ - round declared none. Registering the round without holder discovery \ - rather than deriving a divisor the contract may not use.", + round requested none. Registering the round without holder \ + discovery rather than building a census in units the contract may \ + not use.", e3_id ); (None, true) @@ -491,8 +508,8 @@ pub async fn register_e3_requested( let discovery: eyre::Result> = if divisor_unavailable { Err(eyre::eyre!( "[e3_id={}] Holder discovery skipped: no voting-power divisor is known \ - for this on-chain round, and deriving one would scale the census in \ - units the contract may not use.", + for this on-chain round, and any other value would scale the census in \ + units the contract does not read back.", e3_id )) } else { @@ -503,7 +520,7 @@ pub async fn register_e3_requested( token_address, snapshot_timepoint, &balance_threshold, - divisor_override, + divisor, ) .await }; @@ -1515,8 +1532,8 @@ async fn record_discovery_debt( /// either the stored voting-power divisor could not be read, or discovery itself failed and was /// swallowed to keep an on-chain-census round votable. Such a round is registered and votable but /// serves no mask targets. The event is not replayed once the cursor passes it, so this pass is -/// the only retry. It reads the divisor again for each such round and, when it answers, runs the -/// same discovery the handler would have run. A round that has ended is dropped from the pass: +/// the only retry. It reads the divisor again for each such CUSTOM-credit round and, when it +/// answers, runs the same discovery the handler would have run. A round that has ended is dropped from the pass: /// there is nobody left to mask. /// /// One task for the process, started at registration. It sleeps on `DISCOVERY_OWED` while nothing @@ -1569,9 +1586,15 @@ async fn retry_pending_discovery(store: SharedStore) { let Ok(e3_id_value) = e3_id_to_u256(&e3_id) else { continue; }; - let Some(divisor) = read_stored_divisor(&crisp, e3_id_value, &e3_id).await else { - // Still unreadable. Keep the debt and try again next pass. - continue; + // Only a CUSTOM-credit round has a divisor to read. + let divisor = if round.credit_mode == CreditMode::Custom { + let Some(divisor) = read_stored_divisor(&crisp, e3_id_value, &e3_id).await else { + // Still unreadable. Keep the debt and try again next pass. + continue; + }; + Some(divisor) + } else { + None }; match settle_pending_discovery(&mut repo, &e3_id, &round, divisor).await { Ok(count) => { @@ -1605,7 +1628,7 @@ async fn settle_pending_discovery( repo: &mut CrispE3Repository, e3_id: &str, round: &E3Crisp, - divisor: U256, + divisor: Option, ) -> eyre::Result { let custom_params = CustomParams { token_address: round.token_address.clone(), @@ -1614,8 +1637,8 @@ async fn settle_pending_discovery( credit_mode: round.credit_mode, credits: round.credits.clone(), census_mode: round.census_mode, - // Pending only arises when the round declared no divisor, so the stored value is the - // only source; the field is informational here. + // The round does not retain the requested divisor. The stored value passed to + // `discover_holders` is the source, and this field is informational. voting_power_divisor: "0".to_owned(), }; let requester: Address = round @@ -1635,7 +1658,7 @@ async fn settle_pending_discovery( token_address, round.snapshot_block, &balance_threshold, - Some(divisor), + divisor, ) .await?; let count = holders.len(); @@ -2035,9 +2058,6 @@ mod e3_request_tests { } } -/// The divisor decides the units every scaled balance is expressed in. The contract holds the -/// authoritative value, so the coordinator must prefer it over the requested field and must apply -/// neither one outside ONCHAIN mode. #[cfg(test)] mod pending_discovery_tests { use super::{pending_discovery_step, record_discovery_debt, PendingDiscoveryStep}; @@ -2168,117 +2188,123 @@ mod pending_discovery_tests { } } +/// The divisor decides the units every scaled balance is expressed in. The contract holds the +/// authoritative value for a CUSTOM-credit round of either census mode, so the coordinator prefers +/// it over the requested field and uses neither for a CONSTANT-credit round. #[cfg(test)] mod voting_power_divisor_tests { use super::{plan_registration, resolve_divisor_override, RegistrationPlan}; + use crate::server::models::{CensusMode, CreditMode}; use alloy::primitives::U256; - /// The stored value is what `CRISPProgram` scales every input by, so it wins even when the - /// requested field says something else. A round requested with a zero field has a nonzero + const STORED: u128 = 100_000_000_000_000_000; + + /// The stored value is what `CRISPProgram` scales every voter by, so it wins even when the + /// requested field says something else. A round requested with a zero field has a non-zero /// stored divisor, which is the ordinary DAO configuration. #[test] fn the_stored_divisor_wins_over_the_requested_field() { - let stored = U256::from(10).pow(U256::from(17)); + let stored = U256::from(STORED); assert_eq!( - resolve_divisor_override(Some(stored), "0", true), + resolve_divisor_override(Some(stored), "0", CreditMode::Custom), Some(stored) ); assert_eq!( - resolve_divisor_override(Some(stored), "5000", true), + resolve_divisor_override(Some(stored), "5000", CreditMode::Custom), Some(stored) ); } - /// The fallback for a round whose stored value could not be read. + /// The stored read failed after retries. A non-zero request is what the contract stored, so it + /// is the fallback. A zero or unparseable request asked for the minimum divisor, which only + /// the contract can compute. #[test] - fn the_requested_field_is_used_when_no_stored_divisor_is_available() { + fn the_requested_field_is_the_fallback_only_when_it_names_a_divisor() { assert_eq!( - resolve_divisor_override(None, "5000", true), + resolve_divisor_override(None, "5000", CreditMode::Custom), Some(U256::from(5000)) ); - } - - /// A zero or unparseable field means the round named no divisor. - /// - /// For a Merkle round `None` is the answer: the census leaf carries the bound. For an - /// on-chain round `None` is not a usable discovery input, and `plan_registration` skips - /// discovery rather than deriving a divisor from `decimals()` that the contract may not - /// have stored. Registration itself still happens. - #[test] - fn a_round_that_names_no_divisor_yields_no_override() { - assert_eq!(resolve_divisor_override(None, "0", true), None); - assert_eq!(resolve_divisor_override(Some(U256::ZERO), "0", true), None); - assert_eq!(resolve_divisor_override(None, "not a number", true), None); - assert_eq!(resolve_divisor_override(None, "", true), None); - // The declared field still rescues an on-chain round whose stored read failed. assert_eq!( - resolve_divisor_override(None, "5000", true), - Some(U256::from(5000)) + resolve_divisor_override(None, "0", CreditMode::Custom), + None + ); + assert_eq!( + resolve_divisor_override(Some(U256::ZERO), "0", CreditMode::Custom), + None + ); + assert_eq!( + resolve_divisor_override(None, "not a number", CreditMode::Custom), + None ); + assert_eq!(resolve_divisor_override(None, "", CreditMode::Custom), None); } - /// A Merkle round takes its bound from the census leaf. Scaling its census by a divisor would - /// give a tally that `_tallyScale()` decodes with a different factor. + /// A CONSTANT-credit round stores no divisor, so neither source applies, whatever the census. #[test] - fn a_merkle_round_is_never_scaled_by_an_explicit_divisor() { - let stored = U256::from(10).pow(U256::from(17)); - - assert_eq!(resolve_divisor_override(Some(stored), "5000", false), None); - assert_eq!(resolve_divisor_override(None, "5000", false), None); - } + fn a_constant_credit_round_never_takes_a_divisor() { + let stored = U256::from(STORED); - /// The stored read failed after retries (an RPC outage during live processing) and the - /// round declared no divisor. The round must still be registered: the live listener does - /// not retry a failed handler, so refusing here would drop the round from coordinator - /// metadata until the next backfill. Only discovery, which needs the divisor, is skipped. - #[test] - fn an_onchain_round_is_registered_when_the_stored_divisor_read_fails() { assert_eq!( - plan_registration(None, "0", true), - RegistrationPlan::RegisterWithoutDiscovery + resolve_divisor_override(Some(stored), "5000", CreditMode::Constant), + None ); assert_eq!( - plan_registration(None, "", true), - RegistrationPlan::RegisterWithoutDiscovery + plan_registration( + Some(stored), + "5000", + CreditMode::Constant, + CensusMode::Onchain + ), + RegistrationPlan::Discover(None) + ); + assert_eq!( + plan_registration(None, "0", CreditMode::Constant, CensusMode::Token), + RegistrationPlan::Discover(None) ); } - /// The same outage against a round that declared its own divisor loses nothing: the - /// declared field is what the contract stored, so discovery runs with it. + /// The stored read failed after retries and the round requested no divisor. An on-chain round + /// must still be registered: the live listener does not retry a failed handler, so refusing + /// here would drop the round from coordinator metadata until the next backfill. Only discovery + /// is skipped. #[test] - fn a_declared_divisor_survives_a_failed_stored_read() { + fn an_onchain_custom_round_is_registered_without_a_known_divisor() { assert_eq!( - plan_registration(None, "5000", true), - RegistrationPlan::Discover(Some(U256::from(5000))) + plan_registration(None, "0", CreditMode::Custom, CensusMode::Onchain), + RegistrationPlan::RegisterWithoutDiscovery + ); + assert_eq!( + plan_registration(None, "", CreditMode::Custom, CensusMode::Onchain), + RegistrationPlan::RegisterWithoutDiscovery ); } - /// Once the RPC recovers the stored value is read again and discovery runs with it. A - /// Merkle round never depends on the divisor, so it always discovers. + /// A TOKEN round has no votes without its census, so it never skips discovery. With no known + /// divisor, discovery fails with an error instead of building the census in unknown units. #[test] - fn a_recovered_stored_read_and_a_merkle_round_both_discover() { - let stored = U256::from(10).pow(U256::from(17)); + fn a_token_custom_round_without_a_divisor_still_runs_discovery() { assert_eq!( - plan_registration(Some(stored), "0", true), - RegistrationPlan::Discover(Some(stored)) - ); - assert_eq!( - plan_registration(None, "0", false), + plan_registration(None, "0", CreditMode::Custom, CensusMode::Token), RegistrationPlan::Discover(None) ); } - /// A divisor larger than 128 bits is an ordinary value on the chain, so it must survive the - /// coordinator unchanged. + /// The same outage against a round that requested a divisor loses nothing, for either census + /// mode. Once the RPC recovers, the stored value is read again and wins. #[test] - fn a_divisor_wider_than_128_bits_is_preserved() { - let stored = U256::from(10).pow(U256::from(39)); + fn a_known_divisor_lets_either_census_mode_discover() { + let stored = U256::from(STORED); - assert_eq!( - resolve_divisor_override(Some(stored), "0", true), - Some(stored) - ); - assert!(stored > U256::from(u128::MAX)); + for census_mode in [CensusMode::Token, CensusMode::Onchain] { + assert_eq!( + plan_registration(None, "5000", CreditMode::Custom, census_mode), + RegistrationPlan::Discover(Some(U256::from(5000))) + ); + assert_eq!( + plan_registration(Some(stored), "0", CreditMode::Custom, census_mode), + RegistrationPlan::Discover(Some(stored)) + ); + } } } diff --git a/examples/CRISP/server/src/server/models.rs b/examples/CRISP/server/src/server/models.rs index 1e51bdad72..522d6f8a36 100644 --- a/examples/CRISP/server/src/server/models.rs +++ b/examples/CRISP/server/src/server/models.rs @@ -221,10 +221,11 @@ pub struct CustomParams { pub credit_mode: CreditMode, pub credits: Option, pub census_mode: CensusMode, - /// Divides raw token power into ballot units for a `CensusMode::Onchain` round. `"0"` means - /// the contract derives it from the token's decimals. Recorded because a round may name its - /// own: scaling by the decimals then puts every served balance in different units from the - /// ones `publishInput` will enforce. + /// The divisor requested for a `CreditMode::Custom` round, of either census mode. It divides + /// raw token power into ballot units. `"0"` asks the contract for the smallest divisor that + /// keeps every option total below the plaintext modulus. The contract stores the value that + /// it uses, and `votingPowerDivisorOf` returns that stored value. A `CreditMode::Constant` + /// round ignores the field. pub voting_power_divisor: String, } diff --git a/examples/CRISP/server/src/server/routes/rounds.rs b/examples/CRISP/server/src/server/routes/rounds.rs index aa9cc285d7..9094454515 100644 --- a/examples/CRISP/server/src/server/routes/rounds.rs +++ b/examples/CRISP/server/src/server/routes/rounds.rs @@ -536,10 +536,9 @@ pub async fn initialize_crisp_round( let credit_mode = U256::from(0); // Constant let credits = U256::from(1); let census_mode = U256::from(census_mode); - // Seventh field: the ONCHAIN voting-power divisor. Zero is the "derive from the token's - // decimals" sentinel — for a token without `decimals()`, such as `SelfRegistry`, that derives - // to 1. Required regardless — `_initRound` decodes exactly seven fields, so a shorter - // encoding reverts the request with empty data. + // Seventh field: the requested voting-power divisor. Constant credits ignore it, so zero here. + // Required regardless: `_initRound` decodes exactly seven fields, so a shorter encoding + // reverts the request with empty data. let voting_power_divisor = U256::from(0); let custom_params_bytes = Bytes::from( ( diff --git a/examples/CRISP/server/src/server/token_holders/etherscan.rs b/examples/CRISP/server/src/server/token_holders/etherscan.rs index e2711d89cc..e223a2eede 100644 --- a/examples/CRISP/server/src/server/token_holders/etherscan.rs +++ b/examples/CRISP/server/src/server/token_holders/etherscan.rs @@ -11,7 +11,7 @@ use alloy::providers::{Provider, ProviderBuilder}; use alloy::sol; use alloy::sol_types::SolEvent; use alloy::transports::RpcError; -use eyre::{eyre, Context, Result}; // Add this import +use eyre::{eyre, Context, Result}; use reqwest; use serde::Deserialize; use std::collections::{HashMap, HashSet}; @@ -25,8 +25,6 @@ sol! { #[sol(rpc)] contract ERC20Votes { function getPastVotes(address account, uint256 timepoint) external view returns (uint256); - function balanceOf(address account) external view returns (uint256); - function decimals() external view returns (uint8); function CLOCK_MODE() external view returns (string); } @@ -88,73 +86,17 @@ pub enum ClockMode { Timestamp, } -/// What a `decimals()` read on a census token found. -/// -/// `decimals()` is optional on an ERC20. A token without it is a supported census token, so the -/// two outcomes are separate values rather than an error. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TokenDecimals { - /// The token answered with its decimals. - Reported(u8), - /// The token has no usable `decimals()`. The node evaluated the call, and the token refused - /// it or answered with data that is not a `uint8`. - Absent, -} - -/// The largest `decimals` a default divisor can be derived from. -/// -/// `10 ** 77` is the last power of ten that fits in 256 bits, so 78 decimals is the last value -/// `10 ** (decimals - 1)` supports. Mirrors `MAX_DERIVABLE_DECIMALS` in `CRISPProgram.sol`. -const MAX_DERIVABLE_DECIMALS: u8 = 78; - -/// The divisor to apply to raw voting power when the round names none. -/// -/// Mirrors `CRISPProgram._defaultVotingPowerDivisor`, so an ONCHAIN round and a Merkle round over -/// the same token encode ballots in identical units: 1 for 0 or 1 decimals, and -/// `10 ** (decimals - 1)` for 2 through 78 decimals. -/// -/// The exponentiation happens in `U256`. A 128-bit intermediate overflows at 40 decimals, and the -/// contract accepts decimals through 78, so a narrower type would disagree with the chain over -/// exactly that range — and it would disagree by a panic, which discovery does not catch. -fn default_voting_power_divisor(decimals: u8) -> Result { - if decimals > MAX_DERIVABLE_DECIMALS { - return Err(eyre!( - "The token reports {} decimals, and a default divisor is derivable through {}. \ - Request the round with an explicit voting-power divisor.", - decimals, - MAX_DERIVABLE_DECIMALS - )); - } - - // One ballot unit per token unit. The contract makes the same exception, because - // `10 ** (0 - 1)` has no meaning. - if decimals <= 1 { - return Ok(U256::from(1)); - } - - U256::from(10) - .checked_pow(U256::from(decimals - 1)) - .ok_or_else(|| { - eyre!( - "A divisor for {} decimals does not fit in 256 bits. Request the round with an \ - explicit voting-power divisor.", - decimals - ) - }) -} - /// True when the node evaluated a metadata call and the token refused it. /// /// The two failure kinds must stay separate. A revert, or an answer that does not decode, shows -/// that the token has no usable `decimals()` — which is permitted on an ERC20, and which the -/// contract answers with divisor 1. A timeout or a transport failure judged nothing about the -/// token, and must not silently change the divisor. +/// that the contract has no usable method, which is permitted. A timeout or a transport failure +/// judged nothing about the contract, and must not be read as an absent method. fn is_metadata_revert(error: &alloy::contract::Error) -> bool { match error { // The call succeeded and returned nothing, so the method is absent. A call to an address // with no code takes this path as well. alloy::contract::Error::ZeroData(..) => true, - // The node answered, and the answer is not a `uint8`. + // The node answered, and the answer does not decode. alloy::contract::Error::AbiError(_) => true, alloy::contract::Error::TransportError(RpcError::ErrorResp(payload)) => { payload.as_revert_data().is_some() || payload.message.to_lowercase().contains("revert") @@ -939,36 +881,21 @@ impl EtherscanClient { potential_voters.into_values().collect() } - /// Select the divisor to scale raw voting power by. - /// - /// The order matches `CRISPProgram._initRound`: a nonzero explicit divisor wins, and the - /// token metadata is read only when the round named no divisor. A round with an explicit - /// divisor therefore does not depend on optional token metadata at all. - /// - /// `read_decimals` is a parameter so that this order, the derivation, and the metadata - /// failure handling are testable without a node. - async fn resolve_scale_factor( - divisor_override: Option, - read_decimals: F, - ) -> Result - where - F: FnOnce() -> Fut, - Fut: std::future::Future>, - { - if let Some(divisor) = divisor_override { - if !divisor.is_zero() { - return Ok(divisor); - } - } - - match read_decimals().await? { - TokenDecimals::Reported(decimals) => default_voting_power_divisor(decimals), - // The value the contract records when its own `decimals()` call reverts. - TokenDecimals::Absent => Ok(U256::from(1)), + /// Reject a zero divisor before it reaches a division. + fn ensure_nonzero_divisor(divisor: U256) -> Result<()> { + if divisor.is_zero() { + return Err(eyre!("The voting-power divisor must be non-zero")); } + Ok(()) } - /// Verify voting power for multiple addresses at an EIP-6372 timepoint + /// Verify voting power for multiple addresses at an EIP-6372 timepoint. + /// + /// `divisor` is the value `CRISPProgram` stored for the round. The contract chose it so that + /// the census sums to less than the plaintext modulus, using the total supply at the same + /// snapshot. Every balance is therefore read with `getPastVotes` at that snapshot and divided + /// by it. A voter whose balance cannot be read is left out of the census with a warning, which + /// can only lower the sum. pub async fn verify_voting_power( &self, token_address: Address, @@ -976,30 +903,17 @@ impl EtherscanClient { timepoint: u64, rpc_url: &str, threshold: U256, - // The round's explicit divisor, when it named one. `None` derives from the token's - // decimals, matching what the contract does for a zero divisor. - divisor_override: Option, + divisor: U256, ) -> Result> { + Self::ensure_nonzero_divisor(divisor)?; let mut token_holders: Vec = Vec::new(); - // A round that names its own divisor must be scaled by that, not by the decimals: the - // contract enforces the round's value, so deriving here would serve balances in units the - // chain disagrees with, and a mask built from one would fail in the verifier. - // - // The override is selected before any metadata call, as `CRISPProgram._initRound` does. - // `decimals()` is optional on an ERC20, so a token that has no usable one must not stop - // discovery for a round that already named its divisor. - let scale_factor = Self::resolve_scale_factor(divisor_override, || { - Self::get_decimals(token_address, rpc_url) - }) - .await?; - log::info!( "Verifying {} candidates against {} at timepoint {} (divisor={}, threshold={})", potential_voters.len(), token_address, timepoint, - scale_factor, + divisor, threshold ); @@ -1010,7 +924,7 @@ impl EtherscanClient { match Self::get_past_votes(token_address, voter.address, timepoint, rpc_url).await { Ok(votes) => { if votes >= threshold { - let scaled_votes = votes / scale_factor; + let scaled_votes = votes / divisor; // Both values, because they answer different questions. The raw one is // what the chain reports and what a holder recognises as their balance; @@ -1034,7 +948,7 @@ impl EtherscanClient { divisor={}): it can vote, but carries no weight", voter.address, votes, - scale_factor + divisor ); } @@ -1151,7 +1065,11 @@ impl EtherscanClient { } } - /// Verify actual voting power for an address at an EIP-6372 timepoint + /// Read an address's voting power at an EIP-6372 timepoint. + /// + /// A failed read is returned, never replaced by a current balance: the census must sum to at + /// most the supply at the snapshot the contract used to choose the divisor, and a current + /// balance can exceed it. async fn get_past_votes( token_address: Address, voter_address: Address, @@ -1162,62 +1080,11 @@ impl EtherscanClient { let provider = ProviderBuilder::new().connect_http(url); let token = ERC20Votes::new(token_address, provider); - match token + token .getPastVotes(voter_address, U256::from(timepoint)) .call() .await - { - Ok(votes) => Ok(votes), - Err(e) => { - // Fallback to balanceOf if getPastVotes fails. This substitutes a *current* - // balance for a historical one, so it is logged: a timepoint in the wrong - // unit reverts every call and would otherwise rebuild the whole census - // from live balances without a single error. - log::warn!( - "getPastVotes failed for {} at timepoint {} ({}), falling back to \ - current balanceOf", - voter_address, - timepoint, - e - ); - let balance = token - .balanceOf(voter_address) - .call() - .await - .context("Failed to call balanceOf")?; - Ok(balance) - } - } - } - - /// Get the token decimals. - /// - /// Three outcomes, because they must not be confused. A token that answers gives its - /// decimals. A token that refuses the call has no usable `decimals()`, which is permitted on - /// an ERC20, and the default divisor becomes 1 exactly as `CRISPProgram` does in its `catch`. - /// Every other failure is an error: an RPC timeout or a transport failure judged nothing - /// about the token, and a silent divisor of 1 would rescale the whole census. - async fn get_decimals(token_address: Address, rpc_url: &str) -> Result { - let url = rpc_url.parse().context("Failed to parse RPC URL")?; - let provider = ProviderBuilder::new().connect_http(url); - let token = ERC20Votes::new(token_address, provider); - - match token.decimals().call().await { - Ok(decimals) => Ok(TokenDecimals::Reported(decimals)), - Err(error) if is_metadata_revert(&error) => { - log::warn!( - "decimals() on {} reverted ({}). The default divisor is 1, which is what \ - the contract records for the same token.", - token_address, - error - ); - Ok(TokenDecimals::Absent) - } - Err(error) => Err(error).context( - "Failed to call decimals. The node judged nothing about the token, so the \ - divisor stays underived.", - ), - } + .context("Failed to call getPastVotes") } /// Parse address from 32-byte topic (last 20 bytes) @@ -1262,9 +1129,10 @@ impl EtherscanClient { snapshot_timepoint: u64, rpc_url: &str, threshold: U256, - // The round's explicit voting-power divisor, or `None` to derive it from the decimals. - divisor_override: Option, + // The divisor `CRISPProgram` stored for the round. + divisor: U256, ) -> Result> { + Self::ensure_nonzero_divisor(divisor)?; log::info!("Starting token holder discovery for {}", token_address); let sources = Self::resolve_voting_power_sources(token_address, rpc_url) @@ -1275,7 +1143,7 @@ impl EtherscanClient { snapshot_timepoint, rpc_url, threshold, - divisor_override, + divisor, sources, ) .await @@ -1287,7 +1155,7 @@ impl EtherscanClient { snapshot_timepoint: u64, rpc_url: &str, threshold: U256, - divisor_override: Option, + divisor: U256, sources: VotingPowerSources, ) -> Result> { // The adapter logs only bonded delegation. Scan it, and its token, registry and escrow. @@ -1410,7 +1278,7 @@ impl EtherscanClient { // Step 5: Verify actual voting power. The unit of the `getPastVotes` timepoint is // the census token's to decide, so ask it rather than assuming. Passing the wrong - // unit reverts the call and silently degrades the census to current balances. + // unit reverts every call, which leaves every voter out of the census. let clock_mode = Self::get_clock_mode(token_address, rpc_url).await; let vote_timepoint = match clock_mode { ClockMode::Timestamp => snapshot_timepoint, @@ -1428,7 +1296,7 @@ impl EtherscanClient { vote_timepoint, rpc_url, threshold, - divisor_override, + divisor, ) .await .context("Failed to verify voting power")?; @@ -1468,7 +1336,7 @@ impl EtherscanClient { snapshot_timepoint, rpc_url, U256::from(1), - Some(U256::from(1)), + U256::from(1), sources, ) .await?; @@ -1551,50 +1419,26 @@ impl EtherscanClient { } } -/// Convenience function to get mocked token holder data for testing. -pub fn get_mock_token_holders() -> Vec { - vec![ - TokenHolder { - address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266".to_string(), - balance: "1".to_string(), - }, - TokenHolder { - address: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8".to_string(), - balance: "1".to_string(), - }, - TokenHolder { - address: "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC".to_string(), - balance: "1".to_string(), - }, - TokenHolder { - address: "0x90F79bf6EB2c4f870365E785982E1f101E93b906".to_string(), - balance: "1".to_string(), - }, - TokenHolder { - address: "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65".to_string(), - balance: "1".to_string(), - }, - TokenHolder { - address: "0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc".to_string(), - balance: "1".to_string(), - }, - TokenHolder { - address: "0x976EA74026E726554dB657fA54763abd0C3a0aa9".to_string(), - balance: "1".to_string(), - }, - TokenHolder { - address: "0x14dC79964da2C08b23698B3D3cc7Ca32193d9955".to_string(), - balance: "1".to_string(), - }, - TokenHolder { - address: "0x23618e81E3f5cdF7f54C3d65f7FBc0aBf5B21E8f".to_string(), - balance: "1".to_string(), - }, - TokenHolder { - address: "0xa0Ee7A142d267C1f36714E4a8F75612F20a79720".to_string(), - balance: "1".to_string(), - }, +/// The local-chain census: the first ten Anvil accounts, each with `balance`. +pub fn get_mock_token_holders(balance: &str) -> Vec { + [ + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x90F79bf6EB2c4f870365E785982E1f101E93b906", + "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65", + "0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc", + "0x976EA74026E726554dB657fA54763abd0C3a0aa9", + "0x14dC79964da2C08b23698B3D3cc7Ca32193d9955", + "0x23618e81E3f5cdF7f54C3d65f7FBc0aBf5B21E8f", + "0xa0Ee7A142d267C1f36714E4a8F75612F20a79720", ] + .into_iter() + .map(|address| TokenHolder { + address: address.to_string(), + balance: balance.to_string(), + }) + .collect() } #[cfg(test)] @@ -1951,8 +1795,8 @@ mod tests { snapshot_timepoint, rpc_url, threshold, - // Derive from the token's decimals, as a round with a zero divisor does. - None, + // The divisor `CRISPProgram` stored for the round. + U256::from(1), ) .await .unwrap(); @@ -2059,163 +1903,41 @@ mod bond_owner_discovery_tests { } } -/// The coordinator and `CRISPProgram` must scale voting power by the same divisor. A mismatch is -/// silent: an ONCHAIN client builds a mask against a bound the verifier refuses, and a TOKEN -/// census carries leaf weights the tally cannot decode back. +/// Every census balance is divided by the divisor, so a zero divisor is refused before any read. #[cfg(test)] mod voting_power_divisor_tests { - use super::{ - default_voting_power_divisor, EtherscanClient, TokenDecimals, MAX_DERIVABLE_DECIMALS, - }; - use alloy::primitives::U256; - - /// The token that reverts `decimals()`. `CRISPProgram._defaultVotingPowerDivisor` catches the - /// same revert and records divisor 1. - async fn reverting_decimals() -> eyre::Result { - Ok(TokenDecimals::Absent) - } - - /// An RPC failure. It judged nothing about the token, so it must not become divisor 1. - async fn unreachable_node() -> eyre::Result { - Err(eyre::eyre!("Failed to call decimals")) - } - - fn reports( - decimals: u8, - ) -> impl FnOnce() -> std::pin::Pin>>> - { - move || Box::pin(async move { Ok(TokenDecimals::Reported(decimals)) }) - } - - /// Every value the contract accepts, including the two that the previous `u128` - /// exponentiation could not represent. 39 is the last exponent that fits in 128 bits and 40 - /// is the first that does not, so the pair pins the boundary that used to panic. - #[test] - fn the_default_divisor_matches_the_contract_for_every_supported_decimals() { - // `10 ** (decimals - 1)` has no meaning below two decimals, so the contract returns 1. - assert_eq!(default_voting_power_divisor(0).unwrap(), U256::from(1)); - assert_eq!(default_voting_power_divisor(1).unwrap(), U256::from(1)); - - assert_eq!(default_voting_power_divisor(2).unwrap(), U256::from(10)); - assert_eq!( - default_voting_power_divisor(18).unwrap(), - U256::from(10).pow(U256::from(17)) - ); - assert_eq!( - default_voting_power_divisor(39).unwrap(), - U256::from(10).pow(U256::from(38)) - ); - assert_eq!( - default_voting_power_divisor(40).unwrap(), - U256::from(10).pow(U256::from(39)) - ); - assert_eq!( - default_voting_power_divisor(MAX_DERIVABLE_DECIMALS).unwrap(), - U256::from(10).pow(U256::from(77)) - ); - } - - /// The exact boundary the finding names. `10 ** 39` is larger than `u128::MAX`, so a 128-bit - /// intermediate panics or wraps here while the contract derives the value without difficulty. - #[test] - fn the_divisor_for_40_decimals_exceeds_128_bits() { - assert_eq!( - default_voting_power_divisor(40).unwrap(), - U256::from_str_radix("1000000000000000000000000000000000000000", 10).unwrap() - ); - assert!(default_voting_power_divisor(40).unwrap() > U256::from(u128::MAX)); - assert!(default_voting_power_divisor(39).unwrap() <= U256::from(u128::MAX)); - } - - /// Refused with a message, not with a panic. `10 ** 78` does not fit in 256 bits, and the - /// contract refuses the same value with `UnsupportedTokenDecimals`. - #[test] - fn a_decimals_above_the_contract_bound_is_refused() { - assert!(default_voting_power_divisor(MAX_DERIVABLE_DECIMALS + 1).is_err()); - assert!(default_voting_power_divisor(u8::MAX).is_err()); - } - - /// The order the contract uses: the explicit divisor is selected before any metadata call. - /// The reader here panics, so a test failure shows that metadata was read. - #[tokio::test] - async fn an_explicit_divisor_wins_and_no_metadata_is_read() { - let divisor = U256::from(1_000u64); - - let resolved = EtherscanClient::resolve_scale_factor(Some(divisor), || { - Box::pin(async { - panic!("metadata must not be read when the round names a divisor"); - #[allow(unreachable_code)] - Ok(TokenDecimals::Absent) - }) - as std::pin::Pin>>> - }) - .await - .unwrap(); - - assert_eq!(resolved, divisor); - } - - /// The defect this pairing exists for: a token whose `decimals()` reverts must not stop - /// discovery for a round that already named its divisor. - #[tokio::test] - async fn an_explicit_divisor_survives_a_reverting_decimals_call() { - let divisor = U256::from(10).pow(U256::from(60)); - - let resolved = EtherscanClient::resolve_scale_factor(Some(divisor), reverting_decimals) - .await - .unwrap(); - - assert_eq!(resolved, divisor); - } - - /// A zero divisor is how a round says it named none, so the default is derived. - #[tokio::test] - async fn a_zero_divisor_derives_the_default() { - let resolved = EtherscanClient::resolve_scale_factor(Some(U256::ZERO), reports(18)) - .await - .unwrap(); - - assert_eq!(resolved, U256::from(10).pow(U256::from(17))); - } - - /// The value `CRISPProgram` records when its own `decimals()` call reverts. - #[tokio::test] - async fn a_reverting_decimals_call_gives_divisor_one() { - let resolved = EtherscanClient::resolve_scale_factor(None, reverting_decimals) - .await - .unwrap(); + use super::EtherscanClient; + use alloy::primitives::{Address, U256}; - assert_eq!(resolved, U256::from(1)); + fn client() -> EtherscanClient { + EtherscanClient::new("test_key".to_string(), 1) } - /// An RPC failure judged nothing about the token. Divisor 1 would rescale the whole census by - /// `10 ** (decimals - 1)`, so the failure must propagate instead. + /// A zero divisor would divide by zero, and the contract never stores one for a CUSTOM round. #[tokio::test] - async fn an_rpc_failure_does_not_become_divisor_one() { - assert!( - EtherscanClient::resolve_scale_factor(None, unreachable_node) - .await - .is_err() - ); - } - - /// The derivation the contract performs for a 40-decimal token, through the same path - /// discovery takes. - #[tokio::test] - async fn a_forty_decimal_token_resolves_without_overflow() { - let resolved = EtherscanClient::resolve_scale_factor(None, reports(40)) - .await - .unwrap(); - - assert_eq!(resolved, U256::from(10).pow(U256::from(39))); - } + async fn a_zero_divisor_is_refused() { + let refused = client() + .verify_voting_power( + Address::repeat_byte(1), + &[], + 1, + "http://127.0.0.1:1", + U256::ZERO, + U256::ZERO, + ) + .await; + assert!(refused.is_err()); - /// An unsupported default is an error, not a panic. Discovery handles an error, and an - /// arithmetic panic bypasses that handling. - #[tokio::test] - async fn an_unsupported_decimals_is_an_error_rather_than_a_panic() { - assert!(EtherscanClient::resolve_scale_factor(None, reports(79)) - .await - .is_err()); + let accepted = client() + .verify_voting_power( + Address::repeat_byte(1), + &[], + 1, + "http://127.0.0.1:1", + U256::ZERO, + U256::from(1), + ) + .await; + assert!(accepted.unwrap().is_empty()); } } diff --git a/packages/interfold-dashboard/src/lib/adapt.ts b/packages/interfold-dashboard/src/lib/adapt.ts index 414ebfcbf4..f9f2bae594 100644 --- a/packages/interfold-dashboard/src/lib/adapt.ts +++ b/packages/interfold-dashboard/src/lib/adapt.ts @@ -8,7 +8,7 @@ import { formatUnits, keccak256, numberToHex, toHex } from 'viem' import type { HistoryEntry, Poll } from '../data' import type { E3FullDetails, E3Summary } from './e3' -import { decodeCrispTally, failureReasonLabel, failureReasonToUiIdx, isE3Active } from './e3' +import { failureReasonLabel, failureReasonToUiIdx, isE3Active } from './e3' import { E3Stage, FEE_TOKEN, NETWORK_NAME } from './chain' import { compactE3Id, formatE3Id, isCrispProgram, pollMetaFor, programName, shortHash } from './pollMeta' @@ -55,20 +55,18 @@ export function adaptHistoryEntries(list: E3Summary[], detailsCache: Map): string { - // Without the on-chain option count the segment layout is unknown, so the tally is - // left undecoded rather than guessed from the off-chain label list. - if (detail && detail.numOptions) { - const tally = decodeCrispTally(detail.plaintextOutput, detail.numOptions) - if (tally && tally.length > 0) { - const total = tally.reduce((a, b) => a + b, 0n) - const max = tally.reduce((a, b) => (b > a ? b : a), 0n) - // Integer division truncates, so bias the numerator by half a percentage point - // to round half-up — matching the display before totals became bigint. - const pct = total > 0n ? Number((max * 200n + total) / (total * 2n)) : 0 - const winnerLabel = meta.options[tally.indexOf(max)]?.label ?? 'Outcome' - const verdict = /^no/i.test(winnerLabel) ? 'Declined' : /^abs/i.test(winnerLabel) ? 'Inconclusive' : 'Approved' - return `${verdict} · ${pct}%` - } + // The tally comes from the CRISPProgram that ran the round, so it is never + // guessed from the off-chain label list. + const tally = detail?.tally + if (tally && tally.length > 0) { + const total = tally.reduce((a, b) => a + b, 0n) + const max = tally.reduce((a, b) => (b > a ? b : a), 0n) + // Integer division truncates, so bias the numerator by half a percentage point + // to round half-up — matching the display before totals became bigint. + const pct = total > 0n ? Number((max * 200n + total) / (total * 2n)) : 0 + const winnerLabel = meta.options[tally.indexOf(max)]?.label ?? 'Outcome' + const verdict = /^no/i.test(winnerLabel) ? 'Declined' : /^abs/i.test(winnerLabel) ? 'Inconclusive' : 'Approved' + return `${verdict} · ${pct}%` } if (s.stage === E3Stage.Complete) return 'Completed' if (s.stage === E3Stage.Failed) return 'Failed' diff --git a/packages/interfold-dashboard/src/lib/e3.ts b/packages/interfold-dashboard/src/lib/e3.ts index 5ccb3713f2..a516d80e26 100644 --- a/packages/interfold-dashboard/src/lib/e3.ts +++ b/packages/interfold-dashboard/src/lib/e3.ts @@ -46,21 +46,14 @@ const CRISP_INPUT_PUBLISHED = { ], } as const -// Minimal CRISPProgram view. The option count is round configuration set in -// `validate()`; the tally layout cannot be decoded without it. -const CRISP_GET_ROUND_DATA = { +// Minimal CRISPProgram view. The program that ran a round decodes its own tally, so +// the dashboard holds no copy of the ballot layout and reads every deployment right. +const CRISP_DECODE_TALLY = { type: 'function', - name: 'getRoundData', + name: 'decodeTally', stateMutability: 'view', inputs: [{ name: 'e3Id', type: 'uint256' }], - outputs: [ - { name: 'merkleRoot', type: 'uint256' }, - { name: 'paramsHash', type: 'bytes32' }, - { name: 'numOptions', type: 'uint256' }, - { name: 'creditMode', type: 'uint8' }, - { name: 'inputRoot', type: 'uint256' }, - { name: 'numberOfVotes', type: 'uint40' }, - ], + outputs: [{ name: 'votes', type: 'uint256[]' }], } as const // Public RPCs cap getLogs range. 9_500 keeps us safely under common 10k limits. @@ -217,9 +210,9 @@ export type E3FullDetails = E3Summary & { committeePublicKey: `0x${string}` ciphertextOutput: `0x${string}` plaintextOutput: `0x${string}` - // Option count for CRISP rounds, read from CRISPProgram. Undefined for non-CRISP - // programs or when the round was never initialised. Required to decode the tally. - numOptions?: number + // Per-option totals of a CRISP round, decoded by CRISPProgram. Undefined for + // non-CRISP programs and for rounds without a published, decodable output. + tally?: bigint[] requestedAt?: number // unix seconds (block.timestamp of the request) // Block number of the E3Requested log (distinct from `requestBlock` which on // this contract version is actually a Unix timestamp, not a block number). @@ -418,18 +411,24 @@ export async function fetchE3Details(e3Id: bigint, toBlock?: bigint): Promise FailureReason.None) as Promise, ]) - // CRISP round configuration. Only CRISP E3s expose it, and an uninitialised round - // reports 0 options — in both cases the tally stays undecodable rather than guessed. - const numOptions = isCrispE3(e3.e3Program) - ? await ((publicClient.readContract as any)({ - address: CONTRACTS.CRISPProgram, - abi: [CRISP_GET_ROUND_DATA], - functionName: 'getRoundData', - args: [e3Id], - }) - .then((data: readonly unknown[]) => Number(data[2] as bigint) || undefined) - .catch(() => undefined) as Promise) - : undefined + // CRISP tally. Only CRISP E3s have one, and only once the plaintext output is + // published. A round the program cannot decode reverts or returns no totals; in + // both cases the tally stays undefined rather than guessed. + const tally = + isCrispE3(e3.e3Program) && e3.plaintextOutput && e3.plaintextOutput !== '0x' + ? await publicClient + .readContract({ + address: CONTRACTS.CRISPProgram, + abi: [CRISP_DECODE_TALLY], + functionName: 'decodeTally', + args: [e3Id], + // viem's types require this EIP-7702 field for this client. A read carries no + // authorizations. + authorizationList: undefined, + }) + .then((votes) => (votes.length > 0 ? [...votes] : undefined)) + .catch(() => undefined) + : undefined // `e3.requestBlock` is misnamed: on this contract version it stores // `block.timestamp` (EIP-6372 timestamp clock), not a block number. Using it @@ -595,7 +594,7 @@ export async function fetchE3Details(e3Id: bigint, toBlock?: bigint): Promise= 0; b -= 2) bigEndian += word.slice(b, b + 2) - coefficients.push(BigInt(`0x${bigEndian}`)) - } - - if (coefficients.length < MAX_MSG_NON_ZERO_COEFFS) return null - - const segmentSize = Math.floor(MAX_MSG_NON_ZERO_COEFFS / numOptions) - // More options than payload coefficients leaves nothing to decode. - if (segmentSize === 0) return null - - const totals: bigint[] = [] - for (let optIdx = 0; optIdx < numOptions; optIdx++) { - const segmentStart = optIdx * segmentSize - let value = 0n - for (let i = 0; i < segmentSize; i++) { - value += coefficients[segmentStart + i] << BigInt(segmentSize - 1 - i) - } - totals.push(value) - } - - return totals - } catch { - return null - } -} From 9cdd2ed95a93c2f634a7fb4e4bb7aefa33d022d4 Mon Sep 17 00:00:00 2001 From: ctrlc03 <93448202+ctrlc03@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:52:41 +0100 Subject: [PATCH 02/15] fix(crisp): retry a zero stored divisor read CRISPProgram stores a non-zero divisor for every CUSTOM-credit round in the transaction that requests the E3. A provider node that does not have that block yet returns zero. The server retried only failed reads, so a zero read left a TOKEN-census round that asked for the minimum divisor without a divisor, and the server dropped the round. read_stored_divisor retries a zero read like a failed read, with the E3_VISIBLE_ATTEMPTS that the getE3 read uses (30 s in total). --- examples/CRISP/server/src/server/indexer.rs | 131 +++++++++++++++++--- 1 file changed, 115 insertions(+), 16 deletions(-) diff --git a/examples/CRISP/server/src/server/indexer.rs b/examples/CRISP/server/src/server/indexer.rs index ce5aafcf35..eedaf7d299 100644 --- a/examples/CRISP/server/src/server/indexer.rs +++ b/examples/CRISP/server/src/server/indexer.rs @@ -31,7 +31,7 @@ use e3_sdk::{ CommitteePublicKeyChunkPublished, CommitteePublished, E3Requested, PlaintextOutputPublished, }, - retry::{call_with_retry, call_with_retry_attempts}, + retry::call_with_retry_attempts, }, indexer::{DataStore, IndexerContext, InterfoldIndexer, SharedStore}, }; @@ -50,14 +50,14 @@ static DISCOVERY_OWED: LazyLock = LazyLock::new(Notify::new); type Result = std::result::Result>; -/// Attempts of the `getE3` read that confirms the provider has the block of an `E3Requested` -/// event. +/// Attempts of a read that needs the block of an `E3Requested` event: `getE3`, and the stored +/// divisor of a CUSTOM-credit round. /// /// The subscription delivers the event when one node has the block. The HTTP provider spreads -/// reads over nodes that can trail that node by most of a block, and until they have it every read -/// of the round reverts with `E3DoesNotExist`. Five attempts wait 2 + 4 + 8 + 16 = 30 s in total, -/// which covers more than two Sepolia blocks. A live handler error is not retried later, so a -/// shorter wait loses the round. +/// reads over nodes that can trail that node by most of a block. Until they have it, `getE3` +/// reverts with `E3DoesNotExist` and the stored divisor reads as zero. Five attempts wait +/// 2 + 4 + 8 + 16 = 30 s in total, which covers more than two Sepolia blocks. A live handler error +/// is not retried later, so a shorter wait loses the round. const E3_VISIBLE_ATTEMPTS: u32 = 5; fn is_configured_e3_program(event_program: Address, configured_program: Address) -> bool { @@ -75,22 +75,32 @@ fn stage_ends_input_retrieval(stage: &E3Stage) -> bool { /// /// `CRISPProgram` resolves and stores this value at request time and scales every voter's power by /// exactly it. A transient RPC failure must not leave the coordinator scaling balances in other -/// units, so the read retries with backoff. On exhaustion it returns `None`, which -/// `resolve_divisor_override` answers from the requested field when the round named one. +/// units, so the read retries with backoff. The contract never stores zero for a CUSTOM-credit +/// round, so a zero read comes from a node that does not have the request block yet, and is +/// retried the same way. On exhaustion it returns `None`, which `resolve_divisor_override` answers +/// from the requested field when the round named one. async fn read_stored_divisor( crisp: &CRISPContract, e3_id: U256, label: &str, ) -> Option { - match call_with_retry("stored_voting_power_divisor", &[], || async { - crisp - .stored_voting_power_divisor(e3_id) - .await - .map_err(|error| anyhow::anyhow!(error.to_string())) - }) + match call_with_retry_attempts( + "stored_voting_power_divisor", + &[], + E3_VISIBLE_ATTEMPTS, + || async { + crisp + .stored_voting_power_divisor(e3_id) + .await + .map_err(|error| anyhow::anyhow!(error.to_string()))? + .ok_or_else(|| { + anyhow::anyhow!("the provider holds no stored divisor for the round") + }) + }, + ) .await { - Ok(divisor) => divisor, + Ok(divisor) => Some(divisor), Err(error) => { warn!( "[e3_id={}] Failed to read the stored voting-power divisor after retries: {:#}", @@ -2058,6 +2068,95 @@ mod e3_request_tests { } } +#[cfg(test)] +mod stored_divisor_tests { + use super::read_stored_divisor; + use alloy_primitives::U256; + use evm_helpers::CRISPContractFactory; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + /// A JSON-RPC node over HTTP that does not have the round at its first read. It answers the + /// first `eth_call` with zero and every later one with `stored`. + async fn lagging_node(listener: TcpListener, stored: u64) { + let reads = Arc::new(AtomicUsize::new(0)); + loop { + let (mut socket, _) = listener.accept().await.unwrap(); + let reads = reads.clone(); + tokio::spawn(async move { + let mut buffer = Vec::new(); + let mut chunk = [0u8; 4096]; + loop { + let header_end = loop { + if let Some(at) = buffer.windows(4).position(|bytes| bytes == b"\r\n\r\n") { + break at + 4; + } + let read = socket.read(&mut chunk).await.unwrap(); + if read == 0 { + return; + } + buffer.extend_from_slice(&chunk[..read]); + }; + let body_end = header_end + + String::from_utf8_lossy(&buffer[..header_end]) + .to_ascii_lowercase() + .lines() + .find_map(|line| line.strip_prefix("content-length:")) + .map_or(0, |length| length.trim().parse::().unwrap()); + while buffer.len() < body_end { + let read = socket.read(&mut chunk).await.unwrap(); + if read == 0 { + return; + } + buffer.extend_from_slice(&chunk[..read]); + } + let request: serde_json::Value = + serde_json::from_slice(&buffer[header_end..body_end]).unwrap(); + buffer.drain(..body_end); + + let value = if reads.fetch_add(1, Ordering::SeqCst) == 0 { + 0 + } else { + stored + }; + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": request["id"], + "result": format!("0x{value:064x}"), + }) + .to_string(); + let reply = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{response}", + response.len() + ); + socket.write_all(reply.as_bytes()).await.unwrap(); + } + }); + } + } + + /// `CRISPProgram` never stores a zero divisor for a CUSTOM-credit round. A zero read is a node + /// that does not have the request block yet, and taking it as final drops the round. + #[tokio::test] + async fn a_zero_read_from_a_lagging_node_is_retried() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(lagging_node(listener, 5)); + + let crisp = + CRISPContractFactory::create_read(&url, "0x0000000000000000000000000000000000000001") + .await + .unwrap(); + + assert_eq!( + read_stored_divisor(&crisp, U256::from(1), "1").await, + Some(U256::from(5)) + ); + } +} + #[cfg(test)] mod pending_discovery_tests { use super::{pending_discovery_step, record_discovery_debt, PendingDiscoveryStep}; From 691f45378f6978ec9b29f7e81184d68f8b6ba0b7 Mon Sep 17 00:00:00 2001 From: ctrlc03 <93448202+ctrlc03@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:36:21 +0100 Subject: [PATCH 03/15] refactor(crisp): shorten per-option tally code and tests Fold single-use helpers into their callers, merge overlapping tests, and shorten the docs. The compiled crisp and crisp_onchain circuits do not change on either preset. Add tests for the weight and quotient range checks, a full-width tally word, a tally length that is not whole words, and a zero stored-divisor read from a lagging node. --- agent/flow-trace/00_INDEX.md | 2 +- agent/flow-trace/04_DKG_AND_COMPUTATION.md | 42 +- agent/invariants/02_CRYPTO_CIRCUITS.md | 17 +- docs/pages/CRISP/introduction.mdx | 16 +- docs/pages/CRISP/running-e3.mdx | 51 +-- .../build/tutorials/custom-zk-circuits.mdx | 8 +- examples/CRISP/circuits/lib/src/utils.nr | 316 +++++---------- examples/CRISP/crates/crisp-utils/src/lib.rs | 57 +-- examples/CRISP/crates/evm_helpers/src/lib.rs | 22 +- .../contracts/CRISPProgram.sol | 184 ++------- .../contracts/Mocks/MockInterfold.sol | 34 +- .../contracts/Mocks/MockVotesToken.sol | 5 +- .../contracts/Mocks/MockVotingToken.sol | 5 +- .../contracts/SelfRegistry.sol | 5 +- .../contracts/interfaces/IVotesToken.sol | 11 +- .../crisp-contracts/tests/census-mode.test.ts | 103 ++--- .../tests/crisp.contracts.test.ts | 1 - .../tests/crisp.journal.test.ts | 1 - .../tests/input-availability-flow.test.ts | 11 - .../tests/onchain-census.test.ts | 3 - .../tests/self-registry.test.ts | 4 +- .../tests/tally.decoding.test.ts | 137 ++----- examples/CRISP/packages/crisp-sdk/README.md | 16 +- .../CRISP/packages/crisp-sdk/src/constants.ts | 3 +- .../CRISP/packages/crisp-sdk/src/encoding.ts | 41 +- examples/CRISP/packages/crisp-sdk/src/vote.ts | 4 +- .../packages/crisp-sdk/tests/vote.test.ts | 47 +-- examples/CRISP/server/src/cli/commands.rs | 5 +- examples/CRISP/server/src/server/indexer.rs | 382 +++++------------- examples/CRISP/server/src/server/models.rs | 9 +- .../CRISP/server/src/server/routes/rounds.rs | 5 +- .../src/server/token_holders/etherscan.rs | 49 +-- packages/interfold-dashboard/src/lib/adapt.ts | 7 +- packages/interfold-dashboard/src/lib/e3.ts | 36 +- 34 files changed, 477 insertions(+), 1162 deletions(-) diff --git a/agent/flow-trace/00_INDEX.md b/agent/flow-trace/00_INDEX.md index ff58c13bbc..a350179b58 100644 --- a/agent/flow-trace/00_INDEX.md +++ b/agent/flow-trace/00_INDEX.md @@ -410,7 +410,7 @@ Findings in the ballot path of the reference app (`examples/CRISP`), not the pro | Finding | Status | Implemented behavior | | ---------------------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Ballot rules read only zero padding** | Fixed | `check_coefficient_values_with_balance` and `check_coefficient_zero` indexed `k1` as if it were `MAX_MSG_NON_ZERO_COEFFS` wide, while the circuit receives the full reversed BFV degree. Both read padding, so every vote passed any balance bound and a mask could carry an arbitrary payload. Both now read option `o` at `k1[D - 1 - o]` of the full-degree polynomial. | +| **Ballot rules read only zero padding** | Fixed | `check_coefficient_values_with_balance` and `check_coefficient_zero` indexed `k1` as if it were `MAX_MSG_NON_ZERO_COEFFS` wide, while the circuit receives the full reversed BFV degree. Both read padding, so every vote passed any balance bound and a mask could carry an arbitrary payload. `check_coefficient_values_with_balance` now reads option `o` at `k1[D - 1 - o]` of the full-degree polynomial, and `check_coefficient_zero` checks all `D` coefficients. | | **Coefficients outside the ballot are unconstrained** | Fixed | The whole plaintext is bound: one weight in `[0, t)` for each option, zero everywhere else, and zero everywhere for a mask. An unconstrained coefficient survives to the decrypted tally, so it was both a way to corrupt a slot and a channel a coercer could read a receipt from. | | **Re-votes are accepted and never tallied** | Fixed | The witness published `previous + new` while the circuit committed to `new`. One generator now publishes exactly what the circuit commits to on every branch, and the SDK exposes one commitment for the ballot digest. | | **Occupied-slot masks cannot be published** | Fixed | The client built the digest from the ballot commitment while the contract rebuilt it from the stored one, which differ for a mask over an occupied slot. `PreparedBallot.ctCommitment` is now the published ciphertext's commitment on every branch. | diff --git a/agent/flow-trace/04_DKG_AND_COMPUTATION.md b/agent/flow-trace/04_DKG_AND_COMPUTATION.md index c755905779..b641531242 100644 --- a/agent/flow-trace/04_DKG_AND_COMPUTATION.md +++ b/agent/flow-trace/04_DKG_AND_COMPUTATION.md @@ -2138,16 +2138,12 @@ Capacity: `TREE_DEPTH = 20` gives 2^20 entries. Every input, whether a vote, an appends one entry, and `_verifyInputProof` refuses an input once the round holds `inputLimit` entries (`InputLimitReached`), on both `publishInput` and `validateInputProof`. The limit is at most `MAX_INPUTS_PER_ROUND` (100,000). Every input adds at most one fresh ciphertext to the sum that the -committee decrypts. Every secure parameter set must keep that decryption correct for at least -100,000 additions (`SEARCH_Z` is 100,000 for secure-8192). The insecure-512 test preset is sized -for 1,024 additions. A CUSTOM round on it, or a CONSTANT round with zero credits, can accept more -inputs than that. +committee decrypts. Decryption stays correct for `SEARCH_Z` additions, which is 100,000 for +secure-8192. The insecure-512 test preset is sized for 1,024 additions. A CUSTOM round on it, or a +CONSTANT round with zero credits, can accept more inputs than that. -A mask needs no voter signature, and the contract cannot tell a mask from a vote, so it cannot count -votes alone. Thus any account can send masks to eligible slots until the round reaches `inputLimit`, -and every later input reverts. Each mask still needs a valid proof, an availability attestation, and -a `publishInput` transaction. A CONSTANT round with large `credits` has the smallest limit, so it is -the cheapest round to fill. +A mask needs no voter signature, so any account can fill `inputLimit` with masks (`00_INDEX.md`, +"Masks can fill the input limit"). **Plaintext modulus bound.** The committee decrypts each tally coefficient modulo the plaintext modulus `t` of the round's BFV parameters: 100 for insecure-512 and 17,000,000 for secure-8192. A @@ -2176,10 +2172,9 @@ block-number clock, the server takes the last block at or before the request tim a chain with several blocks per second, that block can be earlier than the contract's snapshot. A CUSTOM round also relies on the token: at the snapshot, `getPastVotes` over all accounts must not sum above `getPastTotalSupply`. ERC20Votes meets this. `BondedVotes` with an escrow votes source -does not guarantee it. Its vesting-locked term reads the current locks and the present wallet -balance, not a checkpointed history. After a slash leaves a lock larger than its bond, that term can -count FOLD that another account also counts at the snapshot. The bound still holds while that -overlap is at most the FOLD that carries no vote at the snapshot. +does not guarantee it (see the `BondedVotes` gap in `invariants/01_PROTOCOL_ONCHAIN.md`). The bound +still holds while the FOLD that two accounts both count is at most the FOLD that carries no vote at +the snapshot. A round where _every_ entry is unusable fails at the output commitment, because the processor's empty ciphertext does not deserialize. That is only reachable when no honest input exists, and is @@ -2222,8 +2217,7 @@ second opening of the parent commitment that satisfied the single equation. A pe relation, aligned across three ciphertexts that pack with the same `BIT_CT`, proves the same statement for the committed coefficients under any opening that keeps the carriers, so the circuit needs no `pack_checked` digit asserts. At secure-8192 the `crisp` circuit is 1,750,827 gates and -`crisp_onchain` 1,731,104, under the `2^21` browser ceiling. With the checked helper on the three -commitments, the secure `crisp` circuit measured 2,520,034 gates. +`crisp_onchain` 1,731,104, under the `2^21` browser ceiling. The circuit returns `sum_ct_commitment` on every path, so the public inputs, the stored commitment, the ballot digest, and the published ciphertext have the same shape whichever operation ran. Telling @@ -2234,12 +2228,12 @@ hides. The SDK has one code path for all three, and `CrispSDK.prepareBallot` mak The plaintext is fully constrained on both branches. The witness generator reverses the message over the full BFV degree, so option `o` is `k1[D - 1 - o]`. `check_coefficient_values_with_balance` requires every coefficient outside the first `num_options` message positions to be zero and decodes -each option weight with `decode_weight`. The prover hints the weight `v` and a quotient `r`. -`check_weight` range-checks the coefficient, `v`, and `r` to `[0, t)` and checks -`k1 + t * r == Q_MOD_T * v`. With every value below `t`, the equation holds over the integers, so -`v` is the value that the coefficient decrypts to. The fold binds this `k1` to the plaintext of -`user_data_encryption_ct0` through `k1_commitment`. A packed commitment has one opening only while -every coefficient fits its slot. `user_data_encryption_ct0` range-checks its `k1` to `[0, t)`, and -the ballot circuit bounds every coefficient of its own `k1`. The weights must sum to at most the -slot's bound, and with two options at most one weight is nonzero. `check_coefficient_zero` requires -the whole polynomial to be zero for a mask. +each option weight. The prover hints the weight `v` and a quotient `r`. `check_weight` range-checks +the coefficient, `v`, and `r` to `[0, t)` and checks `k1 + t * r == Q_MOD_T * v`. With every value +below `t`, the equation holds over the integers, so `v` is the value that the coefficient decrypts +to. The fold binds this `k1` to the plaintext of `user_data_encryption_ct0` through `k1_commitment`. +A packed commitment has one opening only while every coefficient fits its slot. +`user_data_encryption_ct0` range-checks its `k1` to `[0, t)`, and the ballot circuit bounds every +coefficient of its own `k1`. The weights must sum to at most the slot's bound, and with two options +at most one weight is nonzero. `check_coefficient_zero` requires the whole polynomial to be zero for +a mask. diff --git a/agent/invariants/02_CRYPTO_CIRCUITS.md b/agent/invariants/02_CRYPTO_CIRCUITS.md index 84ba903880..6ecf46e3ad 100644 --- a/agent/invariants/02_CRYPTO_CIRCUITS.md +++ b/agent/invariants/02_CRYPTO_CIRCUITS.md @@ -575,14 +575,15 @@ every section. the committee decrypts it modulo the plaintext modulus `t` of the round's BFV parameters. `CRISPProgram.validate` must read `t` from `e3ProgramParams`, not from a constant, and size the round so that no total reaches `t`. A CONSTANT-credit round must refuse `credits >= t` and accept - at most `(t - 1) / credits` inputs, capped at `MAX_INPUTS_PER_ROUND`. A CUSTOM-credit round must - record a divisor of at least `getPastTotalSupply(snapshot) / t + 1`, which bounds the totals only - while the past votes of all accounts sum to at most the past supply. ERC20Votes meets this. - `BondedVotes` with an escrow votes source does not guarantee it (`flow-trace/04`). Every input - counts toward the limit, including updates and masks, on both `publishInput` and - `validateInputProof`. A mask needs no signature, so any account can fill the limit. A census-tree - round also relies on the coordinator writing `credits` or the scaled voting power at the snapshot - into each leaf. A change to the tally format, the credit modes, or the parameter sets needs a new + at most `(t - 1) / credits` inputs, capped at `MAX_INPUTS_PER_ROUND`. Each input adds one fresh + ciphertext to the decrypted sum, so every secure preset must be searched with `SEARCH_Z` of at + least `MAX_INPUTS_PER_ROUND`. A CUSTOM-credit round must record a divisor of at least + `getPastTotalSupply(snapshot) / t + 1`, which bounds the totals only while the past votes of all + accounts sum to at most the past supply. ERC20Votes meets this. `BondedVotes` with an escrow votes + source does not guarantee it (`01_PROTOCOL_ONCHAIN.md`). Every input counts toward the limit, + including updates and masks, on both `publishInput` and `validateInputProof`. A census-tree round + also relies on the coordinator writing `credits` or the scaled voting power at the snapshot into + each leaf. A change to the tally format, the credit modes, or the parameter sets needs a new bound. **Gap:** the server derives its census timepoint from the request timestamp. For a token with a block-number clock, on a chain with several blocks per second, the census block can precede the snapshot. — `flow-trace/04` diff --git a/docs/pages/CRISP/introduction.mdx b/docs/pages/CRISP/introduction.mdx index e83c94b5a2..d5b3d21c8e 100644 --- a/docs/pages/CRISP/introduction.mdx +++ b/docs/pages/CRISP/introduction.mdx @@ -102,14 +102,14 @@ The table shows which CRISP component fills each part of an E3. The requested and when the encrypted output is published. The [program contract guide](/build/e3-program/program-contract) explains the `IE3Program` interface. -| Function | Caller | What it does | -| --------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `validate` | Interfold contract, at the request | Decodes the round parameters and starts an input Merkle tree of depth 20. A round must have 2 to 10 options. It sets the input limits of the round from the plaintext modulus `t` (see [Decode the tally](/CRISP/running-e3#decode-the-tally)). On Avail deployments, it also checks the input window timing. | -| `setMerkleRoot` | Owner (the CRISP server key) | Stores the census Merkle root for a round that uses a census tree. | -| `publishInput` | Voter wallet or server relay | Verifies the Noir proof and the availability signature of the server. Reserves the input leaf and emits `InputCommitted`. | -| `finalizeInput` | Any account (usually the server) | Verifies the data-availability receipt for the committed ciphertext hash. Emits `InputPublished`. | -| `verify` | Interfold contract, at the output | Reverts with `InputAvailabilityPending` while an input waits for finalization. Checks the OpenVM receipt against the parameters hash and the input root. | -| `decodeTally` | Anyone (view) | Decodes the plaintext output into the total weight of each option. | +| Function | Caller | What it does | +| --------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `validate` | Interfold contract, at the request | Decodes the round parameters and starts an input Merkle tree of depth 20. A round must have 2 to 10 options. It sizes the round so that each option total stays below the plaintext modulus `t` (see [Decode the tally](/CRISP/running-e3#decode-the-tally)). On Avail deployments, it also checks the input window timing. | +| `setMerkleRoot` | Owner (the CRISP server key) | Stores the census Merkle root for a round that uses a census tree. | +| `publishInput` | Voter wallet or server relay | Verifies the Noir proof and the availability signature of the server. Reserves the input leaf and emits `InputCommitted`. | +| `finalizeInput` | Any account (usually the server) | Verifies the data-availability receipt for the committed ciphertext hash. Emits `InputPublished`. | +| `verify` | Interfold contract, at the output | Reverts with `InputAvailabilityPending` while an input waits for finalization. Checks the OpenVM receipt against the parameters hash and the input root. | +| `decodeTally` | Anyone (view) | Decodes the plaintext output into the total weight of each option. | The round parameters include a census mode. The census mode selects who can vote and which Noir verifier `publishInput` uses: diff --git a/docs/pages/CRISP/running-e3.mdx b/docs/pages/CRISP/running-e3.mdx index e0e9d92d45..ec2ad33579 100644 --- a/docs/pages/CRISP/running-e3.mdx +++ b/docs/pages/CRISP/running-e3.mdx @@ -267,41 +267,22 @@ The other coefficients are zero. The committee decrypts each coefficient modulo the plaintext modulus `t` of the parameter set: 100 for `insecure-512` and 17,000,000 for `secure-8192`. A total is correct only while it is less than -`t`. `CRISPProgram.validate` reads `t` from the BFV parameters of the request. Then it sets limits -for the credit mode of the round: - -- With `CONSTANT` credits, each voter has the same voting power, `credits`. -- With `CUSTOM` credits, the voting power of a voter is `getPastVotes(voter, snapshot)` divided by - the voting-power divisor of the round. - -The limits keep each total less than `t`: - -| Credit mode | Limits | -| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `CONSTANT` | `credits` must be less than `t`. The round accepts at most `(t - 1) / credits` inputs, rounded down, and not more than `MAX_INPUTS_PER_ROUND` (100,000). | -| `CUSTOM` | The divisor must be at least `getPastTotalSupply(snapshot) / t + 1`. A requested divisor of 0 selects this minimum. The round accepts at most `MAX_INPUTS_PER_ROUND` inputs. | - -Every input counts toward the limit. This includes a new vote that replaces an earlier vote, and a -mask. When a round has its maximum number of inputs, `publishInput` reverts with -`InputLimitReached`. The CLI requests `CONSTANT` credits of 1. Thus, an `insecure-512` round accepts -99 inputs, and a `secure-8192` round accepts 100,000 inputs. - -A mask does not need a signature from the voter. Thus, any account can send masks to eligible slots -until the round has its maximum number of inputs. After that, the round accepts no more votes. A -round with a small limit costs less to fill in this way. A `CONSTANT` round with large `credits` has -a small limit. - -`CRISPProgram.validate` rejects a request with these errors: - -| Error | Condition | -| -------------------------------- | --------------------------------------------------------------------------------------------- | -| `CreditsExceedPlaintextModulus` | A `CONSTANT` round has `credits` of `t` or more. | -| `VotingPowerDivisorBelowMinimum` | A `CUSTOM` round requests a divisor that is less than `getPastTotalSupply(snapshot) / t + 1`. | -| `CustomCreditsRequireVotesToken` | A `CUSTOM` round names a token that does not answer `getPastTotalSupply`. | - -In a census tree round, the census leaf of each voter holds the voting power of that voter. The -CRISP server writes `credits` for a `CONSTANT` round. For a `CUSTOM` round, it writes -`getPastVotes(voter, snapshot) / divisor`. The limits hold only when the census holds these values. +`t`. `CRISPProgram.validate` reads `t` from the BFV parameters of the request and sets these limits, +so that each total stays less than `t`: + +| Credit mode | Voting power | Limits | +| ----------- | --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `CONSTANT` | Each voter has the same voting power, `credits`. | `credits` must be less than `t`, or `validate` reverts with `CreditsExceedPlaintextModulus`. The round accepts at most `(t - 1) / credits` inputs, rounded down, and not more than `MAX_INPUTS_PER_ROUND` (100,000). With 0 credits, the limit is `MAX_INPUTS_PER_ROUND`. | +| `CUSTOM` | `getPastVotes(voter, snapshot)` divided by the voting-power divisor of the round. | The divisor must be at least `getPastTotalSupply(snapshot) / t + 1`, or `validate` reverts with `VotingPowerDivisorBelowMinimum`. A requested divisor of 0 selects this minimum. A token that does not answer `getPastTotalSupply` makes `validate` revert with `CustomCreditsRequireVotesToken`. The round accepts at most `MAX_INPUTS_PER_ROUND` inputs. | + +Every input counts toward the limit, including a new vote that replaces an earlier vote, and a mask. +When a round has its maximum number of inputs, `publishInput` reverts with `InputLimitReached`. A +mask does not need a signature from the voter, so any account can fill the limit with masks. A +`CONSTANT` round with large `credits` has the smallest limit. The CLI requests `CONSTANT` credits of +1, so an `insecure-512` round accepts 99 inputs and a `secure-8192` round accepts 100,000. + +In a census tree round, the CRISP server writes the voting power of each voter into the census leaf. +The limits hold only when the census leaf holds that voting power. The `CUSTOM` limit also relies on the token. At the snapshot, the sum of the voting power of all accounts must not be more than `getPastTotalSupply(snapshot)`. An ERC20Votes token satisfies this diff --git a/docs/pages/build/tutorials/custom-zk-circuits.mdx b/docs/pages/build/tutorials/custom-zk-circuits.mdx index 6a9c91dce3..052ee4712f 100644 --- a/docs/pages/build/tutorials/custom-zk-circuits.mdx +++ b/docs/pages/build/tutorials/custom-zk-circuits.mdx @@ -153,10 +153,10 @@ fn main( ``` The input `k1` is the plaintext polynomial scaled to `[q * m]_t` (`k1 = [q*m]_t` in -`crates/zk-helpers/src/circuits/threshold/user_data_encryption/computation.rs`). A CRISP ballot -holds one weight per option. For each option, `check_coefficient_values_with_balance` proves that -the coefficient in `k1` is a weight in `[0, t)` scaled the same way. The two commitment functions -are the ones that `user_data_encryption` uses, so equal inputs give equal outputs. +`crates/zk-helpers/src/circuits/threshold/user_data_encryption/computation.rs`). For each option, +CRISP's `check_coefficient_values_with_balance` proves that the coefficient in `k1` is a weight in +`[0, t)` scaled the same way. The two commitment functions are the ones that `user_data_encryption` +uses, so equal inputs give equal outputs. Check the circuit: diff --git a/examples/CRISP/circuits/lib/src/utils.nr b/examples/CRISP/circuits/lib/src/utils.nr index d71f67af4a..96584f1e13 100644 --- a/examples/CRISP/circuits/lib/src/utils.nr +++ b/examples/CRISP/circuits/lib/src/utils.nr @@ -14,23 +14,9 @@ use interfold_lib::math::polynomial::Polynomial; use crate::constants::MAX_OPTIONS; -/// Checks the option count of a round, and that the ballot layout fits the compiled degree. -/// -/// A ballot holds one coefficient per option: message coefficient `o` is the weight on option `o`, -/// so the decrypted tally holds one total per option. `k1` reaches this circuit as the whole BFV -/// plaintext polynomial, of degree `D`, reversed, so option `o` is `k1[D - 1 - o]`: -/// -/// ```text -/// [ ------------------ zero ------------------ | option n-1 | ... | option 1 | option 0 ] -/// 0 D -/// ``` -/// -/// The committee publishes the first `MAX_MSG_NON_ZERO_COEFFS` message coefficients as the tally, -/// so every option must sit inside that region. `D` and both bounds are compile-time values, so the -/// last two assertions are decided when the circuit is built. -/// -/// # Arguments -/// * `num_options` - Number of voting options +/// Checks the option count of a round. Every option must sit inside the first +/// `MAX_MSG_NON_ZERO_COEFFS` message coefficients, which the committee publishes as the tally, and +/// the degree `D` must hold them. The last two assertions are decided when the circuit is built. fn check_option_count(num_options: u32) { assert(num_options > 1); assert(num_options <= MAX_OPTIONS); @@ -38,43 +24,17 @@ fn check_option_count(num_options: u32) { assert(MAX_MSG_NON_ZERO_COEFFS <= D); } -/// Reads the weight that one option coefficient of `k1` carries. -/// -/// The witness generator scales each message coefficient `m` to the non-centered residue -/// `k1 = (Q_MOD_T * m) mod t`, so the weight is the `v` in `[0, t)` with -/// `k1 + t * r == Q_MOD_T * v` for a quotient `r` in `[0, t)`. The prover supplies `v` and `r`, -/// and [`check_weight`] constrains them. -/// -/// # Arguments -/// * `coefficient` - The `k1` coefficient of one option -/// -/// # Returns -/// The weight. -fn decode_weight(coefficient: Field) -> Field { - // Safety: `check_weight` constrains both values. - let (weight, quotient) = unsafe { decode_weight_hint(coefficient) }; - check_weight(coefficient, weight, quotient); - - weight -} - -/// Checks that `weight` is the value the committee decrypts `coefficient` to. +/// Checks that `weight` is the value the committee decrypts the option coefficient to. /// -/// The coefficient, the weight and the quotient must all be in `[0, t)`. +/// The witness generator scales each message coefficient `m` to `k1 = (Q_MOD_T * m) mod t`, so +/// `k1 + t * quotient == Q_MOD_T * weight`. With all three values in `[0, t)`, neither side reaches +/// `t * t`, so the equation holds over the integers, and one weight solves it because `Q_MOD_T` is +/// invertible modulo `t`. /// -/// The bound on the coefficient makes this `k1` the plaintext that `user_data_encryption_ct0` -/// encrypts. That circuit range-checks its `k1` to `[0, t)`, and the fold only checks that the two -/// `k1_commitment` values are equal. Plain packing gives a commitment one opening only while every -/// coefficient fits its slot. Every other coefficient of this `k1` must be zero, so this bound -/// keeps all of them in their slots. -/// -/// With every value below `t`, neither side of the equation reaches `t * t`, so the field -/// equation holds over the integers. `Q_MOD_T` is invertible modulo `t`, so one weight solves it. -/// -/// # Arguments -/// * `coefficient` - The `k1` coefficient of one option -/// * `weight` - The weight the coefficient carries -/// * `quotient` - The quotient `r` of `Q_MOD_T * weight` by `t` +/// The bound on the coefficient also makes this `k1` the plaintext that `user_data_encryption_ct0` +/// encrypts. That circuit bounds its `k1` to `[0, t)`, the fold only checks that the two +/// `k1_commitment` values are equal, and a packed commitment has one opening only while every +/// coefficient fits its slot. fn check_weight(coefficient: Field, weight: Field, quotient: Field) { assert_in_range::(coefficient, PLAINTEXT_MODULUS); assert_in_range::(weight, PLAINTEXT_MODULUS); @@ -86,19 +46,21 @@ fn check_weight(coefficient: Field, weight: Field, quotient: Field) { unconstrained fn decode_weight_hint(coefficient: Field) -> (Field, Field) { let (_, weight) = __compute_mod_reduction(coefficient * Q_INVERSE_MOD_T, PLAINTEXT_MODULUS); let (quotient, _) = __compute_mod_reduction(Q_MOD_T * weight, PLAINTEXT_MODULUS); - (weight, quotient) } /// Validates a ballot against the voter's balance. /// -/// Constrains the whole plaintext, not only the options: every coefficient outside the options of -/// the round must be zero, and every option coefficient is decoded to its weight. +/// A ballot holds one coefficient per option, so the decrypted tally holds per-option totals. `k1` +/// is the whole BFV plaintext of degree `D`, reversed, so option `o` is `k1[D - 1 - o]`: +/// +/// ```text +/// [ ------------------ zero ------------------ | option n-1 | ... | option 1 | option 0 ] +/// 0 D +/// ``` /// -/// Rules: -/// - 2 options: Mutually exclusive (at most one weight is non-zero) -/// - 3+ options: Weights can be split between options -/// In both cases the weights must sum to at most the balance. +/// Every other coefficient must be zero, so no payload outside the ballot reaches the tally. The +/// weights must sum to at most `balance`, and with two options at most one weight is non-zero. /// /// # Arguments /// * `k1` - The plaintext polynomial encoding the vote @@ -112,20 +74,17 @@ pub fn check_coefficient_values_with_balance( check_option_count::(num_options); let mut valid = true; - - // Everything below the option coefficients is outside the ballot. A payload there survives - // homomorphic addition and is decrypted with the tally, so it would both corrupt the slot and - // give a coercer a channel to read a receipt out of. for i in 0..(D - MAX_OPTIONS) { valid = valid & (k1.coefficients[i] == 0); } let mut total: Field = 0; let mut non_zero_count: u32 = 0; - for option in 0..MAX_OPTIONS { let coefficient = k1.coefficients[D - 1 - option]; - let weight = decode_weight(coefficient); + // Safety: `check_weight` constrains both values. + let (weight, quotient) = unsafe { decode_weight_hint(coefficient) }; + check_weight(coefficient, weight, quotient); if option < num_options { total += weight; @@ -133,12 +92,10 @@ pub fn check_coefficient_values_with_balance( non_zero_count += 1; } } else { - // An option the round does not have carries nothing. valid = valid & (coefficient == 0); } } - // With two options the ballot picks one side, so its only non-zero weight is the total. if num_options == 2 { valid = valid & (non_zero_count <= 1); } @@ -148,45 +105,24 @@ pub fn check_coefficient_values_with_balance( valid & ((total as u64) <= (balance as u64)) } -/// Checks that the plaintext carries no ballot at all. -/// -/// Every coefficient of `k1` must be zero, not only the option coefficients. A mask needs no -/// signature, so anyone may write one to any eligible slot; a mask that could carry a payload -/// outside the options would let anyone corrupt a slot they cannot vote in, and would give a -/// coercer a channel to mark a ballot with. Checking the whole polynomial closes both. -/// -/// `num_options` is still taken, and still range-checked, so a caller cannot reach this function -/// with a round configuration the vote rules would refuse. -/// -/// # Arguments -/// * `k1` - The plaintext polynomial encoding the vote -/// * `num_options` - Number of voting options -/// -/// # Returns -/// Returns `true` if every coefficient is zero, `false` otherwise. -/// +/// Checks that every coefficient of `k1` is zero, not only the option coefficients. A mask needs no +/// signature, so anyone may write one to any eligible slot, and a payload anywhere in it would +/// reach that slot. `num_options` is still checked, so a mask cannot pass for a round that the +/// vote rules refuse. pub fn check_coefficient_zero(k1: Polynomial, num_options: u32) -> bool { check_option_count::(num_options); let mut valid = true; - for i in 0..D { valid = valid & (k1.coefficients[i] == 0); } - valid } -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- -// -// Every test builds `k1` the way the witness generator does — SDK message layout, scaled by -// `Q_MOD_T` modulo `t`, reversed over the full BFV degree — and takes `t` and `Q_MOD_T` from the -// compiled preset, so the suite holds for any plaintext modulus of at least 100. +// Tests build `k1` the way the witness generator does and take `t` and `Q_MOD_T` from the compiled +// preset, so they hold for any plaintext modulus of at least 100. -/// The BFV degree of the `insecure-512` preset. The option coefficients sit at the top of the -/// reversed polynomial, so a degree well above `MAX_OPTIONS` checks that the layout is read from +/// The degree of the `insecure-512` preset, well above `MAX_OPTIONS`, so the layout is read from /// the real degree. global TEST_DEGREE: u32 = 512; @@ -195,164 +131,94 @@ fn scale(m: u64) -> Field { (((Q_MOD_T as u64) * m) % (PLAINTEXT_MODULUS as u64)) as Field } -/// Builds `k1` for a ballot: `encodeVote` from `@crisp-e3/sdk` writes the weight of option `o` to -/// message coefficient `o`, and the witness generator scales every coefficient and reverses the -/// polynomial over the full degree. -/// -/// # Arguments -/// * `weights` - One weight per option, in option order; entries past `num_options` are ignored -/// * `num_options` - Number of voting options -fn encode_ballot(weights: [u64; MAX_OPTIONS], num_options: u32) -> Polynomial { - let mut coefficients: [Field; D] = [0; D]; - - for option in 0..MAX_OPTIONS { - if option < num_options { - coefficients[D - 1 - option] = scale(weights[option]); - } +/// Builds `k1` the way `encodeVote` and the witness generator do: the weight of option `o`, scaled, +/// at `k1[D - 1 - o]`, and zero everywhere else. +fn encode_ballot(weights: [u64; N]) -> Polynomial { + let mut coefficients = [0; TEST_DEGREE]; + for option in 0..N { + coefficients[TEST_DEGREE - 1 - option] = scale(weights[option]); } - Polynomial { coefficients } } -#[test] -fn test_2_options_vote_first() { - let pol = encode_ballot::([5, 0, 0, 0, 0, 0, 0, 0, 0, 0], 2); - assert(check_coefficient_values_with_balance(pol, 10, 2)); -} - -#[test] -fn test_2_options_vote_second() { - let pol = encode_ballot::([0, 3, 0, 0, 0, 0, 0, 0, 0, 0], 2); - assert(check_coefficient_values_with_balance(pol, 10, 2)); -} - -#[test] -fn test_2_options_both_voted_fails() { - let pol = encode_ballot::([1, 1, 0, 0, 0, 0, 0, 0, 0, 0], 2); - assert(!check_coefficient_values_with_balance(pol, 10, 2)); -} - -#[test] -fn test_2_options_exceeds_balance_fails() { - let pol = encode_ballot::([10, 0, 0, 0, 0, 0, 0, 0, 0, 0], 2); - assert(!check_coefficient_values_with_balance(pol, 5, 2)); +/// Each row: the weights of options 0 to 2, the option count, the balance, and whether the vote +/// rules accept the ballot. +#[test] +fn test_vote_rules() { + let top = (PLAINTEXT_MODULUS as u64) - 1; + let rows: [([u64; 3], u32, u64, bool); 7] = [ + ([0, 3, 0], 2, 10, true), + ([1, 1, 0], 2, 10, false), + // The largest weight decodes exactly, so the balance bound is tight. + ([top, 0, 0], 2, top, true), + ([top, 0, 0], 2, top - 1, false), + ([40, 0, 60], 3, 100, true), + ([60, 60, 0], 3, 100, false), + ([0, 0, 0], 3, 100, true), + ]; + for row in rows { + let (weights, num_options, balance, accepted) = row; + let pol = encode_ballot(weights); + assert( + check_coefficient_values_with_balance(pol, balance as Field, num_options) == accepted, + ); + } } +/// The last option of the largest round sits next to the coefficients that must be zero. #[test] -fn test_2_options_at_balance_passes() { - let pol = encode_ballot::([5, 0, 0, 0, 0, 0, 0, 0, 0, 0], 2); - assert(check_coefficient_values_with_balance(pol, 5, 2)); +fn test_last_option_of_largest_round_counts() { + let pol = encode_ballot([0, 0, 0, 0, 0, 0, 0, 0, 0, 7]); + assert(check_coefficient_values_with_balance(pol, 7, MAX_OPTIONS)); + assert(!check_coefficient_values_with_balance(pol, 6, MAX_OPTIONS)); } -/// The largest weight a coefficient can carry is `t - 1`. It decodes to exactly that value, so a -/// balance one below it refuses the ballot. +/// A payload below the options, or in an option the round does not have, is refused. #[test] -fn test_largest_weight_decodes_exactly() { - let largest = (PLAINTEXT_MODULUS as u64) - 1; - let pol = encode_ballot::([largest, 0, 0, 0, 0, 0, 0, 0, 0, 0], 2); +fn test_coefficient_outside_the_round_options_fails() { + let mut below = encode_ballot([1, 0]); + below.coefficients[0] = 1; + assert(!check_coefficient_values_with_balance(below, 10, 2)); - assert(check_coefficient_values_with_balance(pol, largest as Field, 2)); - assert(!check_coefficient_values_with_balance(pol, (largest - 1) as Field, 2)); + // Option 3 is the first one that a three-option round does not have. + assert(!check_coefficient_values_with_balance(encode_ballot([1, 0, 0, 1]), 10, 3)); } -/// The prover supplies the weight and the quotient. This pair solves the decode equation for the -/// coefficient `Q_MOD_T - 2 * t`, which is below zero. A coefficient outside `[0, t)` could open -/// `k1_commitment` to a polynomial other than the encrypted plaintext, so it is refused. +/// The pair solves the equation for the coefficient `Q_MOD_T - 2 * t`, which is below zero, so +/// only the coefficient bound refuses it. #[test(should_fail)] fn test_coefficient_below_zero_is_refused() { check_weight(Q_MOD_T - 2 * PLAINTEXT_MODULUS, 1, 2); } -/// The prover supplies the weight. A weight the coefficient does not carry is refused, also with -/// the quotient that belongs to that weight. +/// The weight `t` solves the equation for the coefficient 0 with the quotient `Q_MOD_T`, so only +/// the weight bound refuses it. +#[test(should_fail)] +fn test_weight_outside_range_is_refused() { + check_weight(0, PLAINTEXT_MODULUS, Q_MOD_T); +} + +/// A weight the coefficient does not carry is refused, also with the quotient of that weight. #[test(should_fail)] fn test_weight_the_coefficient_does_not_carry_is_refused() { let quotient = ((Q_MOD_T as u64) * 6) / (PLAINTEXT_MODULUS as u64); - check_weight(scale(5), 6, quotient as Field); } -// --- 3+ Options (Split Voting) --- - -#[test] -fn test_3_options_split_vote() { - let pol = encode_ballot::([40, 0, 60, 0, 0, 0, 0, 0, 0, 0], 3); - assert(check_coefficient_values_with_balance(pol, 100, 3)); -} - -#[test] -fn test_3_options_vote_all_three() { - let pol = encode_ballot::([30, 30, 30, 0, 0, 0, 0, 0, 0, 0], 3); - assert(check_coefficient_values_with_balance(pol, 100, 3)); -} - -#[test] -fn test_3_options_total_exceeds_balance_fails() { - let pol = encode_ballot::([60, 60, 0, 0, 0, 0, 0, 0, 0, 0], 3); - assert(!check_coefficient_values_with_balance(pol, 100, 3)); -} - -#[test] -fn test_5_options_single_vote() { - let pol = encode_ballot::([0, 0, 50, 0, 0, 0, 0, 0, 0, 0], 5); - assert(check_coefficient_values_with_balance(pol, 100, 5)); -} - -/// The last option of the largest round sits at the bottom of the option region, next to the -/// coefficients that must be zero. -#[test] -fn test_last_option_of_largest_round_counts() { - let pol = encode_ballot::([0, 0, 0, 0, 0, 0, 0, 0, 0, 7], MAX_OPTIONS); - - assert(check_coefficient_values_with_balance(pol, 7, MAX_OPTIONS)); - assert(!check_coefficient_values_with_balance(pol, 6, MAX_OPTIONS)); -} - -#[test] -fn test_zero_vote_passes() { - let pol = encode_ballot::([0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 3); - assert(check_coefficient_values_with_balance(pol, 100, 3)); -} - -/// Coefficients below the option region must be zero, or a submitter could attach a payload the -/// vote rules never look at. -#[test] -fn test_payload_outside_ballot_region_fails() { - let mut pol: Polynomial = encode_ballot([1, 0, 0, 0, 0, 0, 0, 0, 0, 0], 2); - pol.coefficients[0] = 1; - - assert(!check_coefficient_values_with_balance(pol, 10, 2)); -} - -/// An option the round does not have must carry nothing, although its coefficient sits inside the -/// option region. -#[test] -fn test_unused_option_coefficient_fails() { - let mut pol: Polynomial = encode_ballot([1, 0, 0, 0, 0, 0, 0, 0, 0, 0], 3); - // Option 3, the first one a three-option round does not have. - pol.coefficients[TEST_DEGREE - 1 - 3] = scale(1); - - assert(!check_coefficient_values_with_balance(pol, 10, 3)); -} - -#[test] -fn test_check_coefficient_zero_passes() { - let pol: Polynomial = Polynomial { coefficients: [0; TEST_DEGREE] }; - assert(check_coefficient_zero(pol, 3)); +/// A quotient outside `[0, t)` solves the equation for any weight, so only the quotient bound +/// refuses this weight of zero. +#[test(should_fail)] +fn test_quotient_outside_range_is_refused() { + check_weight(scale(5), 0, -scale(5) / PLAINTEXT_MODULUS); } +/// A mask must be zero everywhere: in the option coefficients and below them. #[test] -fn test_check_coefficient_zero_fails() { - let pol = encode_ballot::([1, 0, 0, 0, 0, 0, 0, 0, 0, 0], 3); - assert(!check_coefficient_zero(pol, 3)); -} +fn test_check_coefficient_zero() { + assert(check_coefficient_zero(encode_ballot([0, 0]), 3)); + assert(!check_coefficient_zero(encode_ballot([1, 0]), 3)); -/// A mask must be zero everywhere, not only where the vote rules look. This coefficient is outside -/// every option. -#[test] -fn test_check_coefficient_zero_outside_ballot_region_fails() { - let mut pol: Polynomial = Polynomial { coefficients: [0; TEST_DEGREE] }; + let mut pol = encode_ballot([0, 0]); pol.coefficients[0] = 1; - assert(!check_coefficient_zero(pol, 3)); } diff --git a/examples/CRISP/crates/crisp-utils/src/lib.rs b/examples/CRISP/crates/crisp-utils/src/lib.rs index 22f85e79a0..66d0a411d6 100644 --- a/examples/CRISP/crates/crisp-utils/src/lib.rs +++ b/examples/CRISP/crates/crisp-utils/src/lib.rs @@ -10,8 +10,8 @@ use num_bigint::BigUint; /// Number of leading polynomial coefficients that carry the tally (must match `@crisp-e3/sdk` / circuits). /// -/// Coefficient `o` holds option `o`'s total, so a round has at most this many options. The remaining -/// coefficients up to the BFV polynomial degree are zero padding. +/// Coefficient `o` holds option `o`'s total; the rest, up to the BFV polynomial degree, is zero +/// padding. pub const MAX_MSG_NON_ZERO_COEFFS: usize = 50; /// Represents decoded vote counts from a tally @@ -25,10 +25,8 @@ pub struct VoteCounts { /// /// # Layout /// -/// Coefficient `o` of the decrypted polynomial is the total weight on option `o`, for -/// `o < num_choices`. Homomorphic addition is coefficient-wise, so the sum of the ballots holds the sum -/// of each option's weights in that option's coefficient. Coefficients at or after `num_choices` are -/// ignored. +/// Coefficient `o` of the decrypted polynomial is option `o`'s total weight, for `o < num_choices`, +/// because homomorphic addition is coefficient-wise. Later coefficients are ignored. /// /// # Arguments /// @@ -72,48 +70,27 @@ pub fn decode_tally(tally_bytes: &[u8], num_choices: usize) -> Result Vec { + fn le_bytes(coeffs: &[u64]) -> Vec { coeffs.iter().flat_map(|c| c.to_le_bytes()).collect() } - /// A tally polynomial of `degree` coefficients with `totals` in the leading coefficients and - /// `noise` in every coefficient from `totals.len()` through the message region and the padding. - fn tally_bytes(totals: &[u64], noise: u64, degree: usize) -> Vec { - let mut coeffs = vec![noise; degree]; - coeffs[..totals.len()].copy_from_slice(totals); - coeffs_to_le_bytes(&coeffs) - } - #[test] fn decode_tally_reads_coefficient_per_option_and_ignores_the_rest() { - let totals = [3u64, 0, 17_000_000, u64::MAX]; - let bytes = tally_bytes(&totals, 99, 512); - let result = decode_tally(&bytes, totals.len()).unwrap(); + let mut coeffs = vec![99u64; 512]; + coeffs[..4].copy_from_slice(&[3, 0, 17_000_000, u64::MAX]); + let expected: Vec = coeffs[..4].iter().map(|&t| BigUint::from(t)).collect(); - let expected: Vec = totals.iter().map(|&t| BigUint::from(t)).collect(); - assert_eq!(result, expected); + assert_eq!(decode_tally(&le_bytes(&coeffs), 4).unwrap(), expected); } #[test] - fn decode_tally_accepts_exactly_max_choices_at_minimum_length() { - let totals: Vec = (1..=MAX_MSG_NON_ZERO_COEFFS as u64).collect(); - let bytes = coeffs_to_le_bytes(&totals); - let result = decode_tally(&bytes, MAX_MSG_NON_ZERO_COEFFS).unwrap(); - - assert_eq!(result.len(), MAX_MSG_NON_ZERO_COEFFS); - assert_eq!( - result[MAX_MSG_NON_ZERO_COEFFS - 1], - BigUint::from(MAX_MSG_NON_ZERO_COEFFS as u64) - ); - } - - #[test] - fn decode_tally_rejects_invalid_arguments() { - let bytes = tally_bytes(&[1, 2], 0, 512); - assert!(decode_tally(&bytes, 0).is_err()); - assert!(decode_tally(&bytes, MAX_MSG_NON_ZERO_COEFFS + 1).is_err()); - - let short = coeffs_to_le_bytes(&vec![0u64; MAX_MSG_NON_ZERO_COEFFS - 1]); - assert!(decode_tally(&short, 2).is_err()); + fn decode_tally_bounds() { + let min = le_bytes(&[7; MAX_MSG_NON_ZERO_COEFFS]); + + let all = decode_tally(&min, MAX_MSG_NON_ZERO_COEFFS).unwrap(); + assert_eq!(all.len(), MAX_MSG_NON_ZERO_COEFFS); + assert!(decode_tally(&min, 0).is_err()); + assert!(decode_tally(&min, MAX_MSG_NON_ZERO_COEFFS + 1).is_err()); + assert!(decode_tally(&min[8..], 2).is_err()); } } diff --git a/examples/CRISP/crates/evm_helpers/src/lib.rs b/examples/CRISP/crates/evm_helpers/src/lib.rs index f7b52af743..b537558e18 100644 --- a/examples/CRISP/crates/evm_helpers/src/lib.rs +++ b/examples/CRISP/crates/evm_helpers/src/lib.rs @@ -651,25 +651,13 @@ impl CRISPContract { Ok(contract.pendingInputCount(e3_id).call().await?.to::()) } - /// The voting-power divisor `CRISPProgram` stored for a CUSTOM-credit round. - /// - /// The authority on the divisor. The contract resolves it once, in the transaction that - /// requests the E3, and every voter's power is then scaled by exactly this value. Reading it - /// keeps the coordinator from deriving a second value that could disagree with the chain. - /// - /// Call it only for a CUSTOM-credit round, whatever its census mode. A CONSTANT-credit round - /// stores no divisor. - /// - /// `Ok(None)` when the contract holds no divisor for the round, which is not authoritative. + /// The voting-power divisor `CRISPProgram` stored for a CUSTOM-credit round at request time; + /// every voter's power is scaled by exactly it. `Ok(None)` when the contract holds zero: a + /// CONSTANT-credit round, a round it never initialized, or a node that lacks the request block. pub async fn stored_voting_power_divisor(&self, e3_id: U256) -> Result> { let contract = CRISPProgram::new(self.contract_address, self.provider.as_ref()); - - let divisor = contract.votingPowerDivisorOf(e3_id).call().await?; - if divisor.is_zero() { - return Ok(None); - } - - Ok(Some(divisor)) + Ok(Some(contract.votingPowerDivisorOf(e3_id).call().await?) + .filter(|divisor| !divisor.is_zero())) } } diff --git a/examples/CRISP/packages/crisp-contracts/contracts/CRISPProgram.sol b/examples/CRISP/packages/crisp-contracts/contracts/CRISPProgram.sol index e7e5f36208..f3c3996ce5 100644 --- a/examples/CRISP/packages/crisp-contracts/contracts/CRISPProgram.sol +++ b/examples/CRISP/packages/crisp-contracts/contracts/CRISPProgram.sol @@ -30,13 +30,9 @@ interface IInterfoldRegistryView { function ciphernodeRegistry() external view returns (ICiphernodeRegistry); } -/// @notice The BFV parameter blob that Interfold passes to `validate` as `e3ProgramParams`. -/// @dev Mirrors the encoding of one `abi.encode` tuple `(degree, plaintext_modulus, moduli, -/// error1_variance)`. Every encoder must keep this field order: `encode_bfv_params` in -/// `crates/fhe-params/src/encoding.rs`, and `encodeBfvParams` in both -/// `packages/interfold-contracts/scripts/protocol/values.ts` and `packages/interfold-sdk/src/utils.ts`. -/// `CRISPProgram.validate` reads `plaintextModulus` only; the other fields are decoded to keep the -/// layout checked. +/// @notice The BFV parameter blob Interfold passes to `validate` as `e3ProgramParams`. +/// @dev Field order must match `encode_bfv_params` (`crates/fhe-params/src/encoding.rs`) and `encodeBfvParams` +/// in `packages/interfold-contracts/scripts/protocol/values.ts` and `packages/interfold-sdk/src/utils.ts`. struct BfvParameters { uint256 degree; uint256 plaintextModulus; @@ -144,13 +140,10 @@ contract CRISPProgram is IE3Program, IE3ProgramDataAvailability, IERC165, Ownabl uint48 snapshot; /// @notice Divides raw token voting power into the units the ballot is encoded in. Never /// zero for a `CreditMode.CUSTOM` round, and zero for a `CreditMode.CONSTANT` round, where no - /// scaling happens. At least `getPastTotalSupply(snapshot) / plaintextModulus + 1`, so the sum - /// of all scaled voting power stays below the plaintext modulus. + /// scaling happens. uint256 votingPowerDivisor; /// @notice The most inputs the round accepts. Every input (vote, update, mask) counts. - /// @dev `MAX_INPUTS_PER_ROUND` for a `CreditMode.CUSTOM` round. For a `CreditMode.CONSTANT` - /// round with non-zero credits it is lowered so that `credits` times the input count stays - /// below the plaintext modulus. A mask needs no signature, so any account can use up the limit. + /// @dev Set by {_initCredits}. A mask needs no signature, so any account can use up the limit. /// The contract cannot tell a mask from a vote, so it cannot count votes alone. uint256 inputLimit; } @@ -173,11 +166,10 @@ contract CRISPProgram is IE3Program, IE3ProgramDataAvailability, IERC165, Ownabl /// vote proof fails. Must stay aligned with the SDK constant of the same name. uint256 constant MAX_VOTE_OPTIONS = 10; /// @notice The most inputs any round accepts. - /// @dev Every input (vote, update, mask) adds one fresh ciphertext to a slot's chain, so the - /// input count bounds the fresh ciphertexts that the tally sums. The BFV parameters keep the - /// decryption correct for a fixed number of such additions (`SEARCH_Z` in - /// `crates/fhe-params/src/constants.rs`). Every secure parameter set that CRISP runs on must - /// allow at least this many. The insecure-512 test preset allows fewer. + /// @dev Every input (vote, update, mask) adds one fresh ciphertext to the tally sum. The BFV + /// parameters stay correct for a fixed number of additions (`SEARCH_Z` in + /// `crates/fhe-params/src/constants.rs`); every secure parameter set CRISP runs on must allow at + /// least this many. The insecure-512 test preset allows fewer. uint256 public constant MAX_INPUTS_PER_ROUND = 100_000; // State variables IInterfold public interfold; @@ -247,17 +239,13 @@ contract CRISPProgram is IE3Program, IE3ProgramDataAvailability, IERC165, Ownabl /// @notice A `CreditMode.CONSTANT` round grants credits that do not fit in the BFV plaintext /// modulus. - /// @dev One ballot of `credits` or more could wrap an option total, so the round is refused. error CreditsExceedPlaintextModulus(uint256 credits, uint256 plaintextModulus); /// @notice The requested voting-power divisor would let the scaled voting power sum to the /// plaintext modulus or more. - /// @dev `minimum` is `getPastTotalSupply(snapshot) / plaintextModulus + 1`. error VotingPowerDivisorBelowMinimum(uint256 divisor, uint256 minimum); /// @notice A `CreditMode.CUSTOM` round needs a votes token that answers `getPastTotalSupply`. - /// @dev The divisor is sized against the total supply at the snapshot, so a round without such - /// a token cannot be sized and is refused in the request transaction. error CustomCreditsRequireVotesToken(); /// @notice The round already holds as many inputs as it accepts. @@ -452,9 +440,8 @@ contract CRISPProgram is IE3Program, IE3ProgramDataAvailability, IERC165, Ownabl /// @notice The divisor applied to raw token voting power in a `CreditMode.CUSTOM` round. /// @dev A client must divide by exactly this before proving. For an ONCHAIN round the contract /// passes the scaled value to the circuit as public input 4 and the proof is checked against - /// it; for a TOKEN round the coordinator scales every census leaf by it. Never zero for a - /// `CreditMode.CUSTOM` round, whatever the census mode. Zero for a `CreditMode.CONSTANT` round, - /// where no scaling happens. + /// it; for a TOKEN round the coordinator scales every census leaf by it. Zero for a + /// `CreditMode.CONSTANT` round, where no scaling happens. /// @param e3Id The E3 to look up. /// @return The divisor recorded at validation. function votingPowerDivisorOf(uint256 e3Id) external view returns (uint256) { @@ -506,10 +493,7 @@ contract CRISPProgram is IE3Program, IE3ProgramDataAvailability, IERC165, Ownabl // this check the owner can create parallel CRISP round state for another program's E3. _requireAssignedE3(e3Id); - // The plaintext modulus bounds every option total of the round. It comes from the registered - // BFV parameters, so the contract sizes the round against the ciphertext arithmetic that will - // actually run. Delegated to its own frame rather than scoped inline: `validate` is close - // enough to the stack limit that holding the decoded values alongside the parameters exceeds it. + // Delegated to its own frame: `validate` is close to the stack limit. _initRound(e3Id, customParams, abi.decode(e3ProgramParams, (BfvParameters)).plaintextModulus); _validateInputTiming(e3Id); @@ -590,8 +574,7 @@ contract CRISPProgram is IE3Program, IE3ProgramDataAvailability, IERC165, Ownabl /// instead of a bare panic. /// @param e3Id The E3 being configured. /// @param customParams The ABI-encoded round configuration. - /// @param plaintextModulus The BFV plaintext modulus `t` of the round. Every option total is - /// kept below it. + /// @param plaintextModulus The BFV plaintext modulus `t` of the round. function _initRound(uint256 e3Id, bytes calldata customParams, uint256 plaintextModulus) internal { ( address token, @@ -668,7 +651,11 @@ contract CRISPProgram is IE3Program, IE3ProgramDataAvailability, IERC165, Ownabl bool onchain = round.censusMode == CensusMode.ONCHAIN; if (round.creditMode == CreditMode.CONSTANT) { - round.inputLimit = _constantCreditsInputLimit(round.credits, plaintextModulus); + uint256 credits = round.credits; + // One ballot of `t` credits or more wraps an option total on its own; zero credits carry no weight. + if (credits != 0 && credits >= plaintextModulus) revert CreditsExceedPlaintextModulus(credits, plaintextModulus); + uint256 limit = credits == 0 ? MAX_INPUTS_PER_ROUND : (plaintextModulus - 1) / credits; + round.inputLimit = limit < MAX_INPUTS_PER_ROUND ? limit : MAX_INPUTS_PER_ROUND; // A CONSTANT round hands the circuit `credits`, but ONCHAIN still reads eligibility from // the token at the snapshot. if (onchain) round.snapshot = _tokenSnapshot(round.token, true); @@ -676,7 +663,15 @@ contract CRISPProgram is IE3Program, IE3ProgramDataAvailability, IERC165, Ownabl } uint48 snapshot = _tokenSnapshot(round.token, onchain); - uint256 divisor = _customCreditsDivisor(round.token, snapshot, requestedDivisor, plaintextModulus); + uint256 divisor; + // Relies on the code check in {_tokenSnapshot}. + try IVotesToken(round.token).getPastTotalSupply(snapshot) returns (uint256 supply) { + uint256 minimum = supply / plaintextModulus + 1; + divisor = requestedDivisor == 0 ? minimum : requestedDivisor; + if (divisor < minimum) revert VotingPowerDivisorBelowMinimum(divisor, minimum); + } catch { + revert CustomCreditsRequireVotesToken(); + } // Where the floor and the divisor meet, the floor is raw and the bound is scaled, so they only // agree when the floor is worth at least one ballot unit. Requiring it here means every slot @@ -692,31 +687,14 @@ contract CRISPProgram is IE3Program, IE3ProgramDataAvailability, IERC165, Ownabl round.inputLimit = MAX_INPUTS_PER_ROUND; } - /// @notice The number of inputs a CONSTANT-credit round accepts. - /// @dev Zero credits carry no weight into the tally, so only the global limit applies. Otherwise - /// `credits` times the limit stays at most `t - 1`, below the plaintext modulus. - /// @param credits The credits every voter carries. - /// @param plaintextModulus The BFV plaintext modulus `t`. - /// @return The input limit of the round. - function _constantCreditsInputLimit(uint256 credits, uint256 plaintextModulus) internal pure returns (uint256) { - if (credits == 0) return MAX_INPUTS_PER_ROUND; - // One ballot of `t` credits or more already wraps an option total on its own. - if (credits >= plaintextModulus) revert CreditsExceedPlaintextModulus(credits, plaintextModulus); - - uint256 limit = (plaintextModulus - 1) / credits; - return limit < MAX_INPUTS_PER_ROUND ? limit : MAX_INPUTS_PER_ROUND; - } - /// @notice Resolve the snapshot of a round that reads voting power from a token. - /// @dev Checked before any call is attempted. A call to an address with no code succeeds and - /// returns nothing, so `clock()` fails while decoding the empty return data rather than inside - /// the call — and a decode failure is not what `try/catch` is there to catch. An EOA would - /// otherwise be refused by a bare panic instead of a named error. + /// @dev The code check comes first: a call to a codeless address succeeds with empty return data, + /// and the resulting decode failure escapes `try`, so an EOA would give a bare panic instead of a + /// named error. /// - /// An ONCHAIN round also probes the exact call every input will make. `_previousTimepoint` - /// swallows a missing `clock()` and falls back to block numbers, which is right for a token that - /// predates ERC-6372 but also lets an address that is not a votes token pass validation — and - /// then every `publishInput` reverts inside `getPastVotes`, after the fee is paid. + /// An ONCHAIN round also probes the call every input makes. `_previousTimepoint` falls back to + /// block numbers when `clock()` is missing, so an address that is not a votes token would pass + /// validation and every `publishInput` would revert after the fee is paid. /// @param token The token voting power is read from. /// @param onchain Whether the round is `CensusMode.ONCHAIN`, which names its own errors. /// @return The last finalized timepoint of the token. @@ -737,34 +715,6 @@ contract CRISPProgram is IE3Program, IE3ProgramDataAvailability, IERC165, Ownabl return snapshot; } - /// @notice The divisor of a CUSTOM-credit round: the requested one, or the minimum. - /// @dev The minimum is `totalSupply / t + 1`. The scaled power of all holders sums to at most - /// `totalSupply / divisor`, which is below `t` exactly when the divisor exceeds - /// `totalSupply / t`. Runs after the code check in {_tokenSnapshot}: `getPastTotalSupply` on a - /// codeless address fails while decoding, which `try` does not catch. - /// @param token The votes token. - /// @param snapshot The timepoint the supply is read at. - /// @param requestedDivisor The requested divisor, or zero to take the minimum. - /// @param plaintextModulus The BFV plaintext modulus `t`. - /// @return divisor The divisor to record, never zero. - function _customCreditsDivisor( - address token, - uint48 snapshot, - uint256 requestedDivisor, - uint256 plaintextModulus - ) internal view returns (uint256 divisor) { - uint256 supply; - try IVotesToken(token).getPastTotalSupply(snapshot) returns (uint256 pastSupply) { - supply = pastSupply; - } catch { - revert CustomCreditsRequireVotesToken(); - } - - uint256 minimum = supply / plaintextModulus + 1; - divisor = requestedDivisor == 0 ? minimum : requestedDivisor; - if (divisor < minimum) revert VotingPowerDivisorBelowMinimum(divisor, minimum); - } - /// @inheritdoc IE3Program /// @dev This is the first transaction of the two-step input flow. It verifies the ballot but /// reserves its tree leaf and index immediately. The ciphertext is published separately and any @@ -918,8 +868,7 @@ contract CRISPProgram is IE3Program, IE3ProgramDataAvailability, IERC165, Ownabl bytes32 encryptedVoteHash, uint40 parentIndexPlusOne ) internal view { - // Checked on every proof path, so `validateInputProof` cannot accept an input that - // `publishInput` rejects. Every leaf the tree holds is an input, pending or finalized. + // Every reserved leaf, pending or finalized, counts toward the limit. uint256 limit = e3Data[e3Id].inputLimit; if (e3Data[e3Id].votes.numberOfLeaves >= limit) revert InputLimitReached(e3Id, limit); @@ -1007,14 +956,8 @@ contract CRISPProgram is IE3Program, IE3ProgramDataAvailability, IERC165, Ownabl uint256 threshold = round.minVotingPower == 0 ? 1 : round.minVotingPower; if (rawPower < threshold) revert SlotNotEligible(); - // Eligibility comes from the power at the snapshot. The weight the circuit enforces comes from - // the credit mode: a CONSTANT round gives every eligible slot the same credits, unscaled. + // CONSTANT gives every eligible slot the same credits; CUSTOM scales raw power by the divisor from {_initCredits}. if (round.creditMode == CreditMode.CONSTANT) return (bytes32(round.credits), onchainHonkVerifier); - - // Scaled only for the circuit, which enforces `vote <= voting_power` in ballot units. Raw - // power from an 18-decimal token is ~1e18 per token, far above the plaintext modulus. The - // divisor, at least `totalSupply / t + 1` since `validate`, keeps the sum of all scaled voting - // power below `t`, which keeps every option total exact. return (bytes32(rawPower / round.votingPowerDivisor), onchainHonkVerifier); } @@ -1032,38 +975,23 @@ contract CRISPProgram is IE3Program, IE3ProgramDataAvailability, IERC165, Ownabl } /// @notice Decode the tally from the plaintext output - /// @dev The decrypted polynomial holds one coefficient per option, so the decrypted tally holds - /// per-option totals: coefficient `o` is the total weight on option `o`. The total is exact - /// because `validate` keeps it below the plaintext modulus `t` that the committee decrypts - /// under. A CUSTOM-credit round bounds it through the divisor, which keeps the sum of all - /// scaled voting power below `t`. A CONSTANT-credit round bounds it through the input limit, - /// which keeps `credits` times the input count below `t`. + /// @dev Coefficient `o` is the total weight on option `o`; {_initCredits} keeps it below the plaintext modulus. /// @param e3Id The E3 program ID /// @return votes - an array with the total weight of each option function decodeTally(uint256 e3Id) public view returns (uint256[] memory votes) { - E3 memory e3 = interfold.getE3(e3Id); - + bytes memory output = interfold.getE3(e3Id).plaintextOutput; uint256 numOptions = e3Data[e3Id].numOptions; - // If num optionsis not configured, return empty array to avoid decoding errors. - // Users might be calling this function too early and there's no - if (numOptions == 0) { - return new uint256[](0); - } - - uint64[] memory tally = _decodeBytesToUint64Array(e3.plaintextOutput); + // A round that was never configured has nothing to decode. + if (numOptions == 0) return new uint256[](0); - // The payload lives in the first MAX_MSG_NON_ZERO_COEFFS coefficients; the rest of - // the polynomial is zero padding and must not be read. - if (tally.length < MAX_MSG_NON_ZERO_COEFFS) revert InvalidTallyLength(); + // One little-endian uint64 per coefficient; the tally is in the first MAX_MSG_NON_ZERO_COEFFS. + if (output.length % 8 != 0 || output.length < MAX_MSG_NON_ZERO_COEFFS * 8) revert InvalidTallyLength(); votes = new uint256[](numOptions); - - for (uint256 optIdx = 0; optIdx < numOptions; optIdx++) { - votes[optIdx] = tally[optIdx]; + for (uint256 o = 0; o < numOptions; o++) { + for (uint256 j = 0; j < 8; j++) votes[o] |= uint256(uint8(output[o * 8 + j])) << (j * 8); } - - return votes; } /// @notice The index of the last input committed to a slot. @@ -1239,30 +1167,4 @@ contract CRISPProgram is IE3Program, IE3ProgramDataAvailability, IERC165, Ownabl bytes32 id = inputId(e3Id, encryptedVoteHash, commitment, slotAddress, parentIndexPlusOne); return e3Data[e3Id].inputStatus[id] == InputStatus.PUBLISHED; } - - /// @notice Decode bytes to uint64 array - /// @param data The bytes to decode (must be multiple of 8) - /// @return result Array of uint64 values - function _decodeBytesToUint64Array(bytes memory data) internal pure returns (uint64[] memory result) { - if (data.length % 8 != 0) { - revert InvalidTallyLength(); - } - - uint256 arrayLength = data.length / 8; - result = new uint64[](arrayLength); - - for (uint256 i = 0; i < arrayLength; i++) { - uint256 offset = i * 8; - uint64 value = 0; - - // Read 8 bytes in little-endian order - for (uint64 j = 0; j < 8; j++) { - value |= uint64(uint8(data[offset + j])) << (j * 8); - } - - result[i] = value; - } - - return result; - } } diff --git a/examples/CRISP/packages/crisp-contracts/contracts/Mocks/MockInterfold.sol b/examples/CRISP/packages/crisp-contracts/contracts/Mocks/MockInterfold.sol index d733a3e566..b6e17f159b 100644 --- a/examples/CRISP/packages/crisp-contracts/contracts/Mocks/MockInterfold.sol +++ b/examples/CRISP/packages/crisp-contracts/contracts/Mocks/MockInterfold.sol @@ -25,31 +25,25 @@ contract MockInterfold { /// @dev Defaults to the value the timing tests relied on before it was settable. uint256 public mockComputeWindow = 100; - /// @notice The BFV parameter blob that every request passes to `validate` as `e3ProgramParams`. - /// @dev Decoded by `CRISPProgram` as a {BfvParameters}, so it must be the encoding Interfold - /// hands a program. Insecure-512 (degree 512, plaintext modulus 100), the preset the deploy - /// scripts and tests run against. A test that needs another plaintext modulus passes `validate` - /// the blob from {bfvParamsWithPlaintextModulus}. - bytes public e3ProgramParams; + uint256 constant PLAINTEXT_MODULUS = 100; - constructor() { - uint256[] memory moduli = new uint256[](2); - moduli[0] = 0xffffee001; - moduli[1] = 0xffffc4001; - e3ProgramParams = abi.encode(BfvParameters({ degree: 512, plaintextModulus: 100, moduli: moduli, error1Variance: "3" })); - } + /// @notice The BFV parameter blob that every request passes to `validate` as `e3ProgramParams`. + /// @dev Insecure-512 (degree 512, plaintext modulus 100), the preset the deploy scripts and tests + /// run against. A test that needs another plaintext modulus passes `validate` the blob from + /// {bfvParamsWithPlaintextModulus}. + bytes public e3ProgramParams = bfvParamsWithPlaintextModulus(PLAINTEXT_MODULUS); /// @notice The plaintext modulus of {e3ProgramParams}, which every round is sized against. - function plaintextModulus() external view returns (uint256) { - return abi.decode(e3ProgramParams, (BfvParameters)).plaintextModulus; + function plaintextModulus() external pure returns (uint256) { + return PLAINTEXT_MODULUS; } - /// @notice {e3ProgramParams} with another plaintext modulus, encoded by the struct `validate` - /// decodes. - function bfvParamsWithPlaintextModulus(uint256 modulus) external view returns (bytes memory) { - BfvParameters memory params = abi.decode(e3ProgramParams, (BfvParameters)); - params.plaintextModulus = modulus; - return abi.encode(params); + /// @notice The blob that `validate` decodes as {BfvParameters}, with the given plaintext modulus. + function bfvParamsWithPlaintextModulus(uint256 modulus) public pure returns (bytes memory) { + uint256[] memory moduli = new uint256[](2); + moduli[0] = 0xffffee001; + moduli[1] = 0xffffc4001; + return abi.encode(BfvParameters({ degree: 512, plaintextModulus: modulus, moduli: moduli, error1Variance: "3" })); } uint256 public nextE3Id; diff --git a/examples/CRISP/packages/crisp-contracts/contracts/Mocks/MockVotesToken.sol b/examples/CRISP/packages/crisp-contracts/contracts/Mocks/MockVotesToken.sol index 065d459401..c0063a58f1 100644 --- a/examples/CRISP/packages/crisp-contracts/contracts/Mocks/MockVotesToken.sol +++ b/examples/CRISP/packages/crisp-contracts/contracts/Mocks/MockVotesToken.sol @@ -12,9 +12,8 @@ import { Nonces } from "@openzeppelin/contracts/utils/Nonces.sol"; /// @title MockVotesToken /// @notice An ERC20Votes token for exercising `CensusMode.ONCHAIN` and `CreditMode.CUSTOM` rounds. -/// @dev `MockVotingToken` is a plain ERC20 with no `getPastVotes` or `getPastTotalSupply`, which -/// makes it the negative case for the token probes in `CRISPProgram._initRound`. This one is the -/// positive case. +/// @dev `MockVotingToken`, a plain ERC20, is the negative case for the token probes in +/// `CRISPProgram._initRound`; this one is the positive case. /// /// Uses the timestamp clock, matching `InterfoldToken`. A round records its snapshot in whatever /// units the token reports, so a mock on the default block-number clock would exercise a diff --git a/examples/CRISP/packages/crisp-contracts/contracts/Mocks/MockVotingToken.sol b/examples/CRISP/packages/crisp-contracts/contracts/Mocks/MockVotingToken.sol index c999b30c1d..80c032200f 100644 --- a/examples/CRISP/packages/crisp-contracts/contracts/Mocks/MockVotingToken.sol +++ b/examples/CRISP/packages/crisp-contracts/contracts/Mocks/MockVotingToken.sol @@ -9,9 +9,8 @@ import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; /// @title MockVotingToken /// @notice A mock voting token for testing purposes -/// @dev Public mint grants 1e9 base units to an account that holds none, and caps every balance at -/// `MAX_BALANCE`. A plain ERC20 without checkpoints: it cannot back a round that reads voting -/// power at a snapshot (`CensusMode.ONCHAIN` or `CreditMode.CUSTOM`). Use `MockVotesToken` there. +/// @dev Public mint grants 1e9 base units to an account that holds none, up to `MAX_BALANCE`. No +/// checkpoints: use `MockVotesToken` for `CensusMode.ONCHAIN` or `CreditMode.CUSTOM`. contract MockVotingToken is ERC20 { uint256 public constant MAX_BALANCE = 1e9; diff --git a/examples/CRISP/packages/crisp-contracts/contracts/SelfRegistry.sol b/examples/CRISP/packages/crisp-contracts/contracts/SelfRegistry.sol index 48657b9e13..276ea79953 100644 --- a/examples/CRISP/packages/crisp-contracts/contracts/SelfRegistry.sol +++ b/examples/CRISP/packages/crisp-contracts/contracts/SelfRegistry.sol @@ -8,9 +8,8 @@ pragma solidity >=0.8.27; /// @notice An open self-registration census for `CensusMode.ONCHAIN` rounds. /// @dev Anyone may register themselves, once, and registration is permanent. The contract answers /// the `IVotesToken` surface `CRISPProgram._eligibility` reads: `getPastVotes` returns 1 for a -/// registered account and 0 otherwise. It has no `clock()`, so the round snapshot falls back to -/// block numbers, which `CRISPProgram` already implements. It has no `getPastTotalSupply`, so it -/// serves `CreditMode.CONSTANT` rounds only, the round shape this census is meant for. +/// registered account and 0 otherwise. Without `clock()` the round snapshot falls back to block +/// numbers; without `getPastTotalSupply` it serves `CreditMode.CONSTANT` rounds only. /// /// `getPastVotes` deliberately ignores the timepoint. An honest checkpointed answer would fix the /// electorate at the round's snapshot, which is taken in the transaction that requests the round — diff --git a/examples/CRISP/packages/crisp-contracts/contracts/interfaces/IVotesToken.sol b/examples/CRISP/packages/crisp-contracts/contracts/interfaces/IVotesToken.sol index e16c21d003..495f917f17 100644 --- a/examples/CRISP/packages/crisp-contracts/contracts/interfaces/IVotesToken.sol +++ b/examples/CRISP/packages/crisp-contracts/contracts/interfaces/IVotesToken.sol @@ -6,11 +6,9 @@ pragma solidity >=0.8.27; /// @notice The subset of an ERC20Votes token that CRISP reads. -/// @dev `getPastVotes` is read by `CensusMode.ONCHAIN` eligibility, once per input. -/// `getPastTotalSupply` is read by every `CreditMode.CUSTOM` round when it is requested, to size -/// the voting-power divisor. Both are required. A round that names a token which cannot answer -/// the call it needs is rejected at request time, because every input would otherwise revert after -/// the fee is paid. +/// @dev `getPastVotes` serves `CensusMode.ONCHAIN` eligibility on each input; `getPastTotalSupply` +/// sizes each `CreditMode.CUSTOM` round at request time. A round whose token cannot answer is +/// rejected at request time, because every input would otherwise revert after the fee is paid. interface IVotesToken { /// @notice The voting power of an account at a past timepoint. /// @param account The account to read. @@ -19,8 +17,7 @@ interface IVotesToken { function getPastVotes(address account, uint256 timepoint) external view returns (uint256); /// @notice The total supply of voting units at a past timepoint. - /// @dev CRISP assumes it bounds the sum of every account's voting power at the same timepoint. - /// ERC20Votes guarantees this. + /// @dev Must bound the sum of every account's voting power at that timepoint, as ERC20Votes does. /// @param timepoint The timepoint, in the ERC-6372 clock units of the token. /// @return The total supply at that timepoint. function getPastTotalSupply(uint256 timepoint) external view returns (uint256); diff --git a/examples/CRISP/packages/crisp-contracts/tests/census-mode.test.ts b/examples/CRISP/packages/crisp-contracts/tests/census-mode.test.ts index 24cf45d28c..04682807c9 100644 --- a/examples/CRISP/packages/crisp-contracts/tests/census-mode.test.ts +++ b/examples/CRISP/packages/crisp-contracts/tests/census-mode.test.ts @@ -112,40 +112,38 @@ describe('CRISPProgram census mode', function () { .withArgs(t, t) await validate(31, encode(CONSTANT, TOKEN, 2, { credits: t - 1n })) - expect(await crispProgram.censusModeOf(31)).to.equal(TOKEN) }) - it('sizes the round against the plaintext modulus of the registered parameters', async () => { - // The same credits are refused under the default modulus and accepted under a larger one, - // so the modulus is read from the blob and not fixed in the contract. - const credits = t + it('sizes every round against the plaintext modulus of the registered parameters', async () => { + // Credits of `t` are refused under the default blob, so acceptance here shows the modulus is + // read from the blob. + const votes = await ethers.deployContract('MockVotesToken') const wider = t * 1000n - await expect(validate(32, encode(CONSTANT, TOKEN, 2, { credits }))).to.be.revertedWithCustomError( - crispProgram, - 'CreditsExceedPlaintextModulus', - ) - const widerParams = await mockInterfold.bfvParamsWithPlaintextModulus(wider) - await crispProgram.validate(33, 0, widerParams, '0x', encode(CONSTANT, TOKEN, 2, { credits })) - expect(await crispProgram.censusModeOf(33)).to.equal(TOKEN) + + await crispProgram.validate(33, 0, widerParams, '0x', encode(CONSTANT, TOKEN, 2, { credits: t })) + await crispProgram.validate(16, 0, widerParams, '0x', encode(CUSTOM, TOKEN, 2, { token: await votes.getAddress() })) + + expect(await crispProgram.votingPowerDivisorOf(16)).to.equal(minimumDivisor(await votes.totalSupply(), wider)) }) - it('records no divisor, whatever the request names', async () => { - await validate(34, encode(CONSTANT, TOKEN, 2, { divisor: 7n })) + it('records no divisor for constant credits, whatever the request names', async () => { + const votes = await ethers.deployContract('MockVotesToken') + const token = await votes.getAddress() - expect(await crispProgram.votingPowerDivisorOf(34)).to.equal(0n) + for (const [e3Id, census] of [ + [34, TOKEN], + [24, ONCHAIN], + ] as const) { + await validate(e3Id, encode(CONSTANT, census, 2, { token, credits: 5n, divisor: 7n })) + expect(await crispProgram.votingPowerDivisorOf(e3Id)).to.equal(0n) + } }) }) /// CUSTOM credits weight each ballot by token voting power. The sum of every holder's scaled /// power must stay below `t`, which holds exactly when the divisor exceeds `supply / t`. describe('custom credits divisor', () => { - const deployVotes = async () => { - const votes = await ethers.deployContract('MockVotesToken') - await votes.waitForDeployment() - return votes - } - it('rejects TOKEN with custom credits and no votes token', async () => { // No code at the address: refused before any call, by a named error. await expect(validate(10, encode(CUSTOM, TOKEN))).to.be.revertedWithCustomError(crispProgram, 'CustomCreditsRequireVotesToken') @@ -158,46 +156,24 @@ describe('CRISPProgram census mode', function () { ) }) - it('rejects a divisor below the minimum and accepts exactly the minimum', async () => { - const votes = await deployVotes() + it('records the requested divisor or the minimum, and refuses less than the minimum', async () => { + const votes = await ethers.deployContract('MockVotesToken') const token = await votes.getAddress() - // The supply is a multiple of `t`, so a divisor of `supply / t` lets the scaled power sum to - // exactly `t` — one past what a coefficient holds. The minimum is strictly above it. + // `minimum - 1` is `supply / t`, whose scaled power can sum to `t` or more. const minimum = minimumDivisor(await votes.totalSupply()) await expect(validate(12, encode(CUSTOM, TOKEN, 2, { token, divisor: minimum - 1n }))) .to.be.revertedWithCustomError(crispProgram, 'VotingPowerDivisorBelowMinimum') .withArgs(minimum - 1n, minimum) - await validate(13, encode(CUSTOM, TOKEN, 2, { token, divisor: minimum })) - expect(await crispProgram.votingPowerDivisorOf(13)).to.equal(minimum) - }) - - it('resolves a requested divisor of zero to exactly the minimum', async () => { - const votes = await deployVotes() - - await validate(14, encode(CUSTOM, TOKEN, 2, { token: await votes.getAddress() })) - - expect(await crispProgram.votingPowerDivisorOf(14)).to.equal(minimumDivisor(await votes.totalSupply())) - }) - - it('keeps an explicit divisor above the minimum', async () => { - const votes = await deployVotes() - const larger = minimumDivisor(await votes.totalSupply()) * 2n - - await validate(15, encode(CUSTOM, TOKEN, 2, { token: await votes.getAddress(), divisor: larger })) - - expect(await crispProgram.votingPowerDivisorOf(15)).to.equal(larger) - }) - - it('sizes the minimum against the plaintext modulus of the registered parameters', async () => { - const votes = await deployVotes() - const wider = t * 1000n - const widerParams = await mockInterfold.bfvParamsWithPlaintextModulus(wider) - - await crispProgram.validate(16, 0, widerParams, '0x', encode(CUSTOM, TOKEN, 2, { token: await votes.getAddress() })) - - expect(await crispProgram.votingPowerDivisorOf(16)).to.equal(minimumDivisor(await votes.totalSupply(), wider)) + for (const [e3Id, divisor, recorded] of [ + [13, minimum, minimum], + [14, 0n, minimum], + [15, 2n * minimum, 2n * minimum], + ] as const) { + await validate(e3Id, encode(CUSTOM, TOKEN, 2, { token, divisor })) + expect(await crispProgram.votingPowerDivisorOf(e3Id)).to.equal(recorded) + } }) }) @@ -263,25 +239,6 @@ describe('CRISPProgram census mode', function () { validate(22, encode(CONSTANT, ONCHAIN, 2, { token: await votes.getAddress(), credits: 0n })), ).to.be.revertedWithCustomError(crispProgram, 'InvalidCredits') }) - - it('accepts ONCHAIN with a votes token', async () => { - const votes = await ethers.deployContract('MockVotesToken') - const divisor = minimumDivisor(await votes.totalSupply()) - - // CUSTOM credits take the bound from scaled power, so the floor must be worth a ballot unit. - await validate(23, encode(CUSTOM, ONCHAIN, 2, { token: await votes.getAddress(), minVotingPower: divisor })) - - expect(await crispProgram.censusModeOf(23)).to.equal(ONCHAIN) - }) - - it('records no divisor for constant credits', async () => { - const votes = await ethers.deployContract('MockVotesToken') - - await validate(24, encode(CONSTANT, ONCHAIN, 2, { token: await votes.getAddress(), credits: 5n, divisor: 7n })) - - expect(await crispProgram.censusModeOf(24)).to.equal(ONCHAIN) - expect(await crispProgram.votingPowerDivisorOf(24)).to.equal(0n) - }) }) it('rejects fewer than two options', async () => { diff --git a/examples/CRISP/packages/crisp-contracts/tests/crisp.contracts.test.ts b/examples/CRISP/packages/crisp-contracts/tests/crisp.contracts.test.ts index d123cd67ef..f4b3df1979 100644 --- a/examples/CRISP/packages/crisp-contracts/tests/crisp.contracts.test.ts +++ b/examples/CRISP/packages/crisp-contracts/tests/crisp.contracts.test.ts @@ -276,7 +276,6 @@ describe('CRISP Contracts', function () { const [merkleRoot, paramsHash, numOptions, creditMode, inputRoot, numberOfVotes] = await crispProgram.getRoundData(e3Id) expect(merkleRoot).to.equal(0n) - // The program hashes the BFV parameter blob that MockInterfold passes to `validate`. expect(paramsHash).to.equal(ethers.keccak256(await mockInterfold.e3ProgramParams())) expect(numOptions).to.equal(2n) // CreditMode.CONSTANT diff --git a/examples/CRISP/packages/crisp-contracts/tests/crisp.journal.test.ts b/examples/CRISP/packages/crisp-contracts/tests/crisp.journal.test.ts index c8e625215c..cf324daad8 100644 --- a/examples/CRISP/packages/crisp-contracts/tests/crisp.journal.test.ts +++ b/examples/CRISP/packages/crisp-contracts/tests/crisp.journal.test.ts @@ -15,7 +15,6 @@ describe('CRISP journal', () => { const encryptionSchemeId = ethers.keccak256(ethers.toUtf8Bytes('fhe.rs:BFV')) const mockInterfold = await deployMockInterfold() - // The program hashes the BFV parameter blob that Interfold passes to `validate`. const paramsHash = ethers.keccak256(await mockInterfold.e3ProgramParams()) await mockInterfold.setCommitteePublicKey(committeePublicKey) const chainId = (await ethers.provider.getNetwork()).chainId diff --git a/examples/CRISP/packages/crisp-contracts/tests/input-availability-flow.test.ts b/examples/CRISP/packages/crisp-contracts/tests/input-availability-flow.test.ts index 316c5b2b9b..ddd4b4b06d 100644 --- a/examples/CRISP/packages/crisp-contracts/tests/input-availability-flow.test.ts +++ b/examples/CRISP/packages/crisp-contracts/tests/input-availability-flow.test.ts @@ -420,19 +420,8 @@ describe('CRISP input availability flow', function () { for (let i = 0; i < Number(limit); i++) { const accepted = await input(program, e3Id, `within-limit-${i}`) - expect( - await program.validateInputProof( - e3Id, - '0x01', - accepted.slotAddress, - accepted.encryptedVoteCommitment, - accepted.encryptedVoteHash, - 0, - ), - ).to.equal(true) await expect(program.publishInput(e3Id, accepted.commitmentPayload)).to.emit(program, 'InputCommitted') } - expect((await program.getRoundData(e3Id)).numberOfVotes).to.equal(limit) const refused = await input(program, e3Id, 'over-limit') await expect( diff --git a/examples/CRISP/packages/crisp-contracts/tests/onchain-census.test.ts b/examples/CRISP/packages/crisp-contracts/tests/onchain-census.test.ts index 403fe1c494..66719997b9 100644 --- a/examples/CRISP/packages/crisp-contracts/tests/onchain-census.test.ts +++ b/examples/CRISP/packages/crisp-contracts/tests/onchain-census.test.ts @@ -138,9 +138,6 @@ describe('CRISP on-chain census', function () { expect(divisor, 'the minimum: supply / t + 1').to.equal(minimumDivisor) votingPower = rawPower / divisor - // The voter holds most of the supply, so the scaled power carries the weight the ballot proves - // and stays below the plaintext modulus. - expect(votingPower, 'voter must carry the ballot weight').to.be.greaterThanOrEqual(BigInt(Math.max(...vote))) voteProof = await buildOnchainProof(votingPower) }) diff --git a/examples/CRISP/packages/crisp-contracts/tests/self-registry.test.ts b/examples/CRISP/packages/crisp-contracts/tests/self-registry.test.ts index e7a836ba56..1b555fc09c 100644 --- a/examples/CRISP/packages/crisp-contracts/tests/self-registry.test.ts +++ b/examples/CRISP/packages/crisp-contracts/tests/self-registry.test.ts @@ -79,8 +79,7 @@ describe('SelfRegistry', function () { /// The registry has no `clock()`, so `CRISPProgram.validate` must accept it through the /// block-number snapshot fallback. This is the round shape the registry is meant for — CONSTANT - /// credits of 1 and a floor of 1, one registrant one vote. Constant credits are never scaled, so - /// the round records no divisor and a slot's voting power is the credits themselves. + /// credits of 1 and a floor of 1, one registrant one vote. it('validates as the token of an ONCHAIN round', async () => { const mockInterfold = await deployMockInterfold() const crispProgram: CRISPProgram = await deployCRISPProgram({ mockInterfold }) @@ -94,7 +93,6 @@ describe('SelfRegistry', function () { await (await crispProgram.validate(1, 0, await mockInterfold.e3ProgramParams(), '0x', params)).wait() expect(await crispProgram.censusModeOf(1)).to.equal(ONCHAIN) - expect(await crispProgram.votingPowerDivisorOf(1)).to.equal(0n) // CONSTANT credits: every eligible slot weighs the configured 1. expect(await crispProgram.votingPowerOf(1, accounts[0].address)).to.equal(1n) }) diff --git a/examples/CRISP/packages/crisp-contracts/tests/tally.decoding.test.ts b/examples/CRISP/packages/crisp-contracts/tests/tally.decoding.test.ts index b9d21aa94c..357432e54d 100644 --- a/examples/CRISP/packages/crisp-contracts/tests/tally.decoding.test.ts +++ b/examples/CRISP/packages/crisp-contracts/tests/tally.decoding.test.ts @@ -5,16 +5,10 @@ // or FITNESS FOR A PARTICULAR PURPOSE. /** - * Cross-layer tally decoding tests. - * - * A ballot stores one integer per option: coefficient `o` of the message polynomial is the weight - * on option `o`. BFV adds ballots coefficient by coefficient, so a round produces `plaintextOutput` - * as the full decrypted polynomial with the total of option `o` at coefficient `o`: one - * little-endian uint64 per coefficient, `degree` coefficients long (Rust `encode_vec_u64_to_bytes` - * in `crates/bfv-client`). Three decoders read that blob: the SDK (`decodeTally`), the CRISP server - * (`crisp_utils::decode_tally`) and the contract (`CRISPProgram.decodeTally`), which the interfold - * dashboard calls. These tests build the blob with the real SDK encoder and assert the SDK and the - * contract agree. + * Cross-layer tally decoding tests. A round's `plaintextOutput` is the decrypted polynomial, one + * little-endian uint64 per coefficient, with the total of option `o` at coefficient `o`. These + * tests build it with the SDK encoder and assert the SDK decoder and `CRISPProgram.decodeTally` + * agree. */ import { encodeVote, decodeTally, MAX_MSG_NON_ZERO_COEFFS, MAX_VOTE_OPTIONS } from '@crisp-e3/sdk' @@ -26,7 +20,7 @@ import type { CRISPProgram, MockInterfold } from '../types' * Pack polynomial coefficients exactly as the ciphernodes publish them: * 8 little-endian bytes per coefficient. */ -const packCoefficients = (coefficients: number[]): string => { +const packCoefficients = (coefficients: (number | bigint)[]): string => { const buffer = new Uint8Array(coefficients.length * 8) const view = new DataView(buffer.buffer) @@ -36,131 +30,84 @@ const packCoefficients = (coefficients: number[]): string => { } /** - * Coefficient-wise sum of encoded ballots. BFV addition is coefficient-wise, so - * this is the plaintext the committee decrypts after aggregating a round. + * Encode each ballot and sum the encodings coefficient by coefficient. BFV addition is + * coefficient-wise, so this is the plaintext the committee decrypts after aggregating a round. */ const aggregateBallots = (ballots: number[][]): number[] => - ballots.reduce((acc, ballot) => acc.map((coefficient, i) => coefficient + ballot[i])) + ballots.map((ballot) => encodeVote(ballot)).reduce((acc, ballot) => acc.map((coefficient, i) => coefficient + ballot[i])) describe('Tally decoding (SDK vs CRISPProgram)', function () { this.timeout(120000) let mockInterfold: MockInterfold let crispProgram: CRISPProgram - /// The plaintext modulus of the parameters the program under test validates against. - let t: bigint before(async function () { mockInterfold = await deployMockInterfold() crispProgram = await deployCRISPProgram({ mockInterfold }) - t = await mockInterfold.plaintextModulus() }) /** Publish a plaintext output for a fresh E3 and read back the on-chain tally. */ - const decodeOnChain = async (coefficients: number[], numOptions: number): Promise => { + const decodeOnChain = async (output: string, numOptions: number): Promise => { const e3Id = await mockInterfold.nextE3Id() await mockInterfold.requestWithOptions(await crispProgram.getAddress(), numOptions) - await mockInterfold.setPlaintextOutput(packCoefficients(coefficients)) + await mockInterfold.setPlaintextOutput(output) return crispProgram.decodeTally(e3Id) } - describe('encoder layout', function () { - it('should place the weight of option o at coefficient o and zero everything else', function () { - const coefficients = encodeVote([5, 0, 7]) + /** Assert that the SDK and the contract both decode `coefficients` to `expected`. */ + const expectDecoded = async (coefficients: (number | bigint)[], numOptions: number, expected: bigint[]) => { + const output = packCoefficients(coefficients) - expect(coefficients.slice(0, 3)).to.deep.equal([5, 0, 7]) - // The encoder emits the full polynomial, not just the message region. - expect(coefficients.length).to.be.greaterThan(MAX_MSG_NON_ZERO_COEFFS) - expect(coefficients.slice(3).every((c) => c === 0)).to.equal(true) - }) - }) + expect(decodeTally(output, numOptions)).to.deep.equal(expected) + expect(Array.from(await decodeOnChain(output, numOptions))).to.deep.equal(expected) + } describe('contract agreement', function () { - it('should decode a single encoded ballot the same way the SDK does', async function () { - const vote = [Number(t / 4n), Number(t / 2n)] - const coefficients = encodeVote(vote) - - const onChain = await decodeOnChain(coefficients, 2) - const offChain = decodeTally(packCoefficients(coefficients), 2) - - expect(offChain).to.deep.equal(vote.map(BigInt)) - expect(Array.from(onChain)).to.deep.equal(offChain) - }) - it('should decode an aggregated round the same way the SDK does', async function () { - // Ballots on different options, with every option total below `t`. - const unit = Number(t / 10n) + // Ballots on different options, with distinct option totals. const ballots = [ - [unit, 0], - [0, 2 * unit], - [0, 2 * unit], - [3 * unit, 0], + [1, 0], + [0, 2], + [0, 3], + [3, 0], ] - const coefficients = aggregateBallots(ballots.map((ballot) => encodeVote(ballot))) - - const onChain = await decodeOnChain(coefficients, 2) - const offChain = decodeTally(packCoefficients(coefficients), 2) - expect(offChain).to.deep.equal([BigInt(4 * unit), BigInt(4 * unit)]) - expect(Array.from(onChain)).to.deep.equal(offChain) + await expectDecoded(aggregateBallots(ballots), 2, [4n, 5n]) }) it('should sum a three-option round whose ballots spread weight over several options', async function () { - const coefficients = aggregateBallots( - [ - [2, 3, 4], - [1, 1, 1], - [0, 0, 0], - ].map((ballot) => encodeVote(ballot)), - ) - - const onChain = await decodeOnChain(coefficients, 3) - const offChain = decodeTally(packCoefficients(coefficients), 3) - - expect(offChain).to.deep.equal([3n, 4n, 5n]) - expect(Array.from(onChain)).to.deep.equal(offChain) - }) - - // `validate` keeps every option total below `t`, so `t - 1` is the largest total a round can - // hold, and the one a decoder is most likely to get wrong. - it('should decode a total of t - 1 exactly', async function () { - const first = Number(t / 2n) - const second = Number(t) - 1 - first - const coefficients = aggregateBallots([encodeVote([first, 0]), encodeVote([second, 0])]) - - const onChain = await decodeOnChain(coefficients, 2) - const offChain = decodeTally(packCoefficients(coefficients), 2) + const ballots = [ + [2, 3, 4], + [1, 1, 1], + [0, 0, 0], + ] - expect(offChain).to.deep.equal([t - 1n, 0n]) - expect(Array.from(onChain)).to.deep.equal(offChain) + await expectDecoded(aggregateBallots(ballots), 3, [3n, 4n, 5n]) }) - it('should read only the first numOptions coefficients', async function () { - const coefficients = encodeVote([4, 6]) + it('should read all 8 bytes of the first numOptions coefficients and nothing after them', async function () { + const max = 2n ** 64n - 1n + const coefficients: (number | bigint)[] = encodeVote([4, 0]) + coefficients[1] = max coefficients[2] = 99 - const onChain = await decodeOnChain(coefficients, 2) - const offChain = decodeTally(packCoefficients(coefficients), 2) - - expect(offChain).to.deep.equal([4n, 6n]) - expect(Array.from(onChain)).to.deep.equal(offChain) + await expectDecoded(coefficients, 2, [4n, max]) }) }) describe('payload length', function () { - // The payload lives in the first MAX_MSG_NON_ZERO_COEFFS coefficients, so a shorter output is - // not a tally. - it('should reject a plaintext output shorter than the payload in both decoders', async function () { - const tooShort = new Array(MAX_MSG_NON_ZERO_COEFFS - 1).fill(0) - const e3Id = await mockInterfold.nextE3Id() - - await mockInterfold.requestWithOptions(await crispProgram.getAddress(), 2) - await mockInterfold.setPlaintextOutput(packCoefficients(tooShort)) - - await expect(crispProgram.decodeTally(e3Id)).to.be.revertedWithCustomError(crispProgram, 'InvalidTallyLength') - expect(() => decodeTally(packCoefficients(tooShort), 2)).to.throw() + // One uint64 per coefficient, with the payload in the first MAX_MSG_NON_ZERO_COEFFS + // coefficients, so a shorter output or a partial coefficient is not a tally. + it('should reject a plaintext output shorter than the payload or not whole coefficients', async function () { + const payload = packCoefficients(new Array(MAX_MSG_NON_ZERO_COEFFS).fill(0)) + + for (const output of [payload.slice(0, -16), `${payload}00`]) { + expect(() => decodeTally(output, 2)).to.throw() + await expect(decodeOnChain(output, 2)).to.be.revertedWithCustomError(crispProgram, 'InvalidTallyLength') + } }) }) diff --git a/examples/CRISP/packages/crisp-sdk/README.md b/examples/CRISP/packages/crisp-sdk/README.md index 48bdda87ff..19167a7e0a 100644 --- a/examples/CRISP/packages/crisp-sdk/README.md +++ b/examples/CRISP/packages/crisp-sdk/README.md @@ -143,11 +143,10 @@ const isValid = await verifyProof(proof) #### Ballot Encoding and Decode Tally -A ballot stores one integer per option: coefficient `o` of the message polynomial is the weight on -option `o`, and every other coefficient up to the BFV degree is zero. BFV adds ballots coefficient -by coefficient, so the decrypted tally holds one total per option in the same positions. Each weight -is a non-negative integer below the BFV plaintext modulus `t` of the active preset, and a round has -2 to `MAX_VOTE_OPTIONS` options. `CRISPProgram` keeps every option total below `t`. +Coefficient `o` of the message polynomial is the weight on option `o`. Every other coefficient up to +the BFV degree is zero. BFV adds ballots coefficient by coefficient, so the decrypted tally holds +one total per option. A weight is a non-negative integer below the BFV plaintext modulus `t`, and a +round has 2 to `MAX_VOTE_OPTIONS` options. `CRISPProgram` keeps every option total below `t`. ```typescript import { encodeVote, decodeTally } from '@crisp-e3/sdk' @@ -158,10 +157,6 @@ const tally = decodeTally(tallyBytes, numOptions) // Returns: bigint[] — the first numOptions coefficients, one total per option ``` -`decodeTally` reads the first `numOptions` of the `MAX_MSG_NON_ZERO_COEFFS` published coefficients. -`CRISPProgram.decodeTally` (Solidity) and `crisp_utils::decode_tally` (Rust) read the same layout. -The interfold dashboard calls `CRISPProgram.decodeTally`. - #### Cryptographic Utilities ```typescript @@ -263,8 +258,7 @@ const { stage, retryOffered } = getSubmissionStage({ mask vote proof (low-level) - `verifyProof(proof: ProofData): Promise` - Verify a proof locally - `encodeVote(vote: Vote): number[]` - Encode one weight per option into message coefficients, - padded to the BFV degree. Rejects a weight that is not a non-negative integer below the plaintext - modulus + padded to the BFV degree - `decodeTally(tallyBytes: string | number[] | bigint[], numChoices: number): TallyResult` - Decode the first `numChoices` coefficients of a tally into one total per option - `validateVote(vote: Vote, balance: bigint): void` - Check the weights against the plaintext diff --git a/examples/CRISP/packages/crisp-sdk/src/constants.ts b/examples/CRISP/packages/crisp-sdk/src/constants.ts index 39dd98ead0..a466b29c95 100644 --- a/examples/CRISP/packages/crisp-sdk/src/constants.ts +++ b/examples/CRISP/packages/crisp-sdk/src/constants.ts @@ -32,8 +32,7 @@ export const CRISP_SERVER_CHAIN_BLOCK_AT_TIMESTAMP_ENDPOINT = 'chain/block-at-ti export const MERKLE_TREE_MAX_DEPTH = 20 // static, hardcoded in the circuit. // @note Must stay aligned with CRISP circuits / threshold message layout (Rust & Noir MAX_MSG_NON_ZERO_COEFFS). -// The committee publishes this many plaintext coefficients as the tally. Option `o` is coefficient `o`, -// so a round can have at most this many options; the rest of the polynomial is zero padding. +// The committee publishes this many plaintext coefficients as the tally; option `o` is coefficient `o`. export const MAX_MSG_NON_ZERO_COEFFS = 50 // Hard limit on the maximum number of vote options supported. export const MAX_VOTE_OPTIONS = 10 diff --git a/examples/CRISP/packages/crisp-sdk/src/encoding.ts b/examples/CRISP/packages/crisp-sdk/src/encoding.ts index 408e8dbff6..9341cbacef 100644 --- a/examples/CRISP/packages/crisp-sdk/src/encoding.ts +++ b/examples/CRISP/packages/crisp-sdk/src/encoding.ts @@ -49,18 +49,10 @@ export const getZkInputsGenerator = () => { return _zkInputsGenerator } -/** - * Returns the BFV plaintext modulus `t` of the active preset. A choice weight, and every option - * total of a round, must stay below it. - */ -export const getPlaintextModulus = (): bigint => BigInt(getZkInputsGenerator().getBFVParams().plaintextModulus) - /** * Checks every choice weight against the plaintext modulus `t`. A weight at or above `t` wraps * around in the plaintext ring and no longer adds as an integer. * - * @param vote - Weight per option - * @param plaintextModulus - The plaintext modulus `t` of the active preset * @throws If a weight is not a non-negative safe integer below `t` */ export const checkVoteWeights = (vote: Vote, plaintextModulus: bigint): void => { @@ -73,10 +65,6 @@ export const checkVoteWeights = (vote: Vote, plaintextModulus: bigint): void => /** * Encodes vote choices into a polynomial coefficient array for BFV encryption. - * Coefficient `o` is the weight on option `o`; every other coefficient, up to the BFV degree, is zero. - * - * Decoded by `decodeTally`, and by the other tally decoders that share this layout: - * `CRISPProgram.decodeTally` (Solidity) and `crisp_utils::decode_tally` (Rust). * * @param vote - Weight per option, for example [10, 0] for 2 options. Each weight is a non-negative * safe integer below the BFV plaintext modulus. @@ -91,25 +79,19 @@ export const encodeVote = (vote: Vote): number[] => { throw new Error('Vote must have at least two choices') } - // The Noir circuit asserts num_options <= MAX_OPTIONS, so a vote beyond this can never - // produce a valid proof. Reject it here rather than encoding an unprovable vote. + // The circuit asserts num_options <= MAX_OPTIONS, so a larger vote can never be proved. if (numChoices > MAX_VOTE_OPTIONS) { throw new Error(`Number of choices (${numChoices}) exceeds MAX_VOTE_OPTIONS (${MAX_VOTE_OPTIONS})`) } - const { degree, plaintextModulus } = getZkInputsGenerator().getBFVParams() as { degree: number; plaintextModulus: bigint } + const { degree, plaintextModulus } = getZkInputsGenerator().getBFVParams() if (degree < MAX_MSG_NON_ZERO_COEFFS) { throw new Error(`BFV degree (${degree}) must be at least MAX_MSG_NON_ZERO_COEFFS (${MAX_MSG_NON_ZERO_COEFFS})`) } checkVoteWeights(vote, plaintextModulus) - const voteArray: number[] = new Array(degree).fill(0) - for (let choiceIdx = 0; choiceIdx < numChoices; choiceIdx += 1) { - voteArray[choiceIdx] = vote[choiceIdx] - } - - return voteArray + return [...vote, ...new Array(degree - numChoices).fill(0)] } /** @@ -126,12 +108,9 @@ export const encryptVote = (vote: Vote, publicKey: Uint8Array): Uint8Array => { } /** - * Decodes raw tally bytes (or coefficients) into a total per choice. - * Expects the layout `encodeVote` produces: coefficient `o` holds the total weight on option `o`. - * - * Same layout as `CRISPProgram.decodeTally` (Solidity) and `crisp_utils::decode_tally` (Rust). - * Only the first MAX_MSG_NON_ZERO_COEFFS coefficients are published as the tally; the first - * `numChoices` of them are the totals and the rest are ignored. + * Decodes raw tally bytes (or coefficients) into one total per choice: the first `numChoices` of the + * MAX_MSG_NON_ZERO_COEFFS published coefficients, in the layout `encodeVote` produces. Same layout as + * `CRISPProgram.decodeTally` (Solidity) and `crisp_utils::decode_tally` (Rust). * * @param tallyBytes - Hex string, or the polynomial coefficients from tally/decryption * @param numChoices - Number of vote options: an integer from 2 to MAX_VOTE_OPTIONS @@ -140,16 +119,12 @@ export const encryptVote = (vote: Vote, publicKey: Uint8Array): Uint8Array => { * coefficients than MAX_MSG_NON_ZERO_COEFFS */ export const decodeTally = (tallyBytes: string | number[] | bigint[], numChoices: number): TallyResult => { - // `CRISPProgram.validate` rejects a round outside 2..MAX_VOTE_OPTIONS, and `encodeVote` refuses - // to encode fewer than two choices, so no tally in that range can exist. `Number.isInteger` also - // screens out NaN, Infinity, and fractions: a fractional count would slice a fractional number of - // coefficients, and NaN passes both bound checks to return an empty tally. + // CRISPProgram.validate rejects a round outside 2..MAX_VOTE_OPTIONS. Number.isInteger also screens + // out NaN, Infinity, and fractions. if (!Number.isInteger(numChoices) || numChoices < 2) { throw new Error(`Number of choices (${numChoices}) must be an integer of at least 2`) } - // Rounds cannot exceed MAX_VOTE_OPTIONS (the circuit's MAX_OPTIONS), so a larger count - // is a caller error rather than a tally to decode. if (numChoices > MAX_VOTE_OPTIONS) { throw new Error(`Number of choices (${numChoices}) exceeds MAX_VOTE_OPTIONS (${MAX_VOTE_OPTIONS})`) } diff --git a/examples/CRISP/packages/crisp-sdk/src/vote.ts b/examples/CRISP/packages/crisp-sdk/src/vote.ts index 4b5467d95a..6981ee94e9 100644 --- a/examples/CRISP/packages/crisp-sdk/src/vote.ts +++ b/examples/CRISP/packages/crisp-sdk/src/vote.ts @@ -6,7 +6,7 @@ import { type Vote, type CensusVariant, type PrepareBallotInputs, type PreparedBallot, ProofData } from './types' import { proofToFields } from './utils' -import { checkVoteWeights, getPlaintextModulus } from './encoding' +import { checkVoteWeights, getZkInputsGenerator } from './encoding' import { attachSignatureImpl, prepareCircuitInputsImpl } from './circuitInputs' import { MASK_SIGNATURE } from './constants' export { encodeVote, encryptVote, decodeTally, decryptVote, generateBFVKeys } from './encoding' @@ -259,7 +259,7 @@ export const generateProof = async (circuitInputs: any, censusMode: CensusVarian export const validateVote = (vote: Vote, balance: bigint): void => { const numChoices = vote.length - checkVoteWeights(vote, getPlaintextModulus()) + checkVoteWeights(vote, getZkInputsGenerator().getBFVParams().plaintextModulus) if (numChoices === 2) { // Binary: mutually exclusive diff --git a/examples/CRISP/packages/crisp-sdk/tests/vote.test.ts b/examples/CRISP/packages/crisp-sdk/tests/vote.test.ts index 21f74cbc06..465b6a622b 100644 --- a/examples/CRISP/packages/crisp-sdk/tests/vote.test.ts +++ b/examples/CRISP/packages/crisp-sdk/tests/vote.test.ts @@ -76,25 +76,12 @@ describe('Vote', () => { }) describe('decodeTally', () => { - it('Should return the leading coefficients and ignore the rest of the payload region', () => { + it('Should return the first numChoices coefficients exactly', () => { + const big = (1n << 60n) + 1n const coefficients = new Array(MAX_MSG_NON_ZERO_COEFFS).fill(0n) - coefficients[0] = 7n - coefficients[1] = 0n - coefficients[2] = 42n - coefficients[3] = 99n + coefficients.splice(0, 4, 7n, 0n, big, 99n) - expect(decodeTally(coefficients, 3)).toEqual([7n, 0n, 42n]) - }) - - it('Should decode totals above Number.MAX_SAFE_INTEGER without losing precision', () => { - const total = (1n << 60n) + 1n - const coefficients = new Array(MAX_MSG_NON_ZERO_COEFFS).fill(0n) - coefficients[1] = total - - const decoded = decodeTally(coefficients, 2) - - expect(decoded[1]).toBe(total) - expect(decoded[1] > BigInt(Number.MAX_SAFE_INTEGER)).toBe(true) + expect(decodeTally(coefficients, 3)).toEqual([7n, 0n, big]) }) it('Should reject a tally shorter than the payload region', () => { @@ -117,8 +104,6 @@ describe('Vote', () => { it('Should reject a non-integer number of choices', () => { const coefficients = new Array(MAX_MSG_NON_ZERO_COEFFS).fill(0) - // A fraction would slice a fractional number of coefficients; NaN passes both bound - // checks and would return an empty tally. expect(() => decodeTally(coefficients, 2.5)).toThrow('must be an integer of at least 2') expect(() => decodeTally(coefficients, Number.NaN)).toThrow('must be an integer of at least 2') expect(() => decodeTally(coefficients, Number.POSITIVE_INFINITY)).toThrow('must be an integer of at least 2') @@ -146,39 +131,27 @@ describe('Vote', () => { expect(decodeTally(encoded, MAX_VOTE_OPTIONS)).toEqual(new Array(MAX_VOTE_OPTIONS).fill(1n)) }) - it('Should place the weight of option o at coefficient o and zero elsewhere', () => { - const { degree } = getZkInputsGenerator().getBFVParams() - const encoded = encodeVote([3, 0, 7]) - - expect(encoded).toHaveLength(degree) - expect(encoded.slice(0, 3)).toEqual([3, 0, 7]) - expect(encoded.slice(3).every((c) => c === 0)).toBe(true) - }) - - it('Should round-trip through decodeTally, including the largest weight below the plaintext modulus', () => { - const t = getZkInputsGenerator().getBFVParams().plaintextModulus as bigint - const largest = Number(t - 1n) - const weights: Vote = [largest, 0, 1, 2, largest] + it('Should place the weight of option o at coefficient o and zero elsewhere, up to t - 1', () => { + const { degree, plaintextModulus } = getZkInputsGenerator().getBFVParams() + const weights = [Number(plaintextModulus - 1n), 0, 7] - expect(decodeTally(encodeVote(weights), weights.length)).toEqual(weights.map(BigInt)) + expect(encodeVote(weights)).toEqual([...weights, ...new Array(degree - 3).fill(0)]) }) it('Should reject a weight that is not a non-negative safe integer below the plaintext modulus', () => { - const t = getZkInputsGenerator().getBFVParams().plaintextModulus as bigint + const t = getZkInputsGenerator().getBFVParams().plaintextModulus expect(() => encodeVote([0, Number(t)])).toThrow( `Vote value for choice 1 must be a non-negative integer below the plaintext modulus (${t})`, ) expect(() => encodeVote([-1, 0])).toThrow('Vote value for choice 0') expect(() => encodeVote([0, 1.5])).toThrow('Vote value for choice 1') - expect(() => encodeVote([Number.NaN, 0])).toThrow('Vote value for choice 0') - expect(() => encodeVote([0, Number.MAX_SAFE_INTEGER + 1])).toThrow('Vote value for choice 1') }) }) describe('validateVote', () => { it('Should reject a choice at or above the plaintext modulus and accept the largest weight below it', () => { - const t = getZkInputsGenerator().getBFVParams().plaintextModulus as bigint + const t = getZkInputsGenerator().getBFVParams().plaintextModulus const balance = t * 2n expect(() => validateVote([Number(t - 1n), 0], balance)).not.toThrow() diff --git a/examples/CRISP/server/src/cli/commands.rs b/examples/CRISP/server/src/cli/commands.rs index 6372427fd1..ff5c50ff8e 100644 --- a/examples/CRISP/server/src/cli/commands.rs +++ b/examples/CRISP/server/src/cli/commands.rs @@ -279,9 +279,8 @@ pub async fn initialize_crisp_round( // `SelfRegistry`, whose power is 1 or 0. BY_REQUESTER is not offered: the requester here is // this CLI's EOA, which cannot answer `getCensus`. let census_mode = U256::from(if onchain { 2u64 } else { 0u64 }); - // Seventh field: the requested voting-power divisor. Constant credits ignore it, so zero here. - // It is not optional: `_initRound` decodes exactly seven fields, so a six-field encoding - // reverts the request with empty data rather than defaulting to anything. + // Seventh field: the requested voting-power divisor, which constant credits ignore. Required: + // `_initRound` decodes exactly seven fields and reverts a shorter encoding. let voting_power_divisor = U256::from(0); let custom_params_bytes = Bytes::from( ( diff --git a/examples/CRISP/server/src/server/indexer.rs b/examples/CRISP/server/src/server/indexer.rs index eedaf7d299..6097f0910d 100644 --- a/examples/CRISP/server/src/server/indexer.rs +++ b/examples/CRISP/server/src/server/indexer.rs @@ -71,20 +71,15 @@ fn stage_ends_input_retrieval(stage: &E3Stage) -> bool { ) } -/// Read the divisor the contract stored for a CUSTOM-credit round, retrying a failed read. -/// -/// `CRISPProgram` resolves and stores this value at request time and scales every voter's power by -/// exactly it. A transient RPC failure must not leave the coordinator scaling balances in other -/// units, so the read retries with backoff. The contract never stores zero for a CUSTOM-credit -/// round, so a zero read comes from a node that does not have the request block yet, and is -/// retried the same way. On exhaustion it returns `None`, which `resolve_divisor_override` answers -/// from the requested field when the round named one. +/// Read the divisor `CRISPProgram` stored for a CUSTOM-credit round, or `None` after +/// `E3_VISIBLE_ATTEMPTS`. The contract never stores zero for such a round, so a zero read is a +/// node that lacks the request block, and it is retried like a failed read. async fn read_stored_divisor( crisp: &CRISPContract, e3_id: U256, label: &str, ) -> Option { - match call_with_retry_attempts( + call_with_retry_attempts( "stored_voting_power_divisor", &[], E3_VISIBLE_ATTEMPTS, @@ -99,16 +94,13 @@ async fn read_stored_divisor( }, ) .await - { - Ok(divisor) => Some(divisor), - Err(error) => { - warn!( - "[e3_id={}] Failed to read the stored voting-power divisor after retries: {:#}", - label, error - ); - None - } - } + .inspect_err(|error| { + warn!( + "[e3_id={}] Failed to read the stored voting-power divisor after retries: {:#}", + label, error + ) + }) + .ok() } /// Discover the holders a client draws mask targets from. @@ -122,9 +114,9 @@ async fn read_stored_divisor( /// /// One function for both callers: the `E3Requested` handler and the retry pass for a round that /// was registered without a census because its divisor could not be read. `divisor` is the -/// divisor of a CUSTOM-credit round, whatever its census mode. A CONSTANT-credit round has none. -/// A CUSTOM-credit round without one fails discovery: the contract scales every voter by its stored -/// divisor, so a census in any other unit would not match the tally. +/// divisor of a CUSTOM-credit round, or `None` for a CONSTANT-credit round. Off a local chain, a +/// CUSTOM-credit round without one fails discovery: a census in any other unit would not match +/// what the contract enforces. async fn discover_holders( e3_id: &str, custom_params: &CustomParams, @@ -183,18 +175,15 @@ async fn discover_holders( e3_id, CONFIG.chain_id ); - // Each leaf is the slot's weight bound. A CONSTANT round sizes its input limit from - // `credits`, so every leaf carries exactly that, as on every other chain. A CUSTOM round - // gets 1 per account, and ten accounts stay below the plaintext modulus of every preset. - let balance = match custom_params.credit_mode { + // A CONSTANT round carries its credits, as on every other chain. A CUSTOM round gets 1, so + // ten accounts stay below the plaintext modulus of every preset. + get_mock_token_holders(match custom_params.credit_mode { CreditMode::Constant => custom_params .credits .as_deref() .expect("credits must be set for Constant mode"), CreditMode::Custom => "1", - }; - - get_mock_token_holders(balance) + }) } else { info!( "[e3_id={}] Using Etherscan API for network (chain_id: {})", @@ -268,8 +257,9 @@ fn order_token_holders(holders: &mut [TokenHolder]) { /// What `E3Requested` does with a round once the divisor sources have answered. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RegistrationPlan { - /// Register the round and run holder discovery with this divisor. A CUSTOM-credit TOKEN round - /// with `None` fails in discovery, as a Merkle round must not be built in unknown units. + /// Register the round and run holder discovery with this divisor. Off a local chain, a + /// CUSTOM-credit TOKEN round with `None` fails in discovery: a Merkle round must not be built + /// in unknown units. Discover(Option), /// Register the round but skip holder discovery: an on-chain round with no known divisor /// is fully votable, and building the census in unknown units would serve wrong balances. @@ -283,50 +273,35 @@ enum RegistrationPlan { /// and moves on, so an `Err` from this handler does not defer anything: it drops the round /// from coordinator metadata until the next backfill replays the event. Only discovery is /// divisor-dependent. An on-chain round is still votable without a census, so only its discovery -/// is skipped. A Merkle round has no votes without a census, so its discovery fails. +/// is skipped. A Merkle round has no votes without a census, so off a local chain its discovery +/// fails. +/// +/// The divisor is chosen by three rules, in order: +/// +/// - A CONSTANT-credit round gets `None`: the contract stores no divisor, and the census carries +/// the constant credits. +/// - For a CUSTOM-credit round the stored value wins: `CRISPProgram` resolved it once at request +/// time and scales every voter by exactly it. +/// - Otherwise a non-zero requested value, which the contract stores verbatim. A zero or +/// unparseable one asked for the minimum, which only the contract can compute. fn plan_registration( stored_divisor: Option, requested_divisor: &str, credit_mode: CreditMode, census_mode: CensusMode, ) -> RegistrationPlan { - let divisor = resolve_divisor_override(stored_divisor, requested_divisor, credit_mode); - if credit_mode == CreditMode::Custom && census_mode == CensusMode::Onchain && divisor.is_none() - { - return RegistrationPlan::RegisterWithoutDiscovery; - } - RegistrationPlan::Discover(divisor) -} - -/// The divisor the coordinator scales voting power by, or `None` when none is known. -/// -/// Three rules, in order: -/// -/// - A CONSTANT-credit round gets `None`. The contract stores no divisor for it, and the census -/// carries the constant credits. -/// - For a CUSTOM-credit round, whatever its census mode, the stored value wins. `CRISPProgram` -/// resolved it once at request time, and every voter is scaled by exactly it. -/// - The requested field is the fallback, for a round whose stored value could not be read. The -/// contract stores a non-zero request verbatim. A zero or unparseable field means the round -/// asked for the minimum divisor, which only the contract can compute. -fn resolve_divisor_override( - stored: Option, - requested: &str, - credit_mode: CreditMode, -) -> Option { if credit_mode != CreditMode::Custom { - return None; - } - - if let Some(divisor) = stored { - if !divisor.is_zero() { - return Some(divisor); - } + return RegistrationPlan::Discover(None); } - - match U256::from_str_radix(requested, 10) { - Ok(divisor) if !divisor.is_zero() => Some(divisor), - _ => None, + let nonzero = |divisor: &U256| !divisor.is_zero(); + let divisor = stored_divisor.filter(nonzero).or_else(|| { + U256::from_str_radix(requested_divisor, 10) + .ok() + .filter(nonzero) + }); + match divisor { + None if census_mode == CensusMode::Onchain => RegistrationPlan::RegisterWithoutDiscovery, + divisor => RegistrationPlan::Discover(divisor), } } @@ -474,24 +449,13 @@ pub async fn register_e3_requested( ); } - // The divisor the contract resolved and stored when the round was requested. - // `CRISPProgram` scales every voter's power by exactly this value for a - // CUSTOM-credit round, TOKEN and ONCHAIN alike, so the coordinator reads it - // instead of computing its own. - // - // A failed read is not fatal when the round requested a divisor: the contract - // stores a non-zero request verbatim. A zero request leaves the coordinator with - // no value to fall back on, because only the contract can compute the minimum. - // Retry before accepting that. + // Only a CUSTOM-credit round stores a divisor. `plan_registration` decides what + // a missing one means. let stored_divisor = if custom_params.credit_mode == CreditMode::Custom { read_stored_divisor(&crisp, event.e3Id, &e3_id).await } else { None }; - - // An on-chain round with no known divisor must not block registration: see - // `plan_registration`. Its census would be built in units the tally does not - // read back, so discovery is skipped instead. let plan = plan_registration( stored_divisor, &custom_params.voting_power_divisor, @@ -1543,8 +1507,8 @@ async fn record_discovery_debt( /// swallowed to keep an on-chain-census round votable. Such a round is registered and votable but /// serves no mask targets. The event is not replayed once the cursor passes it, so this pass is /// the only retry. It reads the divisor again for each such CUSTOM-credit round and, when it -/// answers, runs the same discovery the handler would have run. A round that has ended is dropped from the pass: -/// there is nobody left to mask. +/// answers, runs the same discovery the handler would have run. A round that has ended is +/// dropped from the pass: there is nobody left to mask. /// /// One task for the process, started at registration. It sleeps on `DISCOVERY_OWED` while nothing /// is owed. `notify_one` keeps a permit when the task is mid-pass, so debt recorded after the pass @@ -1596,15 +1560,16 @@ async fn retry_pending_discovery(store: SharedStore) { let Ok(e3_id_value) = e3_id_to_u256(&e3_id) else { continue; }; - // Only a CUSTOM-credit round has a divisor to read. - let divisor = if round.credit_mode == CreditMode::Custom { - let Some(divisor) = read_stored_divisor(&crisp, e3_id_value, &e3_id).await else { - // Still unreadable. Keep the debt and try again next pass. - continue; - }; - Some(divisor) - } else { - None + // Only a CUSTOM-credit round has a divisor. Still unreadable: keep the debt for the + // next pass. + let divisor = match round.credit_mode { + CreditMode::Constant => None, + CreditMode::Custom => { + match read_stored_divisor(&crisp, e3_id_value, &e3_id).await { + None => continue, + divisor => divisor, + } + } }; match settle_pending_discovery(&mut repo, &e3_id, &round, divisor).await { Ok(count) => { @@ -2071,89 +2036,34 @@ mod e3_request_tests { #[cfg(test)] mod stored_divisor_tests { use super::read_stored_divisor; - use alloy_primitives::U256; + use alloy::primitives::{bytes, Address, B256, U256}; + use alloy::providers::{ext::AnvilApi, ProviderBuilder}; use evm_helpers::CRISPContractFactory; - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::sync::Arc; - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - use tokio::net::TcpListener; - - /// A JSON-RPC node over HTTP that does not have the round at its first read. It answers the - /// first `eth_call` with zero and every later one with `stored`. - async fn lagging_node(listener: TcpListener, stored: u64) { - let reads = Arc::new(AtomicUsize::new(0)); - loop { - let (mut socket, _) = listener.accept().await.unwrap(); - let reads = reads.clone(); - tokio::spawn(async move { - let mut buffer = Vec::new(); - let mut chunk = [0u8; 4096]; - loop { - let header_end = loop { - if let Some(at) = buffer.windows(4).position(|bytes| bytes == b"\r\n\r\n") { - break at + 4; - } - let read = socket.read(&mut chunk).await.unwrap(); - if read == 0 { - return; - } - buffer.extend_from_slice(&chunk[..read]); - }; - let body_end = header_end - + String::from_utf8_lossy(&buffer[..header_end]) - .to_ascii_lowercase() - .lines() - .find_map(|line| line.strip_prefix("content-length:")) - .map_or(0, |length| length.trim().parse::().unwrap()); - while buffer.len() < body_end { - let read = socket.read(&mut chunk).await.unwrap(); - if read == 0 { - return; - } - buffer.extend_from_slice(&chunk[..read]); - } - let request: serde_json::Value = - serde_json::from_slice(&buffer[header_end..body_end]).unwrap(); - buffer.drain(..body_end); - - let value = if reads.fetch_add(1, Ordering::SeqCst) == 0 { - 0 - } else { - stored - }; - let response = serde_json::json!({ - "jsonrpc": "2.0", - "id": request["id"], - "result": format!("0x{value:064x}"), - }) - .to_string(); - let reply = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{response}", - response.len() - ); - socket.write_all(reply.as_bytes()).await.unwrap(); - } - }); - } - } + use std::time::Duration; /// `CRISPProgram` never stores a zero divisor for a CUSTOM-credit round. A zero read is a node /// that does not have the request block yet, and taking it as final drops the round. #[tokio::test] async fn a_zero_read_from_a_lagging_node_is_retried() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let url = format!("http://{}", listener.local_addr().unwrap()); - tokio::spawn(lagging_node(listener, 5)); + let anvil = alloy::node_bindings::Anvil::new().try_spawn().unwrap(); + let node = ProviderBuilder::new().connect_http(anvil.endpoint_url()); + let program = Address::repeat_byte(1); + // PUSH1 0 SLOAD PUSH1 0 MSTORE PUSH1 32 PUSH1 0 RETURN: every call returns slot 0. + node.anvil_set_code(program, bytes!("60005460005260206000f3")) + .await + .unwrap(); + let crisp = CRISPContractFactory::create_read(&anvil.endpoint(), &program.to_string()) + .await + .unwrap(); - let crisp = - CRISPContractFactory::create_read(&url, "0x0000000000000000000000000000000000000001") + // The first read sees zero; the slot is set before the retry 2 s later. + let (divisor, _) = tokio::join!(read_stored_divisor(&crisp, U256::from(1), "1"), async { + tokio::time::sleep(Duration::from_secs(1)).await; + node.anvil_set_storage_at(program, U256::ZERO, B256::with_last_byte(5)) .await - .unwrap(); - - assert_eq!( - read_stored_divisor(&crisp, U256::from(1), "1").await, - Some(U256::from(5)) - ); + .unwrap() + }); + assert_eq!(divisor, Some(U256::from(5))); } } @@ -2287,122 +2197,42 @@ mod pending_discovery_tests { } } -/// The divisor decides the units every scaled balance is expressed in. The contract holds the -/// authoritative value for a CUSTOM-credit round of either census mode, so the coordinator prefers -/// it over the requested field and uses neither for a CONSTANT-credit round. #[cfg(test)] mod voting_power_divisor_tests { - use super::{plan_registration, resolve_divisor_override, RegistrationPlan}; - use crate::server::models::{CensusMode, CreditMode}; + use super::{ + plan_registration, + RegistrationPlan::{Discover, RegisterWithoutDiscovery}, + }; + use crate::server::models::{ + CensusMode::{Onchain, Token}, + CreditMode::{Constant, Custom}, + }; use alloy::primitives::U256; - const STORED: u128 = 100_000_000_000_000_000; - - /// The stored value is what `CRISPProgram` scales every voter by, so it wins even when the - /// requested field says something else. A round requested with a zero field has a non-zero - /// stored divisor, which is the ordinary DAO configuration. - #[test] - fn the_stored_divisor_wins_over_the_requested_field() { - let stored = U256::from(STORED); - - assert_eq!( - resolve_divisor_override(Some(stored), "0", CreditMode::Custom), - Some(stored) - ); - assert_eq!( - resolve_divisor_override(Some(stored), "5000", CreditMode::Custom), - Some(stored) - ); - } - - /// The stored read failed after retries. A non-zero request is what the contract stored, so it - /// is the fallback. A zero or unparseable request asked for the minimum divisor, which only - /// the contract can compute. - #[test] - fn the_requested_field_is_the_fallback_only_when_it_names_a_divisor() { - assert_eq!( - resolve_divisor_override(None, "5000", CreditMode::Custom), - Some(U256::from(5000)) - ); - assert_eq!( - resolve_divisor_override(None, "0", CreditMode::Custom), - None - ); - assert_eq!( - resolve_divisor_override(Some(U256::ZERO), "0", CreditMode::Custom), - None - ); - assert_eq!( - resolve_divisor_override(None, "not a number", CreditMode::Custom), - None - ); - assert_eq!(resolve_divisor_override(None, "", CreditMode::Custom), None); - } - - /// A CONSTANT-credit round stores no divisor, so neither source applies, whatever the census. #[test] - fn a_constant_credit_round_never_takes_a_divisor() { - let stored = U256::from(STORED); - - assert_eq!( - resolve_divisor_override(Some(stored), "5000", CreditMode::Constant), - None - ); - assert_eq!( - plan_registration( - Some(stored), - "5000", - CreditMode::Constant, - CensusMode::Onchain - ), - RegistrationPlan::Discover(None) - ); - assert_eq!( - plan_registration(None, "0", CreditMode::Constant, CensusMode::Token), - RegistrationPlan::Discover(None) - ); - } - - /// The stored read failed after retries and the round requested no divisor. An on-chain round - /// must still be registered: the live listener does not retry a failed handler, so refusing - /// here would drop the round from coordinator metadata until the next backfill. Only discovery - /// is skipped. - #[test] - fn an_onchain_custom_round_is_registered_without_a_known_divisor() { - assert_eq!( - plan_registration(None, "0", CreditMode::Custom, CensusMode::Onchain), - RegistrationPlan::RegisterWithoutDiscovery - ); - assert_eq!( - plan_registration(None, "", CreditMode::Custom, CensusMode::Onchain), - RegistrationPlan::RegisterWithoutDiscovery - ); - } - - /// A TOKEN round has no votes without its census, so it never skips discovery. With no known - /// divisor, discovery fails with an error instead of building the census in unknown units. - #[test] - fn a_token_custom_round_without_a_divisor_still_runs_discovery() { - assert_eq!( - plan_registration(None, "0", CreditMode::Custom, CensusMode::Token), - RegistrationPlan::Discover(None) - ); - } - - /// The same outage against a round that requested a divisor loses nothing, for either census - /// mode. Once the RPC recovers, the stored value is read again and wins. - #[test] - fn a_known_divisor_lets_either_census_mode_discover() { - let stored = U256::from(STORED); - - for census_mode in [CensusMode::Token, CensusMode::Onchain] { - assert_eq!( - plan_registration(None, "5000", CreditMode::Custom, census_mode), - RegistrationPlan::Discover(Some(U256::from(5000))) - ); + fn the_stored_divisor_wins_and_a_nonzero_request_is_the_fallback() { + let stored = Some(U256::from(100_000_000_000_000_000u128)); + let requested = Some(U256::from(5000)); + for (s, field, credit, census, plan) in [ + // The stored value is what `CRISPProgram` scales every voter by. + (stored, "0", Custom, Token, Discover(stored)), + (stored, "5000", Custom, Onchain, Discover(stored)), + // After a failed read, a non-zero request is the value the contract stored. + (None, "5000", Custom, Token, Discover(requested)), + (None, "5000", Custom, Onchain, Discover(requested)), + // A zero or unparseable request asked for the minimum: TOKEN still runs discovery, + // which fails; ONCHAIN registers without it. + (None, "0", Custom, Token, Discover(None)), + (None, "", Custom, Onchain, RegisterWithoutDiscovery), + (None, "0", Custom, Onchain, RegisterWithoutDiscovery), + // A CONSTANT-credit round stores no divisor and takes none. + (stored, "5000", Constant, Onchain, Discover(None)), + (None, "0", Constant, Token, Discover(None)), + ] { assert_eq!( - plan_registration(Some(stored), "0", CreditMode::Custom, census_mode), - RegistrationPlan::Discover(Some(stored)) + plan_registration(s, field, credit, census), + plan, + "{s:?} {field:?} {credit:?} {census:?}" ); } } diff --git a/examples/CRISP/server/src/server/models.rs b/examples/CRISP/server/src/server/models.rs index 522d6f8a36..095d3d52ae 100644 --- a/examples/CRISP/server/src/server/models.rs +++ b/examples/CRISP/server/src/server/models.rs @@ -221,11 +221,10 @@ pub struct CustomParams { pub credit_mode: CreditMode, pub credits: Option, pub census_mode: CensusMode, - /// The divisor requested for a `CreditMode::Custom` round, of either census mode. It divides - /// raw token power into ballot units. `"0"` asks the contract for the smallest divisor that - /// keeps every option total below the plaintext modulus. The contract stores the value that - /// it uses, and `votingPowerDivisorOf` returns that stored value. A `CreditMode::Constant` - /// round ignores the field. + /// The divisor requested for a `CreditMode::Custom` round, of either census mode; `"0"` asks + /// for the smallest one that keeps every option total below the plaintext modulus. The + /// contract stores the value it uses (`votingPowerDivisorOf`). A `CreditMode::Constant` round + /// ignores it. pub voting_power_divisor: String, } diff --git a/examples/CRISP/server/src/server/routes/rounds.rs b/examples/CRISP/server/src/server/routes/rounds.rs index 9094454515..60afb69f58 100644 --- a/examples/CRISP/server/src/server/routes/rounds.rs +++ b/examples/CRISP/server/src/server/routes/rounds.rs @@ -536,9 +536,8 @@ pub async fn initialize_crisp_round( let credit_mode = U256::from(0); // Constant let credits = U256::from(1); let census_mode = U256::from(census_mode); - // Seventh field: the requested voting-power divisor. Constant credits ignore it, so zero here. - // Required regardless: `_initRound` decodes exactly seven fields, so a shorter encoding - // reverts the request with empty data. + // Seventh field: the requested voting-power divisor, which constant credits ignore. Required: + // `_initRound` decodes exactly seven fields and reverts a shorter encoding. let voting_power_divisor = U256::from(0); let custom_params_bytes = Bytes::from( ( diff --git a/examples/CRISP/server/src/server/token_holders/etherscan.rs b/examples/CRISP/server/src/server/token_holders/etherscan.rs index e223a2eede..db20637ca3 100644 --- a/examples/CRISP/server/src/server/token_holders/etherscan.rs +++ b/examples/CRISP/server/src/server/token_holders/etherscan.rs @@ -176,6 +176,9 @@ const MIN_REQUEST_INTERVAL: Duration = Duration::from_millis(500); /// How many times a rate-limited request is retried before giving up. const RATE_LIMIT_RETRIES: u32 = 5; +/// A zero divisor would divide by zero, and the contract never stores one for a CUSTOM round. +const ZERO_DIVISOR: &str = "The voting-power divisor must be non-zero"; + /// Client for querying token holder data from Etherscan API. pub struct EtherscanClient { client: reqwest::Client, @@ -881,14 +884,6 @@ impl EtherscanClient { potential_voters.into_values().collect() } - /// Reject a zero divisor before it reaches a division. - fn ensure_nonzero_divisor(divisor: U256) -> Result<()> { - if divisor.is_zero() { - return Err(eyre!("The voting-power divisor must be non-zero")); - } - Ok(()) - } - /// Verify voting power for multiple addresses at an EIP-6372 timepoint. /// /// `divisor` is the value `CRISPProgram` stored for the round. The contract chose it so that @@ -905,7 +900,7 @@ impl EtherscanClient { threshold: U256, divisor: U256, ) -> Result> { - Self::ensure_nonzero_divisor(divisor)?; + eyre::ensure!(!divisor.is_zero(), ZERO_DIVISOR); let mut token_holders: Vec = Vec::new(); log::info!( @@ -1132,7 +1127,7 @@ impl EtherscanClient { // The divisor `CRISPProgram` stored for the round. divisor: U256, ) -> Result> { - Self::ensure_nonzero_divisor(divisor)?; + eyre::ensure!(!divisor.is_zero(), ZERO_DIVISOR); log::info!("Starting token holder discovery for {}", token_address); let sources = Self::resolve_voting_power_sources(token_address, rpc_url) @@ -1909,35 +1904,15 @@ mod voting_power_divisor_tests { use super::EtherscanClient; use alloy::primitives::{Address, U256}; - fn client() -> EtherscanClient { - EtherscanClient::new("test_key".to_string(), 1) - } - /// A zero divisor would divide by zero, and the contract never stores one for a CUSTOM round. #[tokio::test] async fn a_zero_divisor_is_refused() { - let refused = client() - .verify_voting_power( - Address::repeat_byte(1), - &[], - 1, - "http://127.0.0.1:1", - U256::ZERO, - U256::ZERO, - ) - .await; - assert!(refused.is_err()); - - let accepted = client() - .verify_voting_power( - Address::repeat_byte(1), - &[], - 1, - "http://127.0.0.1:1", - U256::ZERO, - U256::from(1), - ) - .await; - assert!(accepted.unwrap().is_empty()); + let verify = |divisor| async move { + EtherscanClient::new("test_key".to_string(), 1) + .verify_voting_power(Address::repeat_byte(1), &[], 1, "", U256::ZERO, divisor) + .await + }; + assert!(verify(U256::ZERO).await.is_err()); + assert!(verify(U256::from(1)).await.unwrap().is_empty()); } } diff --git a/packages/interfold-dashboard/src/lib/adapt.ts b/packages/interfold-dashboard/src/lib/adapt.ts index f9f2bae594..f09b1f2d41 100644 --- a/packages/interfold-dashboard/src/lib/adapt.ts +++ b/packages/interfold-dashboard/src/lib/adapt.ts @@ -55,14 +55,11 @@ export function adaptHistoryEntries(list: E3Summary[], detailsCache: Map): string { - // The tally comes from the CRISPProgram that ran the round, so it is never - // guessed from the off-chain label list. const tally = detail?.tally - if (tally && tally.length > 0) { + if (tally?.length) { const total = tally.reduce((a, b) => a + b, 0n) const max = tally.reduce((a, b) => (b > a ? b : a), 0n) - // Integer division truncates, so bias the numerator by half a percentage point - // to round half-up — matching the display before totals became bigint. + // Integer division truncates, so bias the numerator by half a percentage point to round half-up. const pct = total > 0n ? Number((max * 200n + total) / (total * 2n)) : 0 const winnerLabel = meta.options[tally.indexOf(max)]?.label ?? 'Outcome' const verdict = /^no/i.test(winnerLabel) ? 'Declined' : /^abs/i.test(winnerLabel) ? 'Inconclusive' : 'Approved' diff --git a/packages/interfold-dashboard/src/lib/e3.ts b/packages/interfold-dashboard/src/lib/e3.ts index a516d80e26..5c3c758111 100644 --- a/packages/interfold-dashboard/src/lib/e3.ts +++ b/packages/interfold-dashboard/src/lib/e3.ts @@ -46,8 +46,8 @@ const CRISP_INPUT_PUBLISHED = { ], } as const -// Minimal CRISPProgram view. The program that ran a round decodes its own tally, so -// the dashboard holds no copy of the ballot layout and reads every deployment right. +// Minimal CRISPProgram view. The program decodes the tallies of its own rounds, so the +// dashboard holds no copy of the ballot layout. const CRISP_DECODE_TALLY = { type: 'function', name: 'decodeTally', @@ -210,9 +210,9 @@ export type E3FullDetails = E3Summary & { committeePublicKey: `0x${string}` ciphertextOutput: `0x${string}` plaintextOutput: `0x${string}` - // Per-option totals of a CRISP round, decoded by CRISPProgram. Undefined for - // non-CRISP programs and for rounds without a published, decodable output. - tally?: bigint[] + // Per-option totals of a CRISP round, decoded by CRISPProgram. Undefined or empty + // when the round has no decodable output. + tally?: readonly bigint[] requestedAt?: number // unix seconds (block.timestamp of the request) // Block number of the E3Requested log (distinct from `requestBlock` which on // this contract version is actually a Unix timestamp, not a block number). @@ -411,23 +411,15 @@ export async function fetchE3Details(e3Id: bigint, toBlock?: bigint): Promise FailureReason.None) as Promise, ]) - // CRISP tally. Only CRISP E3s have one, and only once the plaintext output is - // published. A round the program cannot decode reverts or returns no totals; in - // both cases the tally stays undefined rather than guessed. - const tally = - isCrispE3(e3.e3Program) && e3.plaintextOutput && e3.plaintextOutput !== '0x' - ? await publicClient - .readContract({ - address: CONTRACTS.CRISPProgram, - abi: [CRISP_DECODE_TALLY], - functionName: 'decodeTally', - args: [e3Id], - // viem's types require this EIP-7702 field for this client. A read carries no - // authorizations. - authorizationList: undefined, - }) - .then((votes) => (votes.length > 0 ? [...votes] : undefined)) - .catch(() => undefined) + // A round that the program cannot decode reverts, so its tally stays undefined. + const tally: readonly bigint[] | undefined = + isCrispE3(e3.e3Program) && e3.plaintextOutput !== '0x' + ? await (publicClient.readContract as any)({ + address: CONTRACTS.CRISPProgram, + abi: [CRISP_DECODE_TALLY], + functionName: 'decodeTally', + args: [e3Id], + }).catch(() => undefined) : undefined // `e3.requestBlock` is misnamed: on this contract version it stores From 39e3ac81d6ed670e58bce127432d5c1ffc3ce0ca Mon Sep 17 00:00:00 2001 From: ctrlc03 <93448202+ctrlc03@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:12:03 +0100 Subject: [PATCH 04/15] fix(crisp): defer a token-census round without a readable divisor Register a CUSTOM-credit TOKEN round whose stored divisor cannot be read, and whose request names no divisor, with a discovery debt, as an ONCHAIN round already is. The handler error dropped the round, so it never got a census root. The retry pass now builds the census and posts the Merkle root once the divisor reads. --- examples/CRISP/server/src/server/indexer.rs | 225 +++++++++----------- examples/CRISP/server/src/server/models.rs | 10 +- 2 files changed, 105 insertions(+), 130 deletions(-) diff --git a/examples/CRISP/server/src/server/indexer.rs b/examples/CRISP/server/src/server/indexer.rs index 6097f0910d..aa7fdfff23 100644 --- a/examples/CRISP/server/src/server/indexer.rs +++ b/examples/CRISP/server/src/server/indexer.rs @@ -254,15 +254,74 @@ fn order_token_holders(holders: &mut [TokenHolder]) { }); } +/// Post the census root of a Merkle round to `CRISPProgram`, unless that root is already set. +async fn ensure_merkle_root(e3_id: &str, token_holder_hashes: Vec) -> eyre::Result<()> { + let tree = build_tree(token_holder_hashes).with_context(|| "Failed to build tree")?; + let merkle_root = tree + .root() + .ok_or_else(|| eyre::eyre!("Failed to get merkle root from tree"))?; + info!("[e3_id={}] Merkle root: {}", e3_id, merkle_root); + let merkle_root_bytes = hex::decode(&merkle_root) + .with_context(|| format!("[e3_id={}] Merkle root is not valid hex", e3_id))?; + let merkle_root_u256 = U256::from_be_slice(&merkle_root_bytes); + let e3_id_u256 = U256::from_str_radix(e3_id, 10) + .with_context(|| format!("[e3_id={}] Invalid E3 ID", e3_id))?; + info!( + "[e3_id={}] Ensuring CRISPProgram Merkle root: {}", + e3_id, merkle_root_u256 + ); + let contract = CRISPContractFactory::create_write( + &CONFIG.http_rpc_url, + &CONFIG.e3_program_address, + &CONFIG.private_key, + ) + .await + .with_context(|| format!("[e3_id={}] Failed to create CRISP contract", e3_id))?; + let stored_root = contract.get_merkle_root(e3_id_u256).await?; + if stored_root == merkle_root_u256 { + info!( + "[e3_id={}] Merkle root is already set to the expected value", + e3_id + ); + } else if stored_root.is_zero() { + match contract.set_merkle_root(e3_id_u256, merkle_root_u256).await { + Ok(receipt) => info!( + "[e3_id={}] setMerkleRoot successful. TxHash: {:?}", + e3_id, receipt.transaction_hash + ), + Err(error) => { + // A live subscription and its overlap replay can race here. Accept + // the losing transaction only when the desired root landed. + let root_after_error = contract.get_merkle_root(e3_id_u256).await?; + if root_after_error != merkle_root_u256 { + return Err(error).with_context(|| { + format!("[e3_id={}] Failed to call setMerkleRoot", e3_id) + }); + } + info!( + "[e3_id={}] Merkle root was set by a concurrent handler", + e3_id + ); + } + } + } else { + return Err(eyre::eyre!( + "[e3_id={}] CRISPProgram has a different Merkle root: expected {}, got {}", + e3_id, + merkle_root_u256, + stored_root + )); + } + Ok(()) +} + /// What `E3Requested` does with a round once the divisor sources have answered. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum RegistrationPlan { - /// Register the round and run holder discovery with this divisor. Off a local chain, a - /// CUSTOM-credit TOKEN round with `None` fails in discovery: a Merkle round must not be built - /// in unknown units. + /// Register the round and run holder discovery with this divisor. Discover(Option), - /// Register the round but skip holder discovery: an on-chain round with no known divisor - /// is fully votable, and building the census in unknown units would serve wrong balances. + /// Register the round but defer holder discovery to the retry pass: building the census in + /// unknown units would serve wrong balances. RegisterWithoutDiscovery, } @@ -272,9 +331,7 @@ enum RegistrationPlan { /// Registration itself never depends on the divisor. The live listener logs a handler error /// and moves on, so an `Err` from this handler does not defer anything: it drops the round /// from coordinator metadata until the next backfill replays the event. Only discovery is -/// divisor-dependent. An on-chain round is still votable without a census, so only its discovery -/// is skipped. A Merkle round has no votes without a census, so off a local chain its discovery -/// fails. +/// divisor-dependent, so only discovery is deferred. /// /// The divisor is chosen by three rules, in order: /// @@ -288,7 +345,6 @@ fn plan_registration( stored_divisor: Option, requested_divisor: &str, credit_mode: CreditMode, - census_mode: CensusMode, ) -> RegistrationPlan { if credit_mode != CreditMode::Custom { return RegistrationPlan::Discover(None); @@ -300,7 +356,7 @@ fn plan_registration( .filter(nonzero) }); match divisor { - None if census_mode == CensusMode::Onchain => RegistrationPlan::RegisterWithoutDiscovery, + None => RegistrationPlan::RegisterWithoutDiscovery, divisor => RegistrationPlan::Discover(divisor), } } @@ -460,7 +516,6 @@ pub async fn register_e3_requested( stored_divisor, &custom_params.voting_power_divisor, custom_params.credit_mode, - custom_params.census_mode, ); let (divisor, divisor_unavailable) = match plan { RegistrationPlan::Discover(divisor) => (divisor, false), @@ -480,12 +535,7 @@ pub async fn register_e3_requested( // `discover_holders` so the retry pass for a round registered without a census // runs the same code with the same refusals. let discovery: eyre::Result> = if divisor_unavailable { - Err(eyre::eyre!( - "[e3_id={}] Holder discovery skipped: no voting-power divisor is known \ - for this on-chain round, and any other value would scale the census in \ - units the contract does not read back.", - e3_id - )) + Ok(Vec::new()) } else { discover_holders( &e3_id, @@ -525,7 +575,7 @@ pub async fn register_e3_requested( Err(e) => return Err(e), }; - if token_holders.is_empty() { + if token_holders.is_empty() && !divisor_unavailable { if !is_onchain_census { return Err(eyre::eyre!( "[e3_id={}] No eligible token holders found for token address {}.", @@ -557,8 +607,7 @@ pub async fn register_e3_requested( .await?; // Store eligible addresses in the repository. - repo.set_eligible_addresses(token_holders.clone()) - .await?; + repo.set_eligible_addresses(token_holders.clone()).await?; // Record the debt so `retry_pending_discovery` settles it later. Two causes: // @@ -573,12 +622,8 @@ pub async fn register_e3_requested( // the round index and exits when nothing is owed, so starting it here could let // it run before this round is listed, find nothing, and leave the debt to a // restart. - let retry_discovery = record_discovery_debt( - &mut repo, - divisor_unavailable, - discovery_failed, - ) - .await?; + let retry_discovery = + record_discovery_debt(&mut repo, divisor_unavailable, discovery_failed).await?; // Poseidon hashes exist to build the census tree, and an on-chain census has no // tree: `_eligibility` reads power from the token per input. The addresses are @@ -609,80 +654,8 @@ pub async fn register_e3_requested( // Skipped for an on-chain census: `_eligibility` never reads `merkleRoot` in // that mode, so posting one would spend gas to publish a value nothing consults — // and would imply the list gates eligibility when it does not. - if !is_onchain_census { - let tree = - build_tree(token_holder_hashes).with_context(|| "Failed to build tree")?; - let merkle_root = tree - .root() - .ok_or_else(|| eyre::eyre!("Failed to get merkle root from tree"))?; - - info!("[e3_id={}] Merkle root: {}", e3_id, merkle_root); - - // Convert merkle root from hex string to U256. - let merkle_root_bytes = hex::decode(&merkle_root) - .with_context(|| format!("[e3_id={}] Merkle root is not valid hex", e3_id))?; - let merkle_root_u256 = U256::from_be_slice(&merkle_root_bytes); - - let e3_id_u256 = U256::from_str_radix(&e3_id, 10) - .with_context(|| format!("[e3_id={}] Invalid E3 ID", e3_id))?; - - info!( - "[e3_id={}] Ensuring CRISPProgram Merkle root: {}", - e3_id, merkle_root_u256 - ); - - let contract = CRISPContractFactory::create_write( - &CONFIG.http_rpc_url, - &CONFIG.e3_program_address, - &CONFIG.private_key, - ) - .await - .with_context(|| { - format!("[e3_id={}] Failed to create CRISP contract", e3_id) - })?; - - let stored_root = contract.get_merkle_root(e3_id_u256).await?; - if stored_root == merkle_root_u256 { - info!( - "[e3_id={}] Merkle root is already set to the expected value", - e3_id - ); - } else if stored_root.is_zero() { - match contract - .set_merkle_root(e3_id_u256, merkle_root_u256) - .await - { - Ok(receipt) => info!( - "[e3_id={}] setMerkleRoot successful. TxHash: {:?}", - e3_id, receipt.transaction_hash - ), - Err(error) => { - // A live subscription and its overlap replay can race here. Accept - // the losing transaction only when the desired root landed. - let root_after_error = - contract.get_merkle_root(e3_id_u256).await?; - if root_after_error != merkle_root_u256 { - return Err(error).with_context(|| { - format!( - "[e3_id={}] Failed to call setMerkleRoot", - e3_id - ) - }); - } - info!( - "[e3_id={}] Merkle root was set by a concurrent handler", - e3_id - ); - } - } - } else { - return Err(eyre::eyre!( - "[e3_id={}] CRISPProgram has a different Merkle root: expected {}, got {}", - e3_id, - merkle_root_u256, - stored_root - )); - } + if !is_onchain_census && !divisor_unavailable { + ensure_merkle_root(&e3_id, token_holder_hashes).await?; } // Committee and request handlers run concurrently for live logs. If the key was @@ -1504,11 +1477,11 @@ async fn record_discovery_debt( /// /// A round carries `discovery_pending` when its census could not be built at `E3Requested`: /// either the stored voting-power divisor could not be read, or discovery itself failed and was -/// swallowed to keep an on-chain-census round votable. Such a round is registered and votable but -/// serves no mask targets. The event is not replayed once the cursor passes it, so this pass is -/// the only retry. It reads the divisor again for each such CUSTOM-credit round and, when it -/// answers, runs the same discovery the handler would have run. A round that has ended is -/// dropped from the pass: there is nobody left to mask. +/// swallowed to keep an on-chain-census round votable. Such a round serves no mask targets, and a +/// Merkle one takes no ballot until this pass posts its root. The event is not replayed once the +/// cursor passes it, so this pass is the only retry. It reads the divisor again for each such +/// CUSTOM-credit round and, when it answers, runs the same discovery the handler would have run. +/// A round that has ended is dropped from the pass: there is nobody left to mask. /// /// One task for the process, started at registration. It sleeps on `DISCOVERY_OWED` while nothing /// is owed. `notify_one` keeps a permit when the task is mid-pass, so debt recorded after the pass @@ -1598,7 +1571,8 @@ async fn retry_pending_discovery(store: SharedStore) { /// Run the discovery a round was owed, store the holders, and clear the debt. /// /// Returns the holder count. The debt is cleared only after the holders are stored, so a -/// failure between the two leaves the round owed rather than served an empty census. +/// failure between the two leaves the round owed rather than served an empty census. A Merkle +/// round also gets its root posted and its leaf hashes stored first. async fn settle_pending_discovery( repo: &mut CrispE3Repository, e3_id: &str, @@ -1626,7 +1600,7 @@ async fn settle_pending_discovery( .with_context(|| "Invalid stored token address")?; let balance_threshold = BigUint::parse_bytes(round.balance_threshold.as_bytes(), 10) .ok_or_else(|| eyre::eyre!("Invalid stored balance threshold"))?; - let holders = discover_holders( + let mut holders = discover_holders( e3_id, &custom_params, requester, @@ -1637,6 +1611,12 @@ async fn settle_pending_discovery( ) .await?; let count = holders.len(); + order_token_holders(&mut holders); + if round.census_mode != CensusMode::Onchain { + let hashes = compute_token_holder_hashes(&holders)?; + ensure_merkle_root(e3_id, hashes.clone()).await?; + repo.set_token_holder_hashes(hashes).await?; + } repo.set_eligible_addresses(holders).await?; repo.set_discovery_pending(false).await?; Ok(count) @@ -2203,36 +2183,31 @@ mod voting_power_divisor_tests { plan_registration, RegistrationPlan::{Discover, RegisterWithoutDiscovery}, }; - use crate::server::models::{ - CensusMode::{Onchain, Token}, - CreditMode::{Constant, Custom}, - }; + use crate::server::models::CreditMode::{Constant, Custom}; use alloy::primitives::U256; #[test] fn the_stored_divisor_wins_and_a_nonzero_request_is_the_fallback() { let stored = Some(U256::from(100_000_000_000_000_000u128)); let requested = Some(U256::from(5000)); - for (s, field, credit, census, plan) in [ + for (s, field, credit, plan) in [ // The stored value is what `CRISPProgram` scales every voter by. - (stored, "0", Custom, Token, Discover(stored)), - (stored, "5000", Custom, Onchain, Discover(stored)), + (stored, "0", Custom, Discover(stored)), + (stored, "5000", Custom, Discover(stored)), // After a failed read, a non-zero request is the value the contract stored. - (None, "5000", Custom, Token, Discover(requested)), - (None, "5000", Custom, Onchain, Discover(requested)), - // A zero or unparseable request asked for the minimum: TOKEN still runs discovery, - // which fails; ONCHAIN registers without it. - (None, "0", Custom, Token, Discover(None)), - (None, "", Custom, Onchain, RegisterWithoutDiscovery), - (None, "0", Custom, Onchain, RegisterWithoutDiscovery), + (None, "5000", Custom, Discover(requested)), + // A zero or unparseable request asked for the minimum, which only the contract + // computes. The round registers and owes discovery, whatever its census mode. + (None, "0", Custom, RegisterWithoutDiscovery), + (None, "", Custom, RegisterWithoutDiscovery), // A CONSTANT-credit round stores no divisor and takes none. - (stored, "5000", Constant, Onchain, Discover(None)), - (None, "0", Constant, Token, Discover(None)), + (stored, "5000", Constant, Discover(None)), + (None, "0", Constant, Discover(None)), ] { assert_eq!( - plan_registration(s, field, credit, census), + plan_registration(s, field, credit), plan, - "{s:?} {field:?} {credit:?} {census:?}" + "{s:?} {field:?} {credit:?}" ); } } diff --git a/examples/CRISP/server/src/server/models.rs b/examples/CRISP/server/src/server/models.rs index 095d3d52ae..4357b0288b 100644 --- a/examples/CRISP/server/src/server/models.rs +++ b/examples/CRISP/server/src/server/models.rs @@ -390,13 +390,13 @@ pub struct E3Crisp { /// were: `Onchain` did not exist when they were written. #[serde(default)] pub census_mode: CensusMode, - /// True while holder discovery for an on-chain round is owed and not yet done. + /// True while holder discovery for a round is owed and not yet done. /// /// Set when the round was registered without a census because the stored voting-power - /// divisor could not be read. Registration never waits on the divisor, so the round is - /// votable from the start, but clients have no mask targets until discovery runs. A retry - /// pass reads the divisor again and clears this on success. Durable, so a restart retries - /// rather than forgets: the `E3Requested` event is not replayed once the cursor passes it. + /// divisor could not be read. An on-chain round is votable meanwhile but has no mask targets, + /// and a Merkle round takes no ballot until the retry pass posts its root. That pass reads the + /// divisor again and clears this on success. Durable, so a restart retries rather than + /// forgets: the `E3Requested` event is not replayed once the cursor passes it. #[serde(default)] pub discovery_pending: bool, } From 42366fd9c3668d0e2cab5cf7a91b21c0f8ff1143 Mon Sep 17 00:00:00 2001 From: ctrlc03 <93448202+ctrlc03@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:46:00 +0100 Subject: [PATCH 05/15] fix(crisp): read the census at the stored snapshot `CRISPProgram.snapshotOf` returns the timepoint whose total supply sized the divisor of a round. The server reads the divisor and this snapshot, and builds a CUSTOM-credit census at the snapshot, in the clock units of the token. A round whose divisor or snapshot cannot be read registers without a census, and the retry pass builds the census later. The server retries each failed getPastVotes read. A read that keeps failing fails the census, so the census never leaves out an eligible voter. The CRISP docs describe how a CUSTOM weight is rounded down, and why the floor of an ONCHAIN round must be at least the divisor. --- agent/flow-trace/00_INDEX.md | 2 +- agent/flow-trace/04_DKG_AND_COMPUTATION.md | 17 +- agent/invariants/02_CRYPTO_CIRCUITS.md | 8 +- docs/pages/CRISP/running-e3.mdx | 15 +- examples/CRISP/crates/evm_helpers/src/lib.rs | 16 +- .../contracts/CRISPProgram.sol | 10 + .../crisp-contracts/tests/census-mode.test.ts | 4 +- examples/CRISP/server/Cargo.toml | 1 + examples/CRISP/server/src/server/indexer.rs | 189 +++--------- .../src/server/token_holders/etherscan.rs | 285 ++++++++++-------- 10 files changed, 254 insertions(+), 293 deletions(-) diff --git a/agent/flow-trace/00_INDEX.md b/agent/flow-trace/00_INDEX.md index a350179b58..ffd006ae52 100644 --- a/agent/flow-trace/00_INDEX.md +++ b/agent/flow-trace/00_INDEX.md @@ -621,7 +621,7 @@ rows: a `Resolved` row describes this repository's remediation, not an auditor's | ZEN2-16 | **Fee and voting-power calculations use floor division (Zenith #16)** | Info | Documented | Pricing sums the weighted phase terms before one division, so each request rounds down once rather than per phase, and the margin rounds down again. The bound is per request, in raw fee-token units, and is not claimed to be below one unit. The treasury share rounds down into the committee pool, and the remainder joins the allocation for slot `e3Id % activeCount`, so allocations still sum to the whole pool. Pair-count divisions are exact. Behavior is unchanged: no rounding direction, unit, or remainder policy was altered, and each request repeats a positive randomness fee, so splitting a request is not profitable. | | ZEN2-17 | **Large payloads and repeated key events increase processing costs (Zenith #17)** | Info | Documented | Publication interfaces accept dynamic data without wrapper-level caps, and committee-key publication caps a candidate at 512 KiB and a chunk at 90 KiB but does not record already emitted chunks, so a member whose status is not `None` can re-emit valid chunks while the round stays in `KeyPublished`. Senders pay the gas and observers pay the log processing. Required verification still gates acceptance: a failure reverts the publication call, so no input finalizes and no ciphertext is partly accepted. Consumers already select one candidate per publisher and bound retained assembly state under the public-key invariant. Behavior is unchanged. | | ZEN2-18 | **Unvalidated ciphertext funds unusable inputs (Zenith #18)** | Low | Resolved | `validate_input_ciphertext` runs at intake, after proof validation and before any attestation, job persistence, or funded commitment. It builds the BFV tables from the E3's on-chain `paramSet`, checks the derived config id against `e3CryptoConfigIds`, and rejects bytes whose recomputed SAFE commitment does not equal `encryptedVoteCommitment`. Validation slots bound the added processor work. Votes, updates, and masks take the identical path; the publication worker is unchanged, so an already committed input still reaches DA finalization. Intake skips the check for an input that Ethereum already committed (`stage_input`), because the first attestation for that input followed it. | -| ZEN2-19 | **Coordinator voting-power scaling disagrees with the contract (Zenith #19)** | Info | Resolved | `CRISPProgram.validate` records the divisor of every CUSTOM-credit round once: the requested value, or `getPastTotalSupply(snapshot) / t + 1` when the request names 0. The coordinator reads it through `votingPowerDivisorOf` for every CUSTOM-credit round, whatever its census mode, and derives nothing from token metadata. A CONSTANT-credit round has no divisor. Follow-up: for an ONCHAIN round, a failed stored-divisor read no longer blocks registration. The round is registered and votable at once, only holder discovery is deferred, and the deferral is durable (`discovery_pending`): a retry pass re-reads the divisor while the round is votable and runs the same discovery once it answers, so a transient RPC failure costs mask cover for a while rather than the round or its census. Coverage pins the decision and the durable debt, not a live listener with a failing provider. | +| ZEN2-19 | **Coordinator voting-power scaling disagrees with the contract (Zenith #19)** | Info | Resolved | `CRISPProgram.validate` records the divisor of every CUSTOM-credit round once: the requested value, or `getPastTotalSupply(snapshot) / t + 1` when the request names 0. The coordinator reads it through `votingPowerDivisorOf`, and the snapshot through `snapshotOf`, for every CUSTOM-credit round, whatever its census mode, and derives nothing from token metadata. A CONSTANT-credit round has no divisor. Follow-up: a failed read of the stored divisor or snapshot no longer blocks registration. The round is registered at once, only holder discovery is deferred, and the deferral is durable (`discovery_pending`): a retry pass re-reads both while the round is votable and runs the same discovery once they answer. An ONCHAIN round stays votable meanwhile, and a Merkle round takes ballots once that pass posts its root, so a transient RPC failure costs time rather than the round or its census. Coverage pins the zero-read retry and the durable debt, not a live listener with a failing provider. | | ZEN2-20 | **Credited rewards bypass later expulsion (Zenith #20)** | Medium | Resolved | Committee allocations stay keyed by operator in `E3RefundManager._operatorEntitlements` instead of being credited to recipient-keyed balances at settlement. `claimReward`, `claimHeldSuccessReward`, `claimSlashedFunds`, and the permissionless `claimOperator*` entry points all re-check `pendingExpulsions` and `excluded` before paying the frozen recipient, so a proposal opened after completion holds the allocation and an executed expulsion forfeits and reallocates it. Two operators sharing one recipient keep independent entitlements. Balances credited before the upgrade stay claimable through the legacy ledgers; mainnet has processed no E3, so those ledgers are empty at upgrade. Follow-up: `_heldSlashFrom` records each held slash share's penalty target, and an expulsion re-shares each bucket without that target only, so a penalty never returns to its target and non-expelling penalties stay order-independent (one appended mapping, `__gap` 43→42). `claimReward` emits one `RewardClaimed` covering the legacy ledger and the operator-held escrow. | | ZEN2-21 | **Failed requests release another request's quota reservation (Zenith #21)** | Info | Resolved | A global admission reservation is held by an RAII guard, so a failed request returns its own reservation and cannot release one belonging to another request. Follow-up: the reservation is committed inside admission, in the step that writes the job, so a request cancelled during a later await cannot release quota for admitted work. | | ZEN2-22 | **Concurrent status refresh overwrites durable progress (Zenith #22)** | Low | Resolved | A status refresh no longer writes back over a job that a worker advanced concurrently; publication progress is preserved. | diff --git a/agent/flow-trace/04_DKG_AND_COMPUTATION.md b/agent/flow-trace/04_DKG_AND_COMPUTATION.md index b641531242..f72fdecc6a 100644 --- a/agent/flow-trace/04_DKG_AND_COMPUTATION.md +++ b/agent/flow-trace/04_DKG_AND_COMPUTATION.md @@ -2166,15 +2166,14 @@ coefficient `o` is the total weight on option `o`, exact only while it is below An ONCHAIN round takes each slot's bound from the contract, so the bound holds without trusting the coordinator. A census-tree round takes it from the census leaf, so it also relies on the coordinator writing `credits` (CONSTANT) or the scaled voting power (CUSTOM) into each leaf, the same trust the -census already carries. The server reads CUSTOM leaf balances at a timepoint that it derives from -the request timestamp. For a timestamp-clock token, that timepoint is the contract's snapshot. For a -block-number clock, the server takes the last block at or before the request timestamp minus one. On -a chain with several blocks per second, that block can be earlier than the contract's snapshot. A -CUSTOM round also relies on the token: at the snapshot, `getPastVotes` over all accounts must not -sum above `getPastTotalSupply`. ERC20Votes meets this. `BondedVotes` with an escrow votes source -does not guarantee it (see the `BondedVotes` gap in `invariants/01_PROTOCOL_ONCHAIN.md`). The bound -still holds while the FOLD that two accounts both count is at most the FOLD that carries no vote at -the snapshot. +census already carries. The server reads CUSTOM leaf balances at `snapshotOf(e3Id)`, in the clock +units of the token, which is the snapshot that sized the divisor. It retries a failed `getPastVotes` +read. If the read keeps failing, the server builds no census: `setMerkleRoot` accepts one root, so a +census without that voter would bar the voter for the whole round. A CUSTOM round also relies on the +token: at the snapshot, `getPastVotes` over all accounts must not sum above `getPastTotalSupply`. +ERC20Votes meets this. `BondedVotes` with an escrow votes source does not guarantee it (see the +`BondedVotes` gap in `invariants/01_PROTOCOL_ONCHAIN.md`). The bound still holds while the FOLD that +two accounts both count is at most the FOLD that carries no vote at the snapshot. A round where _every_ entry is unusable fails at the output commitment, because the processor's empty ciphertext does not deserialize. That is only reachable when no honest input exists, and is diff --git a/agent/invariants/02_CRYPTO_CIRCUITS.md b/agent/invariants/02_CRYPTO_CIRCUITS.md index 6ecf46e3ad..781b43489d 100644 --- a/agent/invariants/02_CRYPTO_CIRCUITS.md +++ b/agent/invariants/02_CRYPTO_CIRCUITS.md @@ -583,10 +583,10 @@ every section. source does not guarantee it (`01_PROTOCOL_ONCHAIN.md`). Every input counts toward the limit, including updates and masks, on both `publishInput` and `validateInputProof`. A census-tree round also relies on the coordinator writing `credits` or the scaled voting power at the snapshot into - each leaf. A change to the tally format, the credit modes, or the parameter sets needs a new - bound. **Gap:** the server derives its census timepoint from the request timestamp. For a token - with a block-number clock, on a chain with several blocks per second, the census block can precede - the snapshot. — `flow-trace/04` + each leaf. For a CUSTOM-credit round, the server reads that power at `snapshotOf(e3Id)`, in the + clock units of the token. If the server cannot read the power of a voter, it builds no census. A + change to the tally format, the credit modes, or the parameter sets needs a new bound. — + `flow-trace/04` - **CRISP constrains every coefficient of the ballot plaintext, at the real BFV degree.** The witness generator reverses the message over the full degree, so option `o` is `k1[D - 1 - o]`. `check_coefficient_values_with_balance` must decode each option weight from that coefficient, diff --git a/docs/pages/CRISP/running-e3.mdx b/docs/pages/CRISP/running-e3.mdx index ec2ad33579..3325445a7a 100644 --- a/docs/pages/CRISP/running-e3.mdx +++ b/docs/pages/CRISP/running-e3.mdx @@ -282,7 +282,20 @@ mask does not need a signature from the voter, so any account can fill the limit 1, so an `insecure-512` round accepts 99 inputs and a `secure-8192` round accepts 100,000. In a census tree round, the CRISP server writes the voting power of each voter into the census leaf. -The limits hold only when the census leaf holds that voting power. +For a `CUSTOM` round, the server reads this voting power at `CRISPProgram.snapshotOf(e3Id)`, the +timepoint that also sets the divisor. The server retries a failed read. If the read continues to +fail, the server builds no census, so that the census does not exclude an eligible voter. The limits +hold only when the census leaf holds that voting power. + +In a `CUSTOM` round, the weight of a voter is an integer, and the division discards the remainder. +In a census tree round, a voter with less voting power than the divisor has a weight of 0. This +voter can cast only an empty ballot. For example, a total supply of 1,000,000,000,000 and a `t` of +1,000,000 give a minimum divisor of 1,000,001. A holder with 1,000,000 votes then has a weight of 0. + +In an `ONCHAIN` round, `minVotingPower` must be at least the divisor, or `validate` reverts with +`MinVotingPowerBelowScale`. Thus each voter that can publish an input has a weight of at least 1. +The default divisor increases with the total supply. A request with a fixed `minVotingPower` can +thus revert after the supply increases. The `CUSTOM` limit also relies on the token. At the snapshot, the sum of the voting power of all accounts must not be more than `getPastTotalSupply(snapshot)`. An ERC20Votes token satisfies this diff --git a/examples/CRISP/crates/evm_helpers/src/lib.rs b/examples/CRISP/crates/evm_helpers/src/lib.rs index b537558e18..1bc5bc9c57 100644 --- a/examples/CRISP/crates/evm_helpers/src/lib.rs +++ b/examples/CRISP/crates/evm_helpers/src/lib.rs @@ -86,6 +86,8 @@ sol! { /// Non-zero for every CUSTOM-credit round, zero for a CONSTANT-credit round and for a /// round it never initialized. function votingPowerDivisorOf(uint256 e3Id) external view returns (uint256); + /// The timepoint, in the token's clock units, the divisor was sized at. + function snapshotOf(uint256 e3Id) external view returns (uint48); function verify( uint256 e3Id, bytes32 ciphertextOutputHash, @@ -651,13 +653,15 @@ impl CRISPContract { Ok(contract.pendingInputCount(e3_id).call().await?.to::()) } - /// The voting-power divisor `CRISPProgram` stored for a CUSTOM-credit round at request time; - /// every voter's power is scaled by exactly it. `Ok(None)` when the contract holds zero: a - /// CONSTANT-credit round, a round it never initialized, or a node that lacks the request block. - pub async fn stored_voting_power_divisor(&self, e3_id: U256) -> Result> { + /// The voting-power divisor and snapshot `CRISPProgram` stored for a CUSTOM-credit round at + /// request time. A census must scale every voter by exactly this divisor and read every voter at + /// exactly this snapshot. `Ok(None)` when the contract holds zero for either: a CONSTANT-credit + /// round, a round it never initialized, or a node that lacks the request block. + pub async fn stored_voting_power_scale(&self, e3_id: U256) -> Result> { let contract = CRISPProgram::new(self.contract_address, self.provider.as_ref()); - Ok(Some(contract.votingPowerDivisorOf(e3_id).call().await?) - .filter(|divisor| !divisor.is_zero())) + let divisor = contract.votingPowerDivisorOf(e3_id).call().await?; + let snapshot = contract.snapshotOf(e3_id).call().await?.to::(); + Ok((!divisor.is_zero() && snapshot != 0).then_some((divisor, snapshot))) } } diff --git a/examples/CRISP/packages/crisp-contracts/contracts/CRISPProgram.sol b/examples/CRISP/packages/crisp-contracts/contracts/CRISPProgram.sol index f3c3996ce5..a0878beeec 100644 --- a/examples/CRISP/packages/crisp-contracts/contracts/CRISPProgram.sol +++ b/examples/CRISP/packages/crisp-contracts/contracts/CRISPProgram.sol @@ -448,6 +448,16 @@ contract CRISPProgram is IE3Program, IE3ProgramDataAvailability, IERC165, Ownabl return e3Data[e3Id].votingPowerDivisor; } + /// @notice The timepoint at which a round reads voting power, in the ERC-6372 clock units of its token. + /// @dev A coordinator must read a CUSTOM-credit census at exactly this timepoint: the divisor is + /// sized against the total supply here, so balances read at another timepoint can sum past it. + /// Zero for a round that reads no voting power. + /// @param e3Id The E3 to look up. + /// @return The snapshot recorded at validation. + function snapshotOf(uint256 e3Id) external view returns (uint48) { + return e3Data[e3Id].snapshot; + } + /// @notice The voting power a slot may spend in an ONCHAIN round, in ballot units. /// @dev The value `publishInput` will hand the circuit as public input 4, computed by the same /// contract that will check the proof. A client must prove against exactly this: recomputing it diff --git a/examples/CRISP/packages/crisp-contracts/tests/census-mode.test.ts b/examples/CRISP/packages/crisp-contracts/tests/census-mode.test.ts index 04682807c9..3399c1f827 100644 --- a/examples/CRISP/packages/crisp-contracts/tests/census-mode.test.ts +++ b/examples/CRISP/packages/crisp-contracts/tests/census-mode.test.ts @@ -171,8 +171,10 @@ describe('CRISPProgram census mode', function () { [14, 0n, minimum], [15, 2n * minimum, 2n * minimum], ] as const) { - await validate(e3Id, encode(CUSTOM, TOKEN, 2, { token, divisor })) + const receipt = await (await validate(e3Id, encode(CUSTOM, TOKEN, 2, { token, divisor }))).wait() expect(await crispProgram.votingPowerDivisorOf(e3Id)).to.equal(recorded) + // The coordinator reads the census here: the timepoint before the request, whose supply sized the divisor. + expect(await crispProgram.snapshotOf(e3Id)).to.equal((await votes.clock({ blockTag: receipt!.blockNumber })) - 1n) } }) }) diff --git a/examples/CRISP/server/Cargo.toml b/examples/CRISP/server/Cargo.toml index c8db71d0c6..42b281c239 100644 --- a/examples/CRISP/server/Cargo.toml +++ b/examples/CRISP/server/Cargo.toml @@ -88,3 +88,4 @@ dotenvy = "=0.15.7" [dev-dependencies] alloy = { version = "=1.0.41", features = ["node-bindings"] } +tokio = { workspace = true, features = ["test-util"] } diff --git a/examples/CRISP/server/src/server/indexer.rs b/examples/CRISP/server/src/server/indexer.rs index aa7fdfff23..676b069ab3 100644 --- a/examples/CRISP/server/src/server/indexer.rs +++ b/examples/CRISP/server/src/server/indexer.rs @@ -71,32 +71,32 @@ fn stage_ends_input_retrieval(stage: &E3Stage) -> bool { ) } -/// Read the divisor `CRISPProgram` stored for a CUSTOM-credit round, or `None` after +/// Read the divisor and snapshot `CRISPProgram` stored for a CUSTOM-credit round, or `None` after /// `E3_VISIBLE_ATTEMPTS`. The contract never stores zero for such a round, so a zero read is a /// node that lacks the request block, and it is retried like a failed read. -async fn read_stored_divisor( +async fn read_stored_scale( crisp: &CRISPContract, e3_id: U256, label: &str, -) -> Option { +) -> Option<(U256, u64)> { call_with_retry_attempts( - "stored_voting_power_divisor", + "stored_voting_power_scale", &[], E3_VISIBLE_ATTEMPTS, || async { crisp - .stored_voting_power_divisor(e3_id) + .stored_voting_power_scale(e3_id) .await .map_err(|error| anyhow::anyhow!(error.to_string()))? .ok_or_else(|| { - anyhow::anyhow!("the provider holds no stored divisor for the round") + anyhow::anyhow!("the provider holds no stored divisor and snapshot for the round") }) }, ) .await .inspect_err(|error| { warn!( - "[e3_id={}] Failed to read the stored voting-power divisor after retries: {:#}", + "[e3_id={}] Failed to read the stored voting-power divisor and snapshot after retries: {:#}", label, error ) }) @@ -113,10 +113,11 @@ async fn read_stored_divisor( /// round: the contract reads power per input, so the only cost is mask cover. /// /// One function for both callers: the `E3Requested` handler and the retry pass for a round that -/// was registered without a census because its divisor could not be read. `divisor` is the -/// divisor of a CUSTOM-credit round, or `None` for a CONSTANT-credit round. Off a local chain, a -/// CUSTOM-credit round without one fails discovery: a census in any other unit would not match -/// what the contract enforces. +/// was registered without a census because its divisor could not be read. `stored` is the divisor +/// and snapshot of a CUSTOM-credit round, or `None` for a CONSTANT-credit round. A CUSTOM-credit +/// census is read at that snapshot, not at `snapshot_timepoint`: the divisor bounds the census sum +/// only at the timepoint whose supply sized it. Off a local chain, a CUSTOM-credit round without +/// them fails discovery. async fn discover_holders( e3_id: &str, custom_params: &CustomParams, @@ -124,7 +125,7 @@ async fn discover_holders( token_address: Address, snapshot_timepoint: u64, balance_threshold: &BigUint, - divisor: Option, + stored: Option<(U256, u64)>, ) -> eyre::Result> { Ok(if custom_params.census_mode == CensusMode::ByRequester { let credits_str = match custom_params.credit_mode { @@ -214,11 +215,11 @@ async fn discover_holders( .context("Etherscan token-holder discovery failed")? } CreditMode::Custom => { - let divisor = divisor.ok_or_else(|| { + let (divisor, snapshot) = stored.ok_or_else(|| { eyre::eyre!( - "[e3_id={}] No voting-power divisor is known for this CUSTOM-credit \ - round, so the census cannot be built in the units the contract \ - reads back.", + "[e3_id={}] No voting-power divisor and snapshot are known for this \ + CUSTOM-credit round, so the census cannot be built in the units the \ + contract reads back.", e3_id ) })?; @@ -226,7 +227,7 @@ async fn discover_holders( etherscan_client .get_token_holders_with_voting_power( token_address, - snapshot_timepoint, + snapshot, &CONFIG.http_rpc_url, U256::from_str_radix(&balance_threshold.to_string(), 10).map_err(|e| { eyre::eyre!( @@ -315,52 +316,6 @@ async fn ensure_merkle_root(e3_id: &str, token_holder_hashes: Vec) -> ey Ok(()) } -/// What `E3Requested` does with a round once the divisor sources have answered. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum RegistrationPlan { - /// Register the round and run holder discovery with this divisor. - Discover(Option), - /// Register the round but defer holder discovery to the retry pass: building the census in - /// unknown units would serve wrong balances. - RegisterWithoutDiscovery, -} - -/// Decides registration from the divisor sources alone, so the choice can be tested without -/// a chain or an Etherscan client. -/// -/// Registration itself never depends on the divisor. The live listener logs a handler error -/// and moves on, so an `Err` from this handler does not defer anything: it drops the round -/// from coordinator metadata until the next backfill replays the event. Only discovery is -/// divisor-dependent, so only discovery is deferred. -/// -/// The divisor is chosen by three rules, in order: -/// -/// - A CONSTANT-credit round gets `None`: the contract stores no divisor, and the census carries -/// the constant credits. -/// - For a CUSTOM-credit round the stored value wins: `CRISPProgram` resolved it once at request -/// time and scales every voter by exactly it. -/// - Otherwise a non-zero requested value, which the contract stores verbatim. A zero or -/// unparseable one asked for the minimum, which only the contract can compute. -fn plan_registration( - stored_divisor: Option, - requested_divisor: &str, - credit_mode: CreditMode, -) -> RegistrationPlan { - if credit_mode != CreditMode::Custom { - return RegistrationPlan::Discover(None); - } - let nonzero = |divisor: &U256| !divisor.is_zero(); - let divisor = stored_divisor.filter(nonzero).or_else(|| { - U256::from_str_radix(requested_divisor, 10) - .ok() - .filter(nonzero) - }); - match divisor { - None => RegistrationPlan::RegisterWithoutDiscovery, - divisor => RegistrationPlan::Discover(divisor), - } -} - pub async fn register_e3_requested( indexer: InterfoldIndexer, ) -> Result> { @@ -505,31 +460,25 @@ pub async fn register_e3_requested( ); } - // Only a CUSTOM-credit round stores a divisor. `plan_registration` decides what - // a missing one means. - let stored_divisor = if custom_params.credit_mode == CreditMode::Custom { - read_stored_divisor(&crisp, event.e3Id, &e3_id).await + // Only a CUSTOM-credit round stores a divisor and a snapshot. Without them the + // census cannot be built in the units the contract reads back, so the round + // registers and its discovery is deferred to the retry pass. An `Err` here would + // not defer anything: the live listener logs it and moves on, which drops the round. + let stored = if custom_params.credit_mode == CreditMode::Custom { + read_stored_scale(&crisp, event.e3Id, &e3_id).await } else { None }; - let plan = plan_registration( - stored_divisor, - &custom_params.voting_power_divisor, - custom_params.credit_mode, - ); - let (divisor, divisor_unavailable) = match plan { - RegistrationPlan::Discover(divisor) => (divisor, false), - RegistrationPlan::RegisterWithoutDiscovery => { - warn!( - "[e3_id={}] The stored voting-power divisor is unavailable and the \ - round requested none. Registering the round without holder \ - discovery rather than building a census in units the contract may \ - not use.", - e3_id - ); - (None, true) - } - }; + let divisor_unavailable = + custom_params.credit_mode == CreditMode::Custom && stored.is_none(); + if divisor_unavailable { + warn!( + "[e3_id={}] The stored voting-power divisor and snapshot are unavailable. \ + Registering the round without holder discovery rather than building a \ + census in units the contract may not use.", + e3_id + ); + } // Get token holders from Etherscan API or mocked data. Lifted into // `discover_holders` so the retry pass for a round registered without a census @@ -544,7 +493,7 @@ pub async fn register_e3_requested( token_address, snapshot_timepoint, &balance_threshold, - divisor, + stored, ) .await }; @@ -1533,18 +1482,16 @@ async fn retry_pending_discovery(store: SharedStore) { let Ok(e3_id_value) = e3_id_to_u256(&e3_id) else { continue; }; - // Only a CUSTOM-credit round has a divisor. Still unreadable: keep the debt for the - // next pass. - let divisor = match round.credit_mode { + // Only a CUSTOM-credit round has a divisor and a snapshot. Still unreadable: keep the + // debt for the next pass. + let stored = match round.credit_mode { CreditMode::Constant => None, - CreditMode::Custom => { - match read_stored_divisor(&crisp, e3_id_value, &e3_id).await { - None => continue, - divisor => divisor, - } - } + CreditMode::Custom => match read_stored_scale(&crisp, e3_id_value, &e3_id).await { + None => continue, + stored => stored, + }, }; - match settle_pending_discovery(&mut repo, &e3_id, &round, divisor).await { + match settle_pending_discovery(&mut repo, &e3_id, &round, stored).await { Ok(count) => { info!( "[e3_id={}] Pending holder discovery settled with {} holders", @@ -1577,7 +1524,7 @@ async fn settle_pending_discovery( repo: &mut CrispE3Repository, e3_id: &str, round: &E3Crisp, - divisor: Option, + stored: Option<(U256, u64)>, ) -> eyre::Result { let custom_params = CustomParams { token_address: round.token_address.clone(), @@ -1607,7 +1554,7 @@ async fn settle_pending_discovery( token_address, round.snapshot_block, &balance_threshold, - divisor, + stored, ) .await?; let count = holders.len(); @@ -2015,14 +1962,14 @@ mod e3_request_tests { #[cfg(test)] mod stored_divisor_tests { - use super::read_stored_divisor; + use super::read_stored_scale; use alloy::primitives::{bytes, Address, B256, U256}; use alloy::providers::{ext::AnvilApi, ProviderBuilder}; use evm_helpers::CRISPContractFactory; use std::time::Duration; - /// `CRISPProgram` never stores a zero divisor for a CUSTOM-credit round. A zero read is a node - /// that does not have the request block yet, and taking it as final drops the round. + /// `CRISPProgram` never stores a zero divisor or snapshot for a CUSTOM-credit round. A zero read + /// is a node that does not have the request block yet, and taking it as final drops the round. #[tokio::test] async fn a_zero_read_from_a_lagging_node_is_retried() { let anvil = alloy::node_bindings::Anvil::new().try_spawn().unwrap(); @@ -2037,13 +1984,13 @@ mod stored_divisor_tests { .unwrap(); // The first read sees zero; the slot is set before the retry 2 s later. - let (divisor, _) = tokio::join!(read_stored_divisor(&crisp, U256::from(1), "1"), async { + let (stored, _) = tokio::join!(read_stored_scale(&crisp, U256::from(1), "1"), async { tokio::time::sleep(Duration::from_secs(1)).await; node.anvil_set_storage_at(program, U256::ZERO, B256::with_last_byte(5)) .await .unwrap() }); - assert_eq!(divisor, Some(U256::from(5))); + assert_eq!(stored, Some((U256::from(5), 5))); } } @@ -2176,39 +2123,3 @@ mod pending_discovery_tests { assert!(!decoded.discovery_pending); } } - -#[cfg(test)] -mod voting_power_divisor_tests { - use super::{ - plan_registration, - RegistrationPlan::{Discover, RegisterWithoutDiscovery}, - }; - use crate::server::models::CreditMode::{Constant, Custom}; - use alloy::primitives::U256; - - #[test] - fn the_stored_divisor_wins_and_a_nonzero_request_is_the_fallback() { - let stored = Some(U256::from(100_000_000_000_000_000u128)); - let requested = Some(U256::from(5000)); - for (s, field, credit, plan) in [ - // The stored value is what `CRISPProgram` scales every voter by. - (stored, "0", Custom, Discover(stored)), - (stored, "5000", Custom, Discover(stored)), - // After a failed read, a non-zero request is the value the contract stored. - (None, "5000", Custom, Discover(requested)), - // A zero or unparseable request asked for the minimum, which only the contract - // computes. The round registers and owes discovery, whatever its census mode. - (None, "0", Custom, RegisterWithoutDiscovery), - (None, "", Custom, RegisterWithoutDiscovery), - // A CONSTANT-credit round stores no divisor and takes none. - (stored, "5000", Constant, Discover(None)), - (None, "0", Constant, Discover(None)), - ] { - assert_eq!( - plan_registration(s, field, credit), - plan, - "{s:?} {field:?} {credit:?}" - ); - } - } -} diff --git a/examples/CRISP/server/src/server/token_holders/etherscan.rs b/examples/CRISP/server/src/server/token_holders/etherscan.rs index db20637ca3..dba53659de 100644 --- a/examples/CRISP/server/src/server/token_holders/etherscan.rs +++ b/examples/CRISP/server/src/server/token_holders/etherscan.rs @@ -11,6 +11,7 @@ use alloy::providers::{Provider, ProviderBuilder}; use alloy::sol; use alloy::sol_types::SolEvent; use alloy::transports::RpcError; +use e3_sdk::evm_helpers::retry::call_with_retry; use eyre::{eyre, Context, Result}; use reqwest; use serde::Deserialize; @@ -889,14 +890,14 @@ impl EtherscanClient { /// `divisor` is the value `CRISPProgram` stored for the round. The contract chose it so that /// the census sums to less than the plaintext modulus, using the total supply at the same /// snapshot. Every balance is therefore read with `getPastVotes` at that snapshot and divided - /// by it. A voter whose balance cannot be read is left out of the census with a warning, which - /// can only lower the sum. - pub async fn verify_voting_power( + /// by it, never replaced by a current balance, which can exceed that supply. A failed read is + /// retried, and a voter whose read keeps failing fails the census: `setMerkleRoot` takes one + /// root, so a voter left out of it could never vote. + pub async fn verify_voting_power( &self, - token_address: Address, + token: &ERC20Votes::ERC20VotesInstance

, potential_voters: &[PotentialVoter], timepoint: u64, - rpc_url: &str, threshold: U256, divisor: U256, ) -> Result> { @@ -906,7 +907,7 @@ impl EtherscanClient { log::info!( "Verifying {} candidates against {} at timepoint {} (divisor={}, threshold={})", potential_voters.len(), - token_address, + token.address(), timepoint, divisor, threshold @@ -916,54 +917,55 @@ impl EtherscanClient { let mut rounds_to_zero = 0usize; for voter in potential_voters { - match Self::get_past_votes(token_address, voter.address, timepoint, rpc_url).await { - Ok(votes) => { - if votes >= threshold { - let scaled_votes = votes / divisor; - - // Both values, because they answer different questions. The raw one is - // what the chain reports and what a holder recognises as their balance; - // the scaled one is what the ballot is bounded by, and a mismatch between - // a census and a tally is almost always a scaling mismatch. - log::info!( - " eligible {} raw={} scaled={}", - voter.address, - votes, - scaled_votes - ); + let votes = call_with_retry("getPastVotes", &[], || async { + Ok(token + .getPastVotes(voter.address, U256::from(timepoint)) + .call() + .await?) + }) + .await + .map_err(|e| eyre!("Failed to read the votes of {}: {e:#}", voter.address))?; + if votes >= threshold { + let scaled_votes = votes / divisor; + + // Both values, because they answer different questions. The raw one is what the + // chain reports and what a holder recognises as their balance; the scaled one is + // what the ballot is bounded by, and a mismatch between a census and a tally is + // almost always a scaling mismatch. + log::info!( + " eligible {} raw={} scaled={}", + voter.address, + votes, + scaled_votes + ); - // Above the threshold but worth nothing once scaled: the leaf bounds every - // ballot to zero, so this address is in the census and can still only cast - // an empty vote. Worth saying out loud — it looks like eligibility from - // every angle except the one that counts. - if scaled_votes.is_zero() { - rounds_to_zero += 1; - log::warn!( - " {} clears the threshold but scales to zero (raw={}, \ - divisor={}): it can vote, but carries no weight", - voter.address, - votes, - divisor - ); - } - - token_holders.push(TokenHolder { - address: voter.address.to_string(), - balance: scaled_votes.to_string(), - }); - } else { - below_threshold += 1; - log::debug!( - " skipped {} raw={} below threshold {}", - voter.address, - votes, - threshold - ); - } - } - Err(e) => { - log::warn!("Failed to get votes for {}: {}", voter.address, e); + // Above the threshold but worth nothing once scaled: the leaf bounds every ballot + // to zero, so this address is in the census and can still only cast an empty vote. + // Worth saying out loud — it looks like eligibility from every angle except the + // one that counts. + if scaled_votes.is_zero() { + rounds_to_zero += 1; + log::warn!( + " {} clears the threshold but scales to zero (raw={}, divisor={}): it \ + can vote, but carries no weight", + voter.address, + votes, + divisor + ); } + + token_holders.push(TokenHolder { + address: voter.address.to_string(), + balance: scaled_votes.to_string(), + }); + } else { + below_threshold += 1; + log::debug!( + " skipped {} raw={} below threshold {}", + voter.address, + votes, + threshold + ); } // Rate limiting - small delay between RPC calls @@ -1060,28 +1062,6 @@ impl EtherscanClient { } } - /// Read an address's voting power at an EIP-6372 timepoint. - /// - /// A failed read is returned, never replaced by a current balance: the census must sum to at - /// most the supply at the snapshot the contract used to choose the divisor, and a current - /// balance can exceed it. - async fn get_past_votes( - token_address: Address, - voter_address: Address, - timepoint: u64, - rpc_url: &str, - ) -> Result { - let url = rpc_url.parse().context("Failed to parse RPC URL")?; - let provider = ProviderBuilder::new().connect_http(url); - let token = ERC20Votes::new(token_address, provider); - - token - .getPastVotes(voter_address, U256::from(timepoint)) - .call() - .await - .context("Failed to call getPastVotes") - } - /// Parse address from 32-byte topic (last 20 bytes) fn parse_address_from_topic(topic: &str) -> Result { let hex = topic.strip_prefix("0x").unwrap_or(topic); @@ -1111,17 +1091,16 @@ impl EtherscanClient { U256::from_str_radix(hex_data, 16).unwrap_or(U256::ZERO) } - /// Get all token holders with voting power at a census timepoint. + /// Get all token holders with voting power at a round's snapshot. /// - /// `snapshot_timepoint` is an EIP-6372 timestamp, not a block height — `E3.requestBlock` - /// carries `block.timestamp` regardless of which token forms the census. Log discovery - /// needs the equivalent block, so both units are derived here: the block always bounds - /// the log ranges, while `getPastVotes` receives whichever unit the census token's own - /// `CLOCK_MODE()` reports. + /// `snapshot` is the timepoint `CRISPProgram` recorded for the round (`snapshotOf`), in the + /// census token's EIP-6372 clock units. The divisor was sized against the total supply at + /// exactly this timepoint, so every balance is read there. Log discovery needs a block: a + /// block-number clock names it, and a timestamp clock resolves to the last block at or before it. pub async fn get_token_holders_with_voting_power( &self, token_address: Address, - snapshot_timepoint: u64, + snapshot: u64, rpc_url: &str, threshold: U256, // The divisor `CRISPProgram` stored for the round. @@ -1133,9 +1112,16 @@ impl EtherscanClient { let sources = Self::resolve_voting_power_sources(token_address, rpc_url) .await .context("Failed to resolve voting-power sources")?; + let snapshot_block = match Self::get_clock_mode(token_address, rpc_url).await { + ClockMode::BlockNumber => snapshot, + ClockMode::Timestamp => Self::get_block_by_timestamp(snapshot, rpc_url) + .await + .context("Failed to resolve snapshot timepoint to a block")?, + }; self.get_token_holders_with_voting_power_from_sources( token_address, - snapshot_timepoint, + snapshot_block, + snapshot, rpc_url, threshold, divisor, @@ -1144,10 +1130,14 @@ impl EtherscanClient { .await } + /// Scan logs up to `snapshot_block` for candidates, then read each candidate's votes at + /// `timepoint`, in the census token's clock units. + #[allow(clippy::too_many_arguments)] async fn get_token_holders_with_voting_power_from_sources( &self, token_address: Address, - snapshot_timepoint: u64, + snapshot_block: u64, + timepoint: u64, rpc_url: &str, threshold: U256, divisor: U256, @@ -1172,13 +1162,9 @@ impl EtherscanClient { .await .context("Failed to get deployment block")?; log::info!("Token deployed at block: {}", start_block); - - let snapshot_block = Self::get_block_by_timestamp(snapshot_timepoint, rpc_url) - .await - .context("Failed to resolve snapshot timepoint to a block")?; log::info!( "Snapshot timepoint {} resolves to block {}", - snapshot_timepoint, + timepoint, snapshot_block ); @@ -1271,28 +1257,11 @@ impl EtherscanClient { log::info!("Found {} potential voters", potential_voters.len()); - // Step 5: Verify actual voting power. The unit of the `getPastVotes` timepoint is - // the census token's to decide, so ask it rather than assuming. Passing the wrong - // unit reverts every call, which leaves every voter out of the census. - let clock_mode = Self::get_clock_mode(token_address, rpc_url).await; - let vote_timepoint = match clock_mode { - ClockMode::Timestamp => snapshot_timepoint, - ClockMode::BlockNumber => snapshot_block, - }; - log::info!( - "Census token clock is {:?}; verifying voting power at timepoint {}...", - clock_mode, - vote_timepoint - ); + // Step 5: Verify actual voting power. + let url = rpc_url.parse().context("Failed to parse RPC URL")?; + let token = ERC20Votes::new(token_address, ProviderBuilder::new().connect_http(url)); let token_holders = self - .verify_voting_power( - token_address, - &potential_voters, - vote_timepoint, - rpc_url, - threshold, - divisor, - ) + .verify_voting_power(&token, &potential_voters, timepoint, threshold, divisor) .await .context("Failed to verify voting power")?; @@ -1324,11 +1293,29 @@ impl EtherscanClient { let sources = Self::resolve_voting_power_sources(token_address, rpc_url) .await .context("Failed to resolve voting-power sources")?; + + // Eligibility here rests on the logs alone — no `getPastVotes` pass narrows the + // set afterwards — so the range must not reach past the census timepoint. + let snapshot_block = Self::get_block_by_timestamp(snapshot_timepoint, rpc_url) + .await + .context("Failed to resolve snapshot timepoint to a block")?; + log::info!( + "Snapshot timepoint {} resolves to block {}", + snapshot_timepoint, + snapshot_block + ); + if sources.registry.is_some() { + // `getPastVotes` takes the adapter's own clock units. + let timepoint = match Self::get_clock_mode(token_address, rpc_url).await { + ClockMode::Timestamp => snapshot_timepoint, + ClockMode::BlockNumber => snapshot_block, + }; let holders = self .get_token_holders_with_voting_power_from_sources( token_address, - snapshot_timepoint, + snapshot_block, + timepoint, rpc_url, U256::from(1), U256::from(1), @@ -1345,17 +1332,6 @@ impl EtherscanClient { .context("Failed to get deployment block")?; log::info!("Token deployed at block: {}", start_block); - // Eligibility here rests on the logs alone — no `getPastVotes` pass narrows the - // set afterwards — so the range must not reach past the census timepoint. - let snapshot_block = Self::get_block_by_timestamp(snapshot_timepoint, rpc_url) - .await - .context("Failed to resolve snapshot timepoint to a block")?; - log::info!( - "Snapshot timepoint {} resolves to block {}", - snapshot_timepoint, - snapshot_block - ); - // Step 2: Fetch transfer logs log::info!( "Fetching transfer logs from block {} to {}...", @@ -1898,21 +1874,66 @@ mod bond_owner_discovery_tests { } } -/// Every census balance is divided by the divisor, so a zero divisor is refused before any read. +/// `verify_voting_power` refuses what would mis-scale a census or leave a voter out of it. #[cfg(test)] -mod voting_power_divisor_tests { - use super::EtherscanClient; - use alloy::primitives::{Address, U256}; +mod verify_voting_power_tests { + use super::{ERC20Votes, EtherscanClient, PotentialVoter}; + use alloy::primitives::{Address, Bytes, U256}; + use alloy::providers::ProviderBuilder; + use alloy::transports::mock::Asserter; + + /// The addresses `verify_voting_power` keeps, with `responses` answering each `getPastVotes`. + async fn verify( + responses: Asserter, + voters: &[Address], + divisor: U256, + ) -> eyre::Result> { + let token = ERC20Votes::new( + Address::repeat_byte(1), + ProviderBuilder::default().connect_mocked_client(responses), + ); + let voters: Vec = voters + .iter() + .map(|&address| PotentialVoter { + address, + token_balance: U256::ZERO, + has_delegation: true, + }) + .collect(); + let holders = EtherscanClient::new("test_key".to_string(), 1) + .verify_voting_power(&token, &voters, 1, U256::from(1), divisor) + .await?; + Ok(holders.into_iter().map(|holder| holder.address).collect()) + } /// A zero divisor would divide by zero, and the contract never stores one for a CUSTOM round. #[tokio::test] async fn a_zero_divisor_is_refused() { - let verify = |divisor| async move { - EtherscanClient::new("test_key".to_string(), 1) - .verify_voting_power(Address::repeat_byte(1), &[], 1, "", U256::ZERO, divisor) - .await - }; - assert!(verify(U256::ZERO).await.is_err()); - assert!(verify(U256::from(1)).await.unwrap().is_empty()); + assert!(verify(Asserter::new(), &[], U256::ZERO).await.is_err()); + assert!(verify(Asserter::new(), &[], U256::from(1)) + .await + .unwrap() + .is_empty()); + } + + /// `setMerkleRoot` takes one root, so a voter left out of a census can never vote. A read that + /// fails once is retried, and a read that keeps failing fails the whole census. + #[tokio::test(start_paused = true)] + async fn a_census_never_leaves_out_a_voter_it_cannot_read() { + let (alice, bob) = (Address::repeat_byte(0xa), Address::repeat_byte(0xb)); + let votes = Bytes::from(U256::from(7).to_be_bytes::<32>()); + + let flaky = Asserter::new(); + flaky.push_failure_msg("the node lags"); + flaky.push_success(&votes); + assert_eq!( + verify(flaky, &[alice], U256::from(1)).await.unwrap(), + [alice.to_string()] + ); + + // Alice reads. Every read of Bob fails, because no response is left. + let failing = Asserter::new(); + failing.push_success(&votes); + assert!(verify(failing, &[alice, bob], U256::from(1)).await.is_err()); } } From b00604b6bc35df7d2d950b86fe79eaba78caaa33 Mon Sep 17 00:00:00 2001 From: ctrlc03 <93448202+ctrlc03@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:18:26 +0100 Subject: [PATCH 06/15] fix(crisp): defer a census-tree round whose discovery fails The E3Requested log is not replayed. A census-tree round whose holder discovery failed was therefore lost, because the handler returned an error before it recorded the round. The handler now registers the round with an empty list and records the discovery as owed, in every census mode. The retry pass builds the census and posts the root later. discover_holders refuses an empty census-tree list, so the handler and the retry pass refuse the same lists. The server reads the stored divisor and snapshot at one block. A non-zero divisor then vouches for the snapshot beside it. --- agent/flow-trace/00_INDEX.md | 2 +- agent/flow-trace/04_DKG_AND_COMPUTATION.md | 14 +- agent/invariants/02_CRYPTO_CIRCUITS.md | 6 +- docs/pages/CRISP/running-e3.mdx | 4 +- examples/CRISP/crates/evm_helpers/src/lib.rs | 23 ++- examples/CRISP/server/src/server/indexer.rs | 147 +++++++++++------- .../src/server/token_holders/etherscan.rs | 15 +- 7 files changed, 127 insertions(+), 84 deletions(-) diff --git a/agent/flow-trace/00_INDEX.md b/agent/flow-trace/00_INDEX.md index ffd006ae52..8efb278fe8 100644 --- a/agent/flow-trace/00_INDEX.md +++ b/agent/flow-trace/00_INDEX.md @@ -621,7 +621,7 @@ rows: a `Resolved` row describes this repository's remediation, not an auditor's | ZEN2-16 | **Fee and voting-power calculations use floor division (Zenith #16)** | Info | Documented | Pricing sums the weighted phase terms before one division, so each request rounds down once rather than per phase, and the margin rounds down again. The bound is per request, in raw fee-token units, and is not claimed to be below one unit. The treasury share rounds down into the committee pool, and the remainder joins the allocation for slot `e3Id % activeCount`, so allocations still sum to the whole pool. Pair-count divisions are exact. Behavior is unchanged: no rounding direction, unit, or remainder policy was altered, and each request repeats a positive randomness fee, so splitting a request is not profitable. | | ZEN2-17 | **Large payloads and repeated key events increase processing costs (Zenith #17)** | Info | Documented | Publication interfaces accept dynamic data without wrapper-level caps, and committee-key publication caps a candidate at 512 KiB and a chunk at 90 KiB but does not record already emitted chunks, so a member whose status is not `None` can re-emit valid chunks while the round stays in `KeyPublished`. Senders pay the gas and observers pay the log processing. Required verification still gates acceptance: a failure reverts the publication call, so no input finalizes and no ciphertext is partly accepted. Consumers already select one candidate per publisher and bound retained assembly state under the public-key invariant. Behavior is unchanged. | | ZEN2-18 | **Unvalidated ciphertext funds unusable inputs (Zenith #18)** | Low | Resolved | `validate_input_ciphertext` runs at intake, after proof validation and before any attestation, job persistence, or funded commitment. It builds the BFV tables from the E3's on-chain `paramSet`, checks the derived config id against `e3CryptoConfigIds`, and rejects bytes whose recomputed SAFE commitment does not equal `encryptedVoteCommitment`. Validation slots bound the added processor work. Votes, updates, and masks take the identical path; the publication worker is unchanged, so an already committed input still reaches DA finalization. Intake skips the check for an input that Ethereum already committed (`stage_input`), because the first attestation for that input followed it. | -| ZEN2-19 | **Coordinator voting-power scaling disagrees with the contract (Zenith #19)** | Info | Resolved | `CRISPProgram.validate` records the divisor of every CUSTOM-credit round once: the requested value, or `getPastTotalSupply(snapshot) / t + 1` when the request names 0. The coordinator reads it through `votingPowerDivisorOf`, and the snapshot through `snapshotOf`, for every CUSTOM-credit round, whatever its census mode, and derives nothing from token metadata. A CONSTANT-credit round has no divisor. Follow-up: a failed read of the stored divisor or snapshot no longer blocks registration. The round is registered at once, only holder discovery is deferred, and the deferral is durable (`discovery_pending`): a retry pass re-reads both while the round is votable and runs the same discovery once they answer. An ONCHAIN round stays votable meanwhile, and a Merkle round takes ballots once that pass posts its root, so a transient RPC failure costs time rather than the round or its census. Coverage pins the zero-read retry and the durable debt, not a live listener with a failing provider. | +| ZEN2-19 | **Coordinator voting-power scaling disagrees with the contract (Zenith #19)** | Info | Resolved | `CRISPProgram.validate` records the divisor of every CUSTOM-credit round once: the requested value, or `getPastTotalSupply(snapshot) / t + 1` when the request names 0. The coordinator reads it through `votingPowerDivisorOf`, and the snapshot through `snapshotOf`, at one block, for every CUSTOM-credit round, whatever its census mode, and derives nothing from token metadata. A CONSTANT-credit round has no divisor. Follow-up: neither an unreadable divisor nor a failed discovery blocks registration. The round is registered at once, only holder discovery is deferred, and the deferral is durable (`discovery_pending`): a retry pass runs the same discovery while the round is votable. An ONCHAIN round stays votable meanwhile, and a Merkle round takes ballots once that pass posts its root, so a transient read failure costs time rather than the round or its census. Coverage pins the zero-read retry and the durable debt, not a live listener with a failing provider. | | ZEN2-20 | **Credited rewards bypass later expulsion (Zenith #20)** | Medium | Resolved | Committee allocations stay keyed by operator in `E3RefundManager._operatorEntitlements` instead of being credited to recipient-keyed balances at settlement. `claimReward`, `claimHeldSuccessReward`, `claimSlashedFunds`, and the permissionless `claimOperator*` entry points all re-check `pendingExpulsions` and `excluded` before paying the frozen recipient, so a proposal opened after completion holds the allocation and an executed expulsion forfeits and reallocates it. Two operators sharing one recipient keep independent entitlements. Balances credited before the upgrade stay claimable through the legacy ledgers; mainnet has processed no E3, so those ledgers are empty at upgrade. Follow-up: `_heldSlashFrom` records each held slash share's penalty target, and an expulsion re-shares each bucket without that target only, so a penalty never returns to its target and non-expelling penalties stay order-independent (one appended mapping, `__gap` 43→42). `claimReward` emits one `RewardClaimed` covering the legacy ledger and the operator-held escrow. | | ZEN2-21 | **Failed requests release another request's quota reservation (Zenith #21)** | Info | Resolved | A global admission reservation is held by an RAII guard, so a failed request returns its own reservation and cannot release one belonging to another request. Follow-up: the reservation is committed inside admission, in the step that writes the job, so a request cancelled during a later await cannot release quota for admitted work. | | ZEN2-22 | **Concurrent status refresh overwrites durable progress (Zenith #22)** | Low | Resolved | A status refresh no longer writes back over a job that a worker advanced concurrently; publication progress is preserved. | diff --git a/agent/flow-trace/04_DKG_AND_COMPUTATION.md b/agent/flow-trace/04_DKG_AND_COMPUTATION.md index f72fdecc6a..32bb4755e5 100644 --- a/agent/flow-trace/04_DKG_AND_COMPUTATION.md +++ b/agent/flow-trace/04_DKG_AND_COMPUTATION.md @@ -2168,12 +2168,14 @@ coordinator. A census-tree round takes it from the census leaf, so it also relie writing `credits` (CONSTANT) or the scaled voting power (CUSTOM) into each leaf, the same trust the census already carries. The server reads CUSTOM leaf balances at `snapshotOf(e3Id)`, in the clock units of the token, which is the snapshot that sized the divisor. It retries a failed `getPastVotes` -read. If the read keeps failing, the server builds no census: `setMerkleRoot` accepts one root, so a -census without that voter would bar the voter for the whole round. A CUSTOM round also relies on the -token: at the snapshot, `getPastVotes` over all accounts must not sum above `getPastTotalSupply`. -ERC20Votes meets this. `BondedVotes` with an escrow votes source does not guarantee it (see the -`BondedVotes` gap in `invariants/01_PROTOCOL_ONCHAIN.md`). The bound still holds while the FOLD that -two accounts both count is at most the FOLD that carries no vote at the snapshot. +read. If the read keeps failing, the server posts no root: `setMerkleRoot` accepts one root, so a +census without that voter would bar the voter for the whole round. The round stays registered with +`discovery_pending` set, and a retry pass posts the root after it reads every voter. A CUSTOM round +also relies on the token: at the snapshot, `getPastVotes` over all accounts must not sum above +`getPastTotalSupply`. ERC20Votes meets this. `BondedVotes` with an escrow votes source does not +guarantee it (see the `BondedVotes` gap in `invariants/01_PROTOCOL_ONCHAIN.md`). The bound still +holds while the FOLD that two accounts both count is at most the FOLD that carries no vote at the +snapshot. A round where _every_ entry is unusable fails at the output commitment, because the processor's empty ciphertext does not deserialize. That is only reachable when no honest input exists, and is diff --git a/agent/invariants/02_CRYPTO_CIRCUITS.md b/agent/invariants/02_CRYPTO_CIRCUITS.md index 781b43489d..d357921c1e 100644 --- a/agent/invariants/02_CRYPTO_CIRCUITS.md +++ b/agent/invariants/02_CRYPTO_CIRCUITS.md @@ -584,9 +584,9 @@ every section. including updates and masks, on both `publishInput` and `validateInputProof`. A census-tree round also relies on the coordinator writing `credits` or the scaled voting power at the snapshot into each leaf. For a CUSTOM-credit round, the server reads that power at `snapshotOf(e3Id)`, in the - clock units of the token. If the server cannot read the power of a voter, it builds no census. A - change to the tally format, the credit modes, or the parameter sets needs a new bound. — - `flow-trace/04` + clock units of the token. If the server cannot read the power of a voter, it posts no root until a + retry pass reads every voter. A change to the tally format, the credit modes, or the parameter + sets needs a new bound. — `flow-trace/04` - **CRISP constrains every coefficient of the ballot plaintext, at the real BFV degree.** The witness generator reverses the message over the full degree, so option `o` is `k1[D - 1 - o]`. `check_coefficient_values_with_balance` must decode each option weight from that coefficient, diff --git a/docs/pages/CRISP/running-e3.mdx b/docs/pages/CRISP/running-e3.mdx index 3325445a7a..af7a51a007 100644 --- a/docs/pages/CRISP/running-e3.mdx +++ b/docs/pages/CRISP/running-e3.mdx @@ -284,8 +284,8 @@ mask does not need a signature from the voter, so any account can fill the limit In a census tree round, the CRISP server writes the voting power of each voter into the census leaf. For a `CUSTOM` round, the server reads this voting power at `CRISPProgram.snapshotOf(e3Id)`, the timepoint that also sets the divisor. The server retries a failed read. If the read continues to -fail, the server builds no census, so that the census does not exclude an eligible voter. The limits -hold only when the census leaf holds that voting power. +fail, the server does not post the census. It tries again later, so that the census does not exclude +an eligible voter. The limits hold only when the census leaf holds that voting power. In a `CUSTOM` round, the weight of a voter is an integer, and the division discards the remainder. In a census tree round, a voter with less voting power than the divisor has a weight of 0. This diff --git a/examples/CRISP/crates/evm_helpers/src/lib.rs b/examples/CRISP/crates/evm_helpers/src/lib.rs index 1bc5bc9c57..52de26856b 100644 --- a/examples/CRISP/crates/evm_helpers/src/lib.rs +++ b/examples/CRISP/crates/evm_helpers/src/lib.rs @@ -5,6 +5,7 @@ // or FITNESS FOR A PARTICULAR PURPOSE. use alloy::{ + eips::BlockId, network::{Ethereum, EthereumWallet}, primitives::{Address, Bytes, B256, I256, U256}, providers::{ @@ -12,7 +13,7 @@ use alloy::{ BlobGasFiller, ChainIdFiller, FillProvider, GasFiller, JoinFill, NonceFiller, WalletFiller, }, - Identity, ProviderBuilder, RootProvider, WalletProvider, + Identity, Provider, ProviderBuilder, RootProvider, WalletProvider, }, rpc::types::TransactionReceipt, signers::local::PrivateKeySigner, @@ -655,13 +656,23 @@ impl CRISPContract { /// The voting-power divisor and snapshot `CRISPProgram` stored for a CUSTOM-credit round at /// request time. A census must scale every voter by exactly this divisor and read every voter at - /// exactly this snapshot. `Ok(None)` when the contract holds zero for either: a CONSTANT-credit - /// round, a round it never initialized, or a node that lacks the request block. + /// exactly this snapshot. `validate` writes both in one transaction and both are read at one + /// block, so a non-zero divisor vouches for the snapshot beside it. `Ok(None)` when the divisor + /// is zero: a CONSTANT-credit round, a round it never initialized, or a node that lacks the + /// request block. pub async fn stored_voting_power_scale(&self, e3_id: U256) -> Result> { let contract = CRISPProgram::new(self.contract_address, self.provider.as_ref()); - let divisor = contract.votingPowerDivisorOf(e3_id).call().await?; - let snapshot = contract.snapshotOf(e3_id).call().await?.to::(); - Ok((!divisor.is_zero() && snapshot != 0).then_some((divisor, snapshot))) + let block = BlockId::number(self.provider.get_block_number().await?); + let divisor = contract + .votingPowerDivisorOf(e3_id) + .block(block) + .call() + .await?; + if divisor.is_zero() { + return Ok(None); + } + let snapshot = contract.snapshotOf(e3_id).block(block).call().await?; + Ok(Some((divisor, snapshot.to::()))) } } diff --git a/examples/CRISP/server/src/server/indexer.rs b/examples/CRISP/server/src/server/indexer.rs index 676b069ab3..53715e60dc 100644 --- a/examples/CRISP/server/src/server/indexer.rs +++ b/examples/CRISP/server/src/server/indexer.rs @@ -72,8 +72,8 @@ fn stage_ends_input_retrieval(stage: &E3Stage) -> bool { } /// Read the divisor and snapshot `CRISPProgram` stored for a CUSTOM-credit round, or `None` after -/// `E3_VISIBLE_ATTEMPTS`. The contract never stores zero for such a round, so a zero read is a -/// node that lacks the request block, and it is retried like a failed read. +/// `E3_VISIBLE_ATTEMPTS`. The contract never stores a zero divisor for such a round, so a zero read +/// is a node that lacks the request block, and it is retried like a failed read. async fn read_stored_scale( crisp: &CRISPContract, e3_id: U256, @@ -113,11 +113,12 @@ async fn read_stored_scale( /// round: the contract reads power per input, so the only cost is mask cover. /// /// One function for both callers: the `E3Requested` handler and the retry pass for a round that -/// was registered without a census because its divisor could not be read. `stored` is the divisor -/// and snapshot of a CUSTOM-credit round, or `None` for a CONSTANT-credit round. A CUSTOM-credit -/// census is read at that snapshot, not at `snapshot_timepoint`: the divisor bounds the census sum -/// only at the timepoint whose supply sized it. Off a local chain, a CUSTOM-credit round without -/// them fails discovery. +/// was registered without a census, because its divisor and snapshot could not be read or its +/// discovery failed. Both callers therefore refuse the same lists. `stored` is the divisor and +/// snapshot of a CUSTOM-credit round, or `None` for a CONSTANT-credit round. A CUSTOM-credit census +/// is read at that snapshot, not at `snapshot_timepoint`: the divisor bounds the census sum only at +/// the timepoint whose supply sized it. Off a local chain, a CUSTOM-credit round without them fails +/// discovery. async fn discover_holders( e3_id: &str, custom_params: &CustomParams, @@ -127,7 +128,7 @@ async fn discover_holders( balance_threshold: &BigUint, stored: Option<(U256, u64)>, ) -> eyre::Result> { - Ok(if custom_params.census_mode == CensusMode::ByRequester { + let holders = if custom_params.census_mode == CensusMode::ByRequester { let credits_str = match custom_params.credit_mode { CreditMode::Constant => custom_params .credits @@ -242,7 +243,51 @@ async fn discover_holders( .context("Etherscan token-holder discovery failed")? } } - }) + }; + + // A census-tree list is the electorate, so an empty one admits no ballot and fails discovery. + // An on-chain list only indexes mask targets, so the handler warns about an empty one instead. + if holders.is_empty() && custom_params.census_mode != CensusMode::Onchain { + return Err(eyre::eyre!( + "[e3_id={}] No eligible token holders found for token address {}.", + e3_id, + token_address + )); + } + Ok(holders) +} + +/// The holders to register a round with, and whether its discovery is still owed. +/// +/// A failed discovery never drops the round. The `E3Requested` log is not replayed once the cursor +/// passes it, so the round registers with an empty list and the retry pass builds the census. The +/// cause is usually transient: a rate limit, a rejected API key, or a voter whose votes could not +/// be read. An on-chain round stays votable meanwhile. A census-tree round takes no ballot until +/// the retry pass posts its root. +fn holders_or_owed( + e3_id: &str, + is_onchain_census: bool, + discovery: eyre::Result>, +) -> (Vec, bool) { + let error = match discovery { + Ok(holders) => return (holders, false), + Err(error) => error, + }; + if is_onchain_census { + warn!( + "[e3_id={}] CensusMode::Onchain holder discovery failed: {:#}. The round is still \ + recorded and votable — eligibility is read from the token at publish time — but \ + clients have no mask targets until a retry succeeds.", + e3_id, error + ); + } else { + warn!( + "[e3_id={}] Census discovery failed: {:#}. The round is recorded without a census and \ + takes no ballot until a retry posts its root.", + e3_id, error + ); + } + (Vec::new(), true) } fn order_token_holders(holders: &mut [TokenHolder]) { @@ -498,41 +543,16 @@ pub async fn register_e3_requested( .await }; - // Fatal only where the list decides who may vote. For a Merkle round an empty - // census means nobody can ever cast a ballot, so failing loudly is right. For an - // on-chain census the list is an index over what the contract already decides: - // failing here would skip `initialize_round`, leaving a perfectly votable round - // unrecorded and invisible to every client, because discovery happened to come - // back empty — a missing API key or a rate limit would be enough. - - let mut discovery_failed = false; - let mut token_holders = match discovery { - Ok(holders) => holders, - Err(e) if is_onchain_census => { - warn!( - "[e3_id={}] CensusMode::Onchain holder discovery failed: {:#}. The \ - round is still recorded and votable — eligibility is read from the \ - token at publish time — but clients have no mask targets.", - e3_id, e - ); - // Recorded as a debt below: the cause is usually transient (a rate limit - // or a momentarily rejected API key), and without a retry one blip leaves - // the round permanently unmaskable. - discovery_failed = true; - Vec::new() - } - Err(e) => return Err(e), - }; - - if token_holders.is_empty() && !divisor_unavailable { - if !is_onchain_census { - return Err(eyre::eyre!( - "[e3_id={}] No eligible token holders found for token address {}.", - e3_id, - token_address - )); - } + let (mut token_holders, discovery_failed) = + holders_or_owed(&e3_id, is_onchain_census, discovery); + // `discover_holders` refuses an empty census-tree list. An empty on-chain list + // costs mask cover and nothing else, so the round goes ahead. + if is_onchain_census + && token_holders.is_empty() + && !divisor_unavailable + && !discovery_failed + { warn!( "[e3_id={}] CensusMode::Onchain discovery found no holders for {}. The \ round is still recorded and votable — eligibility is read from the token \ @@ -560,11 +580,8 @@ pub async fn register_e3_requested( // Record the debt so `retry_pending_discovery` settles it later. Two causes: // - // - discovery was SKIPPED for want of a divisor, not refused; or - // - discovery RAN and failed, which for an on-chain census is swallowed above to - // keep the round votable. That path previously left no debt, so a transient - // Etherscan rate limit or key rejection permanently denied every client its - // mask targets — the exact failure the comment above anticipates. + // - discovery was SKIPPED for want of a divisor and snapshot, not refused; or + // - discovery RAN and failed, which `holders_or_owed` absorbs to keep the round. // // The event is not replayed once the cursor passes it, so nothing else would // retry. The retry pass itself is started below, after `record_round`: it scans @@ -602,8 +619,9 @@ pub async fn register_e3_requested( // Skipped for an on-chain census: `_eligibility` never reads `merkleRoot` in // that mode, so posting one would spend gas to publish a value nothing consults — - // and would imply the list gates eligibility when it does not. - if !is_onchain_census && !divisor_unavailable { + // and would imply the list gates eligibility when it does not. Skipped for an owed + // census too: the retry pass posts its root. + if !is_onchain_census && !retry_discovery { ensure_merkle_root(&e3_id, token_holder_hashes).await?; } @@ -1425,12 +1443,12 @@ async fn record_discovery_debt( /// Settle holder discovery for rounds that were registered without a census. /// /// A round carries `discovery_pending` when its census could not be built at `E3Requested`: -/// either the stored voting-power divisor could not be read, or discovery itself failed and was -/// swallowed to keep an on-chain-census round votable. Such a round serves no mask targets, and a -/// Merkle one takes no ballot until this pass posts its root. The event is not replayed once the -/// cursor passes it, so this pass is the only retry. It reads the divisor again for each such -/// CUSTOM-credit round and, when it answers, runs the same discovery the handler would have run. -/// A round that has ended is dropped from the pass: there is nobody left to mask. +/// either the stored voting-power divisor and snapshot could not be read, or discovery itself +/// failed. Such a round serves no mask targets, and a Merkle one takes no ballot until this pass +/// posts its root. The event is not replayed once the cursor passes it, so this pass is the only +/// retry. It reads the divisor and snapshot again for each such CUSTOM-credit round and, when they +/// answer, runs the same discovery the handler would have run. A round that has ended is dropped +/// from the pass: it takes no more ballots. /// /// One task for the process, started at registration. It sleeps on `DISCOVERY_OWED` while nothing /// is owed. `notify_one` keeps a permit when the task is mid-pass, so debt recorded after the pass @@ -1968,8 +1986,8 @@ mod stored_divisor_tests { use evm_helpers::CRISPContractFactory; use std::time::Duration; - /// `CRISPProgram` never stores a zero divisor or snapshot for a CUSTOM-credit round. A zero read - /// is a node that does not have the request block yet, and taking it as final drops the round. + /// `CRISPProgram` never stores a zero divisor for a CUSTOM-credit round. A zero read is a node + /// that does not have the request block yet, and taking it as final drops the round. #[tokio::test] async fn a_zero_read_from_a_lagging_node_is_retried() { let anvil = alloy::node_bindings::Anvil::new().try_spawn().unwrap(); @@ -1996,7 +2014,9 @@ mod stored_divisor_tests { #[cfg(test)] mod pending_discovery_tests { - use super::{pending_discovery_step, record_discovery_debt, PendingDiscoveryStep}; + use super::{ + holders_or_owed, pending_discovery_step, record_discovery_debt, PendingDiscoveryStep, + }; use crate::server::models::{CensusMode, CreditMode, CustomParams, E3Crisp, TokenHolder}; use crate::server::repo::CrispE3Repository; use e3_sdk::indexer::{InMemoryStore, SharedStore}; @@ -2122,4 +2142,13 @@ mod pending_discovery_tests { let decoded: E3Crisp = serde_json::from_value(encoded).unwrap(); assert!(!decoded.discovery_pending); } + + /// The `E3Requested` log is not replayed, so a census-tree round whose discovery failed and + /// was not recorded as owed would never get a root, and nobody could vote in it. + #[test] + fn a_failed_census_tree_discovery_is_owed_rather_than_fatal() { + let (holders, owed) = holders_or_owed("1", false, Err(eyre::eyre!("rate limited"))); + assert!(holders.is_empty()); + assert!(owed); + } } diff --git a/examples/CRISP/server/src/server/token_holders/etherscan.rs b/examples/CRISP/server/src/server/token_holders/etherscan.rs index dba53659de..25a03b2f50 100644 --- a/examples/CRISP/server/src/server/token_holders/etherscan.rs +++ b/examples/CRISP/server/src/server/token_holders/etherscan.rs @@ -226,10 +226,10 @@ impl EtherscanClient { /// Resolve an EIP-6372 timestamp timepoint to the highest block mined at or before it. /// - /// The E3 census snapshot is a timepoint, not a block height: `Interfold.request` - /// assigns `block.timestamp` to `E3.requestBlock` regardless of the census token. - /// Log queries address blocks, so the timepoint must be converted before it can - /// bound a `getLogs` range. + /// A census timepoint can be a timestamp rather than a block height: `Interfold.request` + /// assigns `block.timestamp` to `E3.requestBlock`, and a timestamp-clock token records + /// `snapshotOf` in seconds. Log queries address blocks, so such a timepoint must be converted + /// before it can bound a `getLogs` range. /// /// Resolved over RPC by binary search rather than through Etherscan's /// `getblocknobytime`, which is a Pro-tier endpoint and fails on free API keys. @@ -1756,14 +1756,15 @@ mod tests { let api_key = &CONFIG.etherscan_api_key; let rpc_url = &CONFIG.http_rpc_url; let threshold = U256::ZERO; - // A timepoint, matching what `E3.requestBlock` carries — not a block height. - let snapshot_timepoint = 1761201108; + // `snapshotOf` is in the clock units of the token. This token runs a block-number clock, + // and Sepolia block 9471180 was mined at timestamp 1761201108. + let snapshot = 9471180; let client = EtherscanClient::new(api_key.to_string(), chain_id); let res = client .get_token_holders_with_voting_power( token_address, - snapshot_timepoint, + snapshot, rpc_url, threshold, // The divisor `CRISPProgram` stored for the round. From d30fb6e00677f8db4e40c1e563c2adef10cb79c2 Mon Sep 17 00:00:00 2001 From: ctrlc03 <93448202+ctrlc03@users.noreply.github.com> Date: Thu, 8 Oct 2026 08:12:32 +0100 Subject: [PATCH 07/15] test(crisp): hash the BFV parameter blob in the OpenVM verify test MockInterfold passes its BFV parameter blob to validate as e3ProgramParams, and validate stores the hash of that blob as the params hash of the round. The OpenVM verify test now expects that hash instead of the hash of empty params. --- .../packages/crisp-contracts/tests/openvm-receipt.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/CRISP/packages/crisp-contracts/tests/openvm-receipt.test.ts b/examples/CRISP/packages/crisp-contracts/tests/openvm-receipt.test.ts index 79948424d3..3e45181370 100644 --- a/examples/CRISP/packages/crisp-contracts/tests/openvm-receipt.test.ts +++ b/examples/CRISP/packages/crisp-contracts/tests/openvm-receipt.test.ts @@ -152,7 +152,8 @@ describe('OpenVM receipt verifier (call oracle, not proof verification)', () => word(55), word(66), word(77), - ethers.keccak256('0x'), + // `validate` stores the hash of `e3ProgramParams`, the BFV parameter blob that `MockInterfold` passes. + ethers.keccak256(await controller.e3ProgramParams()), '0x2098f5fb9e239eab3ceac3f27b81e481dc3124d55ffed523a839ee8446b64864', ] await verifier.setExpectedCall(journalDigest(words), proofData, exeCommit, vmCommit) From bfe85eadfba67b42310b7368e0cf15cd58c41574 Mon Sep 17 00:00:00 2001 From: ctrlc03 <93448202+ctrlc03@users.noreply.github.com> Date: Thu, 8 Oct 2026 09:23:04 +0100 Subject: [PATCH 08/15] fix(crisp): keep the stored census when a repeated discovery fails A failed holder discovery writes no list. A new round keeps the empty lists from initialize_round. An E3Requested that arrives again keeps the census that the round holds until the retry pass rebuilds it, and a rebuilt Merkle census must match the posted root. store_census replaces record_discovery_debt and owns both outcomes. stored_voting_power_scale names one block by its hash for both reads, so two nodes behind one endpoint cannot answer from two blocks at one height. --- agent/flow-trace/04_DKG_AND_COMPUTATION.md | 3 +- examples/CRISP/crates/evm_helpers/src/lib.rs | 19 ++- examples/CRISP/server/src/server/indexer.rs | 171 ++++++++++--------- 3 files changed, 107 insertions(+), 86 deletions(-) diff --git a/agent/flow-trace/04_DKG_AND_COMPUTATION.md b/agent/flow-trace/04_DKG_AND_COMPUTATION.md index 32bb4755e5..ddc2659cbf 100644 --- a/agent/flow-trace/04_DKG_AND_COMPUTATION.md +++ b/agent/flow-trace/04_DKG_AND_COMPUTATION.md @@ -1928,7 +1928,8 @@ lookup uses the request record, so a round remains visible while its key is pend the round only when both records exist. Either handler can complete the activation after their records converge, and deferred checks cover slow live-handler ordering. Duplicate request and committee events do not reset the round, replace indexed output, or resubmit an already-matching -Merkle root. The shared Interfold contract also emits requests for other E3 programs. The CRISP +Merkle root. A duplicate request whose holder discovery fails keeps the stored census and owes a +retry. The shared Interfold contract also emits requests for other E3 programs. The CRISP indexer ignores those requests before it creates a round or makes a program-specific RPC call. An old program's historical round therefore cannot stop a fresh CRISP backfill. diff --git a/examples/CRISP/crates/evm_helpers/src/lib.rs b/examples/CRISP/crates/evm_helpers/src/lib.rs index 52de26856b..febe856a7c 100644 --- a/examples/CRISP/crates/evm_helpers/src/lib.rs +++ b/examples/CRISP/crates/evm_helpers/src/lib.rs @@ -5,7 +5,7 @@ // or FITNESS FOR A PARTICULAR PURPOSE. use alloy::{ - eips::BlockId, + eips::{BlockId, BlockNumberOrTag}, network::{Ethereum, EthereumWallet}, primitives::{Address, Bytes, B256, I256, U256}, providers::{ @@ -656,13 +656,20 @@ impl CRISPContract { /// The voting-power divisor and snapshot `CRISPProgram` stored for a CUSTOM-credit round at /// request time. A census must scale every voter by exactly this divisor and read every voter at - /// exactly this snapshot. `validate` writes both in one transaction and both are read at one - /// block, so a non-zero divisor vouches for the snapshot beside it. `Ok(None)` when the divisor - /// is zero: a CONSTANT-credit round, a round it never initialized, or a node that lacks the - /// request block. + /// exactly this snapshot. `validate` writes both in one transaction, and both reads name one + /// block by its hash, so a non-zero divisor vouches for the snapshot beside it. `Ok(None)` when + /// the divisor is zero: a CONSTANT-credit round, a round it never initialized, or a node that + /// lacks the request block. pub async fn stored_voting_power_scale(&self, e3_id: U256) -> Result> { let contract = CRISPProgram::new(self.contract_address, self.provider.as_ref()); - let block = BlockId::number(self.provider.get_block_number().await?); + // Nodes behind one endpoint can hold different blocks at one height. A hash names one + // block, and a node without that block fails the call. + let head = self + .provider + .get_block_by_number(BlockNumberOrTag::Latest) + .await? + .ok_or_else(|| eyre::eyre!("The node returned no latest block"))?; + let block = BlockId::hash(head.header.hash); let divisor = contract .votingPowerDivisorOf(e3_id) .block(block) diff --git a/examples/CRISP/server/src/server/indexer.rs b/examples/CRISP/server/src/server/indexer.rs index 53715e60dc..8fc9b15efc 100644 --- a/examples/CRISP/server/src/server/indexer.rs +++ b/examples/CRISP/server/src/server/indexer.rs @@ -56,8 +56,9 @@ type Result = std::result::Result>; /// The subscription delivers the event when one node has the block. The HTTP provider spreads /// reads over nodes that can trail that node by most of a block. Until they have it, `getE3` /// reverts with `E3DoesNotExist` and the stored divisor reads as zero. Five attempts wait -/// 2 + 4 + 8 + 16 = 30 s in total, which covers more than two Sepolia blocks. A live handler error -/// is not retried later, so a shorter wait loses the round. +/// 2 + 4 + 8 + 16 = 30 s in total, which covers more than two Sepolia blocks. Nothing retries a +/// live handler error, so a shorter wait for `getE3` loses the round. A shorter wait for the +/// divisor defers the census to the retry pass. const E3_VISIBLE_ATTEMPTS: u32 = 5; fn is_configured_e3_program(event_program: Address, configured_program: Address) -> bool { @@ -260,10 +261,10 @@ async fn discover_holders( /// The holders to register a round with, and whether its discovery is still owed. /// /// A failed discovery never drops the round. The `E3Requested` log is not replayed once the cursor -/// passes it, so the round registers with an empty list and the retry pass builds the census. The -/// cause is usually transient: a rate limit, a rejected API key, or a voter whose votes could not -/// be read. An on-chain round stays votable meanwhile. A census-tree round takes no ballot until -/// the retry pass posts its root. +/// passes it, so the round registers and the retry pass builds the census. The cause is usually +/// transient: a rate limit, a rejected API key, or a voter whose votes could not be read. An +/// on-chain round stays votable meanwhile. A new census-tree round takes no ballot until the retry +/// pass posts its root. fn holders_or_owed( e3_id: &str, is_onchain_census: bool, @@ -282,8 +283,8 @@ fn holders_or_owed( ); } else { warn!( - "[e3_id={}] Census discovery failed: {:#}. The round is recorded without a census and \ - takes no ballot until a retry posts its root.", + "[e3_id={}] Census discovery failed: {:#}. The round keeps any census it holds, and \ + a new round takes no ballot until a retry posts its root.", e3_id, error ); } @@ -575,36 +576,19 @@ pub async fn register_e3_requested( ) .await?; - // Store eligible addresses in the repository. - repo.set_eligible_addresses(token_holders.clone()).await?; - - // Record the debt so `retry_pending_discovery` settles it later. Two causes: + // Store the census, or record the debt so `retry_pending_discovery` settles it + // later. Two causes of debt: // // - discovery was SKIPPED for want of a divisor and snapshot, not refused; or // - discovery RAN and failed, which `holders_or_owed` absorbs to keep the round. // // The event is not replayed once the cursor passes it, so nothing else would - // retry. The retry pass itself is started below, after `record_round`: it scans - // the round index and exits when nothing is owed, so starting it here could let - // it run before this round is listed, find nothing, and leave the debt to a - // restart. - let retry_discovery = - record_discovery_debt(&mut repo, divisor_unavailable, discovery_failed).await?; - - // Poseidon hashes exist to build the census tree, and an on-chain census has no - // tree: `_eligibility` reads power from the token per input. The addresses are - // stored above and that is all a client needs here — a mask is written to someone - // else's slot, so it needs a list of who holds power, not a membership proof. - let token_holder_hashes = if is_onchain_census { - Vec::new() - } else { - let hashes = compute_token_holder_hashes(&token_holders) - .with_context(|| "Failed to compute token holder hashes")?; - - repo.set_token_holder_hashes(hashes.clone()).await?; - - hashes - }; + // retry. The retry task is woken below, after `record_round`: it scans the round + // index and sleeps when nothing is owed, so waking it here could let it run before + // this round is listed, find nothing, and sleep with the debt unpaid. + let owed = divisor_unavailable || discovery_failed; + let root_leaves = + store_census(&mut repo, token_holders, is_onchain_census, owed).await?; CurrentRoundRepository::new(store.clone()) .record_round(&e3_id) @@ -613,16 +597,16 @@ pub async fn register_e3_requested( // The round is listed, so the retry task can find its debt. Wake the one task // started at registration instead of spawning another: each task rescans every // owed round, so one per debt multiplied the discovery calls. - if retry_discovery { + if owed { DISCOVERY_OWED.notify_one(); } - // Skipped for an on-chain census: `_eligibility` never reads `merkleRoot` in + // No leaves for an on-chain census: `_eligibility` never reads `merkleRoot` in // that mode, so posting one would spend gas to publish a value nothing consults — - // and would imply the list gates eligibility when it does not. Skipped for an owed - // census too: the retry pass posts its root. - if !is_onchain_census && !retry_discovery { - ensure_merkle_root(&e3_id, token_holder_hashes).await?; + // and would imply the list gates eligibility when it does not. No leaves for an + // owed census either: the retry pass posts its root. + if let Some(leaves) = root_leaves { + ensure_merkle_root(&e3_id, leaves).await?; } // Committee and request handlers run concurrently for live logs. If the key was @@ -1427,28 +1411,52 @@ fn pending_discovery_step(round: &E3Crisp) -> PendingDiscoveryStep { } } -/// Persist a missing census and report whether the retry task must be woken. -async fn record_discovery_debt( +/// Store the census that discovery found, or record that the round still owes one. +/// +/// Returns the leaf hashes to post as the census root, or `None` when no root is due: the census +/// is owed, or it is on-chain. An owed census writes no list, so a new round keeps the empty lists +/// from `initialize_round`. A round whose `E3Requested` arrives again and fails discovery keeps the +/// census it holds until the retry pass rebuilds it, and a rebuilt Merkle census must match the +/// posted root. +async fn store_census( repo: &mut CrispE3Repository, - divisor_unavailable: bool, - discovery_failed: bool, -) -> eyre::Result { - let retry_discovery = divisor_unavailable || discovery_failed; - if retry_discovery { + holders: Vec, + is_onchain_census: bool, + owed: bool, +) -> eyre::Result>> { + if owed { repo.set_discovery_pending(true).await?; + return Ok(None); } - Ok(retry_discovery) + // Poseidon hashes exist to build the census tree, and an on-chain census has no tree: + // `_eligibility` reads power from the token per input. The addresses are all a client needs + // there: a mask is written to someone else's slot, so it needs a list of who holds power, not + // a membership proof. + let leaves = if is_onchain_census { + None + } else { + Some( + compute_token_holder_hashes(&holders) + .with_context(|| "Failed to compute token holder hashes")?, + ) + }; + repo.set_eligible_addresses(holders).await?; + if let Some(hashes) = &leaves { + repo.set_token_holder_hashes(hashes.clone()).await?; + } + Ok(leaves) } /// Settle holder discovery for rounds that were registered without a census. /// /// A round carries `discovery_pending` when its census could not be built at `E3Requested`: /// either the stored voting-power divisor and snapshot could not be read, or discovery itself -/// failed. Such a round serves no mask targets, and a Merkle one takes no ballot until this pass -/// posts its root. The event is not replayed once the cursor passes it, so this pass is the only -/// retry. It reads the divisor and snapshot again for each such CUSTOM-credit round and, when they -/// answer, runs the same discovery the handler would have run. A round that has ended is dropped -/// from the pass: it takes no more ballots. +/// failed. A new round in this state serves no mask targets, and a Merkle one takes no ballot until +/// this pass posts its root. A round whose `E3Requested` arrives again keeps the census it holds +/// until this pass rebuilds it. The event is not replayed once the cursor passes it, so this pass +/// is the only retry. It reads the divisor and snapshot again for each such CUSTOM-credit round +/// and, when they answer, runs the same discovery the handler would have run. A round that has +/// ended is dropped from the pass: it takes no more ballots. /// /// One task for the process, started at registration. It sleeps on `DISCOVERY_OWED` while nothing /// is owed. `notify_one` keeps a permit when the task is mid-pass, so debt recorded after the pass @@ -1987,7 +1995,8 @@ mod stored_divisor_tests { use std::time::Duration; /// `CRISPProgram` never stores a zero divisor for a CUSTOM-credit round. A zero read is a node - /// that does not have the request block yet, and taking it as final drops the round. + /// that does not have the request block yet. Taking it as final defers the census to the retry + /// pass. #[tokio::test] async fn a_zero_read_from_a_lagging_node_is_retried() { let anvil = alloy::node_bindings::Anvil::new().try_spawn().unwrap(); @@ -2014,9 +2023,7 @@ mod stored_divisor_tests { #[cfg(test)] mod pending_discovery_tests { - use super::{ - holders_or_owed, pending_discovery_step, record_discovery_debt, PendingDiscoveryStep, - }; + use super::{holders_or_owed, pending_discovery_step, store_census, PendingDiscoveryStep}; use crate::server::models::{CensusMode, CreditMode, CustomParams, E3Crisp, TokenHolder}; use crate::server::repo::CrispE3Repository; use e3_sdk::indexer::{InMemoryStore, SharedStore}; @@ -2082,11 +2089,11 @@ mod pending_discovery_tests { ); } - /// A failed discovery owes a retry even when the divisor was readable, and the debt is - /// durable: it survives a re-read of the store and clears only when told to. A restart - /// therefore retries rather than forgets, which is what makes the event's non-replay safe. + /// A failed discovery owes a retry, and the debt is in the store, not in the handler. A round + /// whose `E3Requested` arrives again and fails discovery keeps the census it holds: voters + /// still need its leaf hashes to prove membership against the posted root. #[tokio::test] - async fn the_discovery_debt_is_durable_and_clears_on_demand() { + async fn a_failed_discovery_owes_a_retry_and_keeps_the_stored_census() { let store = SharedStore::new(Arc::new(RwLock::new(InMemoryStore::new()))); let mut repo = CrispE3Repository::new(store.clone(), "7"); let params = CustomParams { @@ -2095,7 +2102,7 @@ mod pending_discovery_tests { num_options: "2".to_string(), credit_mode: CreditMode::Constant, credits: Some("1".to_string()), - census_mode: CensusMode::Onchain, + census_mode: CensusMode::Token, voting_power_divisor: "0".to_string(), }; repo.initialize_round( @@ -2107,29 +2114,35 @@ mod pending_discovery_tests { ) .await .unwrap(); - assert!(!record_discovery_debt(&mut repo, false, false) + let holders = vec![TokenHolder { + address: "0x0000000000000000000000000000000000000003".to_string(), + balance: "5".to_string(), + }]; + let leaves = store_census(&mut repo, holders.clone(), false, false) .await - .unwrap()); - assert!(!repo.get_crisp().await.unwrap().discovery_pending); + .unwrap() + .unwrap(); + assert_eq!( + pending_discovery_step(&repo.get_crisp().await.unwrap()), + PendingDiscoveryStep::Skip + ); - assert!(record_discovery_debt(&mut repo, false, true).await.unwrap()); - let reread = CrispE3Repository::new(store.clone(), "7"); - assert!(reread.get_crisp().await.unwrap().discovery_pending); assert_eq!( - pending_discovery_step(&reread.get_crisp().await.unwrap()), + store_census(&mut repo, Vec::new(), false, true) + .await + .unwrap(), + None + ); + let replayed = CrispE3Repository::new(store.clone(), "7") + .get_crisp() + .await + .unwrap(); + assert_eq!(replayed.eligible_addresses, holders); + assert_eq!(replayed.token_holder_hashes, leaves); + assert_eq!( + pending_discovery_step(&replayed), PendingDiscoveryStep::Retry ); - - // Settling stores the holders and then clears the debt, in that order. - let holders = vec![TokenHolder { - address: "0x0000000000000000000000000000000000000003".to_string(), - balance: "5".to_string(), - }]; - repo.set_eligible_addresses(holders.clone()).await.unwrap(); - repo.set_discovery_pending(false).await.unwrap(); - let settled = reread.get_crisp().await.unwrap(); - assert_eq!(settled.eligible_addresses, holders); - assert!(!settled.discovery_pending); } /// A stored round written before the field existed decodes as not pending: those rounds From 0cb7ad43d772574b5219d611b647f80f4ac631b1 Mon Sep 17 00:00:00 2001 From: ctrlc03 <93448202+ctrlc03@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:32:20 +0100 Subject: [PATCH 09/15] fix(circuits): commit the minimum-committee secure smudging bounds The committed secure threshold config held the PK_GENERATION smudging bounds of the small committee. The committed configs hold the minimum values. Regenerated with `pnpm build:circuits --preset secure-8192 --committee minimum`. The build regenerates these bounds for each pair, so no built circuit changes. --- circuits/lib/src/configs/secure/threshold.nr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/circuits/lib/src/configs/secure/threshold.nr b/circuits/lib/src/configs/secure/threshold.nr index 5ab2e7cb1c..757073e26c 100644 --- a/circuits/lib/src/configs/secure/threshold.nr +++ b/circuits/lib/src/configs/secure/threshold.nr @@ -24628,11 +24628,11 @@ pub global PK_GENERATION_BIT_PK: u32 = 59; pub global PK_GENERATION_EEK_BOUND: Field = 20; pub global PK_GENERATION_SK_BOUND: Field = 1; -pub global PK_GENERATION_E_SM_BOUND: Field = 13292279957850277295384160648340858470400000; +pub global PK_GENERATION_E_SM_BOUND: Field = 13292279957849975063929256991047181926400000; pub global PK_GENERATION_E_SM_LIMB_BOUNDS: [Field; L] = [288230376158896128, 288230376158699520, 288230376158388224]; pub global PK_GENERATION_E_SM_QUOTIENT_BOUNDS: [Field; L] = - [23058430091564129163314278, 23058430091579857803313506, 23058430091604761483312327]; + [23058430091563604875314291, 23058430091579333515313518, 23058430091604237195312339]; pub global PK_GENERATION_R_BOUNDS: [Field; L] = [4096, 4096, 4096]; pub global PK_GENERATION_B_ENC: Field = 230584300921369395200; From 39a03f0a41e10a2fc800e24ff87fcbe5d36ed8de Mon Sep 17 00:00:00 2001 From: ctrlc03 <93448202+ctrlc03@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:32:20 +0100 Subject: [PATCH 10/15] fix(circuits): check the secure configs without a u128 overflow `verify_share_decryption_bounds` multiplied `qi_bound^2` by `n` in u128, which overflows for the 60-bit secure moduli. It now divides the square by `qi` before it multiplies, which gives the same floor. `scripts/test-circuits.sh` runs `nargo execute` on `circuits/bin/config`, so CI checks the committed secure configs. --- circuits/README.md | 2 +- circuits/bin/config/src/main.nr | 6 +++++- docs/pages/build/noir-circuits.mdx | 18 +++++++++--------- scripts/test-circuits.sh | 4 ++++ 4 files changed, 19 insertions(+), 11 deletions(-) diff --git a/circuits/README.md b/circuits/README.md index 529840bb4e..2da9a6dc91 100644 --- a/circuits/README.md +++ b/circuits/README.md @@ -97,7 +97,7 @@ From the repository root: ```bash pnpm tsx scripts/build-circuits.ts # compile circuits, verification keys, artifacts ./scripts/lint-circuits.sh # nargo fmt --check; nargo check (skipped if nargo absent) -./scripts/test-circuits.sh # nargo test: lib, decryption_aggregator, dkg_aggregator (3 preset/committee pairs) +./scripts/test-circuits.sh # nargo test: lib, decryption_aggregator, dkg_aggregator (3 preset/committee pairs); nargo execute: config ``` Pin **nargo** and **bb** to the versions in `crates/zk-prover` and `versions.json`. For local work, diff --git a/circuits/bin/config/src/main.nr b/circuits/bin/config/src/main.nr index afe247c7a8..a361982fed 100644 --- a/circuits/bin/config/src/main.nr +++ b/circuits/bin/config/src/main.nr @@ -500,7 +500,11 @@ fn verify_share_decryption_bounds() { // d = ct0 + (ct1 * sk mod X^N + 1) + e_sm + q * r: the negacyclic product of n terms with // both operands centered, plus d, ct0 and e_sm. Same expression the unreduced r1 bound used, // so the reduced quotient is no wider. - let expected_r: u128 = (qi_bound * qi_bound * n + 4 * qi_bound) / qi; + // + // `qi_bound^2 * n` overflows u128 for 60-bit moduli, so the square is divided by q_i first. + // With qi_bound^2 = a * q_i + b, the bound is n * a + (n * b + 4 * qi_bound) / q_i exactly. + let square: u128 = qi_bound * qi_bound; + let expected_r: u128 = n * (square / qi) + (n * (square % qi) + 4 * qi_bound) / qi; assert( THRESHOLD_SHARE_DECRYPTION_R_BOUNDS[i] as u128 == expected_r, "SHARE_DECRYPTION_R_BOUNDS mismatch", diff --git a/docs/pages/build/noir-circuits.mdx b/docs/pages/build/noir-circuits.mdx index 674f56ed25..6f020fbce4 100644 --- a/docs/pages/build/noir-circuits.mdx +++ b/docs/pages/build/noir-circuits.mdx @@ -283,15 +283,15 @@ identifies source inputs and is not an archive digest. Other circuit commands: -| Command | What it does | -| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| `pnpm build:circuits --dry-run` | Lists the circuits that a build compiles, and stops. | -| `pnpm build:circuits --group dkg --skip-vk` | Compiles one group and skips the verification keys. | -| `pnpm build:circuits hash` | Prints the source hash that identifies a build. | -| `pnpm build:circuits sync-config --preset

--committee ` | Updates `utils.ts` and `ActiveCryptoConfig.sol` only. It does not compile. | -| `pnpm check:committee` | Checks that the committee and BFV values agree across Noir, Rust, TypeScript, and Solidity. | -| `pnpm noir:lint` (`scripts/lint-circuits.sh`) | Runs `nargo fmt --check` and `nargo check` on the library and each package. It exits with success if `nargo` is missing. | -| `pnpm noir:test` (`scripts/test-circuits.sh`) | Runs `nargo test` on `circuits/lib` and `decryption_aggregator`, then the `dkg_aggregator` tests for each preset and committee. | +| Command | What it does | +| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `pnpm build:circuits --dry-run` | Lists the circuits that a build compiles, and stops. | +| `pnpm build:circuits --group dkg --skip-vk` | Compiles one group and skips the verification keys. | +| `pnpm build:circuits hash` | Prints the source hash that identifies a build. | +| `pnpm build:circuits sync-config --preset

--committee ` | Updates `utils.ts` and `ActiveCryptoConfig.sol` only. It does not compile. | +| `pnpm check:committee` | Checks that the committee and BFV values agree across Noir, Rust, TypeScript, and Solidity. | +| `pnpm noir:lint` (`scripts/lint-circuits.sh`) | Runs `nargo fmt --check` and `nargo check` on the library and each package. It exits with success if `nargo` is missing. | +| `pnpm noir:test` (`scripts/test-circuits.sh`) | Runs `nargo test` on `circuits/lib` and `decryption_aggregator`, then `nargo execute` on `bin/config`, then the `dkg_aggregator` tests for each preset and committee. | To make sample inputs for one circuit, use the `zk_cli` generator. Its `--committee` value must match the active committee of the library: diff --git a/scripts/test-circuits.sh b/scripts/test-circuits.sh index 3a18cecdbb..18651c3f85 100755 --- a/scripts/test-circuits.sh +++ b/scripts/test-circuits.sh @@ -19,6 +19,10 @@ trap restore_active_config EXIT (cd "$REPO_ROOT/circuits/lib" && nargo test) (cd "$REPO_ROOT/circuits/bin/recursive_aggregation/decryption_aggregator" && nargo test) +# The config circuit re-derives the committed secure parameters and bounds for the committed +# committee. It runs before the loop below rewrites the committee selection. +(cd "$REPO_ROOT/circuits/bin/config" && nargo execute) + # The dkg_aggregator and node_fold tests read only H, N_PARTIES, and L_THRESHOLD, and the preset # changes only L_THRESHOLD (insecure 2, secure 3). These pairs run each committee once and cover # both values. From 97d0e15d58629c38f60426efae79a46bf2800dd2 Mon Sep 17 00:00:00 2001 From: ctrlc03 <93448202+ctrlc03@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:32:21 +0100 Subject: [PATCH 11/15] chore(crisp): regenerate the fold key hashes and verifiers on #2195 The fold circuits pin the key hashes of the inner circuits, which #2195 changed. Regenerated with `pnpm compute:vk-hash` and `pnpm compile:circuits`. The docs take the measured secure gate counts and the divisor example for t = 17,000,000. --- agent/flow-trace/04_DKG_AND_COMPUTATION.md | 23 +++++++++---------- agent/invariants/02_CRYPTO_CIRCUITS.md | 17 +++++++------- docs/pages/CRISP/running-e3.mdx | 5 ++-- examples/CRISP/circuits/bin/fold/src/main.nr | 2 +- .../circuits/bin/fold_onchain/src/main.nr | 2 +- .../verifiers/CRISPOnchainVerifier.sol | 6 ++--- .../contracts/verifiers/CRISPVerifier.sol | 6 ++--- 7 files changed, 30 insertions(+), 31 deletions(-) diff --git a/agent/flow-trace/04_DKG_AND_COMPUTATION.md b/agent/flow-trace/04_DKG_AND_COMPUTATION.md index ddc2659cbf..fbda184eaa 100644 --- a/agent/flow-trace/04_DKG_AND_COMPUTATION.md +++ b/agent/flow-trace/04_DKG_AND_COMPUTATION.md @@ -215,13 +215,12 @@ For `secure-8192`, the threshold key uses plaintext modulus 17,000,000 and three (`0x0800000000db4001`, `0x0800000000d54001`, `0x0800000000cbc001`). The paired share-encryption key uses plaintext modulus 576460752317792257 (the largest threshold prime) and two 62-bit primes (`0x2000000000104001`, `0x200000000013c001`). Both use ring degree 8192 and statistical security -parameter 45. The threshold encryption error variance is -17723039943798878305460955570711717478400. These values bind the C1-C7 witness dimensions and the -on-chain BFV parameter hash. C7 uses `Q_INVERSE_MOD_T = 3898177`, the inverse of the product of the -three threshold primes modulo 17,000,000. New secure E3s use on-chain parameter-set index 2. Index 1 -retains the previous secure tuple for historical requests. C3 share encryption and user-data -encryption use non-centered `k1` residues in `[0, t - 1]`; the Rust witness, Noir equation, and -quotient bounds must agree. +parameter 45. The threshold encryption error variance is 17723039943798878305460955570711717478400. +These values bind the C1-C7 witness dimensions and the on-chain BFV parameter hash. C7 uses +`Q_INVERSE_MOD_T = 3898177`, the inverse of the product of the three threshold primes modulo +17,000,000. New secure E3s use on-chain parameter-set index 2. Index 1 retains the previous secure +tuple for historical requests. C3 share encryption and user-data encryption use non-centered `k1` +residues in `[0, t - 1]`; the Rust witness, Noir equation, and quotient bounds must agree. ``` ThresholdKeyshare receives AllEncryptionKeysCollected @@ -1929,9 +1928,9 @@ the round only when both records exist. Either handler can complete the activati records converge, and deferred checks cover slow live-handler ordering. Duplicate request and committee events do not reset the round, replace indexed output, or resubmit an already-matching Merkle root. A duplicate request whose holder discovery fails keeps the stored census and owes a -retry. The shared Interfold contract also emits requests for other E3 programs. The CRISP -indexer ignores those requests before it creates a round or makes a program-specific RPC call. An -old program's historical round therefore cannot stop a fresh CRISP backfill. +retry. The shared Interfold contract also emits requests for other E3 programs. The CRISP indexer +ignores those requests before it creates a round or makes a program-specific RPC call. An old +program's historical round therefore cannot stop a fresh CRISP backfill. Startup rebuilds deadline callbacks for active and expired rounds and releases an interrupted compute submission for retry. The compute transition is atomic, and a synchronous program-server @@ -2218,8 +2217,8 @@ derived from the commitments accepted a mask that published its ballot alone: th second opening of the parent commitment that satisfied the single equation. A per-coefficient linear relation, aligned across three ciphertexts that pack with the same `BIT_CT`, proves the same statement for the committed coefficients under any opening that keeps the carriers, so the circuit -needs no `pack_checked` digit asserts. At secure-8192 the `crisp` circuit is 1,750,827 gates and -`crisp_onchain` 1,731,104, under the `2^21` browser ceiling. +needs no `pack_checked` digit asserts. At secure-8192 the `crisp` circuit is 1,759,587 gates and +`crisp_onchain` 1,739,864, under the `2^21` browser ceiling. The circuit returns `sum_ct_commitment` on every path, so the public inputs, the stored commitment, the ballot digest, and the published ciphertext have the same shape whichever operation ran. Telling diff --git a/agent/invariants/02_CRYPTO_CIRCUITS.md b/agent/invariants/02_CRYPTO_CIRCUITS.md index d357921c1e..c5ff7d0775 100644 --- a/agent/invariants/02_CRYPTO_CIRCUITS.md +++ b/agent/invariants/02_CRYPTO_CIRCUITS.md @@ -368,15 +368,14 @@ every section. not create (the parent's and the ballot's ciphertexts) and keep plain `pack`: `verify_slot_update` checks a relation that is linear and aligned coefficient by coefficient across ciphertexts packed with the same `BIT_CT`, so an opening that keeps the carriers proves the same relation for the - committed coefficients. With the checked helper on the three commitments, the secure `crisp` - circuit measured 2,520,034 gates, above the browser ceiling. The exemption holds only for that - shape of relation, and only while every commitment in it has a bounded opening that something else - fixes: the ballot through the range checks of `user_data_encryption_ct0/ct1`, and the parent and - the published result because `chain_head_per_slot` takes an entry only when its bytes reproduce - its commitment and it extends the selected head. A check at one point over those commitments, or a - Secure Process that follows a parent by its stored commitment without its bytes, needs injective - openings: chained masks could then carry a coefficient past the radix and shift a plaintext - coefficient by a carry. — `flow-trace/04` + committed coefficients. The exemption holds only for that shape of relation, and only while every + commitment in it has a bounded opening that something else fixes: the ballot through the range + checks of `user_data_encryption_ct0/ct1`, and the parent and the published result because + `chain_head_per_slot` takes an entry only when its bytes reproduce its commitment and it extends + the selected head. A check at one point over those commitments, or a Secure Process that follows a + parent by its stored commitment without its bytes, needs injective openings: chained masks could + then carry a coefficient past the radix and shift a plaintext coefficient by a carry. — + `flow-trace/04` - A bound that is not tight enough for the slot is no bound for this purpose. C2b's `as u64` cast limited coefficients to `2^64` while the slot was `radix = 2^64` with `base = 2^60`, so a digit could still overflow. — `flow-trace/04` diff --git a/docs/pages/CRISP/running-e3.mdx b/docs/pages/CRISP/running-e3.mdx index af7a51a007..a8e1244ea3 100644 --- a/docs/pages/CRISP/running-e3.mdx +++ b/docs/pages/CRISP/running-e3.mdx @@ -289,8 +289,9 @@ an eligible voter. The limits hold only when the census leaf holds that voting p In a `CUSTOM` round, the weight of a voter is an integer, and the division discards the remainder. In a census tree round, a voter with less voting power than the divisor has a weight of 0. This -voter can cast only an empty ballot. For example, a total supply of 1,000,000,000,000 and a `t` of -1,000,000 give a minimum divisor of 1,000,001. A holder with 1,000,000 votes then has a weight of 0. +voter can cast only an empty ballot. For example, a total supply of 1,000,000,000,000 and the +`secure-8192` `t` of 17,000,000 give a minimum divisor of 58,824. A holder with 58,823 votes then +has a weight of 0. In an `ONCHAIN` round, `minVotingPower` must be at least the divisor, or `validate` reverts with `MinVotingPowerBelowScale`. Thus each voter that can publish an input has a weight of at least 1. diff --git a/examples/CRISP/circuits/bin/fold/src/main.nr b/examples/CRISP/circuits/bin/fold/src/main.nr index 611ac8ea0d..de92e42a6e 100644 --- a/examples/CRISP/circuits/bin/fold/src/main.nr +++ b/examples/CRISP/circuits/bin/fold/src/main.nr @@ -13,7 +13,7 @@ use interfold_lib::math::commitments::compute_vk_hash; pub global CRISP_FOLD_EXPECTED_KEY_HASH_INSECURE: Field = 0x0fb783a0f9843410cc8252d269ec225a349870415444cbb2263284af2819e1cc; pub global CRISP_FOLD_EXPECTED_KEY_HASH_SECURE: Field = - 0x21762277d29d63c5947d473c545e2caaf26441747f77923558c1f60495037448; + 0x196e81843c72ecfb887e91c1d4243ba605c244193d8e196458c0f0c173f26f58; fn main( // User Data Encryption Section. diff --git a/examples/CRISP/circuits/bin/fold_onchain/src/main.nr b/examples/CRISP/circuits/bin/fold_onchain/src/main.nr index d0305726db..e495da6b4a 100644 --- a/examples/CRISP/circuits/bin/fold_onchain/src/main.nr +++ b/examples/CRISP/circuits/bin/fold_onchain/src/main.nr @@ -13,7 +13,7 @@ use interfold_lib::math::commitments::compute_vk_hash; pub global CRISP_ONCHAIN_FOLD_EXPECTED_KEY_HASH_INSECURE: Field = 0x090d43b8bbd50e0840fd14a5918216321b0fc5dc5696da80da7cca2b22614696; pub global CRISP_ONCHAIN_FOLD_EXPECTED_KEY_HASH_SECURE: Field = - 0x19d907fc728bd5c51dfa27ff251c71fa8bdeb8a6f709ffff4092238ffb4027e7; + 0x225abc9d92035e910c9bd0deac6060640ad49b30ed6ef4de6d094397010ebdac; fn main( // User Data Encryption Section. diff --git a/examples/CRISP/packages/crisp-contracts/contracts/verifiers/CRISPOnchainVerifier.sol b/examples/CRISP/packages/crisp-contracts/contracts/verifiers/CRISPOnchainVerifier.sol index f1ee93ff53..7cbb43968a 100644 --- a/examples/CRISP/packages/crisp-contracts/contracts/verifiers/CRISPOnchainVerifier.sol +++ b/examples/CRISP/packages/crisp-contracts/contracts/verifiers/CRISPOnchainVerifier.sol @@ -8,7 +8,7 @@ pragma solidity >=0.8.21; uint256 constant N = 2097152; uint256 constant LOG_N = 21; uint256 constant NUMBER_OF_PUBLIC_INPUTS = 17; -uint256 constant VK_HASH = 0x087b347c0c36c9c435e29eb9becb2e349021edc54119c90ef05789e529958e5e; +uint256 constant VK_HASH = 0x1b1881be828aabc3b9b8775a27a7e7bfeac72b3732435b206b95c16af0197ca6; library HonkVerificationKey { function loadVerificationKey() internal pure returns (Honk.VerificationKey memory) { Honk.VerificationKey memory vk = Honk.VerificationKey({ @@ -20,8 +20,8 @@ library HonkVerificationKey { y: uint256(0x01137e39f6b1ec6101fad7a4472102bfe11e7534e55e58109734c4b927efe6a6) }), qr: Honk.G1Point({ - x: uint256(0x0d47561eecc2d9f53b0600e6485b8bf75c413c89ea96c23feecc7f32f0de886e), - y: uint256(0x1c25a5ac9c2a80715f717333c0e5038ce0da41a21422d43c8fa3d3894d8de47d) + x: uint256(0x19a714d3cd97e85c603d3a4be5c6b8189813f3856423dd48f9cb550f176d8473), + y: uint256(0x23fb8d7635bec5b80f40ad9b2dee8026a3027b245df704b0ae6d8d42d76c47c9) }), qo: Honk.G1Point({ x: uint256(0x18424976826978ddb710bfdc21c64a296884c27b0d8dadf6ac7681445dde8d8c), diff --git a/examples/CRISP/packages/crisp-contracts/contracts/verifiers/CRISPVerifier.sol b/examples/CRISP/packages/crisp-contracts/contracts/verifiers/CRISPVerifier.sol index 17d15c22cc..f5b949dec5 100644 --- a/examples/CRISP/packages/crisp-contracts/contracts/verifiers/CRISPVerifier.sol +++ b/examples/CRISP/packages/crisp-contracts/contracts/verifiers/CRISPVerifier.sol @@ -8,7 +8,7 @@ pragma solidity >=0.8.21; uint256 constant N = 2097152; uint256 constant LOG_N = 21; uint256 constant NUMBER_OF_PUBLIC_INPUTS = 17; -uint256 constant VK_HASH = 0x0737a10b7c3ebd432c2017ce7f11bfafb6a42283a876ec1095571bb8e825f5ba; +uint256 constant VK_HASH = 0x2f244d4fec2889c37cc4de184e30bb92641a9d076f25a606034d90e858eef9e9; library HonkVerificationKey { function loadVerificationKey() internal pure returns (Honk.VerificationKey memory) { Honk.VerificationKey memory vk = Honk.VerificationKey({ @@ -20,8 +20,8 @@ library HonkVerificationKey { y: uint256(0x01137e39f6b1ec6101fad7a4472102bfe11e7534e55e58109734c4b927efe6a6) }), qr: Honk.G1Point({ - x: uint256(0x0496945b93204262e8cb0efa8f57db0eb9ba1ca06919091b7179b8956cc100bf), - y: uint256(0x0353d0480d2c24fd6b2a70097a3f8119a7a9bca70a001bcba6bd9c8db0cea016) + x: uint256(0x1b347cf90672f338c0aa55623fef4c4243630e9b5e1611c9e21f8e4541bc43af), + y: uint256(0x0450dc0fe7cd67a63016a5f49f4e1263a95840c323b270c8ce2b0841d8d7914b) }), qo: Honk.G1Point({ x: uint256(0x18424976826978ddb710bfdc21c64a296884c27b0d8dadf6ac7681445dde8d8c), From 5f276acb98c776f7a7f047a9a7963fe6fca087f1 Mon Sep 17 00:00:00 2001 From: ctrlc03 <93448202+ctrlc03@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:32:21 +0100 Subject: [PATCH 12/15] fix(crisp): decode a tally only for rounds of the configured program Each CRISPProgram deployment fixes the ballot layout of its rounds. The server records the program of each round at E3Requested. On PlaintextOutputPublished it decodes the tally only for a round of its configured program. Any other stored round, and a round stored without a program, finishes with an empty tally. --- docs/pages/CRISP/running-e3.mdx | 24 ++-- examples/CRISP/server/src/server/database.rs | 3 +- examples/CRISP/server/src/server/indexer.rs | 132 ++++++++++++++++-- examples/CRISP/server/src/server/models.rs | 6 + examples/CRISP/server/src/server/repo.rs | 30 ++-- .../CRISP/server/src/server/routes/voting.rs | 2 + 6 files changed, 165 insertions(+), 32 deletions(-) diff --git a/docs/pages/CRISP/running-e3.mdx b/docs/pages/CRISP/running-e3.mdx index a8e1244ea3..f529e3b3d5 100644 --- a/docs/pages/CRISP/running-e3.mdx +++ b/docs/pages/CRISP/running-e3.mdx @@ -195,20 +195,26 @@ A stage that misses its deadline makes the E3 fail. See the E3 lifecycle page. If a round receives no votes, the server does not request a computation. It logs `E3 has no votes to decrypt. Setting status to Finished.` and sets the status to `Finished`. +The server records the program that requested each round. On `PlaintextOutputPublished`, it decodes +the tally only for a round of the configured `E3_PROGRAM_ADDRESS`, because each `CRISPProgram` +deployment fixes its own ballot layout. Any other round gets an empty tally and the status +`Finished`. This applies also to a round that an earlier server release stored. + ### Server log messages The CRISP server writes these messages to the `pnpm dev:up` terminal (`server/src/server/indexer.rs` and `server/src/server/routes/state.rs`). `` is the E3 ID. -| Message | Meaning | -| ---------------------------------------------------------------------------------- | --------------------------------------------------- | -| `[e3_id=] Using mocked token holders for local network (chain_id: 31337)` | The server uses the fixed local census. | -| `[e3_id=] setMerkleRoot successful. TxHash: ...` | The census root is on-chain. | -| `[e3_id=] Activated CRISP round and registered deadline callbacks` | The key is available and the round is active. | -| `[e3_id=] Starting computation for E3 ( ciphertext input(s), voter(s))` | The input window closed and the computation starts. | -| `Received program server result for E3 ID: , ...` | The program server returned the encrypted sum. | -| `[e3_id=] Handling CiphertextOutputPublished` | The encrypted sum is on-chain. | -| `[e3_id=] Option index: votes: ` | The decoded total for option ``. | +| Message | Meaning | +| -------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| `[e3_id=] Using mocked token holders for local network (chain_id: 31337)` | The server uses the fixed local census. | +| `[e3_id=] setMerkleRoot successful. TxHash: ...` | The census root is on-chain. | +| `[e3_id=] Activated CRISP round and registered deadline callbacks` | The key is available and the round is active. | +| `[e3_id=] Starting computation for E3 ( ciphertext input(s), voter(s))` | The input window closed and the computation starts. | +| `Received program server result for E3 ID: , ...` | The program server returned the encrypted sum. | +| `[e3_id=] Handling CiphertextOutputPublished` | The encrypted sum is on-chain. | +| `[e3_id=] Option index: votes: ` | The decoded total for option ``. | +| `[e3_id=] Leaving the tally empty: the round's program '

' is not the configured program
, ...` | The round finished, and this server stores no tally for it. | The ciphernode `cn1` also serves the node dashboard at `http://127.0.0.1:8080`. See [Node dashboard](/operate/dashboard). diff --git a/examples/CRISP/server/src/server/database.rs b/examples/CRISP/server/src/server/database.rs index 24891e50c7..42f23f72f9 100644 --- a/examples/CRISP/server/src/server/database.rs +++ b/examples/CRISP/server/src/server/database.rs @@ -253,6 +253,7 @@ mod tests { use super::*; use crate::server::models::{CensusMode, CreditMode, CustomParams, E3Crisp}; use crate::server::repo::CrispE3Repository; + use alloy_primitives::Address; use e3_fhe_params::{build_bfv_params_from_set_arc, BfvParamSet, BfvPreset}; use e3_sdk::indexer::SharedStore; use std::sync::Arc; @@ -299,7 +300,7 @@ mod tests { voting_power_divisor: "0".to_string(), }; round - .initialize_round(params, "requester".to_string(), 100, 100, 1) + .initialize_round(params, Address::ZERO, "requester".to_string(), 100, 100, 1) .await .unwrap(); round diff --git a/examples/CRISP/server/src/server/indexer.rs b/examples/CRISP/server/src/server/indexer.rs index 8fc9b15efc..01c9797800 100644 --- a/examples/CRISP/server/src/server/indexer.rs +++ b/examples/CRISP/server/src/server/indexer.rs @@ -569,6 +569,7 @@ pub async fn register_e3_requested( // save the e3 details repo.initialize_round( custom_params, + event.e3.e3Program, e3.requester.to_string(), voting_end_time, input_window[1], @@ -1062,9 +1063,48 @@ pub async fn register_ciphertext_output_reference_published( Ok(indexer) } +/// Record the tally that `PlaintextOutputPublished` carries, and finish the round. +/// +/// `decode_tally` reads the ballot layout of this server. Each `CRISPProgram` deployment fixes the +/// layout of its rounds, so the tally of a round that another program requested stays empty. So +/// does the tally of a round stored without its program, because its layout is unknown. +async fn record_plaintext_output( + repo: &mut CrispE3Repository, + e3_id: &str, + plaintext_output: &[u8], + configured_program: Address, +) -> eyre::Result<()> { + let round = repo.get_crisp().await?; + let same_program = round + .e3_program + .parse::
() + .is_ok_and(|program| is_configured_e3_program(program, configured_program)); + + if same_program { + let vote_counts = decode_tally(plaintext_output, round.num_options.parse()?)?; + for (i, count) in vote_counts.iter().enumerate() { + info!("[e3_id={}] Option index: {} votes: {:?}", e3_id, i, count); + } + repo.set_votes(vote_counts).await?; + } else { + warn!( + "[e3_id={}] Leaving the tally empty: the round's program '{}' is not the configured \ + program {}, so its ballot layout can differ", + e3_id, round.e3_program, configured_program + ); + } + + repo.update_status("Finished").await +} + pub async fn register_plaintext_output_published( indexer: InterfoldIndexer, ) -> Result> { + let configured_program: Address = CONFIG + .e3_program_address + .parse() + .with_context(|| "Invalid configured E3 program address")?; + // PlaintextOutputPublished indexer .add_event_handler(move |event: PlaintextOutputPublished, ctx| { @@ -1073,19 +1113,13 @@ pub async fn register_plaintext_output_published( let mut repo = CrispE3Repository::new(store, &e3_id); async move { info!("[e3_id={}] Handling PlaintextOutputPublished", e3_id); - - let num_options = repo.get_num_options().await?; - - // The plaintextOutput from the event contains the result of the FHE computation. - // Decode the tally using the utility function. - let vote_counts = decode_tally(&event.plaintextOutput, num_options)?; - - for (i, count) in vote_counts.iter().enumerate() { - info!("[e3_id={}] Option index: {} votes: {:?}", e3_id, i, count); - } - - repo.set_votes(vote_counts).await?; - repo.update_status("Finished").await?; + record_plaintext_output( + &mut repo, + &e3_id, + &event.plaintextOutput, + configured_program, + ) + .await?; Ok(()) } }) @@ -2026,6 +2060,7 @@ mod pending_discovery_tests { use super::{holders_or_owed, pending_discovery_step, store_census, PendingDiscoveryStep}; use crate::server::models::{CensusMode, CreditMode, CustomParams, E3Crisp, TokenHolder}; use crate::server::repo::CrispE3Repository; + use alloy_primitives::Address; use e3_sdk::indexer::{InMemoryStore, SharedStore}; use std::sync::Arc; use tokio::sync::RwLock; @@ -2055,6 +2090,7 @@ mod pending_discovery_tests { snapshot_block: 1, census_mode: CensusMode::Onchain, discovery_pending: pending, + e3_program: String::new(), } } @@ -2107,6 +2143,7 @@ mod pending_discovery_tests { }; repo.initialize_round( params, + Address::ZERO, "0x0000000000000000000000000000000000000002".into(), 100, 100, @@ -2165,3 +2202,72 @@ mod pending_discovery_tests { assert!(owed); } } + +#[cfg(test)] +mod plaintext_output_tests { + use super::record_plaintext_output; + use crate::server::models::{CensusMode, CreditMode, CustomParams, E3Crisp}; + use crate::server::repo::{CrispE3Repository, CRISP_KEY_PREFIX}; + use alloy_primitives::Address; + use crisp_utils::MAX_MSG_NON_ZERO_COEFFS; + use e3_sdk::indexer::{DataStore, InMemoryStore, SharedStore}; + use std::sync::Arc; + use tokio::sync::RwLock; + + /// Each `CRISPProgram` deployment fixes the ballot layout of its rounds, and the server decodes + /// its own layout only. A round of another program, or a round stored without its program, + /// finishes with an empty tally. The server does not read it under the wrong layout. + #[tokio::test] + async fn only_a_round_of_the_configured_program_gets_a_tally() { + let configured = Address::repeat_byte(0x11); + let store = SharedStore::new(Arc::new(RwLock::new(InMemoryStore::new()))); + let params = || CustomParams { + token_address: "0x0000000000000000000000000000000000000001".to_string(), + balance_threshold: "1".to_string(), + num_options: "2".to_string(), + credit_mode: CreditMode::Constant, + credits: Some("1".to_string()), + census_mode: CensusMode::Token, + voting_power_divisor: "0".to_string(), + }; + // One little-endian u64 per coefficient: 3 on option 0 and 5 on option 1. + let mut output = vec![0u8; 8 * MAX_MSG_NON_ZERO_COEFFS]; + output[0] = 3; + output[8] = 5; + + for (e3_id, program, keeps_program, tally) in [ + ("1", configured, true, vec!["3", "5"]), + ("2", Address::repeat_byte(0x22), true, vec![]), + ("3", configured, false, vec![]), + ] { + let mut repo = CrispE3Repository::new(store.clone(), e3_id); + repo.initialize_round(params(), program, "requester".to_string(), 100, 100, 1) + .await + .unwrap(); + if !keeps_program { + // A stored record without `e3_program` decodes it as empty. + store + .clone() + .modify( + &format!("{CRISP_KEY_PREFIX}{e3_id}"), + |round: Option| { + round.map(|mut round| { + round.e3_program.clear(); + round + }) + }, + ) + .await + .unwrap(); + } + + record_plaintext_output(&mut repo, e3_id, &output, configured) + .await + .unwrap(); + + let round = repo.get_crisp().await.unwrap(); + assert_eq!(round.tally, tally, "round {e3_id}"); + assert_eq!(round.status, "Finished", "round {e3_id}"); + } + } +} diff --git a/examples/CRISP/server/src/server/models.rs b/examples/CRISP/server/src/server/models.rs index 4357b0288b..2be3d7cc42 100644 --- a/examples/CRISP/server/src/server/models.rs +++ b/examples/CRISP/server/src/server/models.rs @@ -399,6 +399,12 @@ pub struct E3Crisp { /// forgets: the `E3Requested` event is not replayed once the cursor passes it. #[serde(default)] pub discovery_pending: bool, + /// The program that requested the round. Each `CRISPProgram` deployment fixes the ballot + /// layout of its rounds, so the server decodes a tally only for rounds of its configured + /// program. Empty when the stored record has no program, and then the round's layout is + /// unknown. + #[serde(default)] + pub e3_program: String, } impl From for WebResultRequest { diff --git a/examples/CRISP/server/src/server/repo.rs b/examples/CRISP/server/src/server/repo.rs index 54a5ea8965..bcc8338b8a 100644 --- a/examples/CRISP/server/src/server/repo.rs +++ b/examples/CRISP/server/src/server/repo.rs @@ -12,7 +12,7 @@ use super::{ database::{generate_emoji, CIPHERTEXT_KEY_PREFIX, INPUT_GENERATION_KEY_PREFIX}, models::{CurrentRound, E3Crisp, E3StateLite, WebResultRequest}, }; -use alloy::primitives::keccak256; +use alloy::primitives::{keccak256, Address}; use e3_compute_provider::policy::InputRecord; use e3_sdk::indexer::{models::E3 as InterfoldE3, DataStore, E3Repository, SharedStore}; use e3_user_program::policy::chain_head_per_slot; @@ -642,6 +642,7 @@ impl CrispE3Repository { pub async fn initialize_round( &mut self, custom_params: CustomParams, + e3_program: Address, requester: String, voting_end_time: u64, end_time: u64, @@ -672,6 +673,7 @@ impl CrispE3Repository { end_time, snapshot_block, discovery_pending: false, + e3_program: e3_program.to_string(), }; self.store @@ -705,11 +707,6 @@ impl CrispE3Repository { .map_err(|e| eyre::eyre!("Could get e3 at '{key}' due to error: {e}")) } - pub async fn get_num_options(&self) -> Result { - let e3_crisp = self.get_crisp().await?; - Ok(e3_crisp.num_options.parse::()?) - } - /// How many slots hold at least one available, locally indexed entry. /// /// The closest thing to a participation count the server can give. A mask is @@ -1240,7 +1237,7 @@ mod tests { CensusMode, CreditMode, CustomParams, E3Crisp, ExclusionReason, InputSelectionResponse, InputSelectionStatus, }; - use alloy::primitives::keccak256; + use alloy::primitives::{keccak256, Address}; use async_trait::async_trait; use e3_fhe_params::{build_bfv_params_from_set_arc, BfvParamSet, BfvPreset}; use e3_sdk::indexer::{DataStore, InMemoryStore, SharedStore}; @@ -1358,6 +1355,7 @@ mod tests { snapshot_block: 1, census_mode: CensusMode::Token, discovery_pending: false, + e3_program: String::new(), } } @@ -1467,12 +1465,26 @@ mod tests { }; round - .initialize_round(params(), "requester".to_string(), 100, 100, 1) + .initialize_round( + params(), + Address::ZERO, + "requester".to_string(), + 100, + 100, + 1, + ) .await .unwrap(); round.update_status("Finished").await.unwrap(); round - .initialize_round(params(), "requester".to_string(), 200, 200, 2) + .initialize_round( + params(), + Address::ZERO, + "requester".to_string(), + 200, + 200, + 2, + ) .await .unwrap(); diff --git a/examples/CRISP/server/src/server/routes/voting.rs b/examples/CRISP/server/src/server/routes/voting.rs index 50e6b6a263..8a160a3c05 100644 --- a/examples/CRISP/server/src/server/routes/voting.rs +++ b/examples/CRISP/server/src/server/routes/voting.rs @@ -364,6 +364,7 @@ mod tests { rate_limit::ChainRateLimiter, }; use actix_web::{http::StatusCode, test, web, App}; + use alloy_primitives::Address; use e3_sdk::indexer::SharedStore; use serde_json::json; use std::sync::Arc; @@ -388,6 +389,7 @@ mod tests { census_mode: CensusMode::Token, voting_power_divisor: "0".to_owned(), }, + Address::ZERO, "requester".to_owned(), 100, 100, From 57293913710be3f4f4a93e9b11429d8128c429c5 Mon Sep 17 00:00:00 2001 From: ctrlc03 <93448202+ctrlc03@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:32:22 +0100 Subject: [PATCH 13/15] docs: document the per-E3 addition budget SEARCH_Z is the number of fresh ciphertext additions that the smudging noise covers: 100,000 for secure-8192 and 1,024 for insecure-512. Program contracts must limit their inputs to it. --- docs/pages/build/best-practices.mdx | 5 +++++ docs/pages/build/e3-program/secure-process.mdx | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/docs/pages/build/best-practices.mdx b/docs/pages/build/best-practices.mdx index bd042ce4e6..049024bea7 100644 --- a/docs/pages/build/best-practices.mdx +++ b/docs/pages/build/best-practices.mdx @@ -60,6 +60,11 @@ checklist. - **Gate `publishInput` yourself.** Interfold does not check inputs. The template program checks only `inputWindow[1]`. `CRISPProgram` also checks a ballot proof, a commitment deadline, and a signed availability attestation. +- **Keep each E3 inside the addition budget of its preset.** The ciphernodes make the smudging noise + large enough for at most `z` fresh ciphertexts added together. `z` is 100,000 for secure-8192 and + 1,024 for insecure-512. Interfold does not count inputs, so limit them in `publishInput`. + `CRISPProgram` stops at 100,000 inputs with `InputLimitReached`. See + [Limits on the computation](/build/e3-program/secure-process#limits-on-the-computation). - **Bind each ciphertext to its commitment.** The template program inserts the commitment into its input tree without a proof of the binding. A comment in `MyProgram.sol` states that production programs must verify it, because an invalid input can stop the E3 from completing. diff --git a/docs/pages/build/e3-program/secure-process.mdx b/docs/pages/build/e3-program/secure-process.mdx index 6731983628..504f51763a 100644 --- a/docs/pages/build/e3-program/secure-process.mdx +++ b/docs/pages/build/e3-program/secure-process.mdx @@ -239,6 +239,12 @@ Other limits: - BFV arithmetic is modulo the plaintext modulus. With parameter set `0` the modulus is 100, so 60 + 50 decrypts to 10. +- Each preset has an addition budget, `z` (`SEARCH_Z` in `crates/fhe-params/src/constants.rs`). The + ciphernodes make the smudging noise of their decryption shares large enough for an output of at + most `z` fresh ciphertexts added together. `z` is 100,000 for parameter set `2` and 1,024 for + parameter set `0`. With a larger sum, the smudging noise protects the secret key shares with less + statistical security than the preset specifies. A product with a plaintext also increases the + noise, and `z` does not count it. - The processor returns `Vec`, not a `Result`. An `unwrap` that fails panics. Inside the zkVM a panic stops the guest, and the E3 gets no output. - When `select` returns no inputs, `Ciphertext::zero` has no components. The output commitment then From 376642d4ff44bc095abaf22a915482cbc3c1ec8e Mon Sep 17 00:00:00 2001 From: ctrlc03 <93448202+ctrlc03@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:40:05 +0100 Subject: [PATCH 14/15] fix(crisp): skip the plaintext output of a round without a record The Interfold contract emits PlaintextOutputPublished for the rounds of every program, and the server stores no record for a round that another program requested. The handler read the round with get_crisp, so the event of such a round failed the handler, and the backfill cursor did not move past it. The handler now leaves a round without a record unchanged. --- agent/flow-trace/04_DKG_AND_COMPUTATION.md | 9 ++++--- docs/pages/CRISP/running-e3.mdx | 9 +++---- examples/CRISP/server/src/server/indexer.rs | 26 +++++++++++++++++---- examples/CRISP/server/src/server/repo.rs | 2 +- 4 files changed, 33 insertions(+), 13 deletions(-) diff --git a/agent/flow-trace/04_DKG_AND_COMPUTATION.md b/agent/flow-trace/04_DKG_AND_COMPUTATION.md index fbda184eaa..548145021f 100644 --- a/agent/flow-trace/04_DKG_AND_COMPUTATION.md +++ b/agent/flow-trace/04_DKG_AND_COMPUTATION.md @@ -1928,9 +1928,12 @@ the round only when both records exist. Either handler can complete the activati records converge, and deferred checks cover slow live-handler ordering. Duplicate request and committee events do not reset the round, replace indexed output, or resubmit an already-matching Merkle root. A duplicate request whose holder discovery fails keeps the stored census and owes a -retry. The shared Interfold contract also emits requests for other E3 programs. The CRISP indexer -ignores those requests before it creates a round or makes a program-specific RPC call. An old -program's historical round therefore cannot stop a fresh CRISP backfill. +retry. The shared Interfold contract also emits requests and plaintext outputs for other E3 +programs. The CRISP indexer ignores those requests before it creates a round or makes a +program-specific RPC call. Its `PlaintextOutputPublished` handler leaves a round without a CRISP +record unchanged. An old program's historical round therefore cannot stop a fresh CRISP backfill. +The handler decodes the tally only for a stored round whose recorded program is the configured one. +Any other stored round finishes with an empty tally. Startup rebuilds deadline callbacks for active and expired rounds and releases an interrupted compute submission for retry. The compute transition is atomic, and a synchronous program-server diff --git a/docs/pages/CRISP/running-e3.mdx b/docs/pages/CRISP/running-e3.mdx index f529e3b3d5..881d2319cf 100644 --- a/docs/pages/CRISP/running-e3.mdx +++ b/docs/pages/CRISP/running-e3.mdx @@ -195,10 +195,11 @@ A stage that misses its deadline makes the E3 fail. See the E3 lifecycle page. If a round receives no votes, the server does not request a computation. It logs `E3 has no votes to decrypt. Setting status to Finished.` and sets the status to `Finished`. -The server records the program that requested each round. On `PlaintextOutputPublished`, it decodes -the tally only for a round of the configured `E3_PROGRAM_ADDRESS`, because each `CRISPProgram` -deployment fixes its own ballot layout. Any other round gets an empty tally and the status -`Finished`. This applies also to a round that an earlier server release stored. +The server stores a round only when the program at the configured `E3_PROGRAM_ADDRESS` requested it. +On `PlaintextOutputPublished`, it decodes the tally only for a stored round of that program, because +each `CRISPProgram` deployment fixes its own ballot layout. The event changes nothing for a round +that the server did not store. Any other stored round gets an empty tally and the status `Finished`. +This applies also to a round that an earlier server release stored. ### Server log messages diff --git a/examples/CRISP/server/src/server/indexer.rs b/examples/CRISP/server/src/server/indexer.rs index 01c9797800..21392149dc 100644 --- a/examples/CRISP/server/src/server/indexer.rs +++ b/examples/CRISP/server/src/server/indexer.rs @@ -1065,16 +1065,24 @@ pub async fn register_ciphertext_output_reference_published( /// Record the tally that `PlaintextOutputPublished` carries, and finish the round. /// -/// `decode_tally` reads the ballot layout of this server. Each `CRISPProgram` deployment fixes the -/// layout of its rounds, so the tally of a round that another program requested stays empty. So -/// does the tally of a round stored without its program, because its layout is unknown. +/// The Interfold contract emits the event for the rounds of every program. The server stores no +/// record for a round that another program requested, so the event changes nothing for that round. +/// `decode_tally` reads the ballot layout of this server, and each `CRISPProgram` deployment fixes +/// the layout of its rounds. A stored round of another program therefore finishes with an empty +/// tally. So does a round stored without its program, because its layout is unknown. async fn record_plaintext_output( repo: &mut CrispE3Repository, e3_id: &str, plaintext_output: &[u8], configured_program: Address, ) -> eyre::Result<()> { - let round = repo.get_crisp().await?; + let Some(round) = repo.try_get_crisp().await? else { + info!( + "[e3_id={}] Ignoring PlaintextOutputPublished for a round without a CRISP record", + e3_id + ); + return Ok(()); + }; let same_program = round .e3_program .parse::
() @@ -2216,7 +2224,8 @@ mod plaintext_output_tests { /// Each `CRISPProgram` deployment fixes the ballot layout of its rounds, and the server decodes /// its own layout only. A round of another program, or a round stored without its program, - /// finishes with an empty tally. The server does not read it under the wrong layout. + /// finishes with an empty tally. The server does not read it under the wrong layout. A round + /// without a record stays without one, and its event does not fail the indexer. #[tokio::test] async fn only_a_round_of_the_configured_program_gets_a_tally() { let configured = Address::repeat_byte(0x11); @@ -2269,5 +2278,12 @@ mod plaintext_output_tests { assert_eq!(round.tally, tally, "round {e3_id}"); assert_eq!(round.status, "Finished", "round {e3_id}"); } + + // The server stores no record for a round that another program requested. + let mut repo = CrispE3Repository::new(store.clone(), "4"); + record_plaintext_output(&mut repo, "4", &output, configured) + .await + .unwrap(); + assert!(!repo.has_crisp_record().await.unwrap()); } } diff --git a/examples/CRISP/server/src/server/repo.rs b/examples/CRISP/server/src/server/repo.rs index bcc8338b8a..18d98603c9 100644 --- a/examples/CRISP/server/src/server/repo.rs +++ b/examples/CRISP/server/src/server/repo.rs @@ -371,7 +371,7 @@ impl CrispE3Repository { /// The distinction between "absent" and "broken" only exists here: every caller below that /// flattens it into an error loses it, and a handler that cannot tell the two apart has to /// answer 500 to a client that asked for a round that was simply never requested. - async fn try_get_crisp(&self) -> Result> { + pub async fn try_get_crisp(&self) -> Result> { let key = self.crisp_key(); self.store .get::(&key) From 96e30f8448252c91c46357789d99aff9237c1937 Mon Sep 17 00:00:00 2001 From: ctrlc03 <93448202+ctrlc03@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:03:49 +0100 Subject: [PATCH 15/15] docs: count fresh ciphertexts per input and list the node_fold tests A mask adds its fresh ciphertext to the ciphertext already in its slot, so the decrypted sum holds at most one fresh ciphertext per input. The SEARCH_Z bound therefore counts inputs, not slots. best-practices.mdx states that the 100,000 CRISP input limit fits the secure-8192 addition budget but not the insecure-512 budget. The test-circuits.sh descriptions list the node_fold tests. --- agent/invariants/02_CRYPTO_CIRCUITS.md | 25 ++++++++++--------- circuits/README.md | 2 +- docs/pages/build/best-practices.mdx | 3 ++- docs/pages/build/noir-circuits.mdx | 18 ++++++------- .../contracts/CRISPProgram.sol | 4 +-- 5 files changed, 27 insertions(+), 25 deletions(-) diff --git a/agent/invariants/02_CRYPTO_CIRCUITS.md b/agent/invariants/02_CRYPTO_CIRCUITS.md index c5ff7d0775..67fea3f768 100644 --- a/agent/invariants/02_CRYPTO_CIRCUITS.md +++ b/agent/invariants/02_CRYPTO_CIRCUITS.md @@ -574,18 +574,19 @@ every section. the committee decrypts it modulo the plaintext modulus `t` of the round's BFV parameters. `CRISPProgram.validate` must read `t` from `e3ProgramParams`, not from a constant, and size the round so that no total reaches `t`. A CONSTANT-credit round must refuse `credits >= t` and accept - at most `(t - 1) / credits` inputs, capped at `MAX_INPUTS_PER_ROUND`. Each input adds one fresh - ciphertext to the decrypted sum, so every secure preset must be searched with `SEARCH_Z` of at - least `MAX_INPUTS_PER_ROUND`. A CUSTOM-credit round must record a divisor of at least - `getPastTotalSupply(snapshot) / t + 1`, which bounds the totals only while the past votes of all - accounts sum to at most the past supply. ERC20Votes meets this. `BondedVotes` with an escrow votes - source does not guarantee it (`01_PROTOCOL_ONCHAIN.md`). Every input counts toward the limit, - including updates and masks, on both `publishInput` and `validateInputProof`. A census-tree round - also relies on the coordinator writing `credits` or the scaled voting power at the snapshot into - each leaf. For a CUSTOM-credit round, the server reads that power at `snapshotOf(e3Id)`, in the - clock units of the token. If the server cannot read the power of a voter, it posts no root until a - retry pass reads every voter. A change to the tally format, the credit modes, or the parameter - sets needs a new bound. — `flow-trace/04` + at most `(t - 1) / credits` inputs, capped at `MAX_INPUTS_PER_ROUND`. Each input adds at most one + fresh ciphertext to the decrypted sum. A mask adds its ciphertext to the one already in the slot, + so the count follows the inputs, not the slots. Every secure preset must therefore be searched + with `SEARCH_Z` of at least `MAX_INPUTS_PER_ROUND`. A CUSTOM-credit round must record a divisor of + at least `getPastTotalSupply(snapshot) / t + 1`, which bounds the totals only while the past votes + of all accounts sum to at most the past supply. ERC20Votes meets this. `BondedVotes` with an + escrow votes source does not guarantee it (`01_PROTOCOL_ONCHAIN.md`). Every input counts toward + the limit, including updates and masks, on both `publishInput` and `validateInputProof`. A + census-tree round also relies on the coordinator writing `credits` or the scaled voting power at + the snapshot into each leaf. For a CUSTOM-credit round, the server reads that power at + `snapshotOf(e3Id)`, in the clock units of the token. If the server cannot read the power of a + voter, it posts no root until a retry pass reads every voter. A change to the tally format, the + credit modes, or the parameter sets needs a new bound. — `flow-trace/04` - **CRISP constrains every coefficient of the ballot plaintext, at the real BFV degree.** The witness generator reverses the message over the full degree, so option `o` is `k1[D - 1 - o]`. `check_coefficient_values_with_balance` must decode each option weight from that coefficient, diff --git a/circuits/README.md b/circuits/README.md index 2da9a6dc91..416e1d648e 100644 --- a/circuits/README.md +++ b/circuits/README.md @@ -97,7 +97,7 @@ From the repository root: ```bash pnpm tsx scripts/build-circuits.ts # compile circuits, verification keys, artifacts ./scripts/lint-circuits.sh # nargo fmt --check; nargo check (skipped if nargo absent) -./scripts/test-circuits.sh # nargo test: lib, decryption_aggregator, dkg_aggregator (3 preset/committee pairs); nargo execute: config +./scripts/test-circuits.sh # nargo test: lib, decryption_aggregator, dkg_aggregator and node_fold (3 preset/committee pairs); nargo execute: config ``` Pin **nargo** and **bb** to the versions in `crates/zk-prover` and `versions.json`. For local work, diff --git a/docs/pages/build/best-practices.mdx b/docs/pages/build/best-practices.mdx index 049024bea7..5c688dded5 100644 --- a/docs/pages/build/best-practices.mdx +++ b/docs/pages/build/best-practices.mdx @@ -63,7 +63,8 @@ checklist. - **Keep each E3 inside the addition budget of its preset.** The ciphernodes make the smudging noise large enough for at most `z` fresh ciphertexts added together. `z` is 100,000 for secure-8192 and 1,024 for insecure-512. Interfold does not count inputs, so limit them in `publishInput`. - `CRISPProgram` stops at 100,000 inputs with `InputLimitReached`. See + `CRISPProgram` stops at 100,000 inputs with `InputLimitReached`, a limit that fits the secure-8192 + budget but not the insecure-512 budget. See [Limits on the computation](/build/e3-program/secure-process#limits-on-the-computation). - **Bind each ciphertext to its commitment.** The template program inserts the commitment into its input tree without a proof of the binding. A comment in `MyProgram.sol` states that production diff --git a/docs/pages/build/noir-circuits.mdx b/docs/pages/build/noir-circuits.mdx index 6f020fbce4..e471356991 100644 --- a/docs/pages/build/noir-circuits.mdx +++ b/docs/pages/build/noir-circuits.mdx @@ -283,15 +283,15 @@ identifies source inputs and is not an archive digest. Other circuit commands: -| Command | What it does | -| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `pnpm build:circuits --dry-run` | Lists the circuits that a build compiles, and stops. | -| `pnpm build:circuits --group dkg --skip-vk` | Compiles one group and skips the verification keys. | -| `pnpm build:circuits hash` | Prints the source hash that identifies a build. | -| `pnpm build:circuits sync-config --preset

--committee ` | Updates `utils.ts` and `ActiveCryptoConfig.sol` only. It does not compile. | -| `pnpm check:committee` | Checks that the committee and BFV values agree across Noir, Rust, TypeScript, and Solidity. | -| `pnpm noir:lint` (`scripts/lint-circuits.sh`) | Runs `nargo fmt --check` and `nargo check` on the library and each package. It exits with success if `nargo` is missing. | -| `pnpm noir:test` (`scripts/test-circuits.sh`) | Runs `nargo test` on `circuits/lib` and `decryption_aggregator`, then `nargo execute` on `bin/config`, then the `dkg_aggregator` tests for each preset and committee. | +| Command | What it does | +| -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `pnpm build:circuits --dry-run` | Lists the circuits that a build compiles, and stops. | +| `pnpm build:circuits --group dkg --skip-vk` | Compiles one group and skips the verification keys. | +| `pnpm build:circuits hash` | Prints the source hash that identifies a build. | +| `pnpm build:circuits sync-config --preset

--committee ` | Updates `utils.ts` and `ActiveCryptoConfig.sol` only. It does not compile. | +| `pnpm check:committee` | Checks that the committee and BFV values agree across Noir, Rust, TypeScript, and Solidity. | +| `pnpm noir:lint` (`scripts/lint-circuits.sh`) | Runs `nargo fmt --check` and `nargo check` on the library and each package. It exits with success if `nargo` is missing. | +| `pnpm noir:test` (`scripts/test-circuits.sh`) | Runs `nargo test` on `circuits/lib` and `decryption_aggregator`, then `nargo execute` on `bin/config`, then the `dkg_aggregator` and `node_fold` tests for three preset and committee pairs. | To make sample inputs for one circuit, use the `zk_cli` generator. Its `--committee` value must match the active committee of the library: diff --git a/examples/CRISP/packages/crisp-contracts/contracts/CRISPProgram.sol b/examples/CRISP/packages/crisp-contracts/contracts/CRISPProgram.sol index a0878beeec..2aa54dbe28 100644 --- a/examples/CRISP/packages/crisp-contracts/contracts/CRISPProgram.sol +++ b/examples/CRISP/packages/crisp-contracts/contracts/CRISPProgram.sol @@ -166,8 +166,8 @@ contract CRISPProgram is IE3Program, IE3ProgramDataAvailability, IERC165, Ownabl /// vote proof fails. Must stay aligned with the SDK constant of the same name. uint256 constant MAX_VOTE_OPTIONS = 10; /// @notice The most inputs any round accepts. - /// @dev Every input (vote, update, mask) adds one fresh ciphertext to the tally sum. The BFV - /// parameters stay correct for a fixed number of additions (`SEARCH_Z` in + /// @dev Every input (vote, update, mask) adds at most one fresh ciphertext to the tally sum. The + /// BFV parameters stay correct for a fixed number of additions (`SEARCH_Z` in /// `crates/fhe-params/src/constants.rs`); every secure parameter set CRISP runs on must allow at /// least this many. The insecure-512 test preset allows fewer. uint256 public constant MAX_INPUTS_PER_ROUND = 100_000;