@vocdoni/davinci-sdk
v6.0.1
Published
TypeScript SDK for the Vocdoni DAVINCI voting protocol: private, verifiable elections with encrypted ballots, zero-knowledge proofs and zkVM-proved settlement on Ethereum
Downloads
1,135
Readme
DAVINCI SDK
TypeScript SDK for DAVINCI, the Vocdoni protocol for private, verifiable elections on Ethereum. Organizers use it to create and run elections, voters to cast and track their votes, and anyone to read results and vote receipts.
Overview
An election lives in a ProcessRegistry contract; the OP Mainnet deployment is built in. A voter encrypts its ballot, proves it valid with a zk-SNARK computed on its own device, signs it and sends it to a sequencer node. The nodes, run by independent operators, batch ballots, re-encrypt them and prove each batch in a zkVM. The registry verifies the proof and records the new state, and the batch data is published in EIP-4844 blobs. After the end and a short grace window, whoever holds the election key (a node, or a committee) decrypts only the final tally and proves it on-chain.
DavinciSDK covers that whole flow. It takes election parameters from the registry, never from a node, checks at init() that the registry pins the zkVM programs and the ballot circuit this release was built for, and verifies the circuit files it downloads. The layers underneath (registry service, node client, census classes, protocol primitives, prover) are exported too.
A voter may vote again; the latest ballot counts. A vote goes pending → aggregated → processed → settled as nodes batch it, which takes minutes (up to a quarter of an hour with default node settings). After the end, batches of votes cast before it keep landing until the grace window closes; every landing pushes the window out, up to a cap. The key holder then publishes the tally, seconds to a few minutes later.
Pre-production on OP Mainnet
The built-in optimism network (OP Mainnet, chain 10) is the DAVINCI pre-production deployment, and the default. Transitions settle on OP and their blobs live on Ethereum (L1-blob mode). Gnosis is retired. The production sequencer is https://sequencer2.davinci.vote. For elections whose ballots no single party can open, it offers two committees:
- Automatic (
keyMode: 'dkg'): a rotating committee of independent node operators holds the key. Nothing to prepare; results come a few minutes after the grace window. Recommended for most elections. - Election committee (
keyMode: 'council'with aceremonyId): a committee the organizer invites holds the key, set up in the Council app (davinci-dkg-council). Results stay locked until the ceremony opens decryption, on a scheduled date or when its organizer opens it.
Ballots are proved under a pre-production ballot key from a phase-2 ceremony (davinci-circom v1.3.0, CEREMONY.md); a public ceremony replaces it before public production. During the pre-production phase both committees' circuits come from development trusted setups. docs/ai/references/key-modes.md explains both options, how to create a process with each and what every results state means.
The rest of the stack: davinci-sequencer (the node), davinci-contracts (the registry), davinci-zkvm (batch and results proofs), davinci-dkg (committee keys) and davinci-circom (the ballot circuit). The protocol is described in the whitepaper.
Quick start
Requires Node.js 18 or newer, or a current browser through a bundler. The package ships ESM and CommonJS builds with their types, from a single root export.
npm install @vocdoni/davinci-sdk ethers # or: yarn add / pnpm addSave as election.ts and run with npx tsx election.ts:
import { BallotProver, DavinciSDK, OffchainCensus, VoteError, type Uploader } from '@vocdoni/davinci-sdk';
import { JsonRpcProvider, Wallet } from 'ethers';
// Production sequencer on OP Mainnet (see docs/deployments.md in davinci-sequencer for others).
const sequencerUrls = ['https://sequencer2.davinci.vote'];
// Census files and metadata must be served unchanged over public HTTPS (no redirects).
// Replace with your own store: S3, R2, DO Spaces, IPFS, any static host work.
// UPLOAD_URL: a pre-signed base URL accepting PUT; PUBLIC_URL: the public read base URL.
const { UPLOAD_URL, PUBLIC_URL } = process.env as Record<string, string>;
const uploader: Uploader = {
async upload({ data, contentType, sha256 }) {
const name = `${sha256.slice(2)}.json`;
const res = await fetch(`${UPLOAD_URL}/${name}`, {
method: 'PUT', body: new Uint8Array(data), headers: { 'content-type': contentType },
});
if (!res.ok) throw new Error(`upload failed: HTTP ${res.status}`);
return `${PUBLIC_URL}/${name}`;
},
};
// Organizer: a signer with a provider on OP Mainnet, the default network.
const organizer = new DavinciSDK({
signer: new Wallet(process.env.ORGANIZER_KEY!, new JsonRpcProvider(process.env.OP_RPC)),
sequencerUrls,
uploader,
});
await organizer.init();
const voterWallet = new Wallet(process.env.VOTER_KEY!);
const census = new OffchainCensus();
census.add([voterWallet.address, '0x2222222222222222222222222222222222222222']);
const { processId } = await organizer.createProcess({
title: 'Community decision',
census,
electionPreset: { type: 'single_choice' },
timing: { duration: 24 * 3600 }, // starts in the block that creates it; use closeProcessIn to end early
keyMode: 'dkg', // Automatic: the recommended option, a rotating committee holds the key
// 'council' with a ceremonyId: an Election committee the organizer invites
// 'sequencer': sequencer key, lower trust, kept for compatibility
questions: [
{
title: 'Which initiative should we prioritize?',
choices: [
{ title: 'Community garden', value: 0 },
{ title: 'Tech workshop', value: 1 },
{ title: 'Art exhibition', value: 2 },
],
},
],
});
// Voter: a bare wallet is enough; the SDK reads the chain through public RPCs.
const voter = new DavinciSDK({ signer: voterWallet, sequencerUrls });
await voter.init();
// The sequencer indexes the process a few seconds after the transaction mines.
// Retry submitVote up to ~30 s if it comes back unavailable (unknown process).
const vote = await (async () => {
for (let attempt = 0; ; attempt++) {
try { return await voter.submitVote({ processId, choices: [0, 1, 0] }); }
catch (err) {
if (err instanceof VoteError && err.reason === 'unavailable' && attempt < 10)
await new Promise(r => setTimeout(r, 3000));
else throw err;
}
}
})();
const status = await voter.waitForVoteStatus(processId, vote.voteId); // settled, or error with a reason
// Once the election has ended and its grace window has closed:
// (to end early use organizer.closeProcessIn(processId, seconds) — see docs/ai/recipes/close-early.ts)
const results = await voter.waitForResults(processId);
// results.questions[0].choices[i].total is a bigint
for (const c of results.questions[0].choices) console.log(c.title, c.total.toString());
await BallotProver.terminate(); // in Node: stop snarkjs's worker threads so the process exitsNodes refuse census files on private hosts or behind redirects, and ignore a process whose census they cannot load, so the SDK reads every document it publishes back before it sends the transaction.
Usage
Configuration
| Option | Default | Description |
| --- | --- | --- |
| signer | required | An ethers Signer. A bare Wallet can vote; managing elections needs a provider on the network's chain. |
| sequencerUrls | required | The deployment's sequencer nodes. Votes are routed among them per voter, with failover. |
| network | 'optimism' | A preset name, or { chainId, processRegistry, startBlock?, rpcUrls? } for another deployment. |
| uploader | none | Publishes census files and metadata documents. Required to create elections. |
| rpcUrls | signer's provider, else the preset's | JSON-RPCs for chain reads, in order of preference. |
| keySequencerUrl | first usable node | The node that issues sequencer election keys and publishes their results. |
| censusProviders | none | csp: where voters get their attestation in a CSP census. |
| documents | { verify: true } | How documents are downloaded and read back; allowPrivateHosts for local development. |
| artifacts | pinned URLs (CDN, then GitHub) | A mirror, local directory or cache for the ballot circuit files, still checked against their pins. |
| verifyDeployment | true | Check the registry pins at init(); { pins } for a deployment you control. |
| verifyProof | true | Verify each ballot proof locally before it is sent. |
init() checks that the registry pins the program vks, vadcop root, ballot VK hash and verifier code of this release, and checks every node's /info against the registry. A node of another deployment fails init() with NodeMismatchError; one that is down or an observer is left out for the session and listed in sdk.nodeChecks. Process ids carry their registry's prefix, so an id of another deployment is refused before any request.
Creating an election
const { processId, organizerSecret } = await sdk.createProcess({
title: 'Board election',
census,
electionPreset: { type: 'multiple_choice', maxSelections: 2 },
timing: { startDate: '2026-12-07T09:00:00Z', endDate: '2026-12-08T18:00:00Z' },
questions,
keyMode: 'dkg-locked', // returns organizerSecret: store it, the results never unlock without it
grace: 240, // seconds, within the registry's graceFloor..graceCeil
});electionPresetis one ofsingle_choice,multiple_choice,approval,rating,rankingorquadratic. A rawballotmode works too: at most 16 fields, bounds as decimal strings.- Times are checked against the chain clock. Without
startDatethe process starts in the block that creates it. maxVotersdefaults to the member count of a Merkle census object and is required otherwise. The registry capsmaxValue * maxVotersat 1e12.paused: truecreates the process paused: votes queue untilresumeProcess.- A config the registry would reject is refused before anything is uploaded or sent, with the registry error in
err.revertName(e.g.InvalidStartTime). createProcessStream(config)yields the transaction'sTxStatusevents for UIs.
| Census class | Members |
| --- | --- |
| OffchainCensus | a Merkle census file the SDK builds and publishes |
| OffchainDynamicCensus | the same, replaceable until the end with updateCensus |
| OnchainCensus | an append-only census contract; members added later can vote |
| CspCensus, CspSigner | a credential service provider signs each voter's attestation |
| keyMode | Who holds the key | Results |
| --- | --- | --- |
| 'sequencer' (default) | the key node | published by that node |
| 'dkg' | a davinci-dkg committee, as threshold shares | decrypted by the committee |
| 'dkg-locked' | the committee plus the organizer | decrypted once the organizer reveals the secret returned at creation |
| 'council' | a Council committee the organizer invited (ceremonyId) | decrypted by the committee once its ceremony opens decryption |
Managing an election
| Method | When |
| --- | --- |
| endProcess, pauseProcess, resumeProcess, cancelProcess | status changes; end and pause only within the voting period |
| extendProcess(pid, seconds), setProcessMaxVoters(pid, n), updateMetadata(pid, metadata) | before the end |
| closeProcessIn(pid, seconds) | before the end: an earlier end, with the registry's minimum notice |
| setProcessGrace(pid, seconds) | before the end, within graceFloor..graceCeil |
| updateCensus(pid, census) | updatable censuses, before the end |
| revealProcessKey(pid, secret) | dkg-locked processes, any time |
| cancelOpenProcesses() | cleanup of the account's open processes |
Each method except cancelOpenProcesses has a …Stream variant. The SDK checks the process and the chain clock first, then simulates the call before signing. To close a live meeting ("voting closes in one minute"):
const { graceFloor, noticeMin } = await sdk.getGraceParams();
await sdk.setProcessGrace(processId, graceFloor); // or `grace: graceFloor` at creation
await sdk.closeProcessIn(processId, noticeMin);
const results = await sdk.waitForResults(processId, { onStatus: s => console.log(s.state) });Nodes flush every vote they hold during the notice, and results follow a few minutes after the announcement.
Voting
const vote = await voter.submitVote({ processId, choices: [1, 0, 0] });
// { voteId, node, weight, k, status: 'pending', ... }
for await (const s of voter.watchVoteStatus(processId, vote.voteId)) {
console.log(s.status, s.error ?? '');
}
const receipt = await voter.getVoteReceipt(processId, vote.voteId); // once settledchoicesholds one integer per ballot field; fieldiis the choice whose metadatavalueisi.- The first vote downloads the circuit files (about 35 MB) and checks them against the ballot VK hash the registry pins.
- A revote goes to the node that took the voter's previous ballot, so it settles after it. The SDK remembers that node in memory; an app that reloads stores
vote.nodeand passes it back asnode. - A refused vote is a
VoteErrorwith areason:not-in-census,not-started,closed,invalid,duplicate,slot-busy,max-voters,busyorunavailable. isAddressAbleToVote,getAddressWeight(abigint) andhasAddressVotedread membership and past votes.- A receipt is a tracker proof that the vote id is in the process's state, checked against the registry's state roots.
Results
const status = await sdk.getResultsStatus(processId); // voting, grace, awaiting-opening, decrypting, results, ...
const results = await sdk.waitForResults(processId);
console.log(results.kind, results.voters);
for (const q of results.questions) {
for (const c of q.choices) console.log(q.title, c.title, c.total, c.mean);
}Results unlock when the grace window closes. A sequencer key's node publishes them within a couple of minutes, a DKG committee takes a few more, and a dkg-locked key first needs revealProcessKey. A Council process reports awaiting-opening (with status.decryptionOpening) until its ceremony opens decryption, then its committee decrypts. The tally is additive: each field's total is the sum over every voter's latest ballot. Census weights are not multiplied in; a weight is a voter's budget when the ballot mode's maxValueSum is 0.
Errors
Errors are classes: test them with instanceof.
import { ProcessStatusError } from '@vocdoni/davinci-sdk';
try {
await sdk.endProcess(processId);
} catch (err) {
if (err instanceof ProcessStatusError && err.revertName === 'InvalidTimeBounds') {
await sdk.cancelProcess(processId); // it has not started yet
} else throw err;
}- Contract errors (
ProcessCreateError,ProcessStatusError,ProcessDurationError, …) carry the decoded registry error inrevertandrevertName. - A refused vote is a
VoteErrorwith areason(see Voting);slot-busyandbusymean retry later on the same node. - Node errors are
SequencerApiError(with the node'scode),SequencerNetworkErrorandSequencerDecodeError. - Setup errors:
DeploymentPinError,NodeMismatchError,SequencerUnavailableError,ArtifactError. - Results:
ResultsError(canceled,locked,timeout); receipts:VoteReceiptError.
Secrets and trust
- A
dkg-lockedelection'sorganizerSecretis returned once and never stored or logged by the SDK. Losing it loses the results. - The ballot secret
kreturned bysubmitVoteopens the ballot with the election key: keep it private, and never pass it to another vote. Every ballot, revotes included, needs a freshk, whichsubmitVotedraws whenkis left out: two ballots under onekexpose the difference of their choices, and no node refuses them. - A sequencer key trusts its node with ballot secrecy and with publishing the results; the DKG and Council key modes move both to a committee threshold.
Documentation
docs/ai/SKILL.md: start here. The model, a short end-to-end example and a map from task to guide.docs/ai/references/: one guide per topic: setup, processes, key modes, the grace window, censuses, metadata, voting, nodes, receipts, results, ballot modes, the node client, the contracts, errors and the protocol.docs/ai/recipes/: runnable scripts for common tasks.examples/script/: full elections against a configured deployment, including one on an on-chain census.llms.txtandllms-full.txt: the same guides as an index and a single file, for tools that load documentation by URL (llms.txt, llms-full.txt).CHANGELOG.md: release notes, including the migration from 1.x.SECURITY.md: what the SDK checks, and how to report a vulnerability.
Development
yarn install
yarn build # dist/: ESM and CommonJS builds with types
yarn lint && yarn format:check
yarn test:unit # offline
yarn test:anvil # organizer flows on the real contracts, on a local anvil chain (needs Foundry)
yarn docs:check # llms.txt is current and every documentation code block type-checksSee CONTRIBUTING.md for the test suites, the vendored vectors and ABIs, and the conventions. Questions are welcome on Discord.
