A cryptographically real proof-of-concept of the Stellot† e-voting protocol (PhD thesis ch. 04a / 04b / 05a) built on Stellar Soroban.
Stellot† is a receipt-free, coercion-resistant e-voting protocol based on:
- Threshold ElGamal encryption on secp256k1 — votes are encrypted under a combined key; no single party can decrypt alone
- Feldman VSS / DKG — key-holders collectively generate the election key without any trusted dealer
- Shamir secret sharing + Lagrange interpolation — any t-of-m key-holders can perform the decryption without reconstructing the private key
- Hash-based nullifiers — prevent double-voting and double-issuance
- Soroban smart contract — enforces the protocol rules on-chain
stellot-dagger/
├── Cargo.toml # Rust workspace
├── contracts/
│ └── election/
│ ├── Cargo.toml
│ └── src/
│ ├── lib.rs # contract entry points (deploy, issue, cast, tally)
│ ├── types.rs # DataKey, ElectionParams, EncryptedBallot
│ ├── merkle.rs # SHA-256 Merkle verification
│ ├── error.rs # ContractError enum
│ └── test.rs # 7 unit tests
├── web/ # Next.js 15 App Router frontend
│ └── src/
│ ├── app/ # pages
│ └── lib/
│ ├── crypto.ts # ElGamal, nullifiers, Lagrange, message hashing
│ ├── merkle.ts # Merkle tree build + prove
│ ├── dkg.ts # Feldman VSS DKG
│ ├── threshold.ts # Shamir + Lagrange interpolation
│ ├── contract.ts # Soroban RPC wrappers
│ └── wallet.ts # Freighter + in-memory keypair
├── scripts/
│ ├── dkg.ts # CLI: run DKG ceremony
│ ├── distributor.ts # CLI: distributor service (sign issuances)
│ ├── post_share.ts # CLI: KH posts decryption shares + tally
│ └── e2e.sh # full runbook (local sandbox)
└── keys/ # generated by scripts (gitignored)
| Component | Implementation | Status |
|---|---|---|
| ElGamal encryption | Full exponential ElGamal on secp256k1 (@noble/curves) |
Real |
| DKG / VSS | Feldman VSS ceremony with real algebra, simulated multi-party | Real algebra |
| KH threshold decryption | Shamir + Lagrange interpolation on EC partial decryptions | Real |
| Nullifiers | SHA-256(domain ‖ sk ‖ eid) | Real |
| Cast proof | Ed25519 signature from casting account | Real |
| Distributor multi-sig | M-of-N Ed25519 signatures verified on-chain | Real |
| Eligibility proof | SHA-256 Merkle inclusion proof (non-ZK) | Simplified ‡ |
| CP proofs for shares | Generated off-chain, logged; on-chain only checks KH sig | Partial § |
‡ Merkle vs. ZK: The current eligibility proof is a Merkle inclusion proof, which reveals the leaf index (position in the voter list). In the full Stellot† protocol this is replaced by a Groth16 SNARK that proves membership without revealing position. The Merkle approach is a direct simplification with identical on-chain verification cost (~20 SHA-256 calls for 1M voters).
§ CP proofs: Chaum-Pedersen proofs for partial decryptions (proving
D_j = C1^sk_j correctly) are computed off-chain in scripts/post_share.ts and
can be verified by any observer. On-chain, the contract only verifies that each
submitter is in the KH roster and holds the correct Ed25519 identity key. Full
on-chain CP verification would require raw scalar-mul host functions, which are
not yet stable in Soroban.
- Organizer runs
scripts/dkg.ts --m 3 --t 2→ Feldman VSS generates a combined secp256k1 public key and per-KH Shamir shares - Organizer builds a SHA-256 Merkle tree of eligible voter pubkeys
deploy()stores all parameters on-chain
- Voter generates a fresh Ed25519 casting keypair in the browser
- Voter computes
nf_issue = SHA-256("stellot:issue" ‖ voter_sk ‖ eid) - Voter sends
(pk_cast, nf_issue)to distributors - Distributor verifies eligibility and signs
SHA-256("stellot:issue" ‖ eid ‖ pk_cast ‖ nf_issue) - Once
dist_thresholdsignatures collected,issue_account()is called on-chain
- Voter selects option
vand encrypts:C1 = r·G,C2 = (v+1)·G + r·PK - Voter computes
nf_cast = SHA-256("stellot:cast" ‖ sk_cast ‖ eid) - Voter signs the ballot with their casting key (Ed25519)
cast()verifies: window ✓, nullifier fresh ✓, account registered ✓, sig valid ✓
- After
end_time, each KH computesD_ji = C1_i^sk_j(partial decryption per ballot) - KH signs the batch and calls
post_share() - Once
kh_thresholdshares posted:finalize_tally()accepted - Browser / CLI combines shares via Lagrange →
D_i = Σ(λ_j · D_ji) - Recovers
V = C2 - D_i = (v+1)·G, findsvby brute-force DL search
cargo test
# Expected: 7 passed, 0 failed# Install the correct Soroban WASM target first (once):
rustup target add wasm32v1-none
# Build (uses wasm32v1-none — no reference-types, required by the Soroban VM):
stellar contract build
# Output: target/wasm32v1-none/release/election.wasmnpx tsx scripts/dkg.ts --m 3 --t 2 --output ./keys/# Requires stellar-cli and Node.js >= 18
bash scripts/e2e.sh
# The contract id is printed at the end:
# Contract: C...The contract ID is printed by bash scripts/e2e.sh (step 4) at the end of its
output (Contract: C...). To deploy a contract manually without the full e2e
runbook, use:
stellar contract build
stellar contract deploy \
--wasm target/wasm32v1-none/release/election.wasm \
--source <your-stellar-key-name> \
--network local # or testnet / mainnet
# Prints: C<contract-id>Then start the web app pointing at that contract:
cd web
npm install
export NEXT_PUBLIC_CONTRACT_ID=C<contract-id-from-above>
export NEXT_PUBLIC_RPC_URL=http://localhost:8000/soroban/rpc # local sandbox
# export NEXT_PUBLIC_RPC_URL=https://soroban-testnet.stellar.org # testnet
export NEXT_PUBLIC_NETWORK_PASSPHRASE="Standalone Network ; February 2017" # local
# export NEXT_PUBLIC_NETWORK_PASSPHRASE="Test SDF Network ; September 2015" # testnet
npm run devkeygen(): sk ∈ Z_q random, PK = sk · G
encrypt(v): r ∈ Z_q random, C = (r·G, (v+1)·G + r·PK)
decrypt(C): M = C2 - sk·C1 = (v+1)·G, brute-force v
leaf(x): SHA256("stellot:leaf" ‖ x)
node(l, r): SHA256("stellot:node" ‖ l ‖ r)
Domain separation prevents second-preimage attacks.
Party j samples polynomial f_j(x) = a_j0 + a_j1·x + … + a_j(t-1)·x^(t-1) over Z_q,
publishes commitments A_jk = a_jk · G, and sends share s_ji = f_j(i) to party i.
Verification: s_ji · G == Σ_k(A_jk · i^k).
Combined key: PK = Σ_j(A_j0).
λ_j = ∏_{k≠j}(k / (k-j)) mod q
D_i = Σ_j(λ_j · D_ji)
nf_issue = SHA256("stellot:issue" ‖ sk_voter ‖ eid_le64)
nf_cast = SHA256("stellot:cast" ‖ sk_cast ‖ eid_le64)
| Key | Value |
|---|---|
NextElectionId |
u64 — next eid |
Election(eid) |
ElectionParams |
EligibleRoot(eid) |
BytesN<32> — Merkle root |
DistRoster(eid) |
Vec<BytesN<32>> — distributor Ed25519 pubkeys |
KhRoster(eid) |
Vec<BytesN<32>> — KH Ed25519 pubkeys |
KhCommitment(eid, idx) |
Bytes — 33-byte VSS commitment A_j0 |
IssueNullifier(eid, nf) |
bool |
CastingAccount(eid, pk) |
bool |
CastNullifier(eid, nf) |
bool |
Ballot(eid, idx) |
EncryptedBallot { nf_cast, c1, c2 } |
KhShare(eid, idx) |
Bytes — serialised share batch |
Tally(eid) |
Vec<u32> |
| Crate | Version | Purpose |
|---|---|---|
soroban-sdk |
22.0.0 | Soroban smart contract SDK |
ed25519-dalek |
2 | Ed25519 signing (tests only) |
| Package | Version | Purpose |
|---|---|---|
@noble/curves |
^1.6.0 | secp256k1 + Ed25519 |
@noble/hashes |
^1.5.0 | SHA-256 |
@stellar/stellar-sdk |
^13.0.0 | Soroban RPC |
next |
^15.0.0 | Frontend framework |
- ZK eligibility proofs — replace Merkle with Groth16 SNARK for anonymity
- On-chain CP verification — once Soroban exposes raw EC scalar mul
- Full FDKG — federated DKG replacing the simulated ceremony
- ZK cast proof — replace Ed25519 sig with proof linking nullifier to voter secret
- Coercion resistance — receipt-freeness via re-encryption mixnets or homomorphic tallying