-
Notifications
You must be signed in to change notification settings - Fork 6
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(excubia): add
SemaphoreExcubia
contract extension (#20)
- Loading branch information
Showing
6 changed files
with
396 additions
and
8 deletions.
There are no files selected for viewing
75 changes: 75 additions & 0 deletions
75
packages/excubiae/contracts/extensions/SemaphoreExcubia.sol
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,75 @@ | ||
// SPDX-License-Identifier: MIT | ||
pragma solidity >=0.8.0; | ||
|
||
import {Excubia} from "../Excubia.sol"; | ||
import {ISemaphore} from "@semaphore-protocol/contracts/interfaces/ISemaphore.sol"; | ||
|
||
/// @title Semaphore Excubia Contract | ||
/// @notice This contract extends the Excubia contract to integrate with the Semaphore protocol. | ||
/// It verifies the passerby Semaphore group membership proofs to grant access through the gate. | ||
/// @dev To allow only specific Semaphore identities from a group, the contract stores the specific group identifier. | ||
/// To avoid identities from passing twice, nullifiers are stored upon successful verification of the proofs. | ||
contract SemaphoreExcubia is Excubia { | ||
/// @notice The Semaphore contract interface. | ||
ISemaphore public immutable SEMAPHORE; | ||
/// @notice The specific group identifier that proofs must match to pass the gate. | ||
/// @dev Used as a `scope` to ensure consistency during proof membership verification. | ||
uint256 public immutable GROUP_ID; | ||
|
||
/// @notice Mapping to track which nullifiers have been used to avoid passing the | ||
/// gate twice using the same Semaphore identity. | ||
/// @dev The nullifier is derived from the hash of the secret and group identifier, | ||
/// ensuring that the same identity cannot be registered twice for the same group. | ||
mapping(uint256 => bool) public passedNullifiers; | ||
|
||
/// @notice Error thrown when the group identifier does not match the expected one. | ||
error InvalidGroup(); | ||
|
||
/// @notice Error thrown when the proof is invalid. | ||
error InvalidProof(); | ||
|
||
/// @notice Error thrown when the proof scope does not match the expected group identifier. | ||
error UnexpectedScope(); | ||
|
||
/// @notice Constructor to initialize with target Semaphore contract and specific group identifier. | ||
/// @param _semaphore The address of the Semaphore contract. | ||
/// @param _groupId The group identifier that proofs must match. | ||
constructor(address _semaphore, uint256 _groupId) { | ||
if (_semaphore == address(0)) revert ZeroAddress(); | ||
|
||
SEMAPHORE = ISemaphore(_semaphore); | ||
|
||
if (ISemaphore(_semaphore).groupCounter() <= _groupId) revert InvalidGroup(); | ||
|
||
GROUP_ID = _groupId; | ||
} | ||
|
||
/// @notice Internal function to handle the passing logic with check. | ||
/// @dev Calls the parent `_pass` function and registers the nullifier to avoid passing the gate twice. | ||
/// @param passerby The address of the entity attempting to pass the gate. | ||
/// @param data Additional data required for the check (ie., encoded Semaphore proof). | ||
function _pass(address passerby, bytes calldata data) internal override { | ||
ISemaphore.SemaphoreProof memory proof = abi.decode(data, (ISemaphore.SemaphoreProof)); | ||
|
||
// Avoiding passing the gate twice using the same nullifier. | ||
if (passedNullifiers[proof.nullifier]) revert AlreadyPassed(); | ||
|
||
super._pass(passerby, data); | ||
|
||
passedNullifiers[proof.nullifier] = true; | ||
} | ||
|
||
/// @notice Internal function to handle the gate protection (proof check) logic. | ||
/// @dev Checks if the proof matches the group ID, scope, and is valid. | ||
/// @param passerby The address of the entity attempting to pass the gate. | ||
/// @param data Additional data required for the check (i.e., encoded Semaphore proof). | ||
function _check(address passerby, bytes calldata data) internal view override { | ||
super._check(passerby, data); | ||
|
||
ISemaphore.SemaphoreProof memory proof = abi.decode(data, (ISemaphore.SemaphoreProof)); | ||
|
||
if (GROUP_ID != proof.scope) revert UnexpectedScope(); | ||
|
||
if (!SEMAPHORE.verifyProof(GROUP_ID, proof)) revert InvalidProof(); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,80 @@ | ||
// SPDX-License-Identifier: MIT | ||
pragma solidity >=0.8.0; | ||
|
||
import {ISemaphore} from "@semaphore-protocol/contracts/interfaces/ISemaphore.sol"; | ||
|
||
/// @title Mock Semaphore Contract | ||
/// @notice This contract is a mock implementation of the ISemaphore interface for testing purposes. | ||
/// @dev It simulates the behavior of a real Semaphore contract by simulating the storage and verification | ||
/// of a set of predefined mocked proofs. | ||
contract MockSemaphore is ISemaphore { | ||
/// @dev Gets a group id and returns the relative group. | ||
mapping(uint256 => bool) public mockedGroups; | ||
|
||
/// @notice A mapping to store mocked proofs by their unique nullifiers. | ||
mapping(uint256 => bool) private mockedProofs; | ||
|
||
/// @dev Counter to assign an incremental id to the groups. | ||
/// This counter is used to keep track of the number of groups created. | ||
uint256 public groupCounter; | ||
|
||
/// MOCKS /// | ||
/// @notice Constructor to initialize the mock contract with predefined proofs. | ||
/// @param _groupIds An array of identifiers of groups to be intended as the contract managed groups. | ||
/// @param _nullifiers An array of nullifiers to be mocked as proofs. | ||
/// @param _validities An array of booleans to mock the validity of proofs associated with the nullifiers. | ||
constructor(uint256[] memory _groupIds, uint256[] memory _nullifiers, bool[] memory _validities) { | ||
for (uint256 i = 0; i < _groupIds.length; i++) { | ||
mockedGroups[_groupIds[i]] = true; | ||
groupCounter++; | ||
} | ||
|
||
for (uint256 i = 0; i < _nullifiers.length; i++) { | ||
mockedProofs[_nullifiers[i]] = _validities[i]; | ||
} | ||
} | ||
|
||
function verifyProof(uint256 groupId, SemaphoreProof calldata proof) external view returns (bool) { | ||
return mockedGroups[groupId] && mockedProofs[proof.nullifier]; | ||
} | ||
|
||
/// STUBS /// | ||
// The following functions are stubs and do not perform any meaningful operations. | ||
// They are placeholders to comply with the IEAS interface. | ||
function createGroup() external pure override returns (uint256) { | ||
return 0; | ||
} | ||
|
||
function createGroup(address /*admin*/) external pure override returns (uint256) { | ||
return 0; | ||
} | ||
|
||
function createGroup(address /*admin*/, uint256 /*merkleTreeDuration*/) external pure override returns (uint256) { | ||
return 0; | ||
} | ||
|
||
function updateGroupAdmin(uint256 /*groupId*/, address /*newAdmin*/) external override {} | ||
|
||
function acceptGroupAdmin(uint256 /*groupId*/) external override {} | ||
|
||
function updateGroupMerkleTreeDuration(uint256 /*groupId*/, uint256 /*newMerkleTreeDuration*/) external override {} | ||
|
||
function addMember(uint256 groupId, uint256 identityCommitment) external override {} | ||
|
||
function addMembers(uint256 groupId, uint256[] calldata identityCommitments) external override {} | ||
|
||
function updateMember( | ||
uint256 /*groupId*/, | ||
uint256 /*oldIdentityCommitment*/, | ||
uint256 /*newIdentityCommitment*/, | ||
uint256[] calldata /*merkleProofSiblings*/ | ||
) external override {} | ||
|
||
function removeMember( | ||
uint256 /*groupId*/, | ||
uint256 /*identityCommitment*/, | ||
uint256[] calldata /*merkleProofSiblings*/ | ||
) external override {} | ||
|
||
function validateProof(uint256 /*groupId*/, SemaphoreProof calldata /*proof*/) external override {} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,222 @@ | ||
import { expect } from "chai" | ||
import { ethers } from "hardhat" | ||
import { AbiCoder, Signer, ZeroAddress, toBeHex, zeroPadBytes } from "ethers" | ||
import { SemaphoreExcubia, SemaphoreExcubia__factory, MockSemaphore, MockSemaphore__factory } from "../typechain-types" | ||
|
||
describe("SemaphoreExcubia", function () { | ||
let MockSemaphoreContract: MockSemaphore__factory | ||
let SemaphoreExcubiaContract: SemaphoreExcubia__factory | ||
let semaphoreExcubia: SemaphoreExcubia | ||
|
||
let signer: Signer | ||
let signerAddress: string | ||
|
||
let gate: Signer | ||
let gateAddress: string | ||
|
||
let mockSemaphore: MockSemaphore | ||
let mockSemaphoreAddress: string | ||
|
||
const validGroupId = 0n | ||
const invalidGroupId = 1n | ||
|
||
const validProof = { | ||
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: 1n, | ||
message: 0n, | ||
scope: invalidGroupId, | ||
points: [1n, 0n, 0n, 0n, 0n, 0n, 0n, 0n] | ||
} | ||
|
||
const encodedValidProof = AbiCoder.defaultAbiCoder().encode( | ||
["uint256", "uint256", "uint256", "uint256", "uint256", "uint256[8]"], | ||
[ | ||
validProof.merkleTreeDepth, | ||
validProof.merkleTreeRoot, | ||
validProof.nullifier, | ||
validProof.message, | ||
validProof.scope, | ||
validProof.points | ||
] | ||
) | ||
|
||
const encodedInvalidScopeProof = AbiCoder.defaultAbiCoder().encode( | ||
["uint256", "uint256", "uint256", "uint256", "uint256", "uint256[8]"], | ||
[ | ||
validProof.merkleTreeDepth, | ||
validProof.merkleTreeRoot, | ||
validProof.nullifier, | ||
validProof.message, | ||
invalidProof.scope, | ||
validProof.points | ||
] | ||
) | ||
|
||
const encodedInvalidProof = AbiCoder.defaultAbiCoder().encode( | ||
["uint256", "uint256", "uint256", "uint256", "uint256", "uint256[8]"], | ||
[ | ||
invalidProof.merkleTreeDepth, | ||
invalidProof.merkleTreeRoot, | ||
invalidProof.nullifier, | ||
invalidProof.message, | ||
validProof.scope, | ||
invalidProof.points | ||
] | ||
) | ||
|
||
before(async function () { | ||
;[signer, gate] = await ethers.getSigners() | ||
signerAddress = await signer.getAddress() | ||
gateAddress = await gate.getAddress() | ||
|
||
MockSemaphoreContract = await ethers.getContractFactory("MockSemaphore") | ||
mockSemaphore = await MockSemaphoreContract.deploy( | ||
[validGroupId], | ||
[validProof.nullifier, invalidProof.nullifier], | ||
[true, false] | ||
) | ||
mockSemaphoreAddress = await mockSemaphore.getAddress() | ||
|
||
SemaphoreExcubiaContract = await ethers.getContractFactory("SemaphoreExcubia") | ||
semaphoreExcubia = await SemaphoreExcubiaContract.deploy(mockSemaphore, validGroupId) | ||
}) | ||
|
||
describe("constructor()", function () { | ||
it("Should deploy the SemaphoreExcubia contract correctly", async function () { | ||
expect(semaphoreExcubia).to.not.eq(undefined) | ||
}) | ||
|
||
it("Should deploy the MockSemaphore contract correctly", async function () { | ||
expect(mockSemaphore).to.not.eq(undefined) | ||
}) | ||
|
||
it("Should fail to deploy SemaphoreExcubia when semaphore parameter is not valid", async () => { | ||
await expect(SemaphoreExcubiaContract.deploy(ZeroAddress, validGroupId)).to.be.revertedWithCustomError( | ||
semaphoreExcubia, | ||
"ZeroAddress" | ||
) | ||
}) | ||
|
||
it("Should fail to deploy SemaphoreExcubia when groupId parameter is not valid", async () => { | ||
await expect( | ||
SemaphoreExcubiaContract.deploy(mockSemaphoreAddress, invalidGroupId) | ||
).to.be.revertedWithCustomError(semaphoreExcubia, "InvalidGroup") | ||
}) | ||
}) | ||
|
||
describe("setGate()", function () { | ||
it("should fail to set the gate when the caller is not the owner", async () => { | ||
const [, notOwnerSigner] = await ethers.getSigners() | ||
|
||
await expect(semaphoreExcubia.connect(notOwnerSigner).setGate(gateAddress)).to.be.revertedWithCustomError( | ||
semaphoreExcubia, | ||
"OwnableUnauthorizedAccount" | ||
) | ||
}) | ||
|
||
it("should fail to set the gate when the gate address is zero", async () => { | ||
await expect(semaphoreExcubia.setGate(ZeroAddress)).to.be.revertedWithCustomError( | ||
semaphoreExcubia, | ||
"ZeroAddress" | ||
) | ||
}) | ||
|
||
it("Should set the gate contract address correctly", async function () { | ||
const tx = await semaphoreExcubia.setGate(gateAddress) | ||
const receipt = await tx.wait() | ||
const event = SemaphoreExcubiaContract.interface.parseLog( | ||
receipt?.logs[0] as unknown as { topics: string[]; data: string } | ||
) as unknown as { | ||
args: { | ||
gate: string | ||
} | ||
} | ||
|
||
expect(receipt?.status).to.eq(1) | ||
expect(event.args.gate).to.eq(gateAddress) | ||
expect(await semaphoreExcubia.gate()).to.eq(gateAddress) | ||
}) | ||
|
||
it("Should fail to set the gate if already set", async function () { | ||
await expect(semaphoreExcubia.setGate(gateAddress)).to.be.revertedWithCustomError( | ||
semaphoreExcubia, | ||
"GateAlreadySet" | ||
) | ||
}) | ||
}) | ||
|
||
describe("check()", function () { | ||
it("should throw when the scope is not the one expected", async () => { | ||
await expect(semaphoreExcubia.check(signerAddress, encodedInvalidScopeProof)).to.be.revertedWithCustomError( | ||
semaphoreExcubia, | ||
"UnexpectedScope" | ||
) | ||
}) | ||
|
||
it("should throw when the proof is invalid", async () => { | ||
await expect(semaphoreExcubia.check(signerAddress, encodedInvalidProof)).to.be.revertedWithCustomError( | ||
semaphoreExcubia, | ||
"InvalidProof" | ||
) | ||
}) | ||
|
||
it("should check", async () => { | ||
await expect(semaphoreExcubia.check(signerAddress, encodedValidProof)).to.not.be.reverted | ||
|
||
// check does NOT change the state of the contract (see pass()). | ||
expect(await semaphoreExcubia.passedNullifiers(validProof.nullifier)).to.be.false | ||
}) | ||
}) | ||
|
||
describe("pass()", function () { | ||
it("should throw when the callee is not the gate", async () => { | ||
await expect( | ||
semaphoreExcubia.connect(signer).pass(signerAddress, encodedValidProof) | ||
).to.be.revertedWithCustomError(semaphoreExcubia, "GateOnly") | ||
}) | ||
|
||
it("should throw when the scope is not the one expected", async () => { | ||
await expect( | ||
semaphoreExcubia.connect(gate).pass(signerAddress, encodedInvalidScopeProof) | ||
).to.be.revertedWithCustomError(semaphoreExcubia, "UnexpectedScope") | ||
}) | ||
|
||
it("should throw when the proof is invalid", async () => { | ||
await expect( | ||
semaphoreExcubia.connect(gate).pass(signerAddress, encodedInvalidProof) | ||
).to.be.revertedWithCustomError(semaphoreExcubia, "InvalidProof") | ||
}) | ||
it("should pass the check", async () => { | ||
const tx = await semaphoreExcubia.connect(gate).pass(signerAddress, encodedValidProof) | ||
const receipt = await tx.wait() | ||
const event = SemaphoreExcubiaContract.interface.parseLog( | ||
receipt?.logs[0] as unknown as { topics: string[]; data: string } | ||
) as unknown as { | ||
args: { | ||
passerby: string | ||
gate: string | ||
} | ||
} | ||
|
||
expect(receipt?.status).to.eq(1) | ||
expect(event.args.passerby).to.eq(signerAddress) | ||
expect(event.args.gate).to.eq(gateAddress) | ||
expect(await semaphoreExcubia.passedNullifiers(validProof.nullifier)).to.be.true | ||
}) | ||
|
||
it("should prevent to pass twice", async () => { | ||
await expect( | ||
semaphoreExcubia.connect(gate).pass(signerAddress, encodedValidProof) | ||
).to.be.revertedWithCustomError(semaphoreExcubia, "AlreadyPassed") | ||
}) | ||
}) | ||
}) |
Oops, something went wrong.