Enclave is now The Interfold.
CRISP
Run a Voting Round

Run a voting round

This page shows how to run one CRISP voting round on a local chain, from the request to the result. It is for developers who have the CRISP stack running. At the end, you have a completed E3 and a decoded tally, and you know what CRISP does at each E3 stage.

Before you start, complete Set up CRISP. pnpm dev:up must run in a terminal, and the client must be available at http://localhost:3000. Run the pnpm cli commands from the root of the CRISP repository in a second terminal.

Run the round

Set up the wallet

  1. Add a network to your wallet with the RPC URL http://localhost:8545, the chain ID 31337, and the currency symbol ETH.
  2. Import the first Anvil account with this private key: 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80.

The account has test ETH on the local chain. For chain ID 31337, the server does not read token balances. It uses a fixed census of the first ten Anvil accounts, so this account can vote (get_mock_token_holders in server/src/server/token_holders/etherscan.rs).

⚠️

This private key is public. Never use it on a live network. Funds sent to its address on a live network can be lost.

Request a round

pnpm cli init

The CLI prints its log and then the E3 ID alone on the last line.

FlagDefaultMeaning
--token-addressCRISP_VOTING_TOKEN from server/.env, then MockVotingToken from deployed_contracts.jsonVoting eligibility token. With --onchain, the registry or votes token.
--balance-threshold1000000000000000000 for a token census. 1 with --onchain.Minimum balance to vote, in the raw units of the token
--onchainOffRequests an open-registration round (census mode ONCHAIN). The default token is the deployed SelfRegistry.

The CLI does these tasks (server/src/cli/commands.rs):

  1. Registers CRISPProgram on the Interfold contract with registerE3Program if it is not registered.
  2. Encodes the round parameters: 2 options, constant credits of 1 for each voter, and the census mode.
  3. Calculates the input window from VOTING_START_BUFFER_SECONDS and E3_DURATION in server/.env. See Round timing.
  4. Gets the fee with getE3Quote, approves the fee token, and calls request on the Interfold contract. The committee size comes from E3_COMMITTEE_SIZE, and the parameter set comes from E3_PARAM_SET.

Wait for the committee key

  1. Open http://localhost:3000.
  2. Click Connect Wallet and select your wallet.
  3. Click Try the demo →.

While the committee runs DKG, the page shows Round #<id> is preparing its encryption key. To check the key from the terminal, run this command:

pnpm cli check-e3-ready --e3id <id>

The command prints true when the CRISP server has the committee public key. Then reload the page. The poll shows the Live tag and a Closes in countdown.

Cast a vote

  1. Select one of the two options.
  2. Click Cast →.
  3. Sign the ballot request in your wallet.

The client encrypts the ballot, makes the proof, and sends it to the CRISP server. The page then shows Vote finalized successfully! and the You voted tag. With the relay, the wallet signs only the EIP-712 Ballot message and sends no transaction.

You can also do these actions while the poll is live:

  • Select another option and click Update vote →. The new ballot replaces the old ballot in your slot.
  • Click Mask a voter or Mask my slot. A mask adds an encrypted zero ballot to a slot. A mask needs no signature.

Wait for the result

  1. Wait until the countdown ends. The page shows Over · Tallying….
  2. Wait until the page shows Closed and View results →.
  3. Click View results →.

The result page shows the share of the votes for each option. The All Polls page shows all rounds.

With the example values in server/.env, the input window starts 20 seconds after the request and stays open for 300 seconds. The committee key and your vote must both arrive inside that window. The end-to-end test does not wait. It moves the Anvil time past the input deadline.

What happens at each stage

The Interfold contract records the stage of each E3 (E3Stage in IInterfold.sol). The CRISP server keeps its own round status and follows the chain events.

CRISP voting round
CRISP CLIE3Requested

Requested

The CLI calls request. CRISPProgram.validate records the round. For a token census, the server builds the census tree and posts its root with setMerkleRoot.

E3 stageWhat CRISP doesServer round status
RequestedCRISPProgram.validate records the token, threshold, options, credit mode, and census mode, and starts the input tree. On E3Requested, the server builds the census. For a token census, it calls setMerkleRoot. The local randomness helper fulfills the randomness request in a later block.Requested
CommitteeFinalizedThe ciphernodes submit tickets. With E3_COMMITTEE_SIZE=0, the committee is minimum (N = 3, T = 1).Requested
KeyPublishedThe committee publishes the public key. On CommitteePublished, the server activates the round and registers callbacks for the input deadline. Voters send inputs until the input window closes.Active
KeyPublished, after the input windowThe server checks that no input waits for finalization and that it holds every input that CRISPProgram accepted. Then it sends the inputs to the program server. The program server runs fhe_processor and calls back /state/add-result.Expired, then Computing, then PublishingCiphertext
CiphertextReadyThe server publishes the encrypted sum with publishCiphertextOutput. CRISPProgram.verify checks the compute proof against the input root.CiphertextPublished
CompleteT + 1 ciphernodes (2 for minimum) decrypt the sum and publish the plaintext. On PlaintextOutputPublished, the server decodes the tally and stores it.Finished
FailedA stage missed its deadline. See E3 lifecycle.The server has no E3Failed handler

If a round receives no votes, the server does not request a computation. It logs E3 has no votes to decrypt. Setting status to Finished. and sets the status to Finished.

The server stores a round only when the program at the configured E3_PROGRAM_ADDRESS requested it. On PlaintextOutputPublished, it decodes the tally only for a stored round of that program, because each CRISPProgram deployment fixes its own ballot layout. The event changes nothing for a round that the server did not store. Any other stored round gets an empty tally and the status Finished. This applies also to a round that an earlier server release stored.

Server log messages

The CRISP server writes these messages to the pnpm dev:up terminal (server/src/server/indexer.rs and server/src/server/routes/state.rs). <id> is the E3 ID.

MessageMeaning
[e3_id=<id>] Using mocked token holders for local network (chain_id: 31337)The server uses the fixed local census.
[e3_id=<id>] setMerkleRoot successful. TxHash: ...The census root is on-chain.
[e3_id=<id>] Activated CRISP round and registered deadline callbacksThe key is available and the round is active.
[e3_id=<id>] Starting computation for E3 (<n> ciphertext input(s), <m> voter(s))The input window closed and the computation starts.
Received program server result for E3 ID: <id>, ...The program server returned the encrypted sum.
[e3_id=<id>] Handling CiphertextOutputPublishedThe encrypted sum is on-chain.
[e3_id=<id>] Option index: <i> votes: <count>The decoded total for option <i>.
[e3_id=<id>] Leaving the tally empty: the round's program '<address>' is not the configured program <address>, ...The round finished, and this server stores no tally for it.

The ciphernode cn1 also serves the node dashboard at http://127.0.0.1:8080. See Node dashboard.

Round timing

The input window is [start, end] in the E3 request. start is the time when voting can start. end is start + E3_DURATION. The committee key alone does not open voting: publishInput reverts with E3NotAcceptingInputs before start.

Local chain

The local deployment uses the mock data-availability verifier, and availabilityFinalizationWindow is 0. The CLI sets start to the latest block time plus VOTING_START_BUFFER_SECONDS. CRISPProgram.validate does no timing checks in this mode.

The Sepolia program uses the mock verifier too, with availabilityFinalizationWindow set to 0, while Avail Turing is down. The server needs DATA_AVAILABILITY_MODE=mock and ALLOW_SEPOLIA_MOCK_DATA_AVAILABILITY=true.

Avail networks

On Ethereum mainnet, each round has three limits:

LimitValueRule
startearliestVotingStart() + VOTING_START_BUFFER_SECONDSVoting starts. earliestVotingStart() includes the worst-case committee setup time.
Vend - availabilityFinalizationWindowinputCommitmentDeadline(e3Id). No new publishInput at or after this time.
endstart + E3_DURATIONThe target for the last Avail receipts

CRISPProgram.earliestVotingStart() returns the current block time plus randomnessRequestTimeout, sortitionSubmissionWindow, and dkgWindow. The CLI and the server read it for each request. In the mainnet protocol configuration, these values are 3,600 seconds, 600 seconds, and 21,600 seconds, so start is at least 25,800 seconds after the request (mainnet-protocol.config.json (opens in a new tab)). The protocol owner can change these values. For this reason, an application must read earliestVotingStart() and not keep a fixed minimum.

CRISP deployments on Avail set availabilityFinalizationWindow to 10,800 seconds (3 hours). CRISPProgram.validate rejects a request with these errors:

ErrorCondition
VotingStartsBeforeKeyDeadlinestart is before the worst-case key deadline of the request.
InputWindowTooShortE3_DURATION is not greater than availabilityFinalizationWindow.
VotingWindowTooShortV is less than 1 hour (MIN_VOTING_DURATION) after start.
ComputeWindowTooShortThe protocol computeWindow is less than availabilityFinalizationWindow.

For this reason, E3_DURATION must be at least 14,400 seconds: 1 hour of voting and 3 hours of finalization. The server checks this sum at startup in Avail mode. A late Avail receipt is still accepted until the compute deadline of the E3. The computation waits until every committed input has its receipt.

Decode the tally

The plaintext output is a list of polynomial coefficients. The first 50 coefficients (MAX_MSG_NON_ZERO_COEFFS) carry the result. A ballot has one coefficient for each option, so the decrypted tally holds one total for each option. Coefficient o is the total weight on option o. The other coefficients are zero.

The committee decrypts each coefficient modulo the plaintext modulus t of the parameter set: 17,000,000 for insecure-64 and for secure-8192. A total is correct only while it is less than t. CRISPProgram.validate reads t from the BFV parameters of the request and sets these limits, so that each total stays less than t:

Credit modeVoting powerLimits
CONSTANTEach voter has the same voting power, credits.credits must be less than t, or validate reverts with CreditsExceedPlaintextModulus. The round accepts at most (t - 1) / credits inputs, rounded down, and not more than MAX_INPUTS_PER_ROUND (100,000). With 0 credits, the limit is MAX_INPUTS_PER_ROUND.
CUSTOMgetPastVotes(voter, snapshot) divided by the voting-power divisor of the round.The divisor must be at least getPastTotalSupply(snapshot) / t + 1, or validate reverts with VotingPowerDivisorBelowMinimum. A requested divisor of 0 selects this minimum. A token that does not answer getPastTotalSupply makes validate revert with CustomCreditsRequireVotesToken. The round accepts at most MAX_INPUTS_PER_ROUND inputs.

Every input counts toward the limit, including a new vote that replaces an earlier vote, and a mask. When a round has its maximum number of inputs, publishInput reverts with InputLimitReached. A mask does not need a signature from the voter, so any account can fill the limit with masks. A CONSTANT round with large credits has the smallest limit. The CLI requests CONSTANT credits of 1, so a round accepts 100,000 inputs on insecure-64 and on secure-8192.

In a census tree round, the CRISP server writes the voting power of each voter into the census leaf. For a CUSTOM round, the server reads this voting power at CRISPProgram.snapshotOf(e3Id), the timepoint that also sets the divisor. The server retries a failed read. If the read continues to fail, the server does not post the census. It tries again later, so that the census does not exclude an eligible voter. The limits hold only when the census leaf holds that voting power.

In a CUSTOM round, the weight of a voter is an integer, and the division discards the remainder. In a census tree round, a voter with less voting power than the divisor has a weight of 0. This voter can cast only an empty ballot. For example, a total supply of 1,000,000,000,000 and the secure-8192 t of 17,000,000 give a minimum divisor of 58,824. A holder with 58,823 votes then has a weight of 0.

In an ONCHAIN round, minVotingPower must be at least the divisor, or validate reverts with MinVotingPowerBelowScale. Thus each voter that can publish an input has a weight of at least 1. The default divisor increases with the total supply. A request with a fixed minVotingPower can thus revert after the supply increases.

The CUSTOM limit also relies on the token. At the snapshot, the sum of the voting power of all accounts must not be more than getPastTotalSupply(snapshot). An ERC20Votes token satisfies this condition.

Three decoders give the same result (packages/crisp-contracts/tests/tally.decoding.test.ts):

WhereFunctionReturns
ContractCRISPProgram.decodeTally(uint256 e3Id)uint256[]
Servercrisp_utils::decode_tally(tally_bytes: &[u8], num_choices: usize)Vec<BigUint>
SDKdecodeTally(tallyBytes: string | number[] | bigint[], numChoices: number)bigint[] (TallyResult)

Use the SDK to decode the plaintext output yourself. plaintextOutput is the hex string of the plaintextOutput bytes of the E3, for example from the PlaintextOutputPublished event:

import { decodeTally } from '@crisp-e3/sdk'
 
// One total per option. numChoices must be an integer from 2 to MAX_VOTE_OPTIONS (10).
const [option0, option1] = decodeTally(plaintextOutput, 2)

To read the tally that the CRISP server decoded, use getRoundResult. After the round status is Finished, tally has one decimal string for each option:

import { CrispSDK } from '@crisp-e3/sdk'
 
const sdk = new CrispSDK('http://127.0.0.1:4000')
const result = await sdk.getRoundResult(1n)
console.log(result.tally)

decodeTally throws an error when numChoices is outside 2 to 10 or is not an integer. It also throws an error when the output has fewer than 50 coefficients.

Other ways to request a round

You can also request a round in these ways:

  • The CRISP server has a POST /rounds/request endpoint. It requires CRON_API_KEY. When the key is empty, the endpoint is disabled.
  • The cron binary calls that endpoint, then waits 24 hours, in a loop. It reads CRON_API_KEY and INTERFOLD_SERVER_URL from the process environment. A remote INTERFOLD_SERVER_URL must use HTTPS.
  • CrispSDK.requestNewRound sends the same request from TypeScript.

Do not put CRON_API_KEY in a browser bundle. Use it only from protected automation.

Troubleshooting

SymptomCauseFix
request_e3 reverted: InsufficientCiphernodesFewer active operators than the committee size. minimum needs 3.Wait until dev_cipher.sh prints CIPHERNODES HAVE BEEN ADDED.. Or register nodes with pnpm ciphernode:add --ciphernode-address <address> --network localhost.
request_e3 reverted: ... with stale E3_PROGRAM_ADDRESS in the messageThe addresses in server/.env do not match the chain, the window start is in the past, or no ciphernodes are registered.Restart pnpm dev:up. The deployment writes the addresses again.
Voting token address is unset.No --token-address, no CRISP_VOTING_TOKEN, and no MockVotingToken record.Pass --token-address, or restart pnpm dev:up.
No SelfRegistry found.--onchain without a token and without a SelfRegistry record.Pass --token-address, or restart pnpm dev:up.
The page stays on Round #<id> is preparing its encryption key.The committee has not published its key. pnpm cli check-e3-ready --e3id <id> prints false.Read the ciphernode output in the pnpm dev:up terminal. Reload the page when the command prints true.
The input window closes before you can voteDKG and voting took more time than E3_DURATION. A key that arrives too late fails with InputWindowClosedBeforeKeyPublication.Increase E3_DURATION in server/.env, then request a new round.
publishCommittee reverts with VkHashMismatch() (0x0c260259)Full proof aggregation mode, and the circuits were built again after the deployment with other settings.Run pnpm dev:setup, restart pnpm dev:up, and request a new round.
<n> input(s) still await data-availability finalization; refusing to computeAvail mode only. Some inputs wait for a VectorX proof.Wait. The server starts the computation again when each receipt arrives.
the indexer holds <n> input(s), but CRISPProgram accepted <m>The server has not indexed all inputs yet.Wait. The server tries again 60, 180, and 420 seconds after the input deadline.

Next steps