@crisp-e3/contracts 0.16.0 → 0.18.0-insecure.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,9 +12,17 @@ import { IInterfold } from "@interfold/contracts/contracts/interfaces/IInterfold
12
12
  import { E3 } from "@interfold/contracts/contracts/interfaces/IE3.sol";
13
13
  import { Risc0ComputeProof } from "@interfold/contracts/contracts/lib/Risc0ComputeProof.sol";
14
14
  import { LazyIMTData, InternalLazyIMT } from "@zk-kit/lazy-imt.sol/InternalLazyIMT.sol";
15
- import { HonkVerifier } from "./CRISPVerifier.sol";
15
+ import { SNARK_SCALAR_FIELD } from "@zk-kit/lazy-imt.sol/Constants.sol";
16
+ import { EIP712 } from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
17
+ import { IHonkVerifier } from "./interfaces/IHonkVerifier.sol";
18
+ import { IVotesToken } from "./interfaces/IVotesToken.sol";
19
+ import { IERC6372Clock } from "./interfaces/IERC6372Clock.sol";
20
+
21
+ interface IInterfoldProgramRegistry {
22
+ function e3Programs(IE3Program e3Program) external view returns (bool);
23
+ }
16
24
 
17
- contract CRISPProgram is IE3Program, Ownable {
25
+ contract CRISPProgram is IE3Program, Ownable, EIP712 {
18
26
  using InternalLazyIMT for LazyIMTData;
19
27
 
20
28
  /// @notice Enum to represent credit modes
@@ -43,7 +51,12 @@ contract CRISPProgram is IE3Program, Ownable {
43
51
  /// @notice Derived from token balances by the coordinator. The default.
44
52
  TOKEN,
45
53
  /// @notice Supplied by the requester via `getCensus(uint256 e3Id) returns (address[])`.
46
- BY_REQUESTER
54
+ BY_REQUESTER,
55
+ /// @notice Read from the token by this contract, one input at a time. No list is enumerated
56
+ /// and no root is posted, so there is no census producer to trust. `publishInput` calls
57
+ /// `getPastVotes` for the slot and passes the result to the circuit as a public input, which
58
+ /// is why this mode uses the `crisp_onchain` verifier rather than the `crisp` one.
59
+ ONCHAIN
47
60
  }
48
61
 
49
62
  /// @notice Struct to store all data related to a voting round
@@ -51,10 +64,47 @@ contract CRISPProgram is IE3Program, Ownable {
51
64
  uint256 merkleRoot;
52
65
  bytes32 paramsHash;
53
66
  mapping(address slot => uint40 index) voteSlots;
67
+ /// @notice The proven ciphertext commitment of every input, keyed by slot and tree index.
68
+ /// @dev Keyed by both so a parent lookup for the wrong slot returns zero and is refused: it
69
+ /// costs no more storage than a single-key map and removes a separate same-slot check.
70
+ ///
71
+ /// A history rather than one commitment per slot. An input names the entry it extends, and
72
+ /// this contract cannot tell whether the bytes published with an entry deserialize to the
73
+ /// ciphertext its commitment describes — only the Secure Process can, once the input window
74
+ /// closes. Keeping only the newest commitment would therefore let anyone leave a slot whose
75
+ /// head nobody but they can open, and a slot that cannot be masked is a slot whose every later
76
+ /// input is provably its owner voting again. With the history, an entry like that is simply
77
+ /// never extended: the next input names the same parent, and masking continues.
78
+ mapping(address slot => mapping(uint40 index => bytes32 commitment)) inputCommitment;
79
+ /// @notice Leaves already appended to this round's input tree.
80
+ /// @dev A replay guard, not a uniqueness requirement on ballots. The proof constrains the
81
+ /// commitment, not who submits it, so anyone who observes a published input can resubmit the
82
+ /// identical calldata: the proof still verifies and {_processVote} appends again. The tally
83
+ /// does not change — the replay names the same parent as the original, which is no longer the
84
+ /// head, so the Secure Process drops it — but the tree is fixed-depth, so enough replays reach
85
+ /// capacity and every later input reverts, denying the round.
86
+ ///
87
+ /// Keyed by the leaf rather than the proof because the leaf is exactly what an append adds.
88
+ /// Two genuinely distinct inputs differ in bytes, commitment, slot or parent, so they differ
89
+ /// here; only a byte-identical resubmission collides.
90
+ mapping(uint256 leaf => bool) appendedLeaf;
54
91
  LazyIMTData votes;
55
92
  uint256 numOptions;
56
93
  CreditMode creditMode;
57
94
  CensusMode censusMode;
95
+ /// @notice The token that voting power is read from. Only used by `CensusMode.ONCHAIN`.
96
+ address token;
97
+ /// @notice The smallest voting power that may cast an input. Only used by
98
+ /// `CensusMode.ONCHAIN`, where eligibility is checked per input instead of by a census.
99
+ uint256 minVotingPower;
100
+ /// @notice Credits given to each eligible voter under `CreditMode.CONSTANT`.
101
+ uint256 credits;
102
+ /// @notice The timepoint that voting power is read at, in the ERC-6372 clock units of the
103
+ /// token. Recorded when the round is requested, so it is the same for every input.
104
+ uint48 snapshot;
105
+ /// @notice Divides raw token voting power into the units the ballot is encoded in. Only used
106
+ /// by `CensusMode.ONCHAIN`. Never zero for such a round.
107
+ uint256 votingPowerDivisor;
58
108
  }
59
109
 
60
110
  // Constants
@@ -71,11 +121,24 @@ contract CRISPProgram is IE3Program, Ownable {
71
121
  /// (`circuits/lib/src/constants.nr`). A round above this accepts no ballot, because every
72
122
  /// vote proof fails. Must stay aligned with the SDK constant of the same name.
73
123
  uint256 constant MAX_VOTE_OPTIONS = 10;
124
+ /// @notice Largest `decimals` a divisor can be derived from: `10 ** 77` is the last power of ten
125
+ /// that fits in a uint256.
126
+ uint8 constant MAX_DERIVABLE_DECIMALS = 78;
74
127
  // State variables
75
128
  IInterfold public interfold;
76
129
  IRiscZeroVerifier public risc0Verifier;
77
130
  bytes32 public imageId;
78
- HonkVerifier private immutable honkVerifier;
131
+ /// @notice Verifies ballots for the census modes that prove membership of a Merkle tree.
132
+ IHonkVerifier private immutable honkVerifier;
133
+ /// @notice Verifies ballots for `CensusMode.ONCHAIN`, whose circuit has no Merkle inputs and
134
+ /// takes voting power as a public input instead.
135
+ IHonkVerifier private immutable onchainHonkVerifier;
136
+
137
+ /// @notice The EIP-712 type of the message a voter signs to authorise one ballot.
138
+ /// @dev The digest binds the signature to the round, the slot, and this exact ciphertext. The
139
+ /// chain id and this contract address come from the EIP-712 domain, so a signature cannot be
140
+ /// replayed onto another round, another slot, another ballot, or another deployment.
141
+ bytes32 private constant BALLOT_TYPEHASH = keccak256("Ballot(uint256 e3Id,address slot,bytes32 ciphertextCommitment)");
79
142
 
80
143
  // Mappings
81
144
  mapping(uint256 e3Id => RoundData) e3Data;
@@ -84,6 +147,9 @@ contract CRISPProgram is IE3Program, Ownable {
84
147
  error CallerNotAuthorized();
85
148
  error E3AlreadyInitialized();
86
149
  error InterfoldAddressZero();
150
+ error InterfoldAlreadyBound();
151
+ error InterfoldNotContract();
152
+ error ProgramNotRegistered();
87
153
  error Risc0VerifierAddressZero();
88
154
  error InvalidHonkVerifier();
89
155
  error EmptyInputData();
@@ -94,7 +160,35 @@ contract CRISPProgram is IE3Program, Ownable {
94
160
  /// @notice A requester-supplied census names who may vote, not how much each vote weighs, so it
95
161
  /// only has meaning when every voter carries the same credits.
96
162
  error CensusModeRequiresConstantCredits();
163
+ /// @notice `CensusMode.ONCHAIN` reads voting power from a token, so it cannot run without one
164
+ /// that answers `getPastVotes`.
165
+ error CensusModeRequiresToken();
166
+ /// @notice An `ONCHAIN` round with constant credits must grant a non-zero allowance, or it
167
+ /// bounds every ballot to zero and can only accept masks.
168
+ error InvalidCredits();
169
+ /// @notice The slot holds less voting power than the round requires, so it cannot be written to.
170
+ /// @dev Raised for mask inputs as well as real votes. Under `CensusMode.ONCHAIN` this check
171
+ /// replaces the Merkle membership proof that gates both branches in the other modes.
172
+ error SlotNotEligible();
97
173
  error InvalidCensusMode();
174
+
175
+ /// @notice A token reports more decimals than a divisor can be derived from.
176
+ /// @dev `10 ** (decimals - 1)` must fit in a uint256. Pass an explicit divisor for such a token.
177
+ error UnsupportedTokenDecimals(uint8 decimals);
178
+
179
+ /// @notice An ONCHAIN round's floor is below one ballot unit.
180
+ /// @dev `minVotingPower` must be at least the divisor, so every slot that clears the floor
181
+ /// carries at least one unit of weight. Below that, a slot could be eligible to write yet scale
182
+ /// to zero — able to publish, but unable to carry any weight, which is disenfranchisement that
183
+ /// nothing on-chain would report.
184
+ error MinVotingPowerBelowScale();
185
+ /// @notice An input names a parent this slot never wrote to.
186
+ /// @dev Includes an index belonging to another slot, which the per-slot commitment map reads as
187
+ /// absent. Name no parent instead when there is nothing to extend.
188
+ error UnknownParentInput(uint40 parentIndex);
189
+
190
+ /// @notice Thrown when an input identical to one already published is submitted again.
191
+ error InputAlreadyPublished(uint256 leaf);
98
192
  error SlotIsEmpty();
99
193
  error MerkleRootNotSet();
100
194
  error InvalidNumOptions();
@@ -104,24 +198,73 @@ contract CRISPProgram is IE3Program, Ownable {
104
198
  error InvalidComputeContext();
105
199
 
106
200
  // Events
107
- event InputPublished(uint256 indexed e3Id, bytes encryptedVote, uint256 index);
108
-
109
- /// @notice Initialize the contract, binding it to a specified RISC Zero verifier.
110
- /// @param _interfold The interfold address
201
+ event InterfoldBound(address indexed interfold);
202
+
203
+ /// @notice A ciphertext input was accepted for a round.
204
+ /// @dev Carries the slot and the commitment as well as the bytes. Both are already public — the
205
+ /// slot is a plaintext `publishInput` argument and `getSlotIndex` exposes it — so emitting them
206
+ /// leaks nothing and saves every consumer from parsing transaction calldata. The Secure Process
207
+ /// needs the commitment to check that the published bytes are the ciphertext that was proven.
208
+ event InputPublished(
209
+ uint256 indexed e3Id,
210
+ address indexed slotAddress,
211
+ bytes32 encryptedVoteCommitment,
212
+ bytes encryptedVote,
213
+ uint256 index,
214
+ uint40 parentIndexPlusOne
215
+ );
216
+
217
+ /// @notice Initialize the contract without an Interfold controller.
218
+ /// @dev The owner binds the controller after Interfold registers this program.
219
+ /// @param _initialOwner The account that can configure and bind this program.
111
220
  /// @param _risc0Verifier The RISC Zero verifier address
112
221
  /// @param _honkVerifier The honk verifier address
113
222
  /// @param _imageId The image ID for the guest program
114
- constructor(IInterfold _interfold, IRiscZeroVerifier _risc0Verifier, HonkVerifier _honkVerifier, bytes32 _imageId) Ownable(msg.sender) {
115
- if (address(_interfold) == address(0)) revert InterfoldAddressZero();
223
+ constructor(
224
+ address _initialOwner,
225
+ IRiscZeroVerifier _risc0Verifier,
226
+ IHonkVerifier _honkVerifier,
227
+ IHonkVerifier _onchainHonkVerifier,
228
+ bytes32 _imageId
229
+ ) Ownable(_initialOwner) EIP712("CRISP", "1") {
116
230
  if (address(_risc0Verifier) == address(0)) revert Risc0VerifierAddressZero();
117
231
  if (address(_honkVerifier) == address(0)) revert InvalidHonkVerifier();
232
+ if (address(_onchainHonkVerifier) == address(0)) revert InvalidHonkVerifier();
118
233
 
119
- interfold = _interfold;
120
234
  risc0Verifier = _risc0Verifier;
121
235
  honkVerifier = _honkVerifier;
236
+ onchainHonkVerifier = _onchainHonkVerifier;
122
237
  imageId = _imageId;
123
238
  }
124
239
 
240
+ /// @notice Bind this program to its permanent Interfold controller.
241
+ /// @dev Interfold must register this program before the owner calls this function.
242
+ /// @param _interfold The Interfold controller that registered this program.
243
+ function bindInterfold(IInterfold _interfold) external onlyOwner {
244
+ if (address(interfold) != address(0)) revert InterfoldAlreadyBound();
245
+ if (address(_interfold) == address(0)) revert InterfoldAddressZero();
246
+ if (address(_interfold).code.length == 0) revert InterfoldNotContract();
247
+ if (!IInterfoldProgramRegistry(address(_interfold)).e3Programs(IE3Program(address(this)))) {
248
+ revert ProgramNotRegistered();
249
+ }
250
+
251
+ interfold = _interfold;
252
+ emit InterfoldBound(address(_interfold));
253
+ }
254
+
255
+ /// @notice The digest a voter signs to authorise one ballot.
256
+ /// @dev Computed for every input, and for mask inputs as well as real votes. The circuit ignores
257
+ /// it on the mask branch, but the contract must not skip it: a digest that were computed only
258
+ /// for real votes would let an observer tell the two apart on-chain, which is exactly what mask
259
+ /// inputs exist to prevent.
260
+ /// @param e3Id The E3 the ballot belongs to.
261
+ /// @param slot The slot address the ballot is written to.
262
+ /// @param ciphertextCommitment The commitment to the ballot ciphertext.
263
+ /// @return The EIP-712 digest.
264
+ function ballotDigest(uint256 e3Id, address slot, bytes32 ciphertextCommitment) public view returns (bytes32) {
265
+ return _hashTypedDataV4(keccak256(abi.encode(BALLOT_TYPEHASH, e3Id, slot, ciphertextCommitment)));
266
+ }
267
+
125
268
  /// @notice Sets the Merkle root for an E3 program. Can only be set once.
126
269
  /// @param _e3Id The E3 program ID
127
270
  /// @param _root The Merkle root to set.
@@ -133,12 +276,19 @@ contract CRISPProgram is IE3Program, Ownable {
133
276
  }
134
277
 
135
278
  /// @notice Set the Image ID for the guest program
279
+ /// @dev This value is application state, not protocol state. Interfold snapshots the protocol
280
+ /// ciphertext verifier for each E3 at request time, and that verifier's own `imageId` is
281
+ /// immutable, so changing this value cannot replace a computation the protocol already accepted.
282
+ /// It can still break an E3 that is in flight: `verify` would then check the receipt against a
283
+ /// guest that did not produce it, the round would fail as a compute timeout, and
284
+ /// `FailurePayerLib` bills that to the requester. Change it only between rounds.
136
285
  /// @param _imageId The new image ID.
137
286
  function setImageId(bytes32 _imageId) external onlyOwner {
138
287
  imageId = _imageId;
139
288
  }
140
289
 
141
290
  /// @notice Set the RISC Zero verifier.
291
+ /// @dev Carries the same in-flight risk as `setImageId`. Change it only between rounds.
142
292
  /// @param _risc0Verifier The new RISC Zero verifier address
143
293
  function setRisc0Verifier(IRiscZeroVerifier _risc0Verifier) external onlyOwner {
144
294
  if (address(_risc0Verifier) == address(0)) revert Risc0VerifierAddressZero();
@@ -178,6 +328,36 @@ contract CRISPProgram is IE3Program, Ownable {
178
328
  numberOfVotes = round.votes.numberOfLeaves;
179
329
  }
180
330
 
331
+ /// @notice The divisor applied to raw token voting power for a `CensusMode.ONCHAIN` round.
332
+ /// @dev A client must divide by exactly this before proving, because the contract passes the
333
+ /// scaled value to the circuit as public input 4 and the proof is checked against it. Zero for
334
+ /// rounds that are not ONCHAIN, where no scaling happens.
335
+ /// @param e3Id The E3 to look up.
336
+ /// @return The divisor recorded at validation.
337
+ function votingPowerDivisorOf(uint256 e3Id) external view returns (uint256) {
338
+ return e3Data[e3Id].votingPowerDivisor;
339
+ }
340
+
341
+ /// @notice The voting power a slot may spend in an ONCHAIN round, in ballot units.
342
+ /// @dev The value `publishInput` will hand the circuit as public input 4, computed by the same
343
+ /// contract that will check the proof. A client must prove against exactly this: recomputing it
344
+ /// off-chain means re-deriving the snapshot, the divisor and the rounding, and any drift only
345
+ /// shows up as an opaque verifier failure. Returns 0 for a round that is not ONCHAIN, where the
346
+ /// bound comes from the census leaf instead.
347
+ ///
348
+ /// Does not apply the eligibility floor — it answers "how much weight", not "may this slot
349
+ /// write". `publishInput` still enforces the floor.
350
+ /// @param e3Id The round.
351
+ /// @param slot The slot address.
352
+ /// @return The spendable voting power, in ballot units.
353
+ function votingPowerOf(uint256 e3Id, address slot) external view returns (uint256) {
354
+ RoundData storage round = e3Data[e3Id];
355
+ if (round.censusMode != CensusMode.ONCHAIN) return 0;
356
+ if (round.creditMode == CreditMode.CONSTANT) return round.credits;
357
+
358
+ return IVotesToken(round.token).getPastVotes(slot, round.snapshot) / round.votingPowerDivisor;
359
+ }
360
+
181
361
  /// @notice The census source a round was requested with.
182
362
  /// @dev A separate getter rather than a sixth return value on `getRoundData`, whose tuple is
183
363
  /// already consumed by the server and the SDK — widening it would break them for a field most
@@ -199,34 +379,9 @@ contract CRISPProgram is IE3Program, Ownable {
199
379
  if (msg.sender != address(interfold) && msg.sender != owner()) revert CallerNotAuthorized();
200
380
  if (e3Data[e3Id].paramsHash != bytes32(0)) revert E3AlreadyInitialized();
201
381
 
202
- // Scoped so the decoded values do not outlive their use: `validate` is close enough to the
203
- // stack limit that holding all six of them alongside the parameters exceeds it.
204
- {
205
- // One decode, every field required. `censusMode` is read as a uint and range-checked rather
206
- // than decoded straight into the enum, so an unrecognised value gives a named error instead
207
- // of a bare panic.
208
- (, , uint256 numOptions, CreditMode creditMode, , uint256 rawCensusMode) = abi.decode(
209
- customParams,
210
- (address, uint256, uint256, CreditMode, uint256, uint256)
211
- );
212
- // The circuit asserts `num_options <= MAX_OPTIONS`, so a round configured above it accepts no
213
- // ballot at all. Reject at request time rather than stranding a round nobody can vote in.
214
- if (numOptions < 2 || numOptions > MAX_VOTE_OPTIONS) revert InvalidNumOptions();
215
- if (rawCensusMode > uint256(type(CensusMode).max)) revert InvalidCensusMode();
216
-
217
- // Rejected here rather than by the coordinator, so a combination that can never work costs
218
- // nothing: this reverts in the same transaction that requests the E3, before any fee is paid.
219
- if (CensusMode(rawCensusMode) == CensusMode.BY_REQUESTER && creditMode != CreditMode.CONSTANT) {
220
- revert CensusModeRequiresConstantCredits();
221
- }
222
-
223
- // we need to know the number of options for decoding the tally
224
- e3Data[e3Id].numOptions = numOptions;
225
- // we want to save the credit mode so it can be verified on chain by everyone
226
- e3Data[e3Id].creditMode = creditMode;
227
- // recorded so anyone can verify which electorate the round was requested against
228
- e3Data[e3Id].censusMode = CensusMode(rawCensusMode);
229
- }
382
+ // Delegated to its own frame rather than scoped inline: `validate` is close enough to the
383
+ // stack limit that holding the six decoded values alongside the parameters exceeds it.
384
+ _initRound(e3Id, customParams);
230
385
 
231
386
  e3Data[e3Id].paramsHash = keccak256(e3ProgramParams);
232
387
 
@@ -236,6 +391,104 @@ contract CRISPProgram is IE3Program, Ownable {
236
391
  return ENCRYPTION_SCHEME_ID;
237
392
  }
238
393
 
394
+ /// @notice Decode the round configuration and record it.
395
+ /// @dev One decode, every field required. `censusMode` is read as a uint and range-checked
396
+ /// rather than decoded straight into the enum, so an unrecognised value gives a named error
397
+ /// instead of a bare panic.
398
+ /// @param e3Id The E3 being configured.
399
+ /// @param customParams The ABI-encoded round configuration.
400
+ function _initRound(uint256 e3Id, bytes calldata customParams) internal {
401
+ (
402
+ address token,
403
+ uint256 minVotingPower,
404
+ uint256 numOptions,
405
+ CreditMode creditMode,
406
+ uint256 credits,
407
+ uint256 rawCensusMode,
408
+ uint256 votingPowerDivisor
409
+ ) = abi.decode(customParams, (address, uint256, uint256, CreditMode, uint256, uint256, uint256));
410
+
411
+ // The circuit asserts `num_options <= MAX_OPTIONS`, so a round configured above it accepts no
412
+ // ballot at all. Reject at request time rather than stranding a round nobody can vote in.
413
+ if (numOptions < 2 || numOptions > MAX_VOTE_OPTIONS) revert InvalidNumOptions();
414
+ if (rawCensusMode > uint256(type(CensusMode).max)) revert InvalidCensusMode();
415
+
416
+ // Rejected here rather than by the coordinator, so a combination that can never work costs
417
+ // nothing: this reverts in the same transaction that requests the E3, before any fee is paid.
418
+ if (CensusMode(rawCensusMode) == CensusMode.BY_REQUESTER && creditMode != CreditMode.CONSTANT) {
419
+ revert CensusModeRequiresConstantCredits();
420
+ }
421
+
422
+ // ONCHAIN reads every voter's power from this token, so a round without one accepts no ballot
423
+ // at all. Same reasoning as the numOptions bound: fail before the fee is paid.
424
+ if (CensusMode(rawCensusMode) == CensusMode.ONCHAIN && token == address(0)) {
425
+ revert CensusModeRequiresToken();
426
+ }
427
+
428
+ // An ONCHAIN round hands `credits` to the circuit as the voting-power bound, so zero credits
429
+ // bound every ballot to zero: only a mask would be accepted, and the round would tally
430
+ // nothing. Checked for ONCHAIN only — the Merkle modes take the bound from the census leaf,
431
+ // where `credits` never reaches the circuit and the contract has nothing to check.
432
+ if (CensusMode(rawCensusMode) == CensusMode.ONCHAIN && creditMode == CreditMode.CONSTANT && credits == 0) {
433
+ revert InvalidCredits();
434
+ }
435
+
436
+ RoundData storage round = e3Data[e3Id];
437
+ // we need to know the number of options for decoding the tally
438
+ round.numOptions = numOptions;
439
+ // we want to save the credit mode so it can be verified on chain by everyone
440
+ round.creditMode = creditMode;
441
+ // recorded so anyone can verify which electorate the round was requested against
442
+ round.censusMode = CensusMode(rawCensusMode);
443
+ round.token = token;
444
+ round.minVotingPower = minVotingPower;
445
+ round.credits = credits;
446
+
447
+ // The snapshot is taken here rather than supplied by the requester. This function runs in the
448
+ // transaction that requests the E3, so `clock() - 1` is the last finalized timepoint of the
449
+ // round, and a requester cannot name a timepoint that suits it. Recording it once also makes
450
+ // every input of the round read the same electorate.
451
+ if (CensusMode(rawCensusMode) == CensusMode.ONCHAIN) {
452
+ // Checked before any call is attempted. A call to an address with no code succeeds and
453
+ // returns nothing, so `clock()` fails while decoding the empty return data rather than
454
+ // inside the call — and a decode failure is not what `try/catch` is there to catch. An EOA
455
+ // would otherwise be refused by a bare panic instead of a named error.
456
+ if (token.code.length == 0) revert CensusModeRequiresToken();
457
+
458
+ uint48 snapshot = _previousTimepoint(token);
459
+
460
+ // Probe the exact call every input will make. `_previousTimepoint` swallows a missing
461
+ // `clock()` and falls back to block numbers, which is right for a token that predates
462
+ // ERC-6372 but also lets an address that is not a votes token pass validation — and then
463
+ // every `publishInput` reverts inside `getPastVotes`, after the fee is paid.
464
+ try IVotesToken(token).getPastVotes(address(0), snapshot) returns (uint256) {} catch {
465
+ revert CensusModeRequiresToken();
466
+ }
467
+
468
+ round.snapshot = snapshot;
469
+
470
+ // Derived only after the code check above. `decimals()` on a codeless address returns empty
471
+ // data, and the failure happens while decoding rather than inside the call, which `try` does
472
+ // not catch — deriving any earlier would refuse an EOA with a bare panic instead of the
473
+ // named error the check above raises.
474
+ uint256 divisor = votingPowerDivisor == 0 ? _defaultVotingPowerDivisor(token) : votingPowerDivisor;
475
+
476
+ // Only CUSTOM credits take the circuit bound from scaled power; a CONSTANT round hands the
477
+ // circuit `credits` and never reads the scaled value, so the floor and the divisor have
478
+ // nothing to agree about there.
479
+ //
480
+ // Where they do meet, the floor is raw and the bound is scaled, so they only agree when the
481
+ // floor is worth at least one ballot unit. Requiring it here means every slot that passes
482
+ // `_eligibility` carries weight, and it costs nothing: this reverts in the transaction that
483
+ // requests the E3, not per input after the fee is paid. It also keeps masks working — they
484
+ // run the same eligibility check as real votes, so a slot that scaled to zero could not be
485
+ // masked without revealing which inputs were masks.
486
+ if (creditMode == CreditMode.CUSTOM && minVotingPower < divisor) revert MinVotingPowerBelowScale();
487
+
488
+ round.votingPowerDivisor = divisor;
489
+ }
490
+ }
491
+
239
492
  /// @inheritdoc IE3Program
240
493
  function publishInput(uint256 e3Id, bytes memory data) external {
241
494
  E3 memory e3 = interfold.getE3(e3Id);
@@ -256,34 +509,144 @@ contract CRISPProgram is IE3Program, Ownable {
256
509
  revert E3NotAcceptingInputs(e3Id);
257
510
  }
258
511
 
259
- // We need to ensure that the CRISP admin set the merkle root of the census.
260
- if (e3Data[e3Id].merkleRoot == 0) revert MerkleRootNotSet();
261
-
262
512
  if (data.length == 0) revert EmptyInputData();
263
513
 
264
- (bytes memory noirProof, address slotAddress, bytes32 encryptedVoteCommitment, bytes memory encryptedVote) = abi.decode(
265
- data,
266
- (bytes, address, bytes32, bytes)
267
- );
514
+ (
515
+ bytes memory noirProof,
516
+ address slotAddress,
517
+ bytes32 encryptedVoteCommitment,
518
+ bytes memory encryptedVote,
519
+ uint40 parentIndexPlusOne
520
+ ) = abi.decode(data, (bytes, address, bytes32, bytes, uint40));
521
+
522
+ // The two census families differ here and nowhere else. A Merkle round proves membership
523
+ // inside the circuit against a posted root. An ONCHAIN round reads the power from the token
524
+ // and gives it to the circuit, so the eligibility check has to happen here instead.
525
+ (bytes32 eligibility, IHonkVerifier verifier) = _eligibility(e3Id, slotAddress);
526
+
527
+ bytes32 parentCommitment = _parentCommitment(e3Id, slotAddress, parentIndexPlusOne);
268
528
 
269
- (uint40 voteIndex, bytes32 previousEncryptedVoteCommitment) = _processVote(e3Id, slotAddress, encryptedVoteCommitment);
529
+ uint40 voteIndex = _processVote(e3Id, slotAddress, encryptedVoteCommitment, encryptedVote, parentIndexPlusOne);
270
530
 
271
531
  // Set the public inputs for the proof. Order must match Noir circuit.
272
- bytes32[] memory noirPublicInputs = new bytes32[](7);
273
- noirPublicInputs[0] = previousEncryptedVoteCommitment;
274
- noirPublicInputs[1] = bytes32(e3Data[e3Id].merkleRoot);
275
- noirPublicInputs[2] = bytes32(uint256(uint160(slotAddress)));
276
- noirPublicInputs[3] = bytes32(uint256(previousEncryptedVoteCommitment == bytes32(0) ? 1 : 0));
277
- noirPublicInputs[4] = bytes32(e3Data[e3Id].numOptions);
278
- noirPublicInputs[5] = encryptedVoteCommitment;
279
- noirPublicInputs[6] = e3.committeePublicKey;
532
+ bytes32[] memory noirPublicInputs = new bytes32[](9);
533
+ noirPublicInputs[0] = parentCommitment;
534
+ // A Keccak digest does not fit in one field element, so it enters the circuit as its two
535
+ // 16-byte halves. The circuit rebuilds the 32 bytes with `digest_from_halves`.
536
+ {
537
+ uint256 digest = uint256(ballotDigest(e3Id, slotAddress, encryptedVoteCommitment));
538
+ noirPublicInputs[1] = bytes32(digest >> 128);
539
+ noirPublicInputs[2] = bytes32(digest & type(uint128).max);
540
+ }
541
+ noirPublicInputs[3] = bytes32(uint256(uint160(slotAddress)));
542
+ noirPublicInputs[4] = eligibility;
543
+ noirPublicInputs[5] = bytes32(uint256(parentIndexPlusOne == 0 ? 1 : 0));
544
+ noirPublicInputs[6] = bytes32(e3Data[e3Id].numOptions);
545
+ noirPublicInputs[7] = encryptedVoteCommitment;
546
+ noirPublicInputs[8] = e3.committeePublicKey;
280
547
 
281
548
  // Check if the ciphertext was encrypted correctly
282
- if (!honkVerifier.verify(noirProof, noirPublicInputs)) {
549
+ if (!verifier.verify(noirProof, noirPublicInputs)) {
283
550
  revert InvalidNoirProof();
284
551
  }
285
552
 
286
- emit InputPublished(e3Id, encryptedVote, voteIndex);
553
+ emit InputPublished(e3Id, slotAddress, encryptedVoteCommitment, encryptedVote, voteIndex, parentIndexPlusOne);
554
+ }
555
+
556
+ /// @notice The commitment of the entry an input names as its parent.
557
+ /// @dev Zero when the input names none, which is what the circuit reads as `is_first_vote`.
558
+ ///
559
+ /// Naming no parent is allowed even when the slot already holds entries, and it has to be: a
560
+ /// slot whose every entry is unusable has nothing to extend, and refusing that here would leave
561
+ /// it permanently unwritable. Nothing is gained by refusing it either — the Secure Process
562
+ /// accepts an entry only when its parent is the one currently selected for the slot, so an input
563
+ /// that skips a usable parent is dropped from the tally wherever this contract lets it through.
564
+ /// @param e3Id The round.
565
+ /// @param slotAddress The slot the input is written to.
566
+ /// @param parentIndexPlusOne The tree index of the parent entry plus one, or zero for none.
567
+ /// @return The parent's commitment, or zero.
568
+ function _parentCommitment(uint256 e3Id, address slotAddress, uint40 parentIndexPlusOne) internal view returns (bytes32) {
569
+ if (parentIndexPlusOne == 0) return bytes32(0);
570
+
571
+ bytes32 commitment = e3Data[e3Id].inputCommitment[slotAddress][parentIndexPlusOne - 1];
572
+ // Zero for an index this slot never wrote to, including one belonging to another slot. The
573
+ // circuit would read it as `is_first_vote` while this contract reads it as an update, so the
574
+ // two would disagree about the same input.
575
+ if (commitment == bytes32(0)) revert UnknownParentInput(parentIndexPlusOne - 1);
576
+
577
+ return commitment;
578
+ }
579
+
580
+ /// @notice Resolve the eligibility public input and the verifier for a round.
581
+ /// @dev Returns the value that occupies index 4 of the circuit public inputs. The `crisp` and
582
+ /// `crisp_onchain` circuits agree on every other position, so this one value and the verifier
583
+ /// address are the whole difference between the two paths.
584
+ /// @param e3Id The E3 the input belongs to.
585
+ /// @param slotAddress The slot the input is written to.
586
+ /// @return eligibility The Merkle root of the census, or the voting power of the slot.
587
+ /// @return verifier The verifier that matches the circuit of this round.
588
+ function _eligibility(uint256 e3Id, address slotAddress) internal view returns (bytes32 eligibility, IHonkVerifier verifier) {
589
+ RoundData storage round = e3Data[e3Id];
590
+
591
+ if (round.censusMode != CensusMode.ONCHAIN) {
592
+ // We need to ensure that the CRISP admin set the merkle root of the census.
593
+ if (round.merkleRoot == 0) revert MerkleRootNotSet();
594
+ return (bytes32(round.merkleRoot), honkVerifier);
595
+ }
596
+
597
+ uint256 rawPower = IVotesToken(round.token).getPastVotes(slotAddress, round.snapshot);
598
+
599
+ // The floor is compared against RAW power, in the token's own units. `minVotingPower` is a
600
+ // governance setting ("you need N tokens to vote") written the way every other token plugin
601
+ // writes it, so scaling it here would reinterpret a configured value by the divisor.
602
+ uint256 threshold = round.minVotingPower == 0 ? 1 : round.minVotingPower;
603
+ if (rawPower < threshold) revert SlotNotEligible();
604
+
605
+ // Scaled only for the circuit. It enforces `vote <= voting_power`, and the BFV encoding caps
606
+ // each choice at `2**(100/numOptions) - 1` — about 8.6e9 for three options. Raw power from an
607
+ // 18-decimal token is ~1e18 per token, so handing it over unscaled would put every holder
608
+ // above the cap and collapse token weighting into a flat ceiling. Dividing mirrors what the
609
+ // coordinator does when it builds a Merkle census (`balance / 10**(decimals - 1)`), so both
610
+ // census families encode ballots in the same units and a tally decodes the same way.
611
+ uint256 power = rawPower / round.votingPowerDivisor;
612
+
613
+ // Eligibility comes from the power at the snapshot. The weight the circuit enforces comes from
614
+ // the credit mode, so a CONSTANT round gives every eligible slot the same credits.
615
+ return (bytes32(round.creditMode == CreditMode.CONSTANT ? round.credits : power), onchainHonkVerifier);
616
+ }
617
+
618
+ /// @notice The divisor to apply to raw voting power when the requester does not name one.
619
+ /// @dev Mirrors the coordinator's census scaling (`balance / 10**(decimals - 1)`), so an ONCHAIN
620
+ /// round and a Merkle round over the same token encode ballots in identical units. `decimals()`
621
+ /// is optional on an ERC20, so a token without it is left unscaled rather than rejected — a
622
+ /// requester that needs scaling for such a token passes an explicit divisor.
623
+ /// @param token The token voting power is read from.
624
+ /// @return The divisor, never zero.
625
+ function _defaultVotingPowerDivisor(address token) internal view returns (uint256) {
626
+ try IVotesToken(token).decimals() returns (uint8 dec) {
627
+ // `10 ** 78` does not fit in a uint256, and the exponentiation happens in the success body
628
+ // of the `try`, where a revert is NOT caught — an absurd `decimals` would surface as a bare
629
+ // arithmetic panic instead of a named error, which is the failure mode the code check above
630
+ // exists to avoid. Refused explicitly; such a token can still be used by naming a divisor.
631
+ if (dec > MAX_DERIVABLE_DECIMALS) revert UnsupportedTokenDecimals(dec);
632
+
633
+ return dec > 1 ? 10 ** (uint256(dec) - 1) : 1;
634
+ } catch {
635
+ return 1;
636
+ }
637
+ }
638
+
639
+ /// @notice The last finalized timepoint of a token, in its ERC-6372 clock units.
640
+ /// @dev Falls back to block numbers when the token has no `clock()`, which matches the default
641
+ /// ERC20Votes clock.
642
+ /// @param token The token to read the clock of.
643
+ /// @return The timepoint before the current one.
644
+ function _previousTimepoint(address token) internal view returns (uint48) {
645
+ try IERC6372Clock(token).clock() returns (uint48 current) {
646
+ return current == 0 ? 0 : current - 1;
647
+ } catch {
648
+ return uint48(block.number - 1);
649
+ }
287
650
  }
288
651
 
289
652
  /// @notice Decode the tally from the plaintext output
@@ -328,15 +691,32 @@ contract CRISPProgram is IE3Program, Ownable {
328
691
  return votes;
329
692
  }
330
693
 
331
- /// @notice Get the slot index for a given E3 ID and slot address
694
+ /// @notice The index of the last input published to a slot.
695
+ /// @dev The last one *published*, which is not always the one that holds the slot. This contract
696
+ /// cannot tell whether an input's bytes deserialize to the ciphertext its commitment describes,
697
+ /// so the entry at this index may be one the Secure Process will never select. A client naming a
698
+ /// parent must resolve the chain — from the published bytes, or from the CRISP server's
699
+ /// `state/previous-ciphertext` — rather than reading it from here.
332
700
  /// @param e3Id The E3 program ID
333
701
  /// @param slotAddress The slot address
334
- /// @return The slot index, or -1 if the slot is empty
702
+ /// @return The index of the last published input, or -1 if the slot is empty
335
703
  function getSlotIndex(uint256 e3Id, address slotAddress) external view returns (int40) {
336
704
  uint40 storedIndexPlusOne = e3Data[e3Id].voteSlots[slotAddress];
337
705
  return int40(storedIndexPlusOne) - 1;
338
706
  }
339
707
 
708
+ /// @notice The commitment this contract recorded for one entry of a slot.
709
+ /// @dev Zero for an index this slot never wrote to. A client names a parent by index and must
710
+ /// prove against exactly the commitment stored for it, so this is how it checks what that will
711
+ /// be before proving.
712
+ /// @param e3Id The round.
713
+ /// @param slotAddress The slot address.
714
+ /// @param index The tree index of the entry.
715
+ /// @return The stored commitment, or zero when there is no such entry for this slot.
716
+ function inputCommitmentOf(uint256 e3Id, address slotAddress, uint40 index) external view returns (bytes32) {
717
+ return e3Data[e3Id].inputCommitment[slotAddress][index];
718
+ }
719
+
340
720
  /// @inheritdoc IE3Program
341
721
  function verify(
342
722
  uint256 e3Id,
@@ -366,29 +746,56 @@ contract CRISPProgram is IE3Program, Ownable {
366
746
  return true;
367
747
  }
368
748
 
369
- /// @notice Process a vote: insert or update in the merkle tree depending
370
- /// on whether it's the first vote or an override.
749
+ /// @notice Record one input: append its leaf and remember its commitment for later parents.
371
750
  function _processVote(
372
751
  uint256 e3Id,
373
752
  address slotAddress,
374
- bytes32 encryptedVoteCommitment
375
- ) internal returns (uint40 voteIndex, bytes32 previousEncryptedVoteCommitment) {
376
- uint40 storedIndexPlusOne = e3Data[e3Id].voteSlots[slotAddress];
753
+ bytes32 encryptedVoteCommitment,
754
+ bytes memory encryptedVote,
755
+ uint40 parentIndexPlusOne
756
+ ) internal returns (uint40 voteIndex) {
757
+ RoundData storage round = e3Data[e3Id];
377
758
 
378
- // we treat the index 0 as not voted yet
379
- // any valid index will be index + 1
380
- if (storedIndexPlusOne == 0) {
381
- // FIRST VOTE
382
- previousEncryptedVoteCommitment = bytes32(0);
383
- voteIndex = e3Data[e3Id].votes.numberOfLeaves;
384
- e3Data[e3Id].voteSlots[slotAddress] = voteIndex + 1;
385
- e3Data[e3Id].votes._insert(uint256(encryptedVoteCommitment));
386
- } else {
387
- // RE-VOTE
388
- voteIndex = storedIndexPlusOne - 1;
389
- previousEncryptedVoteCommitment = bytes32(e3Data[e3Id].votes.elements[voteIndex]);
390
- e3Data[e3Id].votes._update(uint256(encryptedVoteCommitment), voteIndex);
391
- }
759
+ // Append-only. Updating a slot's leaf in place would let anyone who can write to a slot — and
760
+ // the mask path needs no signature replace the bytes of a vote that was already counted,
761
+ // erasing it. Appending leaves the earlier entry in the tree, so the Secure Process can fall
762
+ // back to it when a later entry is unusable, and nothing is lost.
763
+ uint256 leaf = inputLeaf(encryptedVote, encryptedVoteCommitment, slotAddress, parentIndexPlusOne);
764
+
765
+ // Refuse a byte-identical resubmission. Without this the tree is a free growth surface for
766
+ // anyone replaying a published input, and the round dies at tree capacity rather than at the
767
+ // input deadline.
768
+ if (round.appendedLeaf[leaf]) revert InputAlreadyPublished(leaf);
769
+ round.appendedLeaf[leaf] = true;
770
+
771
+ voteIndex = round.votes.numberOfLeaves;
772
+ round.votes._insert(leaf);
773
+
774
+ round.voteSlots[slotAddress] = voteIndex + 1;
775
+ round.inputCommitment[slotAddress][voteIndex] = encryptedVoteCommitment;
776
+ }
777
+
778
+ /// @notice Builds the input tree leaf for one published input.
779
+ /// @dev Binds four things the Secure Process must be able to trust:
780
+ ///
781
+ /// - the **bytes**, because the Noir proof constrains the commitment and never sees the
782
+ /// serialized ciphertext, so the two can disagree and only the guest can tell;
783
+ /// - the **commitment**, so a submitter cannot pair any commitment with any ciphertext;
784
+ /// - the **slot**, because the tree is append-only and the guest tallies one entry per slot.
785
+ /// Without the slot in the leaf a prover could re-group entries and change which one wins;
786
+ /// - the **parent**, because that is what the guest walks the slot's chain by. An unbound parent
787
+ /// would let a prover re-point entries and select a different one.
788
+ ///
789
+ /// SHA-256 rather than Keccak: the zkVM accelerates SHA-256 inline, while its Keccak accelerator
790
+ /// emits a proof assumption the host must prove separately and compose. The extra on-chain cost
791
+ /// is about 67k gas on a transaction that already carries the ciphertext.
792
+ function inputLeaf(
793
+ bytes memory encryptedVote,
794
+ bytes32 commitment,
795
+ address slotAddress,
796
+ uint40 parentIndexPlusOne
797
+ ) public pure returns (uint256) {
798
+ return uint256(sha256(abi.encodePacked(sha256(encryptedVote), commitment, slotAddress, parentIndexPlusOne))) % SNARK_SCALAR_FIELD;
392
799
  }
393
800
 
394
801
  /// @notice Decode bytes to uint64 array