This module is a validator module adheres to ERC-7579 standard that uses Semaphore to create a multi-signature owner validation. This means the smart account that incorporates this validator gains the following benefits:
- The smart account become a M-N multi-sig wallet controlled by members added to the Semaphore group of the smart account.
- Semaphore feature such as preserving the privacy of which members are signing a transaction, while guaranteeing that members cannot double-sign for a particular transaction.
Development of this project is part of the PSE Acceleration Program (FY24-1847).
# Install dependencies
pnpm install
# Build
pnpm run build
# Test
pnpm run test
SemaphoreMSAValidator contract stores the following information on-chain.
groupMapping
: This object maps from the smart account address to a Semaphore group.thresholds
: The threshold number of proofs a particular smart account needs to collect for a transaction to be executed.memberCount
: The member count of a Semaphore group. The actual member commitments are stored in thesmaphore
contract Lean Incremental Merkle Tree structure.acctTxCount
: This object stores the transaction call data and value that are waiting to be proved (signed), and the proofs it has collected so far. This information is stored in theExtCallCount
data structure.acctSeqNum
: The sequence number corresponding to a smart account. This value is used when generating a transaction signature to uniquely identify a particular transaction.
After installing this validator, the smart account can only call three functions in this validator contract, initiateTx(), signTx(), and executeTx(). Calling other functions, either to other non-validator contract addresses or other funtions beyond the mentioned three, would be rejected in the validateUserOp() check.
-
initiateTx(): for the Semaphore member to initate a new transaction of the Smart account. This function checks the validity of the semaphore proof and corresponding parameters. It takes three paramters.
targetAddr
: The target address of the transaction. It can be an EOA for value transfer, or other smart contract address.txcallData
: The call data to the target address. The first four bytes are the target function selector, and the rest function payload. For EOA value transfer, this value must be null (zero-length byte).proof
: The zero-knowledge proof genereated off-chain to prove a member signed the transaction and value.execute
: A boolean value to indicate if the transaction collects enough proof (namely 1 for initiateTx), it will also execute the transaction.
msg.value
is used as the value to be used for the transaction call. An ExtCallCount object is created to store the user transaction call data.A 32-byte hash txHash is returned, generated from
keccak256(abi.encodePacked(seq, targetAddr, msg.value, txCallData))
. -
signTx(): for other Semaphore member to sign a previously initiated transaction. Again, it checks the Semaphore proof, if the hash and the proof are valid, the proof count is incremented.
txHash
: The hash value returned frominitiatedTx()
, to specify the transaction for signingproof
: The zero-knowledge proof for the transactiontxHash
corresponding to.execute
: Same as initiateTx().
-
executeTx(): call to execute the transaction specified by
txHash
. If the transaction hasn't collected enough proofs, it would revert.txHash
: Same as initiateTx().
Transactions from ERC-4337 will go through validateUserOp() for validation, based on userOp, and userOpHash. In validation, the key logic is to check the userOp hash (userOpHash
), the signature (signature
), and the target call data (targetCallData
).
A proper userOp signature is a 160 bytes value signed by EdDSA signature scheme. The signature itself is 32 * 3 = 96 bytes, but we also prepend the identity public key in it to be used for validation.
The userOpHash
is 32-byte long, it is a keccak256() of sequence number, target address, value, and the target parameters.
For the UserOp calldata passing to getExecOps()
in testing, it is:
Now, when decoding the calldata from PackedUserOperation object in validateUserOp(), the above calldata is combined with other information and what we are interested started from the 100th byte, as shown below.
A Semaphore identity consists of an EdDSA public/private key pair and a commitment. Semaphore uses an EdDSA implementation based on Baby Jubjub and Poseidon. The actual implementation is in zk-kit repository.
We implement the identity verification logic Identity.verifySignature() on-chain. We also have a Identity.verifySignatureFFI() function for testing to compare the result with calling Semaphore typescript-based implementation. It relies on the Baby JubJub curve Solidity implementataion by yondonfu with a minor fix.
The module is also compatible with:
- ERC-1271: Accepting signature from other smart contract by implementing
isValidSignatureWithSender()
. - ERC-7780, Being a Stateless Validator by implementing
validateSignatureWithData()
.
The testing code relies heavily on Foundry FFI to call Semaphore typescript API to generate zero-knowledge proof and EdDSA signature.
Source: ERC-4337 website
Thanks to the following folks on discussing about this project and helps along:
- Saleel on initiating this idea with Semaphore Wallet, showing me that the idea is feasible.
- Cedoor & Vivian on Semaphore development and their opinions.
- John Guilding on the discussion, support, and review of the project.
- Konrad Kopp on the support of using ModuleKit framework which this module is built upon, and answering my question on some details of ERC-4337 standard.