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
- Add a network to your wallet with the RPC URL
http://localhost:8545, the chain ID31337, and the currency symbolETH. - 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 initThe CLI prints its log and then the E3 ID alone on the last line.
| Flag | Default | Meaning |
|---|---|---|
--token-address | CRISP_VOTING_TOKEN from server/.env, then MockVotingToken from deployed_contracts.json | Voting eligibility token. With --onchain, the registry or votes token. |
--balance-threshold | 1000000000000000000 for a token census. 1 with --onchain. | Minimum balance to vote, in the raw units of the token |
--onchain | Off | Requests an open-registration round (census mode ONCHAIN). The default token is the deployed SelfRegistry. |
The CLI does these tasks (server/src/cli/commands.rs):
- Registers
CRISPProgramon the Interfold contract withregisterE3Programif it is not registered. - Encodes the round parameters: 2 options, constant credits of 1 for each voter, and the census mode.
- Calculates the input window from
VOTING_START_BUFFER_SECONDSandE3_DURATIONinserver/.env. See Round timing. - Gets the fee with
getE3Quote, approves the fee token, and callsrequeston the Interfold contract. The committee size comes fromE3_COMMITTEE_SIZE, and the parameter set comes fromE3_PARAM_SET.
Wait for the committee key
- Open
http://localhost:3000. - Click
Connect Walletand select your wallet. - 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
- Select one of the two options.
- Click
Cast →. - 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 voterorMask my slot. A mask adds an encrypted zero ballot to a slot. A mask needs no signature.
Wait for the result
- Wait until the countdown ends. The page shows
Over · Tallying…. - Wait until the page shows
ClosedandView results →. - 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.
E3RequestedRequested
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 stage | What CRISP does | Server round status |
|---|---|---|
Requested | CRISPProgram.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 |
CommitteeFinalized | The ciphernodes submit tickets. With E3_COMMITTEE_SIZE=0, the committee is minimum (N = 3, T = 1). | Requested |
KeyPublished | The 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 window | The 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 |
CiphertextReady | The server publishes the encrypted sum with publishCiphertextOutput. CRISPProgram.verify checks the compute proof against the input root. | CiphertextPublished |
Complete | T + 1 ciphernodes (2 for minimum) decrypt the sum and publish the plaintext. On PlaintextOutputPublished, the server decodes the tally and stores it. | Finished |
Failed | A 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.
| Message | Meaning |
|---|---|
[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 callbacks | The 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 CiphertextOutputPublished | The 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:
| Limit | Value | Rule |
|---|---|---|
start | earliestVotingStart() + VOTING_START_BUFFER_SECONDS | Voting starts. earliestVotingStart() includes the worst-case committee setup time. |
V | end - availabilityFinalizationWindow | inputCommitmentDeadline(e3Id). No new publishInput at or after this time. |
end | start + E3_DURATION | The 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:
| Error | Condition |
|---|---|
VotingStartsBeforeKeyDeadline | start is before the worst-case key deadline of the request. |
InputWindowTooShort | E3_DURATION is not greater than availabilityFinalizationWindow. |
VotingWindowTooShort | V is less than 1 hour (MIN_VOTING_DURATION) after start. |
ComputeWindowTooShort | The 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 mode | Voting power | Limits |
|---|---|---|
CONSTANT | Each 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. |
CUSTOM | getPastVotes(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):
| Where | Function | Returns |
|---|---|---|
| Contract | CRISPProgram.decodeTally(uint256 e3Id) | uint256[] |
| Server | crisp_utils::decode_tally(tally_bytes: &[u8], num_choices: usize) | Vec<BigUint> |
| SDK | decodeTally(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/requestendpoint. It requiresCRON_API_KEY. When the key is empty, the endpoint is disabled. - The
cronbinary calls that endpoint, then waits 24 hours, in a loop. It readsCRON_API_KEYandINTERFOLD_SERVER_URLfrom the process environment. A remoteINTERFOLD_SERVER_URLmust use HTTPS. CrispSDK.requestNewRoundsends the same request from TypeScript.
Do not put CRON_API_KEY in a browser bundle. Use it only from protected automation.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
request_e3 reverted: InsufficientCiphernodes | Fewer 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 message | The 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 vote | DKG 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 compute | Avail 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. |