Skip to main content

End-to-end private voting on the blockchain

This section walks through building and deploying an encrypted voting application using SPF. This example demonstrates the web3 approach, where smart contracts coordinate FHE computations on the blockchain. Contracts submit computation requests to SPF, and results are posted back on-chain via threshold decryption callbacks.

Our demo is available here.

Prerequisites: setting up the development environment

This example requires two development environments: the Parasol compiler for FHE programs (written in C) and Foundry for smart contract development and deployment.

This example uses Foundry for contract development and deployment, but any Ethereum development framework (Hardhat, etc.) can be used. The key requirement is access to the sunscreen-contracts library, which provides the SPF integration components.

We will be using the Monad testnet, but this example works equally well today on the Ethereum Sepolia testnet. The only changes are the RPC URL (a free one is https://ethereum-sepolia-rpc.publicnode.com) and any command referencing --chain monad as an input should use --chain sepolia instead.

Setting up Foundry

Create a new Foundry project for the smart contract:

# Create a new forge environment in the folder `voting`
forge init voting-example
cd voting-example

# Install SPF contracts (using HTTPS URL)
forge install https://github.com/Sunscreen-tech/sunscreen-contracts.git

After installing the contracts, configure the import remappings in foundry.toml:

[profile.default]
src = "src"
out = "out"
libs = ["lib"]
remappings = [
"@sunscreen/=lib/sunscreen-contracts/"
]

This remapping allows you to import the SPF contracts in your Solidity code with import {Spf} from "@sunscreen/contracts/Spf.sol";.

Private Voting Made Easy with Parasol

We want to run a voting system for an issue near and dear to our hearts: whether to adopt a dog as our office pet. We could poll everyone in the office to see if they approve of the idea, but that would reveal how each person voted; no one wants to be known as the person who looked Fido deep into their big, brown eyes and said you can’t be here. Instead, we can use Fully Homomorphic Encryption (FHE) to tally the votes without revealing who would prefer Fido to stay at home.

To implement this secure voting system to keep Fido, we’ll use the Parasol framework to compile and run a simple voting program. This program will accept encrypted votes either that either vote in favor or against Fido, tally them, and return the Fido’s fate without ever revealing individual votes.

#include <parasol.h>

/**
* Performs encrypted voting on a single issue.
*
* Tallies encrypted votes where voters approve or reject an issue. The issue
* passes if there are more approvals then rejections.
*
* @param[in] votes Array of encrypted votes (each vote should be +1 or -1).
* @param[in] num_votes Total number of votes in the array.
* @param[out] didTheIssuePass Encrypted boolean indicating if the issue passed.
*
* @note The [[clang::fhe_program]] attribute marks this as an FHE program.
* @note The [[clang::encrypted]] attribute indicates parameters that are passed
* in as encrypted values.
*/
[[clang::fhe_program]]
void tally_votes([[clang::encrypted]] int8_t *votes,
uint16_t num_votes,
[[clang::encrypted]] bool *didTheIssuePass) {
int16_t sum = 0;

for (uint16_t i = 0; i < num_votes; i++) {
// Use iselect8 to sanitize votes: keep the vote if it is -1 or 1, otherwise
// set the vote to 0. It is equivalent to the ternary operator.
int8_t sanitized_vote =
iselect8(votes[i] == 1 || votes[i] == -1, votes[i], 0);
sum += sanitized_vote;
}

// The issue passes if more people voted in favor of the issue.
*didTheIssuePass = sum > 0;
}

The program is standard C code with two FHE-specific annotations:

  • [[clang::fhe_program]] marks the function as an FHE program, and
  • [[clang::encrypted]] marks parameters that will be passed in as ciphertexts.

To compile the program, save the code to a file (e.g., voting.c) and use the Parasol compiler to compile the result into an FHE program.

# Compile the voting program with clang, targeting the parasol CPU
# and enabling optimizations.
clang \
-target parasol \
-O2 \
voting.c \
-o voting

Congratulations! You’ve just compiled your first FHE program 🎉.

But how do we use the program to run the auction in practice? Luckily we provide the Sunscreen Secure Processing Framework (SPF) service, which allows developers to upload, run, and decrypt the results of FHE programs easily. This enables developers to focus on building their applications without worrying about the complexities of FHE or managing the underlying compute infrastructure.

For web3 applications, you typically upload the program to SPF before you build your application as the output of the upload, known as the library identifier, will need to be referenced by the contract. To do so, use the spf-client CLI. The upload-program command stores the program and returns a unique library identifier for referencing the program in subsequent operations.

spf-client upload-program --file voting

Building the voting application

While the web2 approach uses direct API calls, blockchain applications coordinate FHE computations through smart contracts. This example demonstrates using a Solidity contract to manage encrypted voting on the Monad testnet.

Scenario

In this scenario, the following parties are involved:

Contract deployer: Uploads the voting program to SPF, deploys the smart contract to the blockchain, and configures the contract with the program library ID.

Voters: Each voter independently encrypts their vote (1 for yes, -1 for no), uploads the encrypted vote to SPF, grants the smart contract permission to use their ciphertext, and submits the ciphertext ID to the contract. Individual votes remain hidden throughout the entire voting lifecycle.

Smart contract: The contract collects encrypted votes, triggers the FHE computation on SPF, requests threshold decryption of the result, and receives the decrypted outcome via callback. The contract learns only the final tally, never individual votes.

Step 1: Write and deploy the smart contract

The voting contract integrates with SPF to coordinate encrypted computation. The contract calls the tally_votes function defined in the C program:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.28;

import {Spf} from "@sunscreen/contracts/Spf.sol";
import {ISpfSingleProgram} from "@sunscreen/contracts/ISpfSingleProgram.sol";
import {TfheThresholdDecryption} from "@sunscreen/contracts/TfheThresholdDecryption.sol";

/// Encrypted voting using Fully Homomorphic Encryption (FHE).
///
/// This contract integrates with the tally_votes C program (in voting.c) which
/// performs the FHE computation to tally votes while maintaining vote privacy.
contract Voting is TfheThresholdDecryption, ISpfSingleProgram {
/// The SPF library identifier used for FHE computations.
Spf.SpfLibrary private lib =
Spf.SpfLibrary.wrap(0x7f7e6993da0d371c4f7f4573df315fe3638d253b74c3f98e946f9b9737b5b51b);

/// The name of the program in the library that we want to use
Spf.SpfProgram private program = Spf.SpfProgram.wrap("tally_votes");

/// Array of encrypted vote ciphertexts submitted by voters
Spf.SpfCiphertextIdentifier[] private votes;

/// Decrypted result: true if the issue passed, false otherwise
bool private hasTheVoteBeenTallied;
bool private didTheIssuePass;

/// Submits an encrypted vote to the voting contract.
/// The vote should be an encrypted value representing either 1 (approve) or -1 (reject).
/// Invalid votes (neither 1 nor -1) are set to 0 and don't affect the tally.
function submitVote(Spf.SpfCiphertextIdentifier vote) public {
votes.push(vote);
}

/// Initiates the FHE computation to tally votes and determine if the issue passed.
/// Calls the tally_voting C program.
function tallyVotes() public {
// Pack the parameters for the FHE program.
Spf.SpfParameter[] memory params = new Spf.SpfParameter[](3);
params[0] = Spf.createCiphertextArrayParameter(votes);
params[1] = Spf.createPlaintextParameter(16, int128(uint128(votes.length)));
params[2] = Spf.createOutputCiphertextParameter(8);

// Request the FHE computation. This request returns a handle that can
// be used to identify the outputs of the computation.
Spf.SpfRunHandle runHandle = Spf.requestRunAsContract(lib, program, params);

// Request that the didTheIssuePass parameter be decrypted once the computation is done.
Spf.SpfParameter memory didTheIssuePassCiphertext = Spf.getOutputHandle(runHandle, 0);

// Request decryption of the didTheIssuePass ciphertext. The
// postIssuePassedCallback function will be invoked automatically by the
// SPF system once the decryption is complete.
requestDecryptionAsContract(
this.postIssuePassedCallback.selector, Spf.passToDecryption(didTheIssuePassCiphertext)
);

delete votes;
}

/// Callback function invoked by the SPF decryption system to post the
/// results of the vote tally on-chain.
///
/// The `onlyThresholdDecryption` modifier enforces that this function
/// can only be called by the SPF decryption service. The ignored `bytes32`
/// argument allows contracts determine which ciphertext is being decrypted.
function postIssuePassedCallback(bytes32, uint256 result) public onlyThresholdDecryption {
// Whether the issue passed (more approvals than rejections)
didTheIssuePass = (result != 0);
hasTheVoteBeenTallied = true;
}

/// Users can read off if the issue passed by this function.
function getDidTheIssuePass() public view returns (bool) {
require(hasTheVoteBeenTallied, "Vote has not been tallied yet");
return didTheIssuePass;
}

// Functions needed to implement the ISpfSingleProgram interface
function getLibraryHash() public view returns (bytes32) {
return Spf.SpfLibrary.unwrap(lib);
}

function getProgramName() public view returns (bytes32) {
return Spf.SpfProgram.unwrap(program);
}
}

The contract implements four key components:

  1. Inheritance: The contract inherits from TfheThresholdDecryption (enabling threshold decryption callbacks) and ISpfSingleProgram (allowing spf-client to automatically populate program information).

  2. Vote submission: The submitVote function accepts encrypted vote ciphertext IDs, allowing the contract to collect votes before tallying.

  3. Tally execution: The tallyVotes function packages votes as parameters, calls requestRunAsContract to trigger FHE computation on SPF, and calls requestDecryptionAsContract to decrypt the result. The computation returns a run handle, and output ciphertext IDs are derived using getOutputHandle. The threshold decryption request specifies postIssuePassedCallback as the callback function for receiving the decrypted result.

  4. Result callback: The postIssuePassedCallback function receives the decrypted tally from the SPF threshold decryption service. The onlyThresholdDecryption modifier ensures only SPF can call this function, preventing unauthorized result posting.

Deploying the contract

To deploy the contract, set environment variables for the RPC endpoint URL ($RPC_URL) and wallet private key ($PRIVATE_KEY). For Monad testnet, a free RPC endpoint is available at https://testnet-rpc.monad.xyz.

# Deploy the Voting contract using Foundry. This broadcasts the
# transaction and outputs JSON with the deployed contract address.
forge create \
--rpc-url $RPC_URL \
--private-key $PRIVATE_KEY \
Voting.sol:Voting \
--broadcast \
--json

The command returns JSON with the deployed contract address in the deployedTo field. Store this address in $CONTRACT_ADDRESS for subsequent commands.

Optionally, use jq to extract and export the contract address in a single command:

export CONTRACT_ADDRESS=$(forge create --rpc-url $RPC_URL --private-key $PRIVATE_KEY Voting.sol:Voting --broadcast --json | jq -r '.deployedTo')

Step 2: Encrypt and upload votes

The SPF client can generate and upload ciphertexts that encrypt input values for FHE programs.

# Generate an encrypted ciphertext for an 8-bit value.
# For negative values, use --value=-1 syntax (with equals sign).
export CIPHERTEXT_ID=$(spf-client generate-ciphertext \
--value=-1 \
--bits 8 \
--upload \
--private-key $PRIVATE_KEY)

You will need to do this once for each vote. In practice, each voter would run this command independently to encrypt and upload their vote, each with their own private key.

Step 3: Grant access to the contract

Ciphertexts are private by default. To allow the smart contract to use the ciphertext as input to the tally_votes program, the voter must grant the contract permission. This is done using the access grant run command, specifying the contract address as the executor.

# Grant run access to the contract. The library and entry point are
# automatically queried from the contract (which implements
# ISpfSingleProgram). This returns a new ciphertext ID with updated ACL.
export RUNNABLE_CIPHERTEXT_ID=$(spf-client access grant run \
--ciphertext-id $CIPHERTEXT_ID \
--executor $CONTRACT_ADDRESS \
--chain monad \
--private-key $PRIVATE_KEY)

As each ciphertext is immutable, the command outputs a new ciphertext ID with the access control applied. This new ID can then be submitted to the contract in the next step.

Step 4: Run the vote tally

With encrypted inputs prepared and access control configured, votes can be submitted to the smart contract. The contract workflow consists of three steps: submitting individual votes, triggering the encrypted tally, and retrieving the result.

Submitting votes

The contract’s submitVote function accepts individual ciphertext identifiers. Submit each vote separately using the access-controlled ciphertext identifiers from the previous step, for example:

cast send --rpc-url $RPC_URL $CONTRACT_ADDRESS --private-key $PRIVATE_KEY "submitVote(bytes32)" $RUNNABLE_CIPHERTEXT_ID

Repeat this command for each vote, replacing the ciphertext identifier each time. Here we can see the privacy benefit of FHE: the contract collects votes without ever seeing the plaintext values, only the encrypted ciphertext IDs.

Tallying votes and requesting decryption

After all votes are submitted, call tallyVotes() to initiate the FHE computation. This function automatically triggers both the encrypted vote tally and threshold decryption:

cast send --rpc-url $RPC_URL $CONTRACT_ADDRESS --private-key $PRIVATE_KEY "tallyVotes()"

The function performs the following steps automatically:

  1. Packages the votes and parameters for the FHE program.
  2. Requests SPF to execute the tally_votes program off-chain.
  3. Derives the output ciphertext ID from the computation result.
  4. Requests threshold decryption of the encrypted result.

Retrieving results

After the decryption completes, the SPF service calls the contract’s postIssuePassedCallback function with the decrypted result. The contract stores the result in the didTheIssuePass state variable. To query the contract for the voting result, call the getDidTheIssuePass() function.

cast call --rpc-url $RPC_URL $CONTRACT_ADDRESS "getDidTheIssuePass()"

This returns true if the issue passed (sum of votes greater than 0), or false otherwise.

Live demo

A live demo of the voting application is available for you to explore, which demonstrates how one might build a simple web interface around the above code. You can see the source code for the web app here.

SPF enables privacy-preserving blockchain applications

This example demonstrates how SPF integrates with blockchain applications to enable private computation on encrypted data. Smart contracts coordinate FHE computations through SPF, while the Solidity contract library handles the complexity of program execution and threshold decryption. Access control ensures only authorized contracts can use encrypted data, and threshold decryption brings results on-chain through secure callbacks. Throughout the entire process, individual votes remain encrypted and private.

What do you think Fido’s fate should be? Let us know on Discord or Twitter / X.