End-to-end private voting on the web
This section walks through building and deploying an encrypted voting application using SPF. This example demonstrates the web2 approach, where applications interact directly with the SPF REST API using the TypeScript client library.
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 the TypeScript client for interacting with SPF.
Installing the SPF client
The spf-client package is available on npm. Create a new project directory and install dependencies:
mkdir voting-example
cd voting-example
# Initialize a new node project
npm init -y
# Install spf-client from npm
npm install @sunscreen/spf-client
# Install tsx for running TypeScript examples
npm install --save-dev tsx
# Configure package.json to use ES modules
npm pkg set type=module
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.
Creating a web app for private voting
While we could run this example locally using Rust, it would be nice to have a simple web application where multiple voters can submit their encrypted votes, and Sunscreen’s Secure Processing Framework (SPF) can tally the results. Here we will demonstrate using the TypeScript client to interact with SPF to build a deployed voting application using the compiled program and the SPF service.
Scenario
In this scenario, we will have the following parties involved:
Voters: Each voter independently encrypts their vote (1 for yes, -1 for no). Voters will upload their encrypted votes to SPF for tallying. Individual votes remain hidden for the entire voting lifecycle.
Runner: The runner coordinates the vote tallying (only learning the outcome, not any individual votes). The runner uploads the voting program to SPF, collects encrypted votes from voters, submits the computation request to SPF, and finally decrypts the result.
Initializing the SPF client
Before interacting with SPF, you’ll need to import the spf-client package. We use a namespace import to keep the code clean and make it clear where functions come from:
import * as spf from "@sunscreen/spf-client";
import { readFileSync } from "fs";
With this namespace import, all SPF functions are accessed via the spf. prefix (e.g., spf.initialize(), spf.uploadProgram()).
Initialize the client. By default, it connects to the production SPF service at spf.sunscreen.tech:
// Initialize the client (connects to spf.sunscreen.tech by default)
await spf.initialize();
Step 1: Uploading the voting program
First we have to upload the compiled program to SPF. This step only needs to be done once, and the resulting library ID can be reused for all future runs of this program.
// Read the compiled program file
const programPath = "voting"; // Compiled binary from previous step
const programBytes = new Uint8Array(readFileSync(programPath));
// Upload to SPF
const libraryId: string = await spf.uploadProgram(programBytes);
console.log("Library ID:", libraryId);
// Example output: 0x7f7e6993da0d371c4f7f4573df315fe3638d253b74c3f98e946f9b9737b5b51b
The spf.uploadProgram() function sends the voting binary to SPF and returns a unique library ID that identifies this program. We will need this ID later when submitting program runs, so be sure to save it.
Step 2: Encrypt and upload votes
Each voter independently encrypts their vote value, uploads the ciphertext to SPF, and grants run access to the runner. The encryptUploadAndGrantAccess function below encapsulates this workflow:
/**
* Encrypt a vote, upload it, and grant run access to the runner.
* Returns the ciphertext ID with ACL applied (ready for being counted).
*/
async function encryptUploadAndGrantAccess(
signer: spf.PrivateKeySigner,
approve: boolean,
runnerAddress: spf.Address,
libraryId: string,
programName: string,
): Promise<spf.CiphertextId> {
const approveAsInt = approve ? 1 : -1;
// Encrypt the vote using the public key from SPF
const ciphertext = await spf.encryptValue(approveAsInt, 8);
// Upload the ciphertext and make it owned by the signer in ACL
const uploadedId = await spf.uploadCiphertext(signer, ciphertext);
// Grant run access to runner (returns NEW ciphertext ID with ACL)
const aclAppliedId = await spf.allowRun(
signer,
uploadedId,
runnerAddress,
spf.asLibraryId(libraryId),
spf.asProgramName(programName),
);
return aclAppliedId;
}
In this function, the voter performs three main actions:
spf.encryptValue(approveAsInt, 8): Encrypts the vote as an 8-bit ciphertext using the client library. The value is automatically treated as signed when negative. This happens entirely client-side; the plaintext vote is never sent to SPF.spf.uploadCiphertext(signer, ciphertext): Uploads to the vote to SPF, returning a unique ciphertext ID owned by the voter. A vote signer can be created usingspf.PrivateKeySigner.random(); it is up to the application developer to determine the most secure way to manage user credentials (also known as keys).spf.allowRun(...): Grants the runner permission to use this ciphertext as input to thetally_votesprogram. This returns a new ciphertext ID with the access control list (ACL) applied. Every ciphertext is immutable; applying access control creates a new version of the ciphertext with the updated permissions, while the original remains unchanged.
Each voter follows this pattern independently. The runner collects the ACL-applied ciphertext IDs for the next step.
Step 3: Tally the votes privately
The runner collects all votes and submits the computation request to SPF.
// Runner submits run using the ACL-applied ciphertext IDs
const parameters: spf.SpfParameter[] = [
spf.createCiphertextArrayParameter(voteCiphertextIds),
spf.createPlaintextParameter(16, 4), // num_votes as uint16
spf.createOutputCiphertextArrayParameter(8, 1), // bool output
];
const runHandle = await spf.submitRun(
runner,
libraryId,
spf.asProgramName("tally_votes"),
parameters,
);
console.log("Run submitted:", runHandle);
// Wait for completion
const runStatus = await spf.waitForRun(runHandle);
console.log("Run status:", runStatus.status);
// Verify the run succeeded
if (runStatus.status !== "success") {
throw new Error(`Run failed: ${JSON.stringify(runStatus.payload)}`);
}
The parameters that are submitted for a run must match those in the signature of the tally_votes program.
void tally_votes(int8_t* votes, uint16_t num_votes, bool* didTheIssuePass)
The spf.submitRun() function returns a run handle—a unique identifier for this computation. The spf.waitForRun() function polls until the SPF service completes the encrypted computation.
Step 4: Decrypt the result
After computation completes, the runner decrypts the encrypted result:
// Get result ciphertext ID
const resultId = spf.deriveResultCiphertextId(runHandle, 0);
console.log("Result ciphertext ID:", resultId);
// Decrypt result (8-bit unsigned boolean value)
const decryptHandle = await spf.requestDecryption(runner, resultId);
console.log("Decryption requested:", decryptHandle);
const plaintext = await spf.waitForDecryption(decryptHandle, 8, false);
console.log("Decrypted plaintext:", plaintext);
console.log("Voting Result:", plaintext === 1n ? "Approved" : "Rejected");
Decryption workflow:
- Derive result ciphertext ID: Use
spf.deriveResultCiphertextId(runHandle, 0)to get the ID of the first output ciphertext - Request decryption: Sends the ciphertext to SPF’s threshold decryption committee
- Wait for result: The committee decrypts collaboratively and returns the plaintext value
The result plaintext contains an 8-bit integer:
1if the vote passed (sum of votes > 0)0if the vote failed (sum ≤ 0)
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 makes it easy to build privacy-preserving applications
As this demo shows, SPF makes it easy to build privacy-preserving applications using FHE. The TypeScript client library provides a straightforward interface for uploading programs, encrypting data, managing access control, and running computations. The developer does not need to worry about the complexities of FHE or managing the underlying compute infrastructure. Users can be assured that their data remains private throughout the entire process.
What do you think Fido’s fate should be? Let us know on Discord or Twitter / X.
Appendix: Complete example code
The full code for this example is shown below. To run it, copy the code into a file named voting.ts, then set up and run the project:
# Create a new project directory
mkdir voting-example
cd voting-example
# Copy the code below into a file named voting.ts
# Initialize the project with dependencies
npm init -y
npm pkg set type=module
npm install @sunscreen/spf-client
npm install --save-dev tsx
# Run the example
npx tsx voting.ts
Click to view the complete example code
// ANCHOR: imports
import * as spf from "@sunscreen/spf-client";
import { readFileSync } from "fs";
// ANCHOR_END: imports
import { join, dirname } from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// ANCHOR: setup
// Generate random wallets for testing
const voters: spf.PrivateKeySigner[] = [
spf.PrivateKeySigner.random(),
spf.PrivateKeySigner.random(),
spf.PrivateKeySigner.random(),
spf.PrivateKeySigner.random(),
];
const runner: spf.PrivateKeySigner = spf.PrivateKeySigner.random();
// ANCHOR_END: setup
// ANCHOR: helper
/**
* Encrypt a vote, upload it, and grant run access to the runner.
* Returns the ciphertext ID with ACL applied (ready for being counted).
*/
async function encryptUploadAndGrantAccess(
signer: spf.PrivateKeySigner,
approve: boolean,
runnerAddress: spf.Address,
libraryId: string,
programName: string,
): Promise<spf.CiphertextId> {
const approveAsInt = approve ? 1 : -1;
// Encrypt the vote using the public key from SPF
const ciphertext = await spf.encryptValue(approveAsInt, 8);
// Upload the ciphertext and make it owned by the signer in ACL
const uploadedId = await spf.uploadCiphertext(signer, ciphertext);
// Grant run access to runner (returns NEW ciphertext ID with ACL)
const aclAppliedId = await spf.allowRun(
signer,
uploadedId,
runnerAddress,
spf.asLibraryId(libraryId),
spf.asProgramName(programName),
);
return aclAppliedId;
}
// ANCHOR_END: helper
async function tallyVotes(): Promise<number | bigint> {
// Initialize the client
await spf.initialize();
// Upload program
const programPath = join(__dirname, "../../fhe-programs/compiled/voting");
const programBytes = new Uint8Array(readFileSync(programPath));
const libraryId: string = await spf.uploadProgram(programBytes);
// ANCHOR: encrypt_loop
// Each voter encrypts, uploads, and grants Run access
// Returns ciphertext IDs with ACL applied
const voteValues = [true, false, true, true]; // approve, reject, approve, approve
const voteCiphertextIds: spf.CiphertextId[] = [];
for (let i = 0; i < voters.length; i++) {
const voter = voters[i];
const voteValue = voteValues[i];
if (voter === undefined || voteValue === undefined) {
throw new Error(`Missing voter or vote at index ${i}`);
}
const aclAppliedId = await encryptUploadAndGrantAccess(
voter,
voteValue,
runner.getAddress(),
libraryId,
"tally_votes",
);
voteCiphertextIds.push(aclAppliedId);
}
// ANCHOR_END: encrypt_loop
// ANCHOR: submit_run
// Runner submits run using the ACL-applied ciphertext IDs
const parameters: spf.SpfParameter[] = [
spf.createCiphertextArrayParameter(voteCiphertextIds),
spf.createPlaintextParameter(16, 4), // num_votes as uint16
spf.createOutputCiphertextArrayParameter(8, 1), // bool output
];
const runHandle = await spf.submitRun(
runner,
libraryId,
spf.asProgramName("tally_votes"),
parameters,
);
console.log("Run submitted:", runHandle);
// Wait for completion
const runStatus = await spf.waitForRun(runHandle);
console.log("Run status:", runStatus.status);
// Verify the run succeeded
if (runStatus.status !== "success") {
throw new Error(`Run failed: ${JSON.stringify(runStatus.payload)}`);
}
// ANCHOR_END: submit_run
// ANCHOR: decrypt
// Get result ciphertext ID
const resultId = spf.deriveResultCiphertextId(runHandle, 0);
console.log("Result ciphertext ID:", resultId);
// Decrypt result (8-bit unsigned boolean value)
const decryptHandle = await spf.requestDecryption(runner, resultId);
console.log("Decryption requested:", decryptHandle);
const plaintext = await spf.waitForDecryption(decryptHandle, 8, false);
console.log("Decrypted plaintext:", plaintext);
console.log("Voting Result:", plaintext === 1n ? "Approved" : "Rejected");
// ANCHOR_END: decrypt
return plaintext;
}
// Run if executed directly
if (import.meta.url === `file://${process.argv[1]}`) {
tallyVotes().catch(console.error);
}
export { tallyVotes };