Voting system

In this section we will write Cairo code for a small voting system. This voting system can be used, for example, to run a secure (non-private) voting with a lot of voters on a blockchain. We will assume that each voter has a pair of private and public keys (for the ECDSA signature scheme) and that the list of voters’ public keys is fixed. Each voter may vote “Yes” (1) or “No” (0). The system will not guarantee anonymity.

This section assumes some basic knowledge of cryptographic primitives such as hash functions, digital signatures and Merkle trees. In addition, if you haven’t already read the previous parts of the “Hello, Cairo” tutorial, you are encouraged to do so before reading this part.

Generating dummy data

First, let’s generate some dummy data for the voting. To generate the key pairs and signature we will write a small Python script using StarkWare’s crypto library:

import json

from starkware.crypto.signature.signature import (
    pedersen_hash, private_to_stark_key, sign)

# Set an identifier that will represent what we're voting for.
# This will appear in the user's signature to distinguish
# between different polls.
POLL_ID = 10018

# Generate key pairs.
priv_keys = []
pub_keys = []

for i in range(10):
    priv_key = 123456 * i + 654321  # See "Safety note" below.
    priv_keys.append(priv_key)

    pub_key = private_to_stark_key(priv_key)
    pub_keys.append(pub_key)

# Generate 3 votes of voters 3, 5, and 8.
votes = []
for (voter_id, vote) in [(3, 0), (5, 1), (8, 0)]:
    r, s = sign(
        msg_hash=pedersen_hash(POLL_ID, vote),
        priv_key=priv_keys[voter_id])
    votes.append({
        'voter_id': voter_id,
        'vote': vote,
        'r': hex(r),
        's': hex(s),
    })

# Write the data (public keys and votes) to a JSON file.
input_data = {
    'public_keys': list(map(hex, pub_keys)),
    'votes': votes,
}

with open('voting_input.json', 'w') as f:
    json.dump(input_data, f, indent=4)
    f.write('\n')

We use the Pedersen hash function and the ECDSA signature, which are natively supported in Cairo. For technical details about those cryptographic primitives see STARK Curve and Pedersen Hash Function.

Safety note: In a real system, choosing the private keys must be done using a strong random mechanism. The reason we didn’t use random private keys for our dummy data is to get a deterministic example, which is more convenient for a tutorial.

Here’s what we get:

{
    "public_keys": [
        "0x1c3eb6d67f833a9dac3766b2f22d31299875884f3fc84ebc70c322e8fb18112",
        "0x22285e2a1c84a7b6e283eb1ee28a40ba30874aff62617ba1220d7dc6a2b1e70",
        "0x2226376596c83aa0c5381b42b516bd84e604d7ff65647bab46feb7540a6544d",
        "0x492cf083fdc9d0c48bcc2807abd2a6da8550b872d047cd36e501a5e12cb581d",
        "0x1b16536d44330830c39b119673c7a065fe6592094d8506221053176c9500e54",
        "0x4cb42f213ed6dcfadb7b987fd31b2260334cbe404315708d17a2404fbadb11e",
        "0xb52947e334a58f8041373c44270c90c0bd28f345b0dac2af0886ec3cfab253",
        "0x23592b2754186e35f970c72eea16d46df2570bc68e6ee3069d8aa68d1a1707a",
        "0x529196a1456a35d3ee9138dd7355cb6416fe40deade3adab76f2e66554400ef",
        "0x2854a6d2c60e46b2f176659ccbffdddc3db9e2d1e74d3fa54f2fe252e571db0"
    ],
    "votes": [
        {
            "voter_id": 3,
            "vote": 0,
            "r": "0x315007dfbb13073cac204056c43fa51df0d56f88485c9563e86927f03c039bd",
            "s": "0x51ce6bb918720da62507bf093a6e29877fd77a0f979c0bdcd5684c8bdfefea4"
        },
        {
            "voter_id": 5,
            "vote": 1,
            "r": "0x5640e049062218fece9a6ab3f7871ff8dd7f8f7bc01d0e3b408f03d6477a1b6",
            "s": "0x70adf064b7e317fba19bac2d2677ad0448a4229d2340d5af1eb86a6252d6812"
        },
        {
            "voter_id": 8,
            "vote": 0,
            "r": "0x1749c30845cdf996ec03b79dd8262cf68e504143c93c94c8020d78c6f42b635",
            "s": "0x31a8bac54c17ac9c81dc036bcc761a3f78d7f43a8d42c468d774c1b2a9746c2"
        }
    ]
}

Processing the program input

Let’s define a struct that will represent a single vote:

struct VoteInfo {
    // The ID of the voter.
    voter_id: felt,
    // The voter's public key.
    pub_key: felt,
    // The vote (0 or 1).
    vote: felt,
    // The ECDSA signature (r and s).
    r: felt,
    s: felt,
}

Now, let’s write a function that returns an array of VoteInfo instances based on the program input. Note that since the entire function is basically just one hint, the validity of the returned data (e.g., that the signatures are valid, the votes are restricted to 0 or 1, etc.) is not guaranteed, so we must verify it later.

from starkware.cairo.common.alloc import alloc

// Returns a list of VoteInfo instances representing the claimed
// votes.
// The validity of the returned data is not guaranteed and must
// be verified by the caller.
func get_claimed_votes() -> (votes: VoteInfo*, n: felt) {
    alloc_locals;
    local n;
    let (votes: VoteInfo*) = alloc();
    %{
        ids.n = len(program_input['votes'])
        public_keys = [
            int(pub_key, 16)
            for pub_key in program_input['public_keys']]
        for i, vote in enumerate(program_input['votes']):
            # Get the address of the i-th vote.
            base_addr = \
                ids.votes.address_ + ids.VoteInfo.SIZE * i
            memory[base_addr + ids.VoteInfo.voter_id] = \
                vote['voter_id']
            memory[base_addr + ids.VoteInfo.pub_key] = \
                public_keys[vote['voter_id']]
            memory[base_addr + ids.VoteInfo.vote] = \
                vote['vote']
            memory[base_addr + ids.VoteInfo.r] = \
                int(vote['r'], 16)
            memory[base_addr + ids.VoteInfo.s] = \
                int(vote['s'], 16)
    %}
    return (votes=votes, n=n);
}

Verifying that the votes are signed

One of the first functions we will need is verify_vote_signature(), which gets a pointer to a VoteInfo instance and verifies that the vote was indeed signed by the voter’s public key (note that we still haven’t checked that the voter’s public key is one of the permitted public keys).

The function starts by calling hash2() to compute the message hash. This is the counterpart of the line pedersen_hash(POLL_ID, vote) in the Python code above.

Then, we call verify_ecdsa_signature() to check that the signature is valid. One subtlety is that verify_ecdsa_signature() gets the signature only as a hint for the prover – the fact that it completed successfully only implies that the prover knows a signature for the given message and public key, not that the specific r and s constitute that signature. In our case, it’s enough, as we don’t care about r and s themselves, we just want to make sure the message was signed by the given public key.

from starkware.cairo.common.cairo_builtins import (
    HashBuiltin,
    SignatureBuiltin,
)
from starkware.cairo.common.hash import hash2
from starkware.cairo.common.signature import (
    verify_ecdsa_signature,
)

// The identifier that represents what we're voting for.
// This will appear in the user's signature to distinguish
// between different polls.
const POLL_ID = 10018;

func verify_vote_signature{
    pedersen_ptr: HashBuiltin*, ecdsa_ptr: SignatureBuiltin*
}(vote_info_ptr: VoteInfo*) {
    let (message) = hash2{hash_ptr=pedersen_ptr}(
        x=POLL_ID, y=vote_info_ptr.vote
    );

    verify_ecdsa_signature(
        message=message,
        public_key=vote_info_ptr.pub_key,
        signature_r=vote_info_ptr.r,
        signature_s=vote_info_ptr.s,
    );
    return ();
}

The pedersen builtin, which is required in order to compute the Pedersen hash function, is using an implicit argument called pedersen_ptr. On the other hand hash2() gets an implicit argument called hash_ptr. Therefore, we need to explicitly bind the hash_ptr implicit argument, using hash2{hash_ptr=pedersen_ptr}(...).

Similarly, the implicit argument ecdsa_ptr is used by verify_ecdsa_signature (here the name of the implicit argument of verify_ecdsa_signature is also ecdsa_ptr, so we don’t have to specify the binding explicitly).

Merkle tree

An important feature of our system will be that it will allow splitting the voting process to batches, where each batch can be processed in a separate Cairo run (this way we can support large and ongoing polls). This means that we will need to pass information between each pair of consecutive runs: which of the voters have already cast a vote (or rather, who is still allowed to vote) and what the results have been so far.

We will use a Merkle tree to store the information about the public keys that are allowed to vote. A Merkle tree is a cryptographic primitive that allows “compressing” data of an arbitrary size to a very short value (in our case, it can fit in one field element). It works as follows: you take an array of values (usually of size which is a power of 2, say \(2^k\)) and you compute the hashes of pairs of values so that you obtain \(2^k / 2 = 2^{k - 1}\) hashes. You repeat this step once more to obtain \(2^{k - 2}\) hashes, and continue for a total of \(k\) steps, which results in one hash, called “the Merkle root”. The important property of Merkle trees is that given the Merkle root of an array of values, it is not feasible to find a different array of the same size with the same Merkle root. This way the Merkle root “encodes” all of the tree’s data.

Our Merkle tree will contain all the voters’ public keys (padded with zeros) that haven’t voted yet. When someone votes, we replace their public key with 0 in the Merkle tree. Thus we guarantee that no one can vote more than once.

For simplicity we hard-code the maximal number of voters to \(2^{10} = 1024\):

const LOG_N_VOTERS = 10;

Each Cairo run will output 4 values: the number of “yes” and “no” votes and the Merkle root before and after processing the votes of that batch (note that each run handles one batch, which may include multiple votes). It is up to the system using the Cairo proofs (e.g., a smart contract) to make sure that the new root encoded in one proof is the same as the old root encoded in the next proof, and to add the partial results of the new batch to those accumulated thus far.

Processing the votes

To track the changes to the Merkle tree, we will use a DictAccess array, which will encode the changes to the leaves (changing actual public keys to zeros). Let’s define a VotingState struct to keep track of the current ‘yes’ and ‘no’ counts and the DictAccess array. If you need to recall how a DictAccess array works, see Dictionaries/maps in Cairo.

from starkware.cairo.common.dict import DictAccess

struct VotingState {
    // The number of "Yes" votes.
    n_yes_votes: felt,
    // The number of "No" votes.
    n_no_votes: felt,
    // Start and end pointers to a DictAccess array with the
    // changes to the public key Merkle tree.
    public_key_tree_start: DictAccess*,
    public_key_tree_end: DictAccess*,
}

Now, let’s write a function that returns an initial state, with both the “yes” and “no” counters set to zero, and an empty array for the tree’s changes. Note that we’re using dict_new() to create the dict. dict_new() is one of the high-level dictionary functions defined in dict.cairo. These functions maintain the current values of the dictionary using hints. Therefore, dict_new() expects to get a hint variable called initial_dict with the initial values of the dictionary.

from starkware.cairo.common.dict import dict_new

func init_voting_state() -> (state: VotingState) {
    alloc_locals;
    local state: VotingState;
    assert state.n_yes_votes = 0;
    assert state.n_no_votes = 0;
    %{
        public_keys = [
            int(pub_key, 16)
            for pub_key in program_input['public_keys']]
        initial_dict = dict(enumerate(public_keys))
    %}
    let (dict: DictAccess*) = dict_new();
    assert state.public_key_tree_start = dict;
    assert state.public_key_tree_end = dict;
    return (state=state);
}

The following function verifies that the vote is signed and removes the public key from the tree. There are two options to handle the voting state:

  1. Pass it as an argument and return the new state.

  2. Add it as an implicit argument.

The two options have a different syntax, but they will be compiled to the same bytecode. Here we chose the second option as it simplifies the code calling process_vote.

from starkware.cairo.common.dict import dict_update
from starkware.cairo.common.math import assert_not_zero

func process_vote{
    pedersen_ptr: HashBuiltin*,
    ecdsa_ptr: SignatureBuiltin*,
    state: VotingState,
}(vote_info_ptr: VoteInfo*) {
    alloc_locals;

    // Verify that pub_key != 0.
    assert_not_zero(vote_info_ptr.pub_key);

    // Verify the signature's validity.
    verify_vote_signature(vote_info_ptr=vote_info_ptr);

    // Update the public key dict.
    let public_key_tree_end = state.public_key_tree_end;
    dict_update{dict_ptr=public_key_tree_end}(
        key=vote_info_ptr.voter_id,
        prev_value=vote_info_ptr.pub_key,
        new_value=0,
    );

    // Generate the new state.
    local new_state: VotingState;
    assert new_state.public_key_tree_start = (
        state.public_key_tree_start
    );
    assert new_state.public_key_tree_end = public_key_tree_end;

    // Update the counters.
    tempvar vote = vote_info_ptr.vote;
    if (vote == 0) {
        // Vote "No".
        assert new_state.n_yes_votes = state.n_yes_votes;
        assert new_state.n_no_votes = state.n_no_votes + 1;
    } else {
        // Make sure that in this case vote=1.
        assert vote = 1;

        // Vote "Yes".
        assert new_state.n_yes_votes = state.n_yes_votes + 1;
        assert new_state.n_no_votes = state.n_no_votes;
    }

    // Update the state.
    let state = new_state;
    return ();
}

Finally, let’s write the loop that processes all the votes. It gets a pointer to an array of VoteInfo instances and its size and updates the given state accordingly.

func process_votes{
    pedersen_ptr: HashBuiltin*,
    ecdsa_ptr: SignatureBuiltin*,
    state: VotingState,
}(votes: VoteInfo*, n_votes: felt) {
    if (n_votes == 0) {
        return ();
    }

    process_vote(vote_info_ptr=votes);

    process_votes(
        votes=votes + VoteInfo.SIZE, n_votes=n_votes - 1
    );
    return ();
}

The main() function

As explained above, the program will output 4 values that summarize the batch: the number of “yes” and “no” votes and the Merkle root before and after processing the votes of that batch. The following struct represents that information:

struct BatchOutput {
    n_yes_votes: felt,
    n_no_votes: felt,
    public_keys_root_before: felt,
    public_keys_root_after: felt,
}

The only missing part is the computation of the two Merkle roots, based on the public key dictionary (VotingState.public_key_tree_start and VotingState.public_key_tree_end). In order to do this, we first squash the dict and then call the standard library function small_merkle_tree_update() (a requirement of small_merkle_tree_update() is that we use the high-level function dict_squash() rather than squash_dict(). dict_squash() passes hint information about all of the dict entries to the squashed dict, including entries that haven’t changed.

%builtins output pedersen range_check ecdsa

from starkware.cairo.common.dict import dict_squash
from starkware.cairo.common.small_merkle_tree import (
    small_merkle_tree_update,
)

func main{
    output_ptr: felt*,
    pedersen_ptr: HashBuiltin*,
    range_check_ptr,
    ecdsa_ptr: SignatureBuiltin*,
}() {
    alloc_locals;

    let output = cast(output_ptr, BatchOutput*);
    let output_ptr = output_ptr + BatchOutput.SIZE;

    let (votes, n_votes) = get_claimed_votes();
    let (state) = init_voting_state();
    process_votes{state=state}(votes=votes, n_votes=n_votes);
    local pedersen_ptr: HashBuiltin* = pedersen_ptr;
    local ecdsa_ptr: SignatureBuiltin* = ecdsa_ptr;

    // Write the "yes" and "no" counts to the output.
    assert output.n_yes_votes = state.n_yes_votes;
    assert output.n_no_votes = state.n_no_votes;

    // Squash the dict.
    let (squashed_dict_start, squashed_dict_end) = dict_squash(
        dict_accesses_start=state.public_key_tree_start,
        dict_accesses_end=state.public_key_tree_end,
    );
    local range_check_ptr = range_check_ptr;

    // Compute the two Merkle roots.
    let (root_before, root_after) = small_merkle_tree_update{
        hash_ptr=pedersen_ptr
    }(
        squashed_dict_start=squashed_dict_start,
        squashed_dict_end=squashed_dict_end,
        height=LOG_N_VOTERS,
    );

    // Write the Merkle roots to the output.
    assert output.public_keys_root_before = root_before;
    assert output.public_keys_root_after = root_after;

    return ();
}

Note that we write {state=state} explicitly when we call process_votes. This is required since the compiler does not allow implicit bindings where the bound variable is not an implicit argument of the calling function. See more information here.

One new feature we used here is the cast keyword. The cast keyword in let output = cast(output_ptr, BatchOutput*); converts the felt pointer to a pointer to BatchOutput, so the type of the output reference is BatchOutput*. Now we can write output.n_yes_votes to access the first output cell, which encodes the number of “yes” votes.

Don’t forget to supply the program input file when you run the code (you can find the full Cairo file here):

cairo-compile voting.cairo --output voting_compiled.json

cairo-run --program=voting_compiled.json \
    --print_output --layout=small \
    --program_input=voting_input.json

You should get:

Program output:
  1
  2
  1591806306193441240739433996824056703232153712683022312894504906643112470393
  -1397522753299492751557547967820826962898231398543673030347416450104778351221

Another batch

Our Cairo code supports voting in batches, so let’s try that. Let’s say that we want to run another batch after the one we just did. Modify voting_input.json so that the public keys of the voters who voted in the first batch are 0 and the votes section contains one new vote instead of the old three. You can use the following Python script:

import json

from starkware.crypto.signature.signature import pedersen_hash, sign

POLL_ID = 10018

input_data = json.load(open('voting_input.json'))
input_data['public_keys'][3] = '0x0'
input_data['public_keys'][5] = '0x0'
input_data['public_keys'][8] = '0x0'

# Generate a "yes" vote for voter 6.
voter_id = 6
priv_key = 123456 * voter_id + 654321
vote = 1
r, s = sign(
    msg_hash=pedersen_hash(POLL_ID, vote),
    priv_key=priv_key,
)
input_data['votes'] = [{
    'voter_id': voter_id,
    'vote': vote,
    'r': hex(r),
    's': hex(s),
}]

with open('voting_input2.json', 'w') as f:
    json.dump(input_data, f, indent=4)
    f.write('\n')

Run the same program again (you don’t need to recompile) with voting_input2.json. You should get:

Program output:
  1
  0
  -1397522753299492751557547967820826962898231398543673030347416450104778351221
  -628706650786693403852552424323387050556189030546827265857028820447499605255

Note that indeed, the root of the Merkle tree before the second batch is the same as the root after the first one.