Skip to content

Commit

Permalink
feat(excubia): add SemaphoreExcubia contract extension (#20)
Browse files Browse the repository at this point in the history
  • Loading branch information
0xjei authored Jul 3, 2024
1 parent 2641e84 commit 1aef325
Show file tree
Hide file tree
Showing 6 changed files with 396 additions and 8 deletions.
75 changes: 75 additions & 0 deletions packages/excubiae/contracts/extensions/SemaphoreExcubia.sol
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();
}
}
5 changes: 3 additions & 2 deletions packages/excubiae/contracts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
"access": "public"
},
"dependencies": {
"@ethereum-attestation-service/eas-contracts": "^1.7.1",
"@openzeppelin/contracts": "^5.0.2"
"@ethereum-attestation-service/eas-contracts": "1.7.1",
"@openzeppelin/contracts": "5.0.2",
"@semaphore-protocol/contracts": "4.0.0-beta.16"
}
}
80 changes: 80 additions & 0 deletions packages/excubiae/contracts/test/MockSemaphore.sol
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 {}
}
2 changes: 1 addition & 1 deletion packages/excubiae/hardhat.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { HardhatUserConfig } from "hardhat/config"

const hardhatConfig: HardhatUserConfig = {
solidity: {
version: "0.8.20",
version: "0.8.23",
settings: {
optimizer: {
enabled: true
Expand Down
222 changes: 222 additions & 0 deletions packages/excubiae/test/SemaphoreExcubia.test.ts
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")
})
})
})
Loading

0 comments on commit 1aef325

Please sign in to comment.