FadeChain: how we built a private blockchain voting system with zero-knowledge
FadeChain explained: private voting on Ethereum Sepolia with zk-SNARKs, Merkle trees, a relayer and time-weighted votes.
FadeChain was the project we built during a blockchain hackathon inside the Cryptography and Security course at UPF Computer Engineering. The challenge was to create an innovative proposal on the Ethereum Sepolia testnet, with a realistic technical idea, a coherent use case and a demo that could be presented to the jury.
Our team was FadeChain, formed by Gorka Hernandez, Sara Lopez, Arnau Carbonell and Jordi Lleopart. The project ended up being one of the winners of the hackathon.
The core idea was to solve a common tension in digital governance: how can someone vote privately, without revealing their identity, while keeping results public, auditable and immutable on-chain?
The problem we wanted to solve
In a traditional on-chain vote, everything is public: wallet address, transaction, timestamp and selected option. That is great for auditability, but terrible for ballot secrecy.
On the other side, a private off-chain vote can hide identities, but then another question appears: who guarantees that the tally has not been manipulated?
FadeChain tries to combine both sides:
- Privacy for the voter: nobody should know which person voted for which option.
- Public verifiability: accepted votes, weights and results are stored in the contract.
- Double-vote prevention: each registered voter can only vote once.
- Immutable rules: dates, number of options and critical parameters are fixed at deployment.
- Early-vote incentive: vote weight decreases as the voting window advances.
That last point was our product twist. We wanted to reduce bandwagon behavior: if everyone waits until the end to see where the majority is going, the vote becomes less deliberative. In FadeChain, voting earlier carries more weight.
The architecture
The repository is split into four main parts:
- Solidity contracts in
remix/: the mainZKTimeDecayVotingcontract, a mock Groth16 verifier and a Poseidon stub for the demo. - ZK circuit in
circuits/vote.circom: reference circuit that proves Merkle membership without revealing the voter's identity. - HTML frontend in
docs/andremix/: a demo interface to connect MetaMask, register credentials, vote and view results. - Node.js relayer in
relayer/: an Express server that can submit transactions on behalf of the voter so their address is not linked to the vote.
The demo was designed for a hackathon: easy to deploy in Remix, test on Sepolia and explain in a few minutes. Still, the underlying design touches serious topics: commitments, nullifiers, Merkle trees, zk-SNARKs, relayers and threat modeling.
Phase 1: registration with commitments
The voter does not register by publishing their identity on-chain. Instead, they generate two private values:
nullifiersecret
With those two values, they compute a commitment:
commitment = hash(nullifier, secret)
That commitment is submitted to the contract and inserted as a leaf in an incremental Merkle tree. In the contract, the tree has depth 20, enough for roughly one million leaves.
The idea is simple but powerful: the contract knows there is a list of valid commitments, but it
does not know who is behind each one. The voter must save their nullifier and secret, because
they will need them later to prove membership.
Phase 2: closing registration
Once voters are registered, the admin calls closeRegistration(). This freezes the registration
state and fixes the Merkle root.
This step matters because the ZK proof is generated against a specific root. If the voter set could change during voting, the system would be harder to reason about and easier to manipulate.
The contract also limits admin power: parameters such as verifier, hasher, votingStart,
votingEnd and numChoices are immutable. They are fixed in the constructor and cannot be
silently changed mid-election.
Phase 3: zero-knowledge proof
The most interesting part is the ZK proof. The vote.circom circuit defines what the voter must
prove without revealing sensitive information:
- They know a
nullifierand asecret. - Those values produce a valid commitment.
- That commitment belongs to the frozen Merkle tree.
- The public
nullifierHashcorresponds to theirnullifier. - The proof is bound to a specific voting option.
The mental sentence is:
"I can prove I am a registered voter, but I will not tell you which one."
The circuit uses Poseidon, a common hash for zero-knowledge systems because it is much more friendly to circuits than traditional hashes such as SHA-256.
Phase 4: nullifier for double-vote prevention
Privacy alone is not enough. If nobody knows who you are, the system also needs to stop you from voting ten times.
That is where the nullifierHash appears:
nullifierHash = hash(nullifier)
The contract stores every used nullifierHash in usedNullifiers. If somebody tries to reuse it,
the transaction reverts with NullifierAlreadyUsed.
This gives the system an important property: the vote remains anonymous with respect to the voter identity, but the contract can detect if the same credential tries to vote more than once.
Phase 5: relayer to avoid linking wallet and vote
Even if the ZK proof hides the cryptographic identity of the voter, if the person submits the transaction from their own wallet, that address is visible on-chain. In many contexts, that already breaks part of the privacy.
That is why the repository includes a Node.js relayer. The relayer receives:
- ZK proof,
- Merkle root,
nullifierHash,- selected option,
- Groth16 proof data.
And submits the transaction from its own wallet. That way, the voter does not need to appear as
msg.sender of the vote or pay gas directly.
The relayer is semi-trusted, but it should not be able to alter the vote if the real proof is
properly integrated, because the proof is bound to the public inputs. In the demo, the relayer also
exposes useful endpoints such as /api/status, /api/register, /api/relay-vote and
/api/results.
Phase 6: time-weighted voting
The ZKTimeDecayVoting contract computes vote weight based on when the vote is cast.
The current implementation uses a linear formula:
weight = (votingEnd - now) / (votingEnd - votingStart)
At the start of the voting window, weight is close to 100%. As time passes, it decreases toward 0%.
The contract also includes a 5% minimum (MIN_WEIGHT) to reject insignificant-weight votes.
This design gives earlier votes more influence. Not because they are automatically "better", but because the system rewards deciding before the final trend is visible.
What the main contract does
The ZKTimeDecayVoting.sol contract concentrates the system logic:
- Initializes an incremental Merkle tree.
- Registers commitments with
registerVoter. - Closes registration with
closeRegistration. - Checks that the Merkle root is known.
- Rejects already-used nullifiers.
- Verifies the Groth16 proof through a
verifiercontract. - Computes time-based weight.
- Adds the weighted vote to
voteTally. - Exposes results through
getResults.
It also emits key events:
VoterRegisteredRegistrationClosedVoteCast
That makes the process auditable from the outside and allows interfaces to be built on top of the contract.
The demo and the honest hackathon limitation
There is an important nuance: the hackathon demo uses MockGroth16Verifier, a contract that always
returns true. This makes it possible to test the whole flow without compiling the circuit,
generating keys, running a trusted setup and exporting a real verifier with snarkjs.
It also uses PoseidonT3Stub, replacing Poseidon with keccak256 % BN128_FIELD to simplify the
Remix demo.
This does not invalidate the project. It shows a very typical hackathon scope decision. In a few hours, we prioritized demonstrating:
- voter registration,
- commitments,
- incremental Merkle tree,
- registration closing,
- voting with nullifier,
- double-vote prevention,
- weighted tally,
- working frontend,
- optional relayer,
- ZK-ready architecture.
For production, the mock pieces would need to be replaced with:
- a circuit compiled with
circom, - a trusted setup with
snarkjs, - a real
Groth16Verifier.sol, - a real Poseidon implementation compatible with the circuit,
- frontend proof generation,
- contract security testing and audit.
I like explaining this clearly because it gives the project more technical credibility: FadeChain is not presented as ready for real elections, but as an advanced prototype that demonstrates a viable architecture.
Threat model
The repository also documents several attacks and mitigations:
- Double voting: mitigated with
usedNullifiers. - Fake voters: in the full design, mitigated with Merkle membership proofs.
- Linking wallet and vote: mitigated through the relayer.
- Parameter manipulation: mitigated with
immutablevariables. - Timestamp manipulation: limited by Ethereum PoS timestamp rules and by the minimum weight.
- Front-running or mempool leakage: accepted as a Sepolia limitation; on mainnet it could be combined with a private mempool.
This part was important for the presentation, because saying "we use blockchain" is not enough. You need to explain which threats exist and which property each component protects.
Why it won
I think FadeChain worked well for three reasons:
- It had a clear use case: private and auditable governance for DAOs, participatory budgeting or digital votes.
- It combined real layers: Solidity, Ethereum Sepolia, Merkle trees, ZK, frontend and relayer.
- It was honest about its limits: demo with a mock verifier, but with a Circom circuit and a clear path toward production.
In a hackathon, the most ambitious idea does not always win. The strongest proposal is often the one that can be explained, demonstrated and defended technically. FadeChain had all three.
What I learned
This project left me with several lessons:
- A good blockchain system is not about putting everything on-chain, but deciding what should be public and what should remain private.
- Zero-knowledge is not magic: it requires commitments, nullifiers, Merkle trees, circuits and verifiers that fit together.
- Privacy has several layers. Hiding identity in the proof is not enough if you expose the voter's wallet afterward.
- In a serious prototype, you need to distinguish between a functional demo and a production system.
- Explaining the architecture well is almost as important as building it.
You can see the code in the FadeChain repository or read the project page in my portfolio: FadeChain, UPF blockchain hackathon-winning project.