@crisp-e3/contracts 0.17.0 → 0.18.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.
package/README.md CHANGED
@@ -39,7 +39,7 @@ pnpm deploy:contracts:full # also deploy Interfold stack (no ZK unless ENABL
39
39
 
40
40
  This is the main logic of CRISP - an interfold program for secure voting.
41
41
 
42
- It exposes two main functions:
42
+ It exposes three main functions:
43
43
 
44
44
  - `validate` - that is called when a new E3 instance is requested on Interfold
45
45
  (`Interfold.request`).
@@ -47,10 +47,15 @@ It exposes two main functions:
47
47
  (`Interfold.publishCiphertextOutput`). This function ensures that the ciphertext output is valid.
48
48
  CRISP uses Risc0 as the compute provider for running the FHE program, thus the proof will be a
49
49
  Risc0 proof.
50
- - `validateInput` - validate the input data that is submitted to the E3 instance. It is called by
51
- the Interfold contract when a new input is published (`Interfold.publishInput`). In CRISP, the
52
- data providers (the ones submitting the inputs) are the voters, and the input submitted is the
53
- vote itself. The logic checks that gating conditions are satisfied and that the ciphertext is
54
- constructed correctly using
55
- [Greco](https://github.com/gnosisguild/interfold/tree/main/circuits/crates/libs/greco). See the
56
- Greco [paper](https://eprint.iacr.org/2024/594).
50
+ - `publishInput` - accepts an input for the E3 instance. Data providers call it on this contract
51
+ directly. In CRISP, the data providers are the voters and the input is the vote itself. The
52
+ function checks the stage and the input window, resolves the voter's eligibility from the census,
53
+ and verifies a Noir proof over nine public inputs, which is what establishes that the ciphertext
54
+ was encrypted correctly under the committee public key
55
+ (`examples/CRISP/packages/crisp-contracts/contracts/CRISPProgram.sol:493-554`, paths from the
56
+ repository root). The verifier is the one the round's census selects: `CRISPVerifier.sol` for a
57
+ census posted as a Merkle root, `CRISPOnchainVerifier.sol` for one read from token balances on
58
+ chain. Both files declare a contract named `HonkVerifier`, which is why they are named by file.
59
+ The Greco relations that proof checks are built by
60
+ `crates/zk-helpers/src/circuits/threshold/user_data_encryption/` and proved by the circuits under
61
+ `circuits/bin/threshold/`. See the Greco [paper](https://eprint.iacr.org/2024/594).
@@ -12,11 +12,16 @@ 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 { SNARK_SCALAR_FIELD } from "@zk-kit/lazy-imt.sol/Constants.sol";
15
16
  import { EIP712 } from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
16
17
  import { IHonkVerifier } from "./interfaces/IHonkVerifier.sol";
17
18
  import { IVotesToken } from "./interfaces/IVotesToken.sol";
18
19
  import { IERC6372Clock } from "./interfaces/IERC6372Clock.sol";
19
20
 
21
+ interface IInterfoldProgramRegistry {
22
+ function e3Programs(IE3Program e3Program) external view returns (bool);
23
+ }
24
+
20
25
  contract CRISPProgram is IE3Program, Ownable, EIP712 {
21
26
  using InternalLazyIMT for LazyIMTData;
22
27
 
@@ -59,6 +64,30 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 {
59
64
  uint256 merkleRoot;
60
65
  bytes32 paramsHash;
61
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;
62
91
  LazyIMTData votes;
63
92
  uint256 numOptions;
64
93
  CreditMode creditMode;
@@ -73,6 +102,9 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 {
73
102
  /// @notice The timepoint that voting power is read at, in the ERC-6372 clock units of the
74
103
  /// token. Recorded when the round is requested, so it is the same for every input.
75
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;
76
108
  }
77
109
 
78
110
  // Constants
@@ -89,6 +121,9 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 {
89
121
  /// (`circuits/lib/src/constants.nr`). A round above this accepts no ballot, because every
90
122
  /// vote proof fails. Must stay aligned with the SDK constant of the same name.
91
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;
92
127
  // State variables
93
128
  IInterfold public interfold;
94
129
  IRiscZeroVerifier public risc0Verifier;
@@ -112,6 +147,9 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 {
112
147
  error CallerNotAuthorized();
113
148
  error E3AlreadyInitialized();
114
149
  error InterfoldAddressZero();
150
+ error InterfoldAlreadyBound();
151
+ error InterfoldNotContract();
152
+ error ProgramNotRegistered();
115
153
  error Risc0VerifierAddressZero();
116
154
  error InvalidHonkVerifier();
117
155
  error EmptyInputData();
@@ -133,6 +171,24 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 {
133
171
  /// replaces the Merkle membership proof that gates both branches in the other modes.
134
172
  error SlotNotEligible();
135
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);
136
192
  error SlotIsEmpty();
137
193
  error MerkleRootNotSet();
138
194
  error InvalidNumOptions();
@@ -142,32 +198,60 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 {
142
198
  error InvalidComputeContext();
143
199
 
144
200
  // Events
145
- event InputPublished(uint256 indexed e3Id, bytes encryptedVote, uint256 index);
146
-
147
- /// @notice Initialize the contract, binding it to a specified RISC Zero verifier.
148
- /// @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.
149
220
  /// @param _risc0Verifier The RISC Zero verifier address
150
221
  /// @param _honkVerifier The honk verifier address
151
222
  /// @param _imageId The image ID for the guest program
152
223
  constructor(
153
- IInterfold _interfold,
224
+ address _initialOwner,
154
225
  IRiscZeroVerifier _risc0Verifier,
155
226
  IHonkVerifier _honkVerifier,
156
227
  IHonkVerifier _onchainHonkVerifier,
157
228
  bytes32 _imageId
158
- ) Ownable(msg.sender) EIP712("CRISP", "1") {
159
- if (address(_interfold) == address(0)) revert InterfoldAddressZero();
229
+ ) Ownable(_initialOwner) EIP712("CRISP", "1") {
160
230
  if (address(_risc0Verifier) == address(0)) revert Risc0VerifierAddressZero();
161
231
  if (address(_honkVerifier) == address(0)) revert InvalidHonkVerifier();
162
232
  if (address(_onchainHonkVerifier) == address(0)) revert InvalidHonkVerifier();
163
233
 
164
- interfold = _interfold;
165
234
  risc0Verifier = _risc0Verifier;
166
235
  honkVerifier = _honkVerifier;
167
236
  onchainHonkVerifier = _onchainHonkVerifier;
168
237
  imageId = _imageId;
169
238
  }
170
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
+
171
255
  /// @notice The digest a voter signs to authorise one ballot.
172
256
  /// @dev Computed for every input, and for mask inputs as well as real votes. The circuit ignores
173
257
  /// it on the mask branch, but the contract must not skip it: a digest that were computed only
@@ -192,12 +276,19 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 {
192
276
  }
193
277
 
194
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.
195
285
  /// @param _imageId The new image ID.
196
286
  function setImageId(bytes32 _imageId) external onlyOwner {
197
287
  imageId = _imageId;
198
288
  }
199
289
 
200
290
  /// @notice Set the RISC Zero verifier.
291
+ /// @dev Carries the same in-flight risk as `setImageId`. Change it only between rounds.
201
292
  /// @param _risc0Verifier The new RISC Zero verifier address
202
293
  function setRisc0Verifier(IRiscZeroVerifier _risc0Verifier) external onlyOwner {
203
294
  if (address(_risc0Verifier) == address(0)) revert Risc0VerifierAddressZero();
@@ -237,6 +328,36 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 {
237
328
  numberOfVotes = round.votes.numberOfLeaves;
238
329
  }
239
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
+
240
361
  /// @notice The census source a round was requested with.
241
362
  /// @dev A separate getter rather than a sixth return value on `getRoundData`, whose tuple is
242
363
  /// already consumed by the server and the SDK — widening it would break them for a field most
@@ -277,10 +398,15 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 {
277
398
  /// @param e3Id The E3 being configured.
278
399
  /// @param customParams The ABI-encoded round configuration.
279
400
  function _initRound(uint256 e3Id, bytes calldata customParams) internal {
280
- (address token, uint256 minVotingPower, uint256 numOptions, CreditMode creditMode, uint256 credits, uint256 rawCensusMode) = abi.decode(
281
- customParams,
282
- (address, uint256, uint256, CreditMode, uint256, uint256)
283
- );
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));
284
410
 
285
411
  // The circuit asserts `num_options <= MAX_OPTIONS`, so a round configured above it accepts no
286
412
  // ballot at all. Reject at request time rather than stranding a round nobody can vote in.
@@ -340,6 +466,26 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 {
340
466
  }
341
467
 
342
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;
343
489
  }
344
490
  }
345
491
 
@@ -365,21 +511,26 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 {
365
511
 
366
512
  if (data.length == 0) revert EmptyInputData();
367
513
 
368
- (bytes memory noirProof, address slotAddress, bytes32 encryptedVoteCommitment, bytes memory encryptedVote) = abi.decode(
369
- data,
370
- (bytes, address, bytes32, bytes)
371
- );
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));
372
521
 
373
522
  // The two census families differ here and nowhere else. A Merkle round proves membership
374
523
  // inside the circuit against a posted root. An ONCHAIN round reads the power from the token
375
524
  // and gives it to the circuit, so the eligibility check has to happen here instead.
376
525
  (bytes32 eligibility, IHonkVerifier verifier) = _eligibility(e3Id, slotAddress);
377
526
 
378
- (uint40 voteIndex, bytes32 previousEncryptedVoteCommitment) = _processVote(e3Id, slotAddress, encryptedVoteCommitment);
527
+ bytes32 parentCommitment = _parentCommitment(e3Id, slotAddress, parentIndexPlusOne);
528
+
529
+ uint40 voteIndex = _processVote(e3Id, slotAddress, encryptedVoteCommitment, encryptedVote, parentIndexPlusOne);
379
530
 
380
531
  // Set the public inputs for the proof. Order must match Noir circuit.
381
532
  bytes32[] memory noirPublicInputs = new bytes32[](9);
382
- noirPublicInputs[0] = previousEncryptedVoteCommitment;
533
+ noirPublicInputs[0] = parentCommitment;
383
534
  // A Keccak digest does not fit in one field element, so it enters the circuit as its two
384
535
  // 16-byte halves. The circuit rebuilds the 32 bytes with `digest_from_halves`.
385
536
  {
@@ -389,7 +540,7 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 {
389
540
  }
390
541
  noirPublicInputs[3] = bytes32(uint256(uint160(slotAddress)));
391
542
  noirPublicInputs[4] = eligibility;
392
- noirPublicInputs[5] = bytes32(uint256(previousEncryptedVoteCommitment == bytes32(0) ? 1 : 0));
543
+ noirPublicInputs[5] = bytes32(uint256(parentIndexPlusOne == 0 ? 1 : 0));
393
544
  noirPublicInputs[6] = bytes32(e3Data[e3Id].numOptions);
394
545
  noirPublicInputs[7] = encryptedVoteCommitment;
395
546
  noirPublicInputs[8] = e3.committeePublicKey;
@@ -399,7 +550,31 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 {
399
550
  revert InvalidNoirProof();
400
551
  }
401
552
 
402
- 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;
403
578
  }
404
579
 
405
580
  /// @notice Resolve the eligibility public input and the verifier for a round.
@@ -419,19 +594,48 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 {
419
594
  return (bytes32(round.merkleRoot), honkVerifier);
420
595
  }
421
596
 
422
- uint256 power = IVotesToken(round.token).getPastVotes(slotAddress, round.snapshot);
597
+ uint256 rawPower = IVotesToken(round.token).getPastVotes(slotAddress, round.snapshot);
423
598
 
424
- // A slot with no power is never writable, whatever the round configured. Without this floor a
425
- // round with `minVotingPower == 0` would let anyone mask any address, including addresses that
426
- // never held the token.
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.
427
602
  uint256 threshold = round.minVotingPower == 0 ? 1 : round.minVotingPower;
428
- if (power < threshold) revert SlotNotEligible();
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;
429
612
 
430
613
  // Eligibility comes from the power at the snapshot. The weight the circuit enforces comes from
431
614
  // the credit mode, so a CONSTANT round gives every eligible slot the same credits.
432
615
  return (bytes32(round.creditMode == CreditMode.CONSTANT ? round.credits : power), onchainHonkVerifier);
433
616
  }
434
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
+
435
639
  /// @notice The last finalized timepoint of a token, in its ERC-6372 clock units.
436
640
  /// @dev Falls back to block numbers when the token has no `clock()`, which matches the default
437
641
  /// ERC20Votes clock.
@@ -487,15 +691,32 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 {
487
691
  return votes;
488
692
  }
489
693
 
490
- /// @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.
491
700
  /// @param e3Id The E3 program ID
492
701
  /// @param slotAddress The slot address
493
- /// @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
494
703
  function getSlotIndex(uint256 e3Id, address slotAddress) external view returns (int40) {
495
704
  uint40 storedIndexPlusOne = e3Data[e3Id].voteSlots[slotAddress];
496
705
  return int40(storedIndexPlusOne) - 1;
497
706
  }
498
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
+
499
720
  /// @inheritdoc IE3Program
500
721
  function verify(
501
722
  uint256 e3Id,
@@ -525,29 +746,56 @@ contract CRISPProgram is IE3Program, Ownable, EIP712 {
525
746
  return true;
526
747
  }
527
748
 
528
- /// @notice Process a vote: insert or update in the merkle tree depending
529
- /// 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.
530
750
  function _processVote(
531
751
  uint256 e3Id,
532
752
  address slotAddress,
533
- bytes32 encryptedVoteCommitment
534
- ) internal returns (uint40 voteIndex, bytes32 previousEncryptedVoteCommitment) {
535
- 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];
536
758
 
537
- // we treat the index 0 as not voted yet
538
- // any valid index will be index + 1
539
- if (storedIndexPlusOne == 0) {
540
- // FIRST VOTE
541
- previousEncryptedVoteCommitment = bytes32(0);
542
- voteIndex = e3Data[e3Id].votes.numberOfLeaves;
543
- e3Data[e3Id].voteSlots[slotAddress] = voteIndex + 1;
544
- e3Data[e3Id].votes._insert(uint256(encryptedVoteCommitment));
545
- } else {
546
- // RE-VOTE
547
- voteIndex = storedIndexPlusOne - 1;
548
- previousEncryptedVoteCommitment = bytes32(e3Data[e3Id].votes.elements[voteIndex]);
549
- e3Data[e3Id].votes._update(uint256(encryptedVoteCommitment), voteIndex);
550
- }
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;
551
799
  }
552
800
 
553
801
  /// @notice Decode bytes to uint64 array
@@ -19,6 +19,11 @@ contract MockInterfold {
19
19
  uint256 public nextE3Id;
20
20
 
21
21
  mapping(uint256 => E3) public e3s;
22
+ mapping(IE3Program => bool) public e3Programs;
23
+
24
+ function registerE3Program(IE3Program program) external {
25
+ e3Programs[program] = true;
26
+ }
22
27
 
23
28
  function request(address program) external {
24
29
  _request(program, 2);
@@ -68,7 +73,7 @@ contract MockInterfold {
68
73
  encryptionSchemeId: ENCRYPTION_SCHEME_ID,
69
74
  e3Program: IE3Program(address(0)),
70
75
  paramSet: 0, // Insecure512
71
- customParams: abi.encode(address(0), nextE3Id, numOptions, 0, 0, 0),
76
+ customParams: abi.encode(address(0), nextE3Id, numOptions, 0, 0, 0, 0),
72
77
  decryptionVerifier: IDecryptionVerifier(address(0)),
73
78
  pkVerifier: IPkVerifier(address(0)),
74
79
  committeePublicKey: committeePublicKey,
@@ -78,7 +83,7 @@ contract MockInterfold {
78
83
  ciphertextCommitment: bytes32(0)
79
84
  });
80
85
 
81
- IE3Program(program).validate(nextE3Id, 0, bytes(""), bytes(""), abi.encode(address(0), nextE3Id, numOptions, 0, 0, 0));
86
+ IE3Program(program).validate(nextE3Id, 0, bytes(""), bytes(""), abi.encode(address(0), nextE3Id, numOptions, 0, 0, 0, 0));
82
87
 
83
88
  nextE3Id++;
84
89
  }
@@ -105,7 +110,7 @@ contract MockInterfold {
105
110
  encryptionSchemeId: ENCRYPTION_SCHEME_ID,
106
111
  e3Program: IE3Program(address(0)),
107
112
  paramSet: 0, // Insecure512
108
- customParams: abi.encode(address(0), 0, 2, 0, 0, 0),
113
+ customParams: abi.encode(address(0), 0, 2, 0, 0, 0, 0),
109
114
  decryptionVerifier: IDecryptionVerifier(address(0)),
110
115
  pkVerifier: IPkVerifier(address(0)),
111
116
  committeePublicKey: committeePublicKey,
@@ -0,0 +1,101 @@
1
+ // SPDX-License-Identifier: LGPL-3.0-only
2
+ //
3
+ // This file is provided WITHOUT ANY WARRANTY;
4
+ // without even the implied warranty of MERCHANTABILITY
5
+ // or FITNESS FOR A PARTICULAR PURPOSE.
6
+ pragma solidity >=0.8.27;
7
+
8
+ /// @notice An open self-registration census for `CensusMode.ONCHAIN` rounds.
9
+ /// @dev Anyone may register themselves, once, and registration is permanent. The contract answers
10
+ /// the `IVotesToken` surface `CRISPProgram._eligibility` reads: `getPastVotes` returns 1 for a
11
+ /// registered account and 0 otherwise. It has no `decimals()`, so the voting-power divisor
12
+ /// derives to 1, and no `clock()`, so the round snapshot falls back to block numbers — both
13
+ /// fallbacks `CRISPProgram` already implements.
14
+ ///
15
+ /// `getPastVotes` deliberately ignores the timepoint. An honest checkpointed answer would fix the
16
+ /// electorate at the round's snapshot, which is taken in the transaction that requests the round —
17
+ /// before anyone knew there was a round to register for. Ignoring it is what lets a voter register
18
+ /// during the input window and vote in the same round. The cost is that the electorate is not
19
+ /// fixed per round: an input's eligibility depends on when it is published, not on one snapshot
20
+ /// every input shares. That is a deliberate trade for low-stakes polls.
21
+ ///
22
+ /// Registration is open and unpriced, so one person can register any number of addresses. Use
23
+ /// this census only where one-address-one-vote is not worth attacking — a meme poll, a temperature
24
+ /// check — never where the outcome carries value. Pair it with `CreditMode.CONSTANT` and credits
25
+ /// of 1: every registrant carries the same weight, and `minVotingPower` of 1 is exactly what a
26
+ /// registered account reports.
27
+ ///
28
+ /// Registrants are enumerable so mask submitters can draw a target: a mask is written to someone
29
+ /// else's slot, and without a public list of who is eligible there is nobody to mask. The list is
30
+ /// append-only, which keeps indices stable for random selection.
31
+ contract SelfRegistry {
32
+ /// @notice One entry per registered account, in registration order. Append-only.
33
+ address[] private _registrants;
34
+
35
+ /// @notice Whether an account has registered.
36
+ mapping(address account => bool) public isRegistered;
37
+
38
+ /// @notice An account registered for voting.
39
+ /// @param account The account that registered.
40
+ /// @param index Its position in the registrant list.
41
+ event Registered(address indexed account, uint256 index);
42
+
43
+ /// @notice The account has already registered; registration is permanent and single-shot.
44
+ error AlreadyRegistered(address account);
45
+
46
+ /// @notice Register the caller as an eligible voter.
47
+ /// @dev Permanent: there is no deregistration. Removing an entry would shift or hole the
48
+ /// registrant list that mask submitters index into, and would let a voter provably leave the
49
+ /// electorate mid-round — a slot that cannot be masked any more is a receipt.
50
+ function register() external {
51
+ if (isRegistered[msg.sender]) revert AlreadyRegistered(msg.sender);
52
+
53
+ isRegistered[msg.sender] = true;
54
+ _registrants.push(msg.sender);
55
+
56
+ emit Registered(msg.sender, _registrants.length - 1);
57
+ }
58
+
59
+ /// @notice The voting power of an account: 1 when registered, 0 otherwise.
60
+ /// @dev The timepoint is ignored — see the contract-level note. The parameter stays in the
61
+ /// signature because `CRISPProgram` calls through `IVotesToken`.
62
+ /// @param account The account to read.
63
+ /// @return One for a registered account, zero otherwise.
64
+ function getPastVotes(address account, uint256) external view returns (uint256) {
65
+ return isRegistered[account] ? 1 : 0;
66
+ }
67
+
68
+ /// @notice How many accounts have registered.
69
+ /// @return The registrant count.
70
+ function totalRegistrants() external view returns (uint256) {
71
+ return _registrants.length;
72
+ }
73
+
74
+ /// @notice The registrant at one index of the append-only list.
75
+ /// @param index The position, in registration order.
76
+ /// @return The registered account.
77
+ function registrantAt(uint256 index) external view returns (address) {
78
+ return _registrants[index];
79
+ }
80
+
81
+ /// @notice A page of the registrant list, for clients drawing mask targets.
82
+ /// @dev Clamped rather than reverting past the end, so a caller can page with a fixed size and
83
+ /// not race registrations between reading the count and reading the page.
84
+ /// @param start The first index to read.
85
+ /// @param count How many entries to read at most.
86
+ /// @return page The registrants in `[start, min(start + count, total))`.
87
+ function registrants(uint256 start, uint256 count) external view returns (address[] memory page) {
88
+ uint256 total = _registrants.length;
89
+ if (start >= total) return new address[](0);
90
+
91
+ // Compared by subtraction, not by `start + count > total`: the addition runs under checked
92
+ // arithmetic, so a large `count` would revert before the clamp it exists to trigger.
93
+ // `total - start` cannot underflow — `start < total` is established above.
94
+ uint256 end = count > total - start ? total : start + count;
95
+ page = new address[](end - start);
96
+
97
+ for (uint256 i = start; i < end; i++) {
98
+ page[i - start] = _registrants[i];
99
+ }
100
+ }
101
+ }
@@ -7,8 +7,10 @@ pragma solidity >=0.8.27;
7
7
 
8
8
  /// @notice The subset of a generated Honk verifier that `CRISPProgram` calls.
9
9
  /// @dev Declared as an interface so the census paths can hold verifiers generated from different
10
- /// circuits. `CRISPVerifier.sol` and `CRISPOnchainVerifier.sol` both declare a contract named
11
- /// `HonkVerifier`, so importing both concrete types would collide.
10
+ /// circuits. Every generated verifier declares a contract named `HonkVerifier`, and there is one
11
+ /// per census mode per BFV preset under `contracts/verifiers/<preset>/`, so importing the concrete
12
+ /// types would collide. Deployment resolves the right one by fully qualified name; see
13
+ /// `scripts/verifiers.ts`.
12
14
  interface IHonkVerifier {
13
15
  /// @notice Verify a folded ballot proof against its public inputs.
14
16
  /// @dev Reverts rather than returning false when the public inputs do not match the proof.
@@ -14,4 +14,10 @@ interface IVotesToken {
14
14
  /// @param timepoint The timepoint, in the ERC-6372 clock units of the token.
15
15
  /// @return The voting power at that timepoint.
16
16
  function getPastVotes(address account, uint256 timepoint) external view returns (uint256);
17
+
18
+ /// @notice The token's decimals, used to derive the default voting-power divisor.
19
+ /// @dev Optional: a token without it falls back to a divisor of 1, so the probe must not be
20
+ /// treated as a requirement the way `getPastVotes` is.
21
+ /// @return The number of decimals.
22
+ function decimals() external view returns (uint8);
17
23
  }
@@ -8,7 +8,7 @@ pragma solidity >=0.8.21;
8
8
  uint256 constant N = 2097152;
9
9
  uint256 constant LOG_N = 21;
10
10
  uint256 constant NUMBER_OF_PUBLIC_INPUTS = 17;
11
- uint256 constant VK_HASH = 0x0b1a8d6509209282299c53d628ce472a47ef437bdb6799ccbc0423372bb530d4;
11
+ uint256 constant VK_HASH = 0x0a12a4d2efe48fe7bee586e156eb7cb5d2d7dc71bccf0fa4b4a9b91c097b4c8c;
12
12
  library HonkVerificationKey {
13
13
  function loadVerificationKey() internal pure returns (Honk.VerificationKey memory) {
14
14
  Honk.VerificationKey memory vk = Honk.VerificationKey({
@@ -20,8 +20,8 @@ library HonkVerificationKey {
20
20
  y: uint256(0x01137e39f6b1ec6101fad7a4472102bfe11e7534e55e58109734c4b927efe6a6)
21
21
  }),
22
22
  qr: Honk.G1Point({
23
- x: uint256(0x289b159ca7e4437437f18d73d2b649c2043fa68dfcf97dbfeedf6f1b424d068b),
24
- y: uint256(0x28ed000f4b9a44c36695072beaab8802db7f39473ce933426e62ee4f817e0899)
23
+ x: uint256(0x2ac08692659e39f2a9f8cff82c54221ef67e138279c03ffbe9cba0976cb5c5bb),
24
+ y: uint256(0x18d2a25f4040b02577dcec145c99e389c578119bc9652bd2e07cf1e6ac2edbd9)
25
25
  }),
26
26
  qo: Honk.G1Point({
27
27
  x: uint256(0x18424976826978ddb710bfdc21c64a296884c27b0d8dadf6ac7681445dde8d8c),
@@ -8,7 +8,7 @@ pragma solidity >=0.8.21;
8
8
  uint256 constant N = 2097152;
9
9
  uint256 constant LOG_N = 21;
10
10
  uint256 constant NUMBER_OF_PUBLIC_INPUTS = 17;
11
- uint256 constant VK_HASH = 0x1d0e6ec8f2fe720a1f528f02a66b66c1479c1648afaaedec06d89b889a0ff0c2;
11
+ uint256 constant VK_HASH = 0x00d736cd7cf93a94fe0fa02be1bd85f7ea05d3bfa1697f95456c05a7cce354ce;
12
12
  library HonkVerificationKey {
13
13
  function loadVerificationKey() internal pure returns (Honk.VerificationKey memory) {
14
14
  Honk.VerificationKey memory vk = Honk.VerificationKey({
@@ -20,8 +20,8 @@ library HonkVerificationKey {
20
20
  y: uint256(0x01137e39f6b1ec6101fad7a4472102bfe11e7534e55e58109734c4b927efe6a6)
21
21
  }),
22
22
  qr: Honk.G1Point({
23
- x: uint256(0x0c9d534606fb88b2814caaec28c8f0ccd9f903b2236fe399ce723cb7a89cff54),
24
- y: uint256(0x12d1f3a930e1c9be768e98d5ed2c5c74b3bd1bce2f50ce042cde7270f8032df1)
23
+ x: uint256(0x048bf2f40b9f0e61917b3bd9d156debaa531af8449eebbb46c6ba23cb0de4ae2),
24
+ y: uint256(0x25842efe61b33504731c38e50c899a32ec606172705f7a3c74ca1427d83d0fe4)
25
25
  }),
26
26
  qo: Honk.G1Point({
27
27
  x: uint256(0x18424976826978ddb710bfdc21c64a296884c27b0d8dadf6ac7681445dde8d8c),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crisp-e3/contracts",
3
- "version": "0.17.0",
3
+ "version": "0.18.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.6.0"
34
+ "@interfold/contracts": "0.13.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/sdk": "^0.17.0",
57
- "@crisp-e3/zk-inputs": "^0.17.0"
56
+ "@crisp-e3/sdk": "^0.18.0",
57
+ "@crisp-e3/zk-inputs": "^0.18.0"
58
58
  },
59
59
  "scripts": {
60
60
  "compile": "hardhat compile",
@@ -66,8 +66,16 @@
66
66
  "deploy:contracts:mock": "export USE_MOCKS=true PRINT_ENV_VARS=true && pnpm deploy:contracts",
67
67
  "deploy:contracts:full": "export DEPLOY_INTERFOLD=true && pnpm deploy:contracts",
68
68
  "deploy:contracts:full:mock": "export DEPLOY_INTERFOLD=true USE_MOCKS=true PRINT_ENV_VARS=true && pnpm deploy:contracts",
69
+ "governance:builder": "hardhat run deploy/create-governance-builder.ts",
69
70
  "test": "hardhat test mocha",
71
+ "check:test-legs": "node scripts/check-test-legs.mjs",
72
+ "test:unit": "pnpm check:test-legs && hardhat test mocha tests/census-mode.test.ts tests/crisp.journal.test.ts tests/input-leaf.test.ts tests/interfold-binding.test.ts tests/self-registry.test.ts tests/tally.decoding.test.ts",
73
+ "test:input-tree": "hardhat test mocha tests/input-tree-e2e.test.ts",
74
+ "test:input-tree:poisoning": "hardhat test mocha --grep \"accepts an input whose bytes|lets an honest mask follow\" -- tests/input-tree-e2e.test.ts",
75
+ "test:input-tree:canonical": "hardhat test mocha --grep \"^(?!.*(?:accepts an input whose bytes|lets an honest mask follow))\" -- tests/input-tree-e2e.test.ts",
76
+ "test:ballots": "hardhat test mocha tests/crisp.contracts.test.ts tests/onchain-census.test.ts",
70
77
  "verify": "hardhat run deploy/verify.ts",
71
- "updateSubmissionWindow": "hardhat ciphernode:window"
78
+ "updateSubmissionWindow": "hardhat ciphernode:window",
79
+ "check:presets": "node ../../scripts/check-presets.mjs"
72
80
  }
73
81
  }