@crisp-e3/contracts 0.14.0 → 0.15.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.
@@ -24,6 +24,27 @@ contract CRISPProgram is IE3Program, Ownable {
24
24
  CUSTOM
25
25
  }
26
26
 
27
+ /// @notice Where the eligible voter set for a round comes from.
28
+ /// @dev Two sources with opposite economics. TOKEN derives the electorate from balances at a
29
+ /// snapshot: the coordinator enumerates holders, which is expensive and needs an indexer, but it
30
+ /// is the only way to answer "everyone holding this token". BY_REQUESTER asks the requesting
31
+ /// contract, which already knows its own membership — a game roster, an allowlisted cohort, a
32
+ /// committee — so nothing is enumerated and no indexer is involved.
33
+ ///
34
+ /// Declared explicitly rather than inferred. A coordinator that probed every requester and
35
+ /// silently fell back on failure would turn a broken census provider into a token vote with the
36
+ /// wrong electorate, and nothing would error.
37
+ ///
38
+ /// Required, not optional: params must carry it. Making it defaultable would mean a caller that
39
+ /// forgot it silently got token discovery, which is the same silent-wrong-electorate failure one
40
+ /// level up.
41
+ enum CensusMode {
42
+ /// @notice Derived from token balances by the coordinator. The default.
43
+ TOKEN,
44
+ /// @notice Supplied by the requester via `getCensus(uint256 e3Id) returns (address[])`.
45
+ BY_REQUESTER
46
+ }
47
+
27
48
  /// @notice Struct to store all data related to a voting round
28
49
  struct RoundData {
29
50
  uint256 merkleRoot;
@@ -32,6 +53,7 @@ contract CRISPProgram is IE3Program, Ownable {
32
53
  LazyIMTData votes;
33
54
  uint256 numOptions;
34
55
  CreditMode creditMode;
56
+ CensusMode censusMode;
35
57
  }
36
58
 
37
59
  // Constants
@@ -39,8 +61,15 @@ contract CRISPProgram is IE3Program, Ownable {
39
61
  bytes32 public constant ENCRYPTION_SCHEME_ID = keccak256("fhe.rs:BFV");
40
62
  /// @notice The depth of the input Merkle tree.
41
63
  uint8 public constant TREE_DEPTH = 20;
42
- /// @notice Maximum number of bits allocated for vote counts in the plaintext output per option.
43
- uint256 constant MAX_VOTE_BITS = 50;
64
+ /// @notice Number of leading plaintext coefficients that carry the vote payload.
65
+ /// @dev Must stay aligned with `@crisp-e3/sdk` and `crisp_utils` (`MAX_MSG_NON_ZERO_COEFFS`).
66
+ /// The remaining coefficients up to the BFV degree are zero padding.
67
+ uint256 constant MAX_MSG_NON_ZERO_COEFFS = 100;
68
+ /// @notice Maximum number of vote options a round may configure.
69
+ /// @dev Bounded by the Noir circuit, which asserts `num_options <= MAX_OPTIONS`
70
+ /// (`circuits/lib/src/constants.nr`). A round above this accepts no ballot, because every
71
+ /// vote proof fails. Must stay aligned with the SDK constant of the same name.
72
+ uint256 constant MAX_VOTE_OPTIONS = 10;
44
73
  // State variables
45
74
  IInterfold public interfold;
46
75
  IRiscZeroVerifier public risc0Verifier;
@@ -61,6 +90,10 @@ contract CRISPProgram is IE3Program, Ownable {
61
90
  error InvalidMerkleRoot();
62
91
  error MerkleRootAlreadySet();
63
92
  error InvalidTallyLength();
93
+ /// @notice A requester-supplied census names who may vote, not how much each vote weighs, so it
94
+ /// only has meaning when every voter carries the same credits.
95
+ error CensusModeRequiresConstantCredits();
96
+ error InvalidCensusMode();
64
97
  error SlotIsEmpty();
65
98
  error MerkleRootNotSet();
66
99
  error InvalidNumOptions();
@@ -143,6 +176,16 @@ contract CRISPProgram is IE3Program, Ownable {
143
176
  numberOfVotes = round.votes.numberOfLeaves;
144
177
  }
145
178
 
179
+ /// @notice The census source a round was requested with.
180
+ /// @dev A separate getter rather than a sixth return value on `getRoundData`, whose tuple is
181
+ /// already consumed by the server and the SDK — widening it would break them for a field most
182
+ /// callers do not want.
183
+ /// @param e3Id The E3 to look up.
184
+ /// @return The census mode recorded at validation.
185
+ function censusModeOf(uint256 e3Id) external view returns (CensusMode) {
186
+ return e3Data[e3Id].censusMode;
187
+ }
188
+
146
189
  /// @inheritdoc IE3Program
147
190
  function validate(
148
191
  uint256 e3Id,
@@ -154,14 +197,34 @@ contract CRISPProgram is IE3Program, Ownable {
154
197
  if (msg.sender != address(interfold) && msg.sender != owner()) revert CallerNotAuthorized();
155
198
  if (e3Data[e3Id].paramsHash != bytes32(0)) revert E3AlreadyInitialized();
156
199
 
157
- // decode custom params to get the number of options
158
- (, , uint256 numOptions, CreditMode creditMode, ) = abi.decode(customParams, (address, uint256, uint256, CreditMode, uint256));
159
- if (numOptions < 2) revert InvalidNumOptions();
200
+ // Scoped so the decoded values do not outlive their use: `validate` is close enough to the
201
+ // stack limit that holding all six of them alongside the parameters exceeds it.
202
+ {
203
+ // One decode, every field required. `censusMode` is read as a uint and range-checked rather
204
+ // than decoded straight into the enum, so an unrecognised value gives a named error instead
205
+ // of a bare panic.
206
+ (, , uint256 numOptions, CreditMode creditMode, , uint256 rawCensusMode) = abi.decode(
207
+ customParams,
208
+ (address, uint256, uint256, CreditMode, uint256, uint256)
209
+ );
210
+ // The circuit asserts `num_options <= MAX_OPTIONS`, so a round configured above it accepts no
211
+ // ballot at all. Reject at request time rather than stranding a round nobody can vote in.
212
+ if (numOptions < 2 || numOptions > MAX_VOTE_OPTIONS) revert InvalidNumOptions();
213
+ if (rawCensusMode > uint256(type(CensusMode).max)) revert InvalidCensusMode();
214
+
215
+ // Rejected here rather than by the coordinator, so a combination that can never work costs
216
+ // nothing: this reverts in the same transaction that requests the E3, before any fee is paid.
217
+ if (CensusMode(rawCensusMode) == CensusMode.BY_REQUESTER && creditMode != CreditMode.CONSTANT) {
218
+ revert CensusModeRequiresConstantCredits();
219
+ }
160
220
 
161
- // we need to know the number of options for decoding the tally
162
- e3Data[e3Id].numOptions = numOptions;
163
- // we want to save the credit mode so it can be verified on chain by everyone
164
- e3Data[e3Id].creditMode = creditMode;
221
+ // we need to know the number of options for decoding the tally
222
+ e3Data[e3Id].numOptions = numOptions;
223
+ // we want to save the credit mode so it can be verified on chain by everyone
224
+ e3Data[e3Id].creditMode = creditMode;
225
+ // recorded so anyone can verify which electorate the round was requested against
226
+ e3Data[e3Id].censusMode = CensusMode(rawCensusMode);
227
+ }
165
228
 
166
229
  e3Data[e3Id].paramsHash = keccak256(e3ProgramParams);
167
230
 
@@ -237,20 +300,24 @@ contract CRISPProgram is IE3Program, Ownable {
237
300
 
238
301
  uint64[] memory tally = _decodeBytesToUint64Array(e3.plaintextOutput);
239
302
 
240
- uint256 segmentSize = tally.length / numOptions;
241
- uint256 effectiveSize = segmentSize > MAX_VOTE_BITS ? MAX_VOTE_BITS : segmentSize;
303
+ // The payload lives in the first MAX_MSG_NON_ZERO_COEFFS coefficients; the rest of
304
+ // the polynomial is zero padding and must not be read.
305
+ if (tally.length < MAX_MSG_NON_ZERO_COEFFS) revert InvalidTallyLength();
306
+
307
+ uint256 segmentSize = MAX_MSG_NON_ZERO_COEFFS / numOptions;
308
+ // More options than payload coefficients leaves nothing to decode.
309
+ if (segmentSize == 0) return new uint256[](0);
242
310
 
243
311
  votes = new uint256[](numOptions);
244
312
 
245
313
  for (uint256 optIdx = 0; optIdx < numOptions; optIdx++) {
246
314
  uint256 segmentStart = optIdx * segmentSize;
247
- // Read only the last effectiveSize bits (where the value is, MSB first)
248
- uint256 readStart = segmentStart + segmentSize - effectiveSize;
249
315
  uint256 value = 0;
250
316
 
251
- for (uint256 i = 0; i < effectiveSize; i++) {
252
- uint256 weight = 2 ** (effectiveSize - 1 - i);
253
- value += uint256(tally[readStart + i]) * weight;
317
+ // Each segment holds the count in binary, most significant coefficient first.
318
+ for (uint256 i = 0; i < segmentSize; i++) {
319
+ uint256 weight = 2 ** (segmentSize - 1 - i);
320
+ value += uint256(tally[segmentStart + i]) * weight;
254
321
  }
255
322
 
256
323
  votes[optIdx] = value;
@@ -269,15 +336,21 @@ contract CRISPProgram is IE3Program, Ownable {
269
336
  }
270
337
 
271
338
  /// @inheritdoc IE3Program
272
- function verify(uint256 e3Id, bytes32 ciphertextOutputHash, bytes memory proof) external view override returns (bool) {
339
+ function verify(
340
+ uint256 e3Id,
341
+ bytes32 ciphertextOutputHash,
342
+ bytes32 ciphertextCommitment,
343
+ bytes memory proof
344
+ ) external view override returns (bool) {
273
345
  bytes32 paramsHash = getParamsHash(e3Id);
274
346
 
275
347
  bytes32 inputRoot = bytes32(e3Data[e3Id].votes._root(TREE_DEPTH));
276
- bytes memory journal = new bytes(396); // (32 + 1) * 4 * 3
348
+ bytes memory journal = new bytes(528); // (32 + 1) * 4 * 4
277
349
 
278
350
  _encodeLengthPrefixAndHash(journal, 0, ciphertextOutputHash);
279
- _encodeLengthPrefixAndHash(journal, 132, paramsHash);
280
- _encodeLengthPrefixAndHash(journal, 264, inputRoot);
351
+ _encodeLengthPrefixAndHash(journal, 132, ciphertextCommitment);
352
+ _encodeLengthPrefixAndHash(journal, 264, paramsHash);
353
+ _encodeLengthPrefixAndHash(journal, 396, inputRoot);
281
354
 
282
355
  risc0Verifier.verify(proof, imageId, sha256(journal));
283
356
  return true;
@@ -20,6 +20,16 @@ contract MockInterfold {
20
20
  mapping(uint256 => E3) public e3s;
21
21
 
22
22
  function request(address program) external {
23
+ _request(program, 2);
24
+ }
25
+
26
+ /// @notice Request an E3 with a caller-supplied option count, so tests can
27
+ /// cover tallies with more than two options.
28
+ function requestWithOptions(address program, uint256 numOptions) external {
29
+ _request(program, numOptions);
30
+ }
31
+
32
+ function _request(address program, uint256 numOptions) internal {
23
33
  e3s[nextE3Id] = E3({
24
34
  seed: 0,
25
35
  committeeSize: IInterfold.CommitteeSize.Minimum,
@@ -28,7 +38,7 @@ contract MockInterfold {
28
38
  encryptionSchemeId: bytes32(0),
29
39
  e3Program: IE3Program(address(0)),
30
40
  paramSet: 0, // Insecure512
31
- customParams: abi.encode(address(0), nextE3Id, 2, 0, 0),
41
+ customParams: abi.encode(address(0), nextE3Id, numOptions, 0, 0, 0),
32
42
  decryptionVerifier: IDecryptionVerifier(address(0)),
33
43
  pkVerifier: IPkVerifier(address(0)),
34
44
  committeePublicKey: committeePublicKey,
@@ -38,7 +48,7 @@ contract MockInterfold {
38
48
  ciphertextCommitment: bytes32(0)
39
49
  });
40
50
 
41
- IE3Program(program).validate(nextE3Id, 0, bytes(""), bytes(""), abi.encode(address(0), nextE3Id, 2, 0, 0));
51
+ IE3Program(program).validate(nextE3Id, 0, bytes(""), bytes(""), abi.encode(address(0), nextE3Id, numOptions, 0, 0, 0));
42
52
 
43
53
  nextE3Id++;
44
54
  }
@@ -65,7 +75,7 @@ contract MockInterfold {
65
75
  encryptionSchemeId: bytes32(0),
66
76
  e3Program: IE3Program(address(0)),
67
77
  paramSet: 0, // Insecure512
68
- customParams: abi.encode(address(0), 0, 2, 0, 0),
78
+ customParams: abi.encode(address(0), 0, 2, 0, 0, 0),
69
79
  decryptionVerifier: IDecryptionVerifier(address(0)),
70
80
  pkVerifier: IPkVerifier(address(0)),
71
81
  committeePublicKey: committeePublicKey,
@@ -8,7 +8,19 @@ pragma solidity ^0.8.27;
8
8
  import { IRiscZeroVerifier, Receipt } from "risc0/IRiscZeroVerifier.sol";
9
9
 
10
10
  contract MockRISC0Verifier is IRiscZeroVerifier {
11
- function verify(bytes calldata seal, bytes32 imageId, bytes32 journalDigest) public view override {}
11
+ bytes32 public expectedJournalDigest;
12
+
13
+ error UnexpectedJournalDigest(bytes32 actual, bytes32 expected);
14
+
15
+ function setExpectedJournalDigest(bytes32 journalDigest) external {
16
+ expectedJournalDigest = journalDigest;
17
+ }
18
+
19
+ function verify(bytes calldata, bytes32, bytes32 journalDigest) public view override {
20
+ if (expectedJournalDigest != bytes32(0) && journalDigest != expectedJournalDigest) {
21
+ revert UnexpectedJournalDigest(journalDigest, expectedJournalDigest);
22
+ }
23
+ }
12
24
 
13
25
  function verifyIntegrity(Receipt calldata receipt) external view override {}
14
26
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crisp-e3/contracts",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "contracts",
@@ -31,7 +31,7 @@
31
31
  "@zk-kit/lazy-imt.sol": "2.0.0-beta.12",
32
32
  "poseidon-solidity": "^0.0.5",
33
33
  "solady": "^0.1.13",
34
- "@interfold/contracts": "0.4.0"
34
+ "@interfold/contracts": "0.5.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@nomicfoundation/hardhat-keystore": "3.0.3",
@@ -53,8 +53,8 @@
53
53
  "typechain": "^8.3.0",
54
54
  "typescript": "5.8.3",
55
55
  "viem": "2.30.6",
56
- "@crisp-e3/zk-inputs": "^0.14.0",
57
- "@crisp-e3/sdk": "^0.14.0"
56
+ "@crisp-e3/zk-inputs": "^0.15.0",
57
+ "@crisp-e3/sdk": "^0.15.0"
58
58
  },
59
59
  "scripts": {
60
60
  "compile": "hardhat compile",