@crisp-e3/contracts 0.15.0 → 0.17.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.
@@ -10,10 +10,14 @@ import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol";
10
10
  import { IE3Program } from "@interfold/contracts/contracts/interfaces/IE3Program.sol";
11
11
  import { IInterfold } from "@interfold/contracts/contracts/interfaces/IInterfold.sol";
12
12
  import { E3 } from "@interfold/contracts/contracts/interfaces/IE3.sol";
13
+ import { Risc0ComputeProof } from "@interfold/contracts/contracts/lib/Risc0ComputeProof.sol";
13
14
  import { LazyIMTData, InternalLazyIMT } from "@zk-kit/lazy-imt.sol/InternalLazyIMT.sol";
14
- import { HonkVerifier } from "./CRISPVerifier.sol";
15
+ import { EIP712 } from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
16
+ import { IHonkVerifier } from "./interfaces/IHonkVerifier.sol";
17
+ import { IVotesToken } from "./interfaces/IVotesToken.sol";
18
+ import { IERC6372Clock } from "./interfaces/IERC6372Clock.sol";
15
19
 
16
- contract CRISPProgram is IE3Program, Ownable {
20
+ contract CRISPProgram is IE3Program, Ownable, EIP712 {
17
21
  using InternalLazyIMT for LazyIMTData;
18
22
 
19
23
  /// @notice Enum to represent credit modes
@@ -42,7 +46,12 @@ contract CRISPProgram is IE3Program, Ownable {
42
46
  /// @notice Derived from token balances by the coordinator. The default.
43
47
  TOKEN,
44
48
  /// @notice Supplied by the requester via `getCensus(uint256 e3Id) returns (address[])`.
45
- BY_REQUESTER
49
+ BY_REQUESTER,
50
+ /// @notice Read from the token by this contract, one input at a time. No list is enumerated
51
+ /// and no root is posted, so there is no census producer to trust. `publishInput` calls
52
+ /// `getPastVotes` for the slot and passes the result to the circuit as a public input, which
53
+ /// is why this mode uses the `crisp_onchain` verifier rather than the `crisp` one.
54
+ ONCHAIN
46
55
  }
47
56
 
48
57
  /// @notice Struct to store all data related to a voting round
@@ -54,6 +63,16 @@ contract CRISPProgram is IE3Program, Ownable {
54
63
  uint256 numOptions;
55
64
  CreditMode creditMode;
56
65
  CensusMode censusMode;
66
+ /// @notice The token that voting power is read from. Only used by `CensusMode.ONCHAIN`.
67
+ address token;
68
+ /// @notice The smallest voting power that may cast an input. Only used by
69
+ /// `CensusMode.ONCHAIN`, where eligibility is checked per input instead of by a census.
70
+ uint256 minVotingPower;
71
+ /// @notice Credits given to each eligible voter under `CreditMode.CONSTANT`.
72
+ uint256 credits;
73
+ /// @notice The timepoint that voting power is read at, in the ERC-6372 clock units of the
74
+ /// token. Recorded when the round is requested, so it is the same for every input.
75
+ uint48 snapshot;
57
76
  }
58
77
 
59
78
  // Constants
@@ -74,7 +93,17 @@ contract CRISPProgram is IE3Program, Ownable {
74
93
  IInterfold public interfold;
75
94
  IRiscZeroVerifier public risc0Verifier;
76
95
  bytes32 public imageId;
77
- HonkVerifier private immutable honkVerifier;
96
+ /// @notice Verifies ballots for the census modes that prove membership of a Merkle tree.
97
+ IHonkVerifier private immutable honkVerifier;
98
+ /// @notice Verifies ballots for `CensusMode.ONCHAIN`, whose circuit has no Merkle inputs and
99
+ /// takes voting power as a public input instead.
100
+ IHonkVerifier private immutable onchainHonkVerifier;
101
+
102
+ /// @notice The EIP-712 type of the message a voter signs to authorise one ballot.
103
+ /// @dev The digest binds the signature to the round, the slot, and this exact ciphertext. The
104
+ /// chain id and this contract address come from the EIP-712 domain, so a signature cannot be
105
+ /// replayed onto another round, another slot, another ballot, or another deployment.
106
+ bytes32 private constant BALLOT_TYPEHASH = keccak256("Ballot(uint256 e3Id,address slot,bytes32 ciphertextCommitment)");
78
107
 
79
108
  // Mappings
80
109
  mapping(uint256 e3Id => RoundData) e3Data;
@@ -93,6 +122,16 @@ contract CRISPProgram is IE3Program, Ownable {
93
122
  /// @notice A requester-supplied census names who may vote, not how much each vote weighs, so it
94
123
  /// only has meaning when every voter carries the same credits.
95
124
  error CensusModeRequiresConstantCredits();
125
+ /// @notice `CensusMode.ONCHAIN` reads voting power from a token, so it cannot run without one
126
+ /// that answers `getPastVotes`.
127
+ error CensusModeRequiresToken();
128
+ /// @notice An `ONCHAIN` round with constant credits must grant a non-zero allowance, or it
129
+ /// bounds every ballot to zero and can only accept masks.
130
+ error InvalidCredits();
131
+ /// @notice The slot holds less voting power than the round requires, so it cannot be written to.
132
+ /// @dev Raised for mask inputs as well as real votes. Under `CensusMode.ONCHAIN` this check
133
+ /// replaces the Merkle membership proof that gates both branches in the other modes.
134
+ error SlotNotEligible();
96
135
  error InvalidCensusMode();
97
136
  error SlotIsEmpty();
98
137
  error MerkleRootNotSet();
@@ -100,6 +139,7 @@ contract CRISPProgram is IE3Program, Ownable {
100
139
  error InputDeadlinePassed(uint256 e3Id, uint256 deadline);
101
140
  error KeyNotPublished(uint256 e3Id);
102
141
  error E3NotAcceptingInputs(uint256 e3Id);
142
+ error InvalidComputeContext();
103
143
 
104
144
  // Events
105
145
  event InputPublished(uint256 indexed e3Id, bytes encryptedVote, uint256 index);
@@ -109,17 +149,38 @@ contract CRISPProgram is IE3Program, Ownable {
109
149
  /// @param _risc0Verifier The RISC Zero verifier address
110
150
  /// @param _honkVerifier The honk verifier address
111
151
  /// @param _imageId The image ID for the guest program
112
- constructor(IInterfold _interfold, IRiscZeroVerifier _risc0Verifier, HonkVerifier _honkVerifier, bytes32 _imageId) Ownable(msg.sender) {
152
+ constructor(
153
+ IInterfold _interfold,
154
+ IRiscZeroVerifier _risc0Verifier,
155
+ IHonkVerifier _honkVerifier,
156
+ IHonkVerifier _onchainHonkVerifier,
157
+ bytes32 _imageId
158
+ ) Ownable(msg.sender) EIP712("CRISP", "1") {
113
159
  if (address(_interfold) == address(0)) revert InterfoldAddressZero();
114
160
  if (address(_risc0Verifier) == address(0)) revert Risc0VerifierAddressZero();
115
161
  if (address(_honkVerifier) == address(0)) revert InvalidHonkVerifier();
162
+ if (address(_onchainHonkVerifier) == address(0)) revert InvalidHonkVerifier();
116
163
 
117
164
  interfold = _interfold;
118
165
  risc0Verifier = _risc0Verifier;
119
166
  honkVerifier = _honkVerifier;
167
+ onchainHonkVerifier = _onchainHonkVerifier;
120
168
  imageId = _imageId;
121
169
  }
122
170
 
171
+ /// @notice The digest a voter signs to authorise one ballot.
172
+ /// @dev Computed for every input, and for mask inputs as well as real votes. The circuit ignores
173
+ /// it on the mask branch, but the contract must not skip it: a digest that were computed only
174
+ /// for real votes would let an observer tell the two apart on-chain, which is exactly what mask
175
+ /// inputs exist to prevent.
176
+ /// @param e3Id The E3 the ballot belongs to.
177
+ /// @param slot The slot address the ballot is written to.
178
+ /// @param ciphertextCommitment The commitment to the ballot ciphertext.
179
+ /// @return The EIP-712 digest.
180
+ function ballotDigest(uint256 e3Id, address slot, bytes32 ciphertextCommitment) public view returns (bytes32) {
181
+ return _hashTypedDataV4(keccak256(abi.encode(BALLOT_TYPEHASH, e3Id, slot, ciphertextCommitment)));
182
+ }
183
+
123
184
  /// @notice Sets the Merkle root for an E3 program. Can only be set once.
124
185
  /// @param _e3Id The E3 program ID
125
186
  /// @param _root The Merkle root to set.
@@ -172,7 +233,7 @@ contract CRISPProgram is IE3Program, Ownable {
172
233
  paramsHash = round.paramsHash;
173
234
  numOptions = round.numOptions;
174
235
  creditMode = round.creditMode;
175
- inputRoot = round.votes._root(TREE_DEPTH);
236
+ inputRoot = round.votes._root();
176
237
  numberOfVotes = round.votes.numberOfLeaves;
177
238
  }
178
239
 
@@ -197,34 +258,9 @@ contract CRISPProgram is IE3Program, Ownable {
197
258
  if (msg.sender != address(interfold) && msg.sender != owner()) revert CallerNotAuthorized();
198
259
  if (e3Data[e3Id].paramsHash != bytes32(0)) revert E3AlreadyInitialized();
199
260
 
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
- }
220
-
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
- }
261
+ // Delegated to its own frame rather than scoped inline: `validate` is close enough to the
262
+ // stack limit that holding the six decoded values alongside the parameters exceeds it.
263
+ _initRound(e3Id, customParams);
228
264
 
229
265
  e3Data[e3Id].paramsHash = keccak256(e3ProgramParams);
230
266
 
@@ -234,6 +270,79 @@ contract CRISPProgram is IE3Program, Ownable {
234
270
  return ENCRYPTION_SCHEME_ID;
235
271
  }
236
272
 
273
+ /// @notice Decode the round configuration and record it.
274
+ /// @dev One decode, every field required. `censusMode` is read as a uint and range-checked
275
+ /// rather than decoded straight into the enum, so an unrecognised value gives a named error
276
+ /// instead of a bare panic.
277
+ /// @param e3Id The E3 being configured.
278
+ /// @param customParams The ABI-encoded round configuration.
279
+ 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
+ );
284
+
285
+ // The circuit asserts `num_options <= MAX_OPTIONS`, so a round configured above it accepts no
286
+ // ballot at all. Reject at request time rather than stranding a round nobody can vote in.
287
+ if (numOptions < 2 || numOptions > MAX_VOTE_OPTIONS) revert InvalidNumOptions();
288
+ if (rawCensusMode > uint256(type(CensusMode).max)) revert InvalidCensusMode();
289
+
290
+ // Rejected here rather than by the coordinator, so a combination that can never work costs
291
+ // nothing: this reverts in the same transaction that requests the E3, before any fee is paid.
292
+ if (CensusMode(rawCensusMode) == CensusMode.BY_REQUESTER && creditMode != CreditMode.CONSTANT) {
293
+ revert CensusModeRequiresConstantCredits();
294
+ }
295
+
296
+ // ONCHAIN reads every voter's power from this token, so a round without one accepts no ballot
297
+ // at all. Same reasoning as the numOptions bound: fail before the fee is paid.
298
+ if (CensusMode(rawCensusMode) == CensusMode.ONCHAIN && token == address(0)) {
299
+ revert CensusModeRequiresToken();
300
+ }
301
+
302
+ // An ONCHAIN round hands `credits` to the circuit as the voting-power bound, so zero credits
303
+ // bound every ballot to zero: only a mask would be accepted, and the round would tally
304
+ // nothing. Checked for ONCHAIN only — the Merkle modes take the bound from the census leaf,
305
+ // where `credits` never reaches the circuit and the contract has nothing to check.
306
+ if (CensusMode(rawCensusMode) == CensusMode.ONCHAIN && creditMode == CreditMode.CONSTANT && credits == 0) {
307
+ revert InvalidCredits();
308
+ }
309
+
310
+ RoundData storage round = e3Data[e3Id];
311
+ // we need to know the number of options for decoding the tally
312
+ round.numOptions = numOptions;
313
+ // we want to save the credit mode so it can be verified on chain by everyone
314
+ round.creditMode = creditMode;
315
+ // recorded so anyone can verify which electorate the round was requested against
316
+ round.censusMode = CensusMode(rawCensusMode);
317
+ round.token = token;
318
+ round.minVotingPower = minVotingPower;
319
+ round.credits = credits;
320
+
321
+ // The snapshot is taken here rather than supplied by the requester. This function runs in the
322
+ // transaction that requests the E3, so `clock() - 1` is the last finalized timepoint of the
323
+ // round, and a requester cannot name a timepoint that suits it. Recording it once also makes
324
+ // every input of the round read the same electorate.
325
+ if (CensusMode(rawCensusMode) == CensusMode.ONCHAIN) {
326
+ // Checked before any call is attempted. A call to an address with no code succeeds and
327
+ // returns nothing, so `clock()` fails while decoding the empty return data rather than
328
+ // inside the call — and a decode failure is not what `try/catch` is there to catch. An EOA
329
+ // would otherwise be refused by a bare panic instead of a named error.
330
+ if (token.code.length == 0) revert CensusModeRequiresToken();
331
+
332
+ uint48 snapshot = _previousTimepoint(token);
333
+
334
+ // Probe the exact call every input will make. `_previousTimepoint` swallows a missing
335
+ // `clock()` and falls back to block numbers, which is right for a token that predates
336
+ // ERC-6372 but also lets an address that is not a votes token pass validation — and then
337
+ // every `publishInput` reverts inside `getPastVotes`, after the fee is paid.
338
+ try IVotesToken(token).getPastVotes(address(0), snapshot) returns (uint256) {} catch {
339
+ revert CensusModeRequiresToken();
340
+ }
341
+
342
+ round.snapshot = snapshot;
343
+ }
344
+ }
345
+
237
346
  /// @inheritdoc IE3Program
238
347
  function publishInput(uint256 e3Id, bytes memory data) external {
239
348
  E3 memory e3 = interfold.getE3(e3Id);
@@ -254,9 +363,6 @@ contract CRISPProgram is IE3Program, Ownable {
254
363
  revert E3NotAcceptingInputs(e3Id);
255
364
  }
256
365
 
257
- // We need to ensure that the CRISP admin set the merkle root of the census.
258
- if (e3Data[e3Id].merkleRoot == 0) revert MerkleRootNotSet();
259
-
260
366
  if (data.length == 0) revert EmptyInputData();
261
367
 
262
368
  (bytes memory noirProof, address slotAddress, bytes32 encryptedVoteCommitment, bytes memory encryptedVote) = abi.decode(
@@ -264,26 +370,81 @@ contract CRISPProgram is IE3Program, Ownable {
264
370
  (bytes, address, bytes32, bytes)
265
371
  );
266
372
 
373
+ // The two census families differ here and nowhere else. A Merkle round proves membership
374
+ // inside the circuit against a posted root. An ONCHAIN round reads the power from the token
375
+ // and gives it to the circuit, so the eligibility check has to happen here instead.
376
+ (bytes32 eligibility, IHonkVerifier verifier) = _eligibility(e3Id, slotAddress);
377
+
267
378
  (uint40 voteIndex, bytes32 previousEncryptedVoteCommitment) = _processVote(e3Id, slotAddress, encryptedVoteCommitment);
268
379
 
269
380
  // Set the public inputs for the proof. Order must match Noir circuit.
270
- bytes32[] memory noirPublicInputs = new bytes32[](7);
381
+ bytes32[] memory noirPublicInputs = new bytes32[](9);
271
382
  noirPublicInputs[0] = previousEncryptedVoteCommitment;
272
- noirPublicInputs[1] = bytes32(e3Data[e3Id].merkleRoot);
273
- noirPublicInputs[2] = bytes32(uint256(uint160(slotAddress)));
274
- noirPublicInputs[3] = bytes32(uint256(previousEncryptedVoteCommitment == bytes32(0) ? 1 : 0));
275
- noirPublicInputs[4] = bytes32(e3Data[e3Id].numOptions);
276
- noirPublicInputs[5] = encryptedVoteCommitment;
277
- noirPublicInputs[6] = e3.committeePublicKey;
383
+ // A Keccak digest does not fit in one field element, so it enters the circuit as its two
384
+ // 16-byte halves. The circuit rebuilds the 32 bytes with `digest_from_halves`.
385
+ {
386
+ uint256 digest = uint256(ballotDigest(e3Id, slotAddress, encryptedVoteCommitment));
387
+ noirPublicInputs[1] = bytes32(digest >> 128);
388
+ noirPublicInputs[2] = bytes32(digest & type(uint128).max);
389
+ }
390
+ noirPublicInputs[3] = bytes32(uint256(uint160(slotAddress)));
391
+ noirPublicInputs[4] = eligibility;
392
+ noirPublicInputs[5] = bytes32(uint256(previousEncryptedVoteCommitment == bytes32(0) ? 1 : 0));
393
+ noirPublicInputs[6] = bytes32(e3Data[e3Id].numOptions);
394
+ noirPublicInputs[7] = encryptedVoteCommitment;
395
+ noirPublicInputs[8] = e3.committeePublicKey;
278
396
 
279
397
  // Check if the ciphertext was encrypted correctly
280
- if (!honkVerifier.verify(noirProof, noirPublicInputs)) {
398
+ if (!verifier.verify(noirProof, noirPublicInputs)) {
281
399
  revert InvalidNoirProof();
282
400
  }
283
401
 
284
402
  emit InputPublished(e3Id, encryptedVote, voteIndex);
285
403
  }
286
404
 
405
+ /// @notice Resolve the eligibility public input and the verifier for a round.
406
+ /// @dev Returns the value that occupies index 4 of the circuit public inputs. The `crisp` and
407
+ /// `crisp_onchain` circuits agree on every other position, so this one value and the verifier
408
+ /// address are the whole difference between the two paths.
409
+ /// @param e3Id The E3 the input belongs to.
410
+ /// @param slotAddress The slot the input is written to.
411
+ /// @return eligibility The Merkle root of the census, or the voting power of the slot.
412
+ /// @return verifier The verifier that matches the circuit of this round.
413
+ function _eligibility(uint256 e3Id, address slotAddress) internal view returns (bytes32 eligibility, IHonkVerifier verifier) {
414
+ RoundData storage round = e3Data[e3Id];
415
+
416
+ if (round.censusMode != CensusMode.ONCHAIN) {
417
+ // We need to ensure that the CRISP admin set the merkle root of the census.
418
+ if (round.merkleRoot == 0) revert MerkleRootNotSet();
419
+ return (bytes32(round.merkleRoot), honkVerifier);
420
+ }
421
+
422
+ uint256 power = IVotesToken(round.token).getPastVotes(slotAddress, round.snapshot);
423
+
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.
427
+ uint256 threshold = round.minVotingPower == 0 ? 1 : round.minVotingPower;
428
+ if (power < threshold) revert SlotNotEligible();
429
+
430
+ // Eligibility comes from the power at the snapshot. The weight the circuit enforces comes from
431
+ // the credit mode, so a CONSTANT round gives every eligible slot the same credits.
432
+ return (bytes32(round.creditMode == CreditMode.CONSTANT ? round.credits : power), onchainHonkVerifier);
433
+ }
434
+
435
+ /// @notice The last finalized timepoint of a token, in its ERC-6372 clock units.
436
+ /// @dev Falls back to block numbers when the token has no `clock()`, which matches the default
437
+ /// ERC20Votes clock.
438
+ /// @param token The token to read the clock of.
439
+ /// @return The timepoint before the current one.
440
+ function _previousTimepoint(address token) internal view returns (uint48) {
441
+ try IERC6372Clock(token).clock() returns (uint48 current) {
442
+ return current == 0 ? 0 : current - 1;
443
+ } catch {
444
+ return uint48(block.number - 1);
445
+ }
446
+ }
447
+
287
448
  /// @notice Decode the tally from the plaintext output
288
449
  /// @param e3Id The E3 program ID
289
450
  /// @return votes - an array of vote counts for each option
@@ -342,17 +503,25 @@ contract CRISPProgram is IE3Program, Ownable {
342
503
  bytes32 ciphertextCommitment,
343
504
  bytes memory proof
344
505
  ) external view override returns (bool) {
506
+ E3 memory e3 = interfold.getE3(e3Id);
345
507
  bytes32 paramsHash = getParamsHash(e3Id);
508
+ bytes32 inputRoot = bytes32(e3Data[e3Id].votes._root());
509
+ Risc0ComputeProof.Proof memory computeProof = Risc0ComputeProof.decode(proof);
510
+ if (computeProof.paramsHash != paramsHash || computeProof.inputRoot != inputRoot) revert InvalidComputeContext();
511
+
512
+ bytes memory journal = Risc0ComputeProof.journal(
513
+ bytes32(block.chainid),
514
+ bytes32(uint256(uint160(address(interfold)))),
515
+ bytes32(e3Id),
516
+ e3.encryptionSchemeId,
517
+ e3.committeePublicKey,
518
+ ciphertextOutputHash,
519
+ ciphertextCommitment,
520
+ paramsHash,
521
+ inputRoot
522
+ );
346
523
 
347
- bytes32 inputRoot = bytes32(e3Data[e3Id].votes._root(TREE_DEPTH));
348
- bytes memory journal = new bytes(528); // (32 + 1) * 4 * 4
349
-
350
- _encodeLengthPrefixAndHash(journal, 0, ciphertextOutputHash);
351
- _encodeLengthPrefixAndHash(journal, 132, ciphertextCommitment);
352
- _encodeLengthPrefixAndHash(journal, 264, paramsHash);
353
- _encodeLengthPrefixAndHash(journal, 396, inputRoot);
354
-
355
- risc0Verifier.verify(proof, imageId, sha256(journal));
524
+ risc0Verifier.verify(computeProof.seal, imageId, sha256(journal));
356
525
  return true;
357
526
  }
358
527
 
@@ -381,19 +550,6 @@ contract CRISPProgram is IE3Program, Ownable {
381
550
  }
382
551
  }
383
552
 
384
- /// @notice Encode length prefix and hash
385
- /// @param journal The journal to encode into
386
- /// @param startIndex The start index in the journal
387
- /// @param hashVal The hash value to encode
388
- function _encodeLengthPrefixAndHash(bytes memory journal, uint256 startIndex, bytes32 hashVal) internal pure {
389
- journal[startIndex] = 0x20;
390
- startIndex += 4;
391
-
392
- for (uint256 i = 0; i < 32; i++) {
393
- journal[startIndex + i * 4] = hashVal[i];
394
- }
395
- }
396
-
397
553
  /// @notice Decode bytes to uint64 array
398
554
  /// @param data The bytes to decode (must be multiple of 8)
399
555
  /// @return result Array of uint64 values