Skip to content
Condorcet.vote Alpha
← Technical documentation

Blockchain

Everything an independent verifier needs to read, check and re-tally an election chain without asking us anything.

Each election publishes its register as a sequence of JSON files, chained by hash and signed by the platform. This page is the reference of that format: the shared envelope, the four block types field by field, the byte-level rules, and the JSON Schemas the platform validates every block against before sealing it (downloadable below).

The version is bound to each election for good and repeated in every block; a format change would ride a new version with its own schema set.

Protocol v1

Downloads

The JSON Schemas, as sealed.

JSON Schema draft 2020-12, one file per block type plus the ballot payload envelope. These are the exact files the platform uses: no additional property is ever accepted, and each is served at the canonical URL written in its $id.

Envelope

Five fields on every block.

Whatever its type, a block opens with the same envelope, in this reading order. The block’s own hash and signature are never inside the JSON (a document cannot contain its own digest); they travel alongside it.

protocol_version integer
Always 1 for this version; identical on every block of a chain.
type string
One of configuration, rings, votes, closure.
election_id UUID v4, lowercase
The election identifier, the same on every block, and the election_id parameter of every ring signature.
block_number integer ≥ 0
Consecutive per election, starting at 0 for the configuration block.
previous_hash null or 128 hex chars
SHA3-512 of the previous block’s stored bytes, lowercase hex; null on block 0 only.
Envelope · block.*.json
{
  "protocol_version": 1,
  "type": "configuration | rings | votes | closure",
  "election_id": "0c2e7e2a-5df3-4c0e-9d2b-3f6a8f0c9e21",
  "block_number": 0,
  "previous_hash": null,
  ...
}
Block types

Four types, one fixed cycle.

One configuration block at the head, a rings block for the launch rings, a rings block per emergency ring created during the vote, votes blocks in batches, and a single closure block that terminates the chain. The result is never a block: it is recomputed from the published ballots.

Block 0 · genesis

configuration · the frozen definition

What will never change once the vote opens. Cosmetic presentation (logo, photos, thank-you message) is deliberately outside the block, unhashed.

title, description?, organizer_name strings
Informative context, signed with the block but never a tally input.
election_type preferential | fptp
Ranked ballot (Condorcet methods) or single choice. The Condorcet method itself is published with the result, not here.
seats integer ≥ 1
Seats to fill.
voter_access_mode string
closed_nominative_list in version 1.
enrolment_mandatory boolean
Voter-brought keys only (BYO-only) when true; server-generated keys allowed when false.
non_anonymous_voting, nominative_participation, weighted_voting booleans
The three privacy and weighting options. non_anonymous_voting = true forces nominative_participation = true (schema-enforced).
max_ring_size integer ≥ 1
The ring-size ceiling of this election: no ring exceeds it, every emergency ring is exactly this size.
initial_electoral_roll_size integer ≥ 0
The electoral roll size at the opening. Voters may be added during the vote; the closure roll gives the final size, so the number of additions is public, never who.
ballot_rules object
ties_allowed, partial_ranking_allowed, blank_allowed, full_ranking_required: the ballot parameters from which tally constraints are derived deterministically.
candidates[].label array of 2 to 30 objects
Unique labels, 1 to 64 characters, sorted by label (byte order). The label is the sole candidate identifier.
Block 1 · then one per emergency ring

rings · the keys allowed to sign

The complete list of public keys of each ring. Block 1 carries the launch rings; any later rings block carries one emergency ring, deducible from its position. A ring’s member set is frozen for good once published.

rings[].ring_id UUID
The ring identifier referenced by every ballot signed against it; rings sorted by ring_id.
rings[].ring_digest ring_<64 hex>_<8 hex>
The canonical cryptoVote digest of the ring’s member set, independent of order. Derivable from public_keys (a verifier recomputes it); it is published so a voter can compare it with the digest their browser computed from the ring they were served.
rings[].public_keys[] pk_<64 hex>_<8 hex>
Checksummed prefixed public keys, sorted ascending, unique; at most max_ring_size per ring.

Launch rings share one size per election (between the 100-key floor and the ceiling); emergency rings hold exactly the ceiling; a BYO-only election’s rings hold its enrolled membership, evenly partitioned.

Next blocks · batched

votes · the signed ballots

Ballots are published in batches of at least 600 (the remainder at closing), never one by one, and sorted by key image within each block: no timestamp, no arrival order.

ballots[].ring_id UUID
The ring whose public keys the signature verifies against.
ballots[].key_image ki_<64 hex>_<8 hex>
The election-scoped key image: unique per election. A second ballot with the same key image is a double vote, publicly evident. Sort key of the block.
ballots[].signature blsag_…
The linkable ring signature (bLSAG variant over Ristretto255) over the exact payload bytes.
ballots[].payload string (opaque JSON)
The signed document, verbatim: {"v":1,"election":"…","ballot":"A>B=C","weight":1[,"voter":"…"]}. Never re-serialized (see the payload schema).

The ballot field is a flat string in CEF ranking syntax (or the /EMPTY_RANKING/ sentinel). Its content is deliberately not validated: a malformed expression is classified as a null ballot at tally by a published, reproducible rule.

Final block · terminator

closure · totals and participation register

Exactly one, last, nothing after it. Holding the signed closure block is the proof a chain is complete. The participation register appears here once, never during the vote.

totals object of integers
block_count, ballot_count, ring_count, voted_count, voted_weight, cross-checkable against the chain itself.
electoral_roll[].voter_id UUID v4
The published voter ID (a random pseudonym); the roll at the close, sorted by voter_id, one line per voter, none marked as added.
electoral_roll[].key_provisioning_mode string
server_generated, organizer_imported or voter_enrolled: whether the platform ever held this voter’s key is a public fact.
electoral_roll[].ring_id UUID or null
The ring the voter voted in; null if they did not vote. The verifiable voter → ring link.
electoral_roll[].distribution_count integer ≥ 0
How many keys were distributed to the voter (3 at most by default; organizer-granted extras stay visible).
electoral_roll[].has_voted, weight boolean, integer ≥ 1
Participation flag and authoritative ballot weight (1 unless weighted_voting).
electoral_roll[].voter_name, email_digest strings, optional
Nominative disclosure only, on voters who voted: clear name and SHA3-256(election_id ‖ normalized e-mail).
Serialization

The bytes are the contract.

  • Stored = served = signed. The block file is written once and never re-serialized. There is no canonical-JSON step: what is hashed and signed is the file as served.
  • UTF-8 JSON, one item per line. The outer object is indented; each item of the large collections (candidates, rings, ballots, electoral_roll) is one compact object on its own line. Slashes and Unicode are left unescaped.
  • Deterministic ordering, byte order. candidates by label, public_keys ascending, ballots by key_image, electoral_roll by voter_id, rings by ring_id. Plain byte comparison, never a linguistic collation.
  • Integers only, no timestamps. protocol_version, block_number, seats, sizes and totals are non-negative integers; every other value is a string. No block carries a date, no ballot carries an arrival order.
  • No additional properties, no duplicate keys. Each schema forbids unknown fields; a verifier must reject a block whose JSON repeats a key, since parsers disagree on which value wins.
Integrity

Hash, signature, timestamp.

  • SHA3-512 chaining. A block’s digest is SHA3-512 over its stored bytes, 128 lowercase hex characters; it is the next block’s previous_hash. Recompute it with openssl dgst -sha3-512.
  • Detached OpenPGP v6 signature. Each block is signed over its exact bytes by the platform’s Ed25519 key (RFC 9580), as an armored detached signature in the .sig sidecar. Its creation time is the block’s signed_at; a notation domain@condorcet.vote = chain-block/v1 prevents replay on another artifact class.
  • Key rotation stays verifiable. Retired certificates remain published with their expiration: standard OpenPGP validity rules accept blocks signed within a key’s window and reject the rest.
  • OpenTimestamps anchor. Each block’s plain SHA-256 is committed to the Bitcoin blockchain; the .ots sidecar is served pending or completed and verifies offline with stock tooling.
  • Self-identifying files. Stored artifacts are named with the election id ({election}_{n}.json and sidecars), so a mirrored file remains identifiable once detached from its URL.
OpenTimestamps timestamping in detail
Endpoints

Plain files, no API.

Everything is served as cacheable, mirrorable files on the main domain, from the moment a vote opens until the election is archived. A cancelled election withdraws its register.

/elections/{election}/chain
The manifest: ordered list of blocks with type, hash, signed_at, artifact URLs and the timestamp status.
/elections/{election}/chain/{n}.json
Block n, the exact stored bytes.
/elections/{election}/chain/{n}.json.sig
The armored detached OpenPGP signature of block n.
/elections/{election}/chain/{n}.json.ots
The OpenTimestamps proof of block n, pending or completed.
/.well-known/blockchain-signing-key
The platform’s signing certificates, active and retired, concatenated (application/pgp-keys).
/schemas/blockchain/block.{type}.schema.json
The JSON Schemas above, at their canonical $id URLs.
The full verification procedure, check by check