Tally algorithm
tally() is a pure function. Given the same (poll, ballots, chain snapshot),
every correct implementation returns the same result — byte-for-byte. This is
the property that makes OC Vote trust-minimized: anyone can re-run the tally and
check.
Signature
function tally(opts: {
poll: Poll;
ballots: Ballot[];
utxosAt: (address: string, blockHeight: number) => Promise<Utxo[]>;
revealedOptions?: Record<BallotId, OptionIndex>; // for secret-mode polls
// Preferred: named arguments can't be swapped.
verify?: (a: {
address: string;
message: string;
signature: string;
}) => Promise<boolean> | boolean;
// Positional, and vote-core's order is (address, message, signature), unlike
// every sibling package. A sibling's verifier compiles here and checks nothing.
verifyBip322?: (
address: string,
message: string,
signatureB64: string
) => Promise<boolean> | boolean;
skipSignatures?: boolean; // for offline / cached-signature re-tallies
}): Promise<TallyResult>;
tally throws unless it gets verify, verifyBip322 or an explicit
skipSignatures, so it never counts unverified ballots by accident.
The function is async only because utxosAt is async. Everything else is
deterministic synchronous computation.
Steps
1. Verify the poll
- Recompute
poll.id = sha256(canonical_bytes(poll with sig="")). Reject if it doesn't match what's claimed. - Verify
poll.sigis a valid BIP-322 signature frompoll.creator.addressover the canonical bytes. - Reject if
poll.version !== "oc-vote/v0".
2. Verify each ballot
For each ballot in the input:
- Reject if the ballot's poll_id doesn't match this poll.
- Reject if the ballot's signature doesn't verify.
- Reject if the ballot was issued after
poll.closes_at. - If
weight_mode === 'secret', verify the commitment matches the revealed option when available.
Invalid ballots are silently dropped (not failing the whole tally). This is important: a tally-operator can't DoS the tally by submitting a bad ballot.
3. Fetch snapshot UTXOs
For each unique voter address, call utxosAt(address, poll.snapshot_block) to
get the voter's UTXOs at the declared block height.
This is the ONLY network-dependent step. A caller with an offline chain snapshot
(e.g., a compact block-filter database) can supply their own utxosAt
implementation and compute the tally fully offline.
4. Compute per-voter weight
Applying the selected weight mode:
for voter in unique_voters_with_valid_ballots:
weight[voter] = compute_weight(utxos[voter], poll.weight_mode, poll.params)
5. Handle vote changes
If a voter has multiple valid ballots for the same poll, only the latest (by
issued_at) is counted. Earlier ballots are discarded. This is tested by
conformance vector v04.
6. Sum into options
for voter in voters_with_latest_ballot:
choice = latest_ballot[voter].option_index
option_totals[choice] += weight[voter]
7. Declare winner
The option with the highest total wins. Ties are broken deterministically by option index (lowest index wins). This makes the result reproducible across implementations without a tiebreak convention debate.
Output shape
{
"poll_id": "<content hash>",
"option_totals": {
"Keep current": 10000000,
"Midnight": 30000000,
"Abstain": 0
},
"winner": "Midnight",
"total_weight": 40000000,
"valid_ballots": 2,
"invalid_ballots": 0,
"voter_count": 2
}
Every field is byte-deterministic given the same inputs. Two independent implementations that disagree on any field have a bug.
Conformance
The 5 canonical vectors in oc-vote-protocol/test-vectors/:
| Vector | What it pins |
|---|---|
v01 | Minimal public poll — 3 options, 2 voters, one_per_address |
v02 | Sats-weighted with dust floor |
v03 | sats_days with per-UTXO cap |
v04 | Vote-change semantics — voter submits two ballots, only the later counts |
v05 | Secret ballot — commit-reveal, partial reveal, correct handling of un-revealed votes |
@orangecheck/vote-core passes all 5 on every CI run. A reimplementation in
another language is conformant iff it also passes all 5.
Edge cases
- Voter has no UTXOs at snapshot. Weight = 0. Ballot is technically valid (signature verifies) but contributes nothing.
- Poll closes mid-tally. Ballots
issued_at > poll.closes_atare rejected during ballot verification, so by tally time there are no "race" ballots. - Two ballots from same voter with same
issued_at. Useballot.id(content hash) as the tiebreaker — lexicographically smaller wins. - Zero valid ballots. Tally returns zero weights,
winner = null,voter_count = 0.
See also
- Weight modes — the formulas
tally()applies - OC Vote overview — when each mode fits
- Conformance vectors — how we prove the algorithm is deterministic across impls