Introduction
In the rapidly evolving landscape of blockchain technology, privacy and scalability remain two of the most critical challenges. Semaphore is a zero-knowledge protocol that enables users to prove membership in a group without revealing their identity. It leverages zero-knowledge proofs to ensure privacy while maintaining trust within decentralized systems. On the other hand, Scroll is a zkEVM-based zkRollup on Ethereum that aims to enhance scalability without compromising on security or decentralization.
Combining Semaphore with Scroll opens up new possibilities for building scalable and privacy-preserving applications on Ethereum. This integration allows developers to create systems where users can authenticate and interact anonymously, all while benefiting from the scalability of zkRollups.
Setup
Since I wanted to make this as easy as possible for beginner blockchain developers, readers can try to deploy the contract and play around with it without learning Hardhat by following the instructions..
First, get your Scroll Sepolia ETH ready by bridging your Ethereum Sepolia ETH: https://sepolia.scroll.io/bridge. It takes up to 20 mins to complete (in my case it was 12 minutes)
For deployment and RPC node-related tasks, I prefer using MultiBaas by Curvegrid: https://console.curvegrid.com/. You can sign up for free and create up to two deployments on both Scroll Mainnet and Testnet (or other EVM chains).
Extra steps: Deploy the three key contracts
When you check out semaphore
documentation, you would notice that three key contracts ( Semaphore, SemaphoreVerifier, PoseidonT3
) are usually provided by the PSE team.
https://docs.semaphore.pse.dev/deployed-contracts
At the time I started writing this article, these contracts were not available on Scroll Testnet. (They are now officially available as of v4.7.0) In other words, you have to deploy them on your own if they are not available on which the network you are seeking to develop, or you should wait for the official contracts by the PSE team.
After bits of digging around documents and reading Solidity code, I’ve managed to deploy them as below. (This is only for temporarily uses. Be sure to use the original contracts.)
SemaphoreVerifier
: 0x76C994dA048f14F4153Ac550cA6f6b327fCE9180PoseidonT3
: 0x5A7242de32803bC5329Ca287167eE726E53b219ASemaphore
: 0x0303e10025D7578aC8e4fcCD0249622ac1D17B82
Step by Step to deploy, call the functions, and connect with a frontend app
Step 1: Deploy
When you click on the deployment URL on Curvegrid console, you get to your deployment dashboard. From there, click Contracts > On-Chain
and then you see nothing in the On-Chain Contracts
. Click the plus button.
It will show a modal. From there:
- Click
Deploy Contract
- Select
Contract from Compilation Artifact
- Upload the
artifacts/contracts/TestSemaphore.json
from my repo. - Label it as you like (I just named it “semophore”) with a version number.
- Finally, label your deploying contract with “Sync Events” checked and
_verifier
argument with theSemaphoreVerifier
address above (0x76C…) …and then click “Deploy”… tada!
(The UI should prompt your Metamask to confirm a tx. If you haven’t connected your signer wallet yet, do it from the top right corner.)
Alternatively, instead of uploading your contract artifact or solidity file, you can fetch the contract’s bytecode and ABI for verified contracts. In that case, you can link the official Semaphore contract by following the screenshots below:
After you succeed with your Semaphore
deployment, you would see this overview of the contract.
Step 2: Interacting with the Contract
On “On-Chain Contracts”, Click “Functions” of your deployed Semaphore
contract from the side panel menu.
Once you see the list of functions of Semaphore
contract, you can play around with it. In the video below, I’ve created two Semaphore Groups by calling createGroup
: one with an argument of null address (no admin group) and the other without argument, which sets the signer address as admin. And then you can call getGroupAdmin
to check these admin addresses.
Demo of calling functions on Semaphore contract
Step 3: Connecting with a Frontend App
Okay, now you sort of understand what Semaphore
contract can do in terms of its functionalities. When it comes to a real use-case, you would only need to interact with your SemaphoreGroup
, which is determined by GroupId
when you createGroup
.
Curvegrid provides a sample app for on-chain voting contract. I’ve modified its contract to add Semaphore elements to play around with in this article. You can checkout the repo: https://github.com/tawago/scroll-semaphore
Checkout the modified version of the sample app at: https://scroll-semaphore.vercel.app/
In the repo, you can find a contract named [SimpleVoting](https://github.com/tawago/scroll-semaphore/blob/main/contracts/SimpleVoting.sol)
. Let’s deploy this contract and start running the frontend app to interact with it. This contract needs an existing Semaphore
contract address as one of the constructor arguments so use either TestSemaphore I’ve deployed above or the official one. The contract also sets a “secret code” to prevent total strangers from obtaining membership. The secret code is a string GM!
and for the constructor argument, you need to hash it with keccak. (This is very insecure because anyone can check the tx to learn the secret code. In a real use case, this should be probably done in a zkp way)
Constructor arguments for SimpleVoting
contract would something like:
- _numChoices: 4
- semaphoreAddress: 0x0303e10025D7578aC8e4fcCD0249622ac1D17B82
- _secretCodeHash: 0xc87a2838ff5cbcb7515eef22d409b3271b26f101f3b1a51873086460417c4454
After you’ve successfully deployed, let’s get the API key and whitelist your frontend app in the CORS setting.
- Go to
Admin > API Keys
and click “+ New Key” button. - Label and Select
DApp User
then “Create”
- Go to
Admin > CORS Origins
and click “+ Add Origin” button. - Input http://localhost:3000 for local development and then “Continue”
Interact with the voting app
From here, we will mostly do things on the frontend app under the repo. git clone if you haven’t yet.
$ cd frontend && yarn
$ cp .env.template .env.development
edit .env.development
to reflect your API key and deployment URL.
$ yarn dev
and open http://localhost:3000. You should be able to see the app lik below.
Click “Create” to generate you Semaphore Identity! Once you create you Identity, you can check if your Identity’s commitment already exists in the SimpleVoting
contract Group; in other words, your membership of the group. It would prompt an alert either telling you that you are a part of the group or not. This is done by calling hasMember(groupId, commitment)
function of Semaphore
contract. (But you do not want to convince a third party of your membership in this way because you are basically telling your Identity’s commitment.)
Let’s join the Group!
- Connect your wallet. Make sure your current network is “Scroll Sepolia”
- Enter “GM!‘ in the text field
- Click “Join” and send a transaction.
Once you obtain a membership, you can cast a vote using your Semaphore Proof!
And you might wonder “But… how do I actually generate Semaphore Proof?” and that’s a valid question. I didn’t know either until I created this sample app.
In order to generate a proof of your membership, you basically need all the commitments of the group. You have several options to get the all the commitments of a group.
- If you are using the official Semaphore, you can query to the Graph endpoint. For Scroll Semaphore: https://api.studio.thegraph.com/query/14377/semaphore-sepolia/v4.1.0/graphql and query can be as simple below
{
groups(where: { id: 1 }) {
id
merkleTree {
root
depth
size
}
admin
members(orderBy: index) {
identityCommitment
}
}
}
-
You can add “@semaphore-protocol/data” and import SemaphoreEthers. It has a convenient
getGroupMembers
method. Check out the official boilerplate: https://github.com/semaphore-protocol/boilerplate/blob/9dd9518c4fc6eda8864656eb6697f04db25dbef8/apps/web-app/src/context/SemaphoreContext.tsx#L37 -
MultiBaas indexes Events emitted by the contracts you’ve added. There are two API endpoints that I could use. The first one is List Events endpoint. By using the endpoint, I could simply write a code as below (though this does not take other events like
MemberRemoved
in consideration)
//https://github.com/tawago/scroll-semaphore/blob/258256b08cf11c1069093f547c712e3841656915/frontend/app/hooks/useMultiBaas.ts#L107-L132
const getCommitmentsFromMemberAddedEvents = async (): Promise<Array<bigint> | null> => {
try {
const eventSignature = "MemberAdded(uint256,uint256,uint256,uint256)";
const response = await eventsApi.listEvents(
undefined,
undefined,
undefined,
undefined,
undefined,
false,
chain,
semaphoreAddressLabel,
semaphoreContractLabel,
eventSignature,
50
);
const events: Event[] = response.data.result.filter(event => event.transaction.contract.addressLabel === votingAddressLabel)
const commitments = events
.sort((a, b) => new Date(a.triggeredAt).getTime() - new Date(b.triggeredAt).getTime())
.map(item => item.event.inputs[2].value)
return commitments;
} catch (err) {
console.error("Error getting member added events:", err);
return null;
}
};
The other one is Execute Query endpoint. For this endpoint, you first need to set up an event query in the UI (or through another API).
- Go to
Blockchain > Event Queries
and click the plus botton in the side menu. - Name the query “commitments”
- Click the plus botton next to “Events”
- Select “MemberAdded(uint256,uint256,uint256,uint256)”
- Click “Add Event Field” and add three fields, “groupId”, “identityCommitment” and “triggered_at”
- Click “Add Filter” — — set “groupId” as Operand — — set “Equal” as Operator — — set your groupId value
- Click “Save Query”
If you’ve successfully created the query, you should be able to see the preview data below the QueryBuilder.
And then, you could get the commitments as below.
const queryLabel = "commitments";
const response = await eventQueriesApi.executeEventQuery(queryLabel);
const commitments = response.data.result.rows.map((row: any) => row.identitycommitment);
return commitments;
Now, that you have all the commitments of the group, let’s generate a Semaphore proof (zero knowled proof). The Semaphore proof consists of your identity, the merkle tree proof of your commitment, message and scope. While the message can be just an empty string, setting a right scope is important. Together with your identity’s private key, it generates a nullifier which each user may only generate one valid proof per scope. Though, I’m not sure if this is practical, you can set a periodical datetime as a scope for a session to grant a group member for a login and its expiration.
Internally, lean-imt
is used for the merkle tree. With the all commitments and index of your commitment within the group, you can get the merkle tree proof as below:
import { poseidon2 } from "poseidon-lite"
import { LeanIMT } from "@zk-kit/lean-imt";
export async function generateMerkleProof(
commitmentsArray: bigint[],
index: number,
) {
const hash = (a, b) => poseidon2([a, b])
// Initialize the Merkle tree
const tree = new LeanIMT<bigint>(hash);
// Insert the commitments into the tree
for (const commitment of commitmentsArray) {
tree.insert(commitment);
}
// Generate the proof for the leaf at the given index
const proof = tree.generateProof(index);
// The proof object already has the structure we need
const merkleProof = {
root: proof.root,
leaf: proof.leaf,
index: proof.index,
siblings: proof.siblings,
};
return merkleProof;
}
In summary, generating a Semaphore Proof looks like below in my sample app. https://github.com/tawago/scroll-semaphore/blob/095194445ccf5d2c5710429ba46730deb3458834/frontend/app/hooks/useMultiBaas.ts#L183-L210
const castVote = useCallback(async (choice: string): Promise<SendTransactionParameters> => {
if (!identity) throw Error("No identity")
const scope = groupId;
// You have two API to get commitments
// First choice: Get all events and filter on frontend side
// const commitments = await _getCommitmentsFromMemberAddedEvents()
// Second choice: Get commitments from a event query
const commitments = await _getCommitmentsFromQuery()
if (!commitments?.length) throw Error("No members in this group")
const index = await callContractFunction("indexOf", [groupId, identity?.commitment.toString()], true)
const merkelProof = await generateMerkleProof(commitments, Number(index))
const proof = await generateSemaphoreProof(identity, merkelProof, choice, scope)
console.log('generateSemaphoreProof', proof)
return await callContractFunction("vote", [ proof.merkleTreeDepth,
proof.merkleTreeRoot,
proof.nullifier,
proof.message,
proof.points,
]);
}, [callContractFunction, groupId, identity]);
Conclusion: What’s Next?
Overall, interacting with Semaphore Contract and creating a smart contract for an membership application seems very intuitive. The only one-time hassle you may have to go through is when the key Contracts do not exist on the chain for which you are seeking to develop.
More advanced examples could include making the secret code to join in a ZKP manner, adding a feature to open a new ballot with a unique identifier scope, using MACI to make the app a more robust anonymous voting system, adding delegation mechanisms, etc!
All the above ambitious ideas are becoming possible by the advancement of technologies in the family tree of Programmable Cryptography. I’m personally very interested in Liquid Democracy and with ZKP, MPC and FHE, it feels like we can soon upgrade our societies with a new modern decision making algorithm.
Links:
- Getting-started: https://docs.semaphore.pse.dev/getting-started
- Semaphore Repo: https://github.com/semaphore-protocol/semaphore
- Boilerplate: https://github.com/semaphore-protocol/boilerplate
- The Sample Voting App: https://github.com/tawago/scroll-semaphore
- The Graph endpoint: https://api.studio.thegraph.com/query/14377/semaphore-sepolia/v4.1.0/graphql
- Scroll Sepolia Bridge: https://sepolia.scroll.io/bridge
- Curvegrid Console: https://console.curvegrid.com/
Lastly! This article was encouraged by Scroll’s “Articles Bounty Contest” https://www.levelup.xyz/events/writers-competition-2024q4
More Content
Dive into the world of Solidity in pursuit of leveling up! Venturing into delegatecall and staticcall functions!
Learn ZK by Deploying a Battle Tested Project.
This guide will show you how to use Noir on Scroll!
Learn smart contract development with Foundry, a blazingly fast framework for building and deploying smart contracts!
This is the ultimate guide on attestations. We'll go from 0 to 100% with practical examples for developers.