diff --git a/contracts/contracts/gatekeepers/SemaphoreGatekeeper.sol b/contracts/contracts/gatekeepers/SemaphoreGatekeeper.sol new file mode 100644 index 0000000000..7194b2fe38 --- /dev/null +++ b/contracts/contracts/gatekeepers/SemaphoreGatekeeper.sol @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; + +import { SignUpGatekeeper } from "./SignUpGatekeeper.sol"; +import { ISemaphore } from "../interfaces/ISemaphore.sol"; + +/// @title SemaphoreGatekeeper +/// @notice A gatekeeper contract which allows users to sign up to MACI +/// only if they can prove they are part of a semaphore group. +/// @dev Please note that once a identity is used to register, it cannot be used again. +/// This is because we store the nullifier which is +/// hash(secret, groupId) +contract SemaphoreGatekeeper is SignUpGatekeeper, Ownable(msg.sender) { + /// @notice The group id of the semaphore group + uint256 public immutable groupId; + + /// @notice The semaphore contract + ISemaphore public immutable semaphoreContract; + + /// @notice The address of the MACI contract + address public maci; + + /// @notice The registered identities + mapping(uint256 => bool) public registeredIdentities; + + /// @notice Errors + error ZeroAddress(); + error OnlyMACI(); + error AlreadyRegistered(); + error InvalidGroup(); + error InvalidProof(); + + /// @notice Create a new instance of the gatekeeper + /// @param _semaphoreContract The address of the semaphore contract + /// @param _groupId The group id of the semaphore group + constructor(address _semaphoreContract, uint256 _groupId) payable { + if (_semaphoreContract == address(0)) revert ZeroAddress(); + semaphoreContract = ISemaphore(_semaphoreContract); + groupId = _groupId; + } + + /// @notice Adds an uninitialised MACI instance to allow for token signups + /// @param _maci The MACI contract interface to be stored + function setMaciInstance(address _maci) public override onlyOwner { + if (_maci == address(0)) revert ZeroAddress(); + maci = _maci; + } + + /// @notice Register an user if they can prove they belong to a semaphore group + /// @dev Throw if the proof is not valid or just complete silently + /// @param _data The ABI-encoded schemaId as a uint256. + function register(address /*_user*/, bytes memory _data) public override { + // decode the argument + ISemaphore.SemaphoreProof memory proof = abi.decode(_data, (ISemaphore.SemaphoreProof)); + + // ensure that the caller is the MACI contract + if (maci != msg.sender) revert OnlyMACI(); + + // ensure that the nullifier has not been registered yet + if (registeredIdentities[proof.nullifier]) revert AlreadyRegistered(); + + // check that the scope is equal to the group id + if (proof.scope != groupId) revert InvalidGroup(); + + // register the nullifier so it cannot be called again with the same one + registeredIdentities[proof.nullifier] = true; + + // check if the proof validates + if (!semaphoreContract.verifyProof(proof.scope, proof)) revert InvalidProof(); + } +} diff --git a/contracts/contracts/interfaces/ISemaphore.sol b/contracts/contracts/interfaces/ISemaphore.sol new file mode 100644 index 0000000000..706ac62aae --- /dev/null +++ b/contracts/contracts/interfaces/ISemaphore.sol @@ -0,0 +1,20 @@ +//SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +/// @title Semaphore contract interface. +interface ISemaphore { + /// It defines all the Semaphore proof parameters used by Semaphore.sol. + struct SemaphoreProof { + uint256 merkleTreeDepth; + uint256 merkleTreeRoot; + uint256 nullifier; + uint256 message; + uint256 scope; + uint256[8] points; + } + + /// @dev Verifies a zero-knowledge proof by returning true or false. + /// @param groupId: Id of the group. + /// @param proof: Semaphore zero-knowledge proof. + function verifyProof(uint256 groupId, SemaphoreProof calldata proof) external view returns (bool); +} diff --git a/contracts/contracts/mocks/MockSemaphore.sol b/contracts/contracts/mocks/MockSemaphore.sol new file mode 100644 index 0000000000..abb560ac50 --- /dev/null +++ b/contracts/contracts/mocks/MockSemaphore.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import { ISemaphore } from "../interfaces/ISemaphore.sol"; + +/// @title MockSemaphore +/// @notice A mock contract to test the Semaphore gatekeeper +contract MockSemaphore is ISemaphore { + /// @notice The group id + uint256 public immutable groupId; + + bool public valid = true; + + /// @notice Create a new instance + /// @param _groupId The group id + constructor(uint256 _groupId) { + groupId = _groupId; + } + + /// @notice mock function to flip the valid state + function flipValid() external { + valid = !valid; + } + + /// @notice Verify a proof for the group + function verifyProof(uint256 _groupId, SemaphoreProof calldata proof) external view returns (bool) { + return valid; + } +} diff --git a/contracts/package.json b/contracts/package.json index 076ea1bb63..f49be0a2ca 100644 --- a/contracts/package.json +++ b/contracts/package.json @@ -51,6 +51,7 @@ "test:hats_gatekeeper": "pnpm run test ./tests/HatsGatekeeper.test.ts", "test:gitcoin_gatekeeper": "pnpm run test ./tests/GitcoinPassportGatekeeper.test.ts", "test:zupass_gatekeeper": "pnpm run test ./tests/ZupassGatekeeper.test.ts", + "test:semaphore_gatekeeper": "pnpm run test ./tests/SemaphoreGatekeeper.test.ts", "deploy": "hardhat deploy-full", "deploy-poll": "hardhat deploy-poll", "verify": "hardhat verify-full", diff --git a/contracts/tests/SemaphoreGatekeeper.test.ts b/contracts/tests/SemaphoreGatekeeper.test.ts new file mode 100644 index 0000000000..4e77c91025 --- /dev/null +++ b/contracts/tests/SemaphoreGatekeeper.test.ts @@ -0,0 +1,159 @@ +import { expect } from "chai"; +import { AbiCoder, Signer, ZeroAddress } from "ethers"; +import { Keypair } from "maci-domainobjs"; + +import { deployContract } from "../ts/deploy"; +import { getDefaultSigner, getSigners } from "../ts/utils"; +import { MACI, SemaphoreGatekeeper, MockSemaphore } from "../typechain-types"; + +import { STATE_TREE_DEPTH, initialVoiceCreditBalance } from "./constants"; +import { deployTestContracts } from "./utils"; + +describe("Semaphore Gatekeeper", () => { + let semaphoreGatekeeper: SemaphoreGatekeeper; + let mockSemaphore: MockSemaphore; + let signer: Signer; + let signerAddress: string; + + const user = new Keypair(); + + const validGroupId = 0n; + const invalidGroupId = 1n; + + const proof = { + merkleTreeDepth: 1n, + merkleTreeRoot: 0n, + nullifier: 0n, + message: 0n, + scope: validGroupId, + points: [0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n], + }; + + const invalidProof = { + merkleTreeDepth: 1n, + merkleTreeRoot: 0n, + nullifier: 0n, + message: 0n, + scope: invalidGroupId, + points: [1n, 0n, 0n, 0n, 0n, 0n, 0n, 0n], + }; + + const encodedProof = AbiCoder.defaultAbiCoder().encode( + ["uint256", "uint256", "uint256", "uint256", "uint256", "uint256[8]"], + [proof.merkleTreeDepth, proof.merkleTreeRoot, proof.nullifier, proof.message, proof.scope, proof.points], + ); + + const encodedProofInvalidGroupId = AbiCoder.defaultAbiCoder().encode( + ["uint256", "uint256", "uint256", "uint256", "uint256", "uint256[8]"], + [proof.merkleTreeDepth, proof.merkleTreeRoot, proof.nullifier, proof.message, invalidGroupId, proof.points], + ); + + const encodedInvalidProof = AbiCoder.defaultAbiCoder().encode( + ["uint256", "uint256", "uint256", "uint256", "uint256", "uint256[8]"], + [ + invalidProof.merkleTreeDepth, + invalidProof.merkleTreeRoot, + invalidProof.nullifier, + invalidProof.message, + validGroupId, + invalidProof.points, + ], + ); + + before(async () => { + signer = await getDefaultSigner(); + mockSemaphore = await deployContract("MockSemaphore", signer, true, validGroupId); + const mockSemaphoreAddress = await mockSemaphore.getAddress(); + signerAddress = await signer.getAddress(); + semaphoreGatekeeper = await deployContract("SemaphoreGatekeeper", signer, true, mockSemaphoreAddress, validGroupId); + }); + + describe("Deployment", () => { + it("The gatekeeper should be deployed correctly", () => { + expect(semaphoreGatekeeper).to.not.eq(undefined); + }); + }); + + describe("Gatekeeper", () => { + let maciContract: MACI; + + before(async () => { + const r = await deployTestContracts( + initialVoiceCreditBalance, + STATE_TREE_DEPTH, + signer, + true, + semaphoreGatekeeper, + ); + + maciContract = r.maciContract; + }); + + it("sets MACI instance correctly", async () => { + const maciAddress = await maciContract.getAddress(); + await semaphoreGatekeeper.setMaciInstance(maciAddress).then((tx) => tx.wait()); + + expect(await semaphoreGatekeeper.maci()).to.eq(maciAddress); + }); + + it("should fail to set MACI instance when the caller is not the owner", async () => { + const [, secondSigner] = await getSigners(); + await expect( + semaphoreGatekeeper.connect(secondSigner).setMaciInstance(signerAddress), + ).to.be.revertedWithCustomError(semaphoreGatekeeper, "OwnableUnauthorizedAccount"); + }); + + it("should fail to set MACI instance when the MACI instance is not valid", async () => { + await expect(semaphoreGatekeeper.setMaciInstance(ZeroAddress)).to.be.revertedWithCustomError( + semaphoreGatekeeper, + "ZeroAddress", + ); + }); + + it("should not register a user if the register function is called with invalid groupId", async () => { + await semaphoreGatekeeper.setMaciInstance(await maciContract.getAddress()).then((tx) => tx.wait()); + + await expect( + maciContract.signUp( + user.pubKey.asContractParam(), + encodedProofInvalidGroupId, + AbiCoder.defaultAbiCoder().encode(["uint256"], [1]), + ), + ).to.be.revertedWithCustomError(semaphoreGatekeeper, "InvalidGroup"); + }); + + it("should revert if the proof is invalid (mock)", async () => { + await mockSemaphore.flipValid(); + await expect( + maciContract.signUp( + user.pubKey.asContractParam(), + encodedInvalidProof, + AbiCoder.defaultAbiCoder().encode(["uint256"], [1]), + ), + ).to.be.revertedWithCustomError(semaphoreGatekeeper, "InvalidProof"); + await mockSemaphore.flipValid(); + }); + + it("should register a user if the register function is called with the valid data", async () => { + const tx = await maciContract.signUp( + user.pubKey.asContractParam(), + encodedProof, + AbiCoder.defaultAbiCoder().encode(["uint256"], [1]), + ); + + const receipt = await tx.wait(); + + expect(receipt?.status).to.eq(1); + }); + + it("should prevent signing up twice", async () => { + await expect( + maciContract.signUp( + user.pubKey.asContractParam(), + encodedProof, + AbiCoder.defaultAbiCoder().encode(["uint256"], [1]), + ), + ).to.be.revertedWithCustomError(semaphoreGatekeeper, "AlreadyRegistered"); + }); + }); +});