Semaphore Modular Smart Account Modules
Project Artifacts
Origin
This project emerges from the idea of integrating Semaphore within an account abstraction framework. While previous attempts have been made (as detailed in this blog post), they faced limitations due to the available tool stacks and Semaphore's storage access pattern at that time. These constraints made it challenging to create modules that could comply with the account abstraction validation scope rules.
However, the landscape has evolved significantly in the past year. New EIPs have emerged, notably the Modular Smart Account standard (ERC-7579). Additionally, numerous teams have developed tools that simplify smart account module development. This progress has created an opportune moment to revisit our design choices and approach.
After engaging in discussions with the Privacy and Scaling Exploration team, we've formulated a proposal to create Semaphore modules that adhere to the ERC-7579 standard. Smart accounts equipped with these modules will be able to leverage Semaphore's capabilities, allowing them to prove that their members have initiated transactions in a privacy-preserving manner.
To showcase these features, we have also developed a frontend demo UI. This demonstration will provide developers with a hands-on experience of the power and potential of Semaphore in account abstraction.
What are Semaphore Modules?
At its core lie two ERC-7579 compliant modules:
- Semaphore Executor (Core transaction workflow)
- Semaphore Validator (Signature verification)
Sempahore Executor has all the relevant account states stored and provides three APIs that dictate the main user flow: initiateTx(), signTx(), and executeTx(). initiateTx() is responsible for Semaphore members of a smart account to initiate an on-chain transactions. signTx() is for collecting enough proofs from other members in the group. A proof could be seen as a "signature" from the members who approve the tx. Lastly, executeTx() is to actually trigger the execution of the transaction.
Of course, Semaphore executor itself allow membership management such as addMembers() and removeMember(), and updating the threshold proofs needed to allow a transaction be executed via setThreshold().
As an ERC-7579 Executor, transactions appear to come directly from the smart account itself (msg.sender
), maintaining ERC-4337 compliance while adding privacy layers.
Semaphore Validator is responsible for validating the signature of the UserOp is indeed a valid EdDSA signature of the UserOp hash from the public key included in the signature. This module also check the commitment of the public key is likely (likely, but not a guarantee due to one implementation detail to be improved) to be a member of the Sempahore group of the smart account. This module also restricts the smart account only be able to call SempahoreExecutor contract and its three APIs: initiateTx(), signTx(), and executeTx().
When integrated with these modules, smart accounts get the following Semaphore's privacy guarantees:
- Membership Proof - Only group members can generate valid proofs
- Anti-Sybil Protection - Unique scope binding prevents proof reuse
- Full Anonymity - On-chain activities reveal nothing about individual members
So smart account with these modules installed behaves like a privacy-preserving multi-sig wallet.
Smart Contract Design
Upon installing the Semaphore Executor on a smart account, the account owner specify commitments of the account members, and a threshold of proofs that each transaction should collected for it to be executed. Internally, each smart account address is associated with a Semaphore Group. These commitments are then added into the semaphore group.
Semaphore Validator also need to be installed into the smart account together with the executor. It validates a userOp contains a valid custom signature. The custom signature is composed of the public key of the Semaphore identity and the EdDSA signature itself. The validator also limit the smart account so it could only call the initiateTx()
, signTx()
, and executeTx()
functions on the SemaphoreExecutor and revert on the rest. This is to ensure the multi-signature flow is honored by everyone associated with the account.
Transaction Flow
-
Now a transaction can be made by any members who have commitments added to the account Sempahore group. A member start by sending userOp of
initiateTx()
on the smart account. The caller specifies thetarget
,value
, andcalldata
of the transaction so this allows user to specify any kind of on-chain transactions to be executed later.The user also need to submit a Semaphore proof with a scope of the transaction hash. This will restrict the user could only submit a valid proof on the transaction once only. A transaction hash is the keccak256 hash of a tuple
(account sequence number, target, value, calldata)
. A sequence number is added so multiple transactions to the same target, value, calldata set would yield different transaction hash. -
Once the Sempahore proof is validated by Semaphore, the (
target
,value
,calldata
,count = 1
) are added in the on-chain storage acctTxCount. The last value is the valid proof count. (it will sometimes be called as signature count in this article). -
Other members of the group could sign the transaction by calling
signTx()
, passing in the transaction hash and a Seamphore proof. If their proofs are validated by Semaphore protocol, the proof count of the transaction is incremented. -
Once the proof count reaches the threshold for the account, any members can call
executeTx()
to execute the transaction. Semaphore Executor will execute the transaction on behalf of the smart account.
Storage
SemaphoreExecutor contract stores the following information on-chain.
groupMapping
: A mapping from a smart account address to its corresponding Semaphore group ID.thresholds
: A mapping from an address to its proof threshold.acctMembers
: A mapping from an address to all its member commitments, each 32 bytes, storing only their last 20 bytes. The data structure used here is SentinelList with SentinelList4337Lib, a mapping of address to an address list (that's why we are only storing the last 20 bytes). This structure is used instead of a regular Soldity mapping because this piece of storage need to be accessed by the validator during the validator phase and satisfies the validation scope rules on storage access. Given it is storing only the last 20 bytes of member commitments, it 1) provides an accurate count of the member of an account, used inaccountMemberCount()
call, and 2) rule out if a given member commitment is associated with an account. We could never retrieve a full list of member commitments without further extension.acctTxCount
: This object stores the transaction target, value, call data, and proof count collected so far.acctSeqNum
: The sequence number a smart account used so far. This value is used when generating a transaction hash to uniquely identify a particular transaction.
Signature and Calldata
UserOp signature checking is deferred to _validateSignatureWithConfig()
for validation. So signature coming in other fashions, such as ERC-1271 and ERC-7780, could leverage the same signature checking logics.
In validation, it first check if the smart account has a corresponding Semaphore group associated, and check if the userOp signature is valid. A proper signature is a 160 bytes value with the first 64 bytes as the identity public key and last 96 bytes the EdDSA signature (with an implementation in zk-kit).
To verify the signature on-chain, we perform elliptic curve computations on the Baby JubJub curve, initially implemented by yondonfu.
When decoding the calldata from PackedUserOperation object in validateUserOp(), what we are interested in starting from the 100th byte, as shown below.
The validator ensures the target address is calling Semaphore Executor contract with the function selector only be one of initTx()
, signTx
, or executeTx
.
Other On-chain Calls
There are other helper functions written:
getAcctTx()
- given an account address and transaction hash, retrieve the transaction object fromacctTxCount
storage.accountMemberCount()
- given an account address, get its associated member count.accountHasMember()
- given an account address and member commitment, check if the member is associated with the account.setThreshold()
- update the tranasction proof threshold needed to reach for an account.addMembers()
- adding member commitments to a corresponding account. It takes an array of commitments.removeMember
- removing member commitment from a corresponding account. It only takes a single commitment.
Javascript API
Javascript API are implemented to allow Javscript / Typescript projects be able to easily interact with Semaphore modules.
It exposes the following APIs:
getSemaphoreExecutor()
: retrieve an executor module object that can be further checked or installed in a smart account.getSemaphoreValidator()
: retrieve an validator module object that can be further checked or installed in a smart account.getInitTxAction()
: retrieve ainitiateTx()
action object that can be further passed into a userOp.getSignTxAction()
: retrieve asignTx()
action object that can be further passed into a userOp.getExecuteTxAction()
: retrieve aexecuteTx()
action object that can be further passed into a userOp.getExtCallCount()
: retrieve the transaction object from Semaphore Executor, i.e the target address, value, calldata, and proof count.
Finally there are helper functions:
getTxHash()
: compute the transaction hash given a sequence number, target address, value, and calldata.signMessage()
: to generate a userOp signature from a given identity and transaction hash.sendSemaphoreTransaction()
: given an identity, smart account, and an action, the function prepares the userOp (i.e. simulate it first to ensure its success), get the userOpHash, sign the userOp given the hash, and finally submit the userOp and wait for its receipt from the bundler.
Demo UI
There is also a Next.js Demo UI that allow users to generate a smart account, installing the two modules, and initiate, sign, and execute balance transfer transactions.
Try It Out
You are welcome to check out the demo UI video, or even try out the project Demo UI. Smart contract are deployed on Base Sepolia testnet.
If you are a developer, feel free to browse through the source code at the Github respository. Currently it is in prototype stage and need further enhancement and gas optimization to be production-ready, but its core functionality is in place.
Acknowledgement
Thanks to the following folks on discussion and feedback on this project and their helps along the way:
- Saleel P on initiating this idea with Semaphore Wallet, showing me that the idea is feasible.
- Cedoor and Vivian Plasencia on Semaphore development and their opinions.
- John Guilding on the discussion, support, and review of the project.
- Rhinestone team and Konrad Kopp support on using Module Kit, Module SDK and a lot more of their work on ERC-7579 that make this project possible, and from which I have learned a lot from.