diff --git a/packages/contracts/contracts/src/Lock.sol b/packages/contracts/contracts/src/Lock.sol deleted file mode 100644 index ca5c91c..0000000 --- a/packages/contracts/contracts/src/Lock.sol +++ /dev/null @@ -1,29 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.27; - -import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; - -// Uncomment this line to use console.log -// import "hardhat/console.sol"; - -contract Lock is Ownable(msg.sender) { - uint256 public unlockTime; - - event Withdrawal(uint256 amount, uint256 when); - - constructor(uint256 _unlockTime) payable { - require(block.timestamp < _unlockTime, "Unlock time should be in the future"); - - unlockTime = _unlockTime; - } - - function withdraw() public onlyOwner { - // Uncomment this line, and the import of "hardhat/console.sol", to print a log in your terminal - // console.log("Unlock time is %o and block timestamp is %o", unlockTime, block.timestamp); - require(block.timestamp >= unlockTime, "You can't withdraw yet"); - - emit Withdrawal(address(this).balance, block.timestamp); - - payable(owner()).transfer(address(this).balance); - } -} diff --git a/packages/contracts/contracts/src/core/Excubia.sol b/packages/contracts/contracts/src/core/Excubia.sol new file mode 100644 index 0000000..e7a5edb --- /dev/null +++ b/packages/contracts/contracts/src/core/Excubia.sol @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {IExcubia} from "./IExcubia.sol"; + +/// @title Excubia. +/// @notice Abstract base contract which can be extended to implement a specific excubia. +/// @dev Inherit from this contract and implement the `_pass` & `_check` methods to define +/// your custom gatekeeping logic. +abstract contract Excubia is IExcubia, Ownable(msg.sender) { + /// @notice The excubia-protected contract address. + /// @dev The gate can be any contract address that requires a prior check to enable logic. + /// For example, the gate is a Semaphore group that requires the passerby + /// to meet certain criteria before joining. + address public gate; + + /// @dev Modifier to restrict function calls to only from the gate address. + modifier onlyGate() { + if (msg.sender != gate) revert GateOnly(); + _; + } + + /// @inheritdoc IExcubia + function trait() external pure virtual returns (string memory) {} + + /// @inheritdoc IExcubia + function setGate(address _gate) public virtual onlyOwner { + if (_gate == address(0)) revert ZeroAddress(); + if (gate != address(0)) revert GateAlreadySet(); + + gate = _gate; + + emit GateSet(_gate); + } + + /// @inheritdoc IExcubia + function pass(address passerby, bytes calldata data) external onlyGate { + _pass(passerby, data); + } + + /// @inheritdoc IExcubia + function check(address passerby, bytes calldata data) external view { + _check(passerby, data); + } + + /// @notice Internal function to enforce the custom gate passing logic. + /// @dev Calls the `_check` internal logic and emits the relative event if successful. + /// @param passerby The address of the entity attempting to pass the gate. + /// @param data Additional data required for the check (e.g., encoded token identifier). + function _pass(address passerby, bytes calldata data) internal virtual { + _check(passerby, data); + + emit GatePassed(passerby, gate); + } + + /// @notice Internal function to define the custom gate protection logic. + /// @dev Custom logic to determine if the passerby can pass the gate. + /// @param passerby The address of the entity attempting to pass the gate. + /// @param data Additional data that may be required for the check. + function _check(address passerby, bytes calldata data) internal view virtual {} +} diff --git a/packages/contracts/contracts/src/core/IExcubia.sol b/packages/contracts/contracts/src/core/IExcubia.sol new file mode 100644 index 0000000..b7d2862 --- /dev/null +++ b/packages/contracts/contracts/src/core/IExcubia.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +/// @title IExcubia. +/// @notice Excubia contract interface. +interface IExcubia { + /// @notice Event emitted when someone passes the gate check. + /// @param passerby The address of those who have successfully passed the check. + /// @param gate The address of the excubia-protected contract address. + event GatePassed(address indexed passerby, address indexed gate); + + /// @notice Event emitted when the gate address is set. + /// @param gate The address of the contract set as the gate. + event GateSet(address indexed gate); + + /// @notice Error thrown when an address equal to zero is given. + error ZeroAddress(); + + /// @notice Error thrown when the gate address is not set. + error GateNotSet(); + + /// @notice Error thrown when the callee is not the gate contract. + error GateOnly(); + + /// @notice Error thrown when the gate address has been already set. + error GateAlreadySet(); + + /// @notice Error thrown when the passerby has already passed the gate. + error AlreadyPassed(); + + /// @notice Gets the trait of the Excubia contract. + /// @return The specific trait of the Excubia contract (e.g., SemaphoreExcubia has trait `Semaphore`). + function trait() external pure returns (string memory); + + /// @notice Sets the gate address. + /// @dev Only the owner can set the destination gate address. + /// @param _gate The address of the contract to be set as the gate. + function setGate(address _gate) external; + + /// @notice Enforces the custom gate passing logic. + /// @dev Must call the `check` to handle the logic of checking passerby for specific gate. + /// @param passerby The address of the entity attempting to pass the gate. + /// @param data Additional data required for the check (e.g., encoded token identifier). + function pass(address passerby, bytes calldata data) external; + + /// @dev Defines the custom gate protection logic. + /// @param passerby The address of the entity attempting to pass the gate. + /// @param data Additional data that may be required for the check. + function check(address passerby, bytes calldata data) external view; +} diff --git a/packages/contracts/contracts/src/extensions/FreeForAllExcubia.sol b/packages/contracts/contracts/src/extensions/FreeForAllExcubia.sol new file mode 100644 index 0000000..faed571 --- /dev/null +++ b/packages/contracts/contracts/src/extensions/FreeForAllExcubia.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.27; + +import {Excubia} from "../core/Excubia.sol"; + +/// @title FreeForAll Excubia Contract. +/// @notice This contract extends the Excubia contract to allow free access through the gate. +/// This contract does not perform any checks and allows any passerby to pass the gate. +/// @dev The contract overrides the `_check` function to always return true. +contract FreeForAllExcubia is Excubia { + /// @notice Constructor for the FreeForAllExcubia contract. + constructor() {} + + /// @notice Mapping to track already passed passersby. + mapping(address => bool) public passedPassersby; + + /// @notice The trait of the Excubia contract. + function trait() external pure override returns (string memory) { + return "FreeForAll"; + } + + /// @notice Internal function to handle the gate passing logic. + /// @dev This function calls the parent `_pass` function and then tracks the passerby. + /// @param passerby The address of the entity passing the gate. + /// @param data Additional data required for the pass (not used in this implementation). + function _pass(address passerby, bytes calldata data) internal override { + // Avoiding passing the gate twice with the same address. + if (passedPassersby[passerby]) revert AlreadyPassed(); + + passedPassersby[passerby] = true; + + super._pass(passerby, data); + } + + /// @notice Internal function to handle the gate protection logic. + /// @dev This function always returns true, signaling that any passerby is able to pass the gate. + /// @param passerby The address of the entity attempting to pass the gate. + /// @param data Additional data required for the check (e.g., encoded attestation ID). + function _check(address passerby, bytes calldata data) internal view override { + super._check(passerby, data); + } +} diff --git a/packages/contracts/contracts/test/FreeForAllExcubia.t.sol b/packages/contracts/contracts/test/FreeForAllExcubia.t.sol new file mode 100644 index 0000000..743fa10 --- /dev/null +++ b/packages/contracts/contracts/test/FreeForAllExcubia.t.sol @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.25 <0.9.0; + +import {Test} from "forge-std/src/Test.sol"; +import {FreeForAllExcubia} from "../src/extensions/FreeForAllExcubia.sol"; + +contract FreeForAllExcubiaTest is Test { + FreeForAllExcubia internal freeForAllExcubia; + + address public owner = vm.addr(0x1); + address public gate = vm.addr(0x2); + address public passerbyA = vm.addr(0x3); + address public passerbyB = vm.addr(0x4); + + event GateSet(address indexed gate); + event GatePassed(address indexed passerby, address indexed gate); + + error OwnableUnauthorizedAccount(address); + error ZeroAddress(); + error GateNotSet(); + error GateOnly(); + error GateAlreadySet(); + error AlreadyPassed(); + + function setUp() public virtual { + vm.prank(owner); + freeForAllExcubia = new FreeForAllExcubia(); + } + + /** + * setGate() + */ + function testGateOnlyOwner() external { + vm.prank(address(0)); + vm.expectRevert(abi.encodeWithSelector(OwnableUnauthorizedAccount.selector, address(0))); + freeForAllExcubia.setGate(gate); + } + + function testGateZeroAddress() external { + vm.prank(owner); + vm.expectRevert(ZeroAddress.selector); + freeForAllExcubia.setGate(address(0)); + } + + function testSetGate() external { + vm.expectEmit(true, true, true, true); + emit GateSet(gate); + + vm.prank(owner); + freeForAllExcubia.setGate(gate); + + assertEq(freeForAllExcubia.gate(), gate); + } + + function testGateAlreadySet() external { + vm.prank(owner); + freeForAllExcubia.setGate(gate); + + vm.prank(owner); + vm.expectRevert(GateAlreadySet.selector); + freeForAllExcubia.setGate(gate); + } + + function testTrait() external view { + assertEq(freeForAllExcubia.trait(), "FreeForAll"); + } + + /** + * pass() & implicitly _check() + */ + function testPassNotGate() external { + vm.prank(passerbyA); + vm.expectRevert(GateOnly.selector); + freeForAllExcubia.pass(passerbyA, ""); + } + + function testPass() external { + vm.prank(owner); + freeForAllExcubia.setGate(gate); + + vm.expectEmit(true, true, true, true); + emit GatePassed(passerbyA, gate); + + vm.prank(gate); + freeForAllExcubia.pass(passerbyA, ""); + + assertTrue(freeForAllExcubia.passedPassersby(passerbyA)); + } + + function testNotPassTwice() external { + vm.prank(owner); + freeForAllExcubia.setGate(gate); + + assertEq(gate, freeForAllExcubia.gate()); + vm.prank(gate); + freeForAllExcubia.pass(passerbyA, ""); + + assertTrue(freeForAllExcubia.passedPassersby(passerbyA)); + + vm.prank(gate); + vm.expectRevert(AlreadyPassed.selector); + freeForAllExcubia.pass(passerbyA, ""); + } + + function testPassAnotherPasserby() external { + vm.prank(owner); + freeForAllExcubia.setGate(gate); + + vm.expectEmit(true, true, true, true); + emit GatePassed(passerbyA, gate); + + vm.prank(gate); + freeForAllExcubia.pass(passerbyA, ""); + + vm.expectEmit(true, true, true, true); + emit GatePassed(passerbyB, gate); + + vm.prank(gate); + freeForAllExcubia.pass(passerbyB, ""); + + assertTrue(freeForAllExcubia.passedPassersby(passerbyB)); + } + + /** + * Fuzz Tests + */ + function testFuzzSetGate(address _gate) external { + vm.assume(_gate != address(0)); + vm.prank(owner); + freeForAllExcubia.setGate(_gate); + assertEq(freeForAllExcubia.gate(), _gate); + } + + function testFuzzPass(address _gate, address _passerby) external { + vm.assume(_gate != address(0) && _passerby != address(0)); + vm.prank(owner); + freeForAllExcubia.setGate(_gate); + + vm.expectEmit(true, true, true, true); + emit GatePassed(_passerby, _gate); + + vm.prank(_gate); + freeForAllExcubia.pass(_passerby, ""); + + assertTrue(freeForAllExcubia.passedPassersby(_passerby)); + } +} diff --git a/packages/contracts/contracts/test/Lock.t.sol b/packages/contracts/contracts/test/Lock.t.sol deleted file mode 100644 index ff3fcbd..0000000 --- a/packages/contracts/contracts/test/Lock.t.sol +++ /dev/null @@ -1,22 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity >=0.8.25 <0.9.0; - -import {Test} from "forge-std/src/Test.sol"; -import {console2} from "forge-std/src/console2.sol"; - -import {Lock} from "../src/Lock.sol"; - -contract LockTest is Test { - Lock internal lock; - - function setUp() public virtual { - // Set unlock time to 1 hour from now - lock = new Lock{value: 1 ether}(block.timestamp + 1 hours); - } - - function test_CannotWithdrawYet() external { - // Attempt to withdraw before unlock time - vm.expectRevert("You can't withdraw yet"); - lock.withdraw(); - } -} diff --git a/packages/contracts/ignition/modules/FreeForAllExcubia.ts b/packages/contracts/ignition/modules/FreeForAllExcubia.ts new file mode 100644 index 0000000..14914e6 --- /dev/null +++ b/packages/contracts/ignition/modules/FreeForAllExcubia.ts @@ -0,0 +1,9 @@ +import { buildModule } from "@nomicfoundation/hardhat-ignition/modules" + +const FreeForAllExcubiaModule = buildModule("FreeForAllExcubiaModule", (m) => { + const freeForAllExcubia = m.contract("FreeForAllExcubia") + + return { freeForAllExcubia } +}) + +export default FreeForAllExcubiaModule diff --git a/packages/contracts/ignition/modules/Lock.ts b/packages/contracts/ignition/modules/Lock.ts deleted file mode 100644 index 34c4a93..0000000 --- a/packages/contracts/ignition/modules/Lock.ts +++ /dev/null @@ -1,20 +0,0 @@ -// This setup uses Hardhat Ignition to manage smart contract deployments. -// Learn more about it at https://hardhat.org/ignition - -import { buildModule } from "@nomicfoundation/hardhat-ignition/modules" - -const JAN_1ST_2030 = 1893456000 -const ONE_GWEI: bigint = 1_000_000_000n - -const LockModule = buildModule("LockModule", (m) => { - const unlockTime = m.getParameter("unlockTime", JAN_1ST_2030) - const lockedAmount = m.getParameter("lockedAmount", ONE_GWEI) - - const lock = m.contract("Lock", [unlockTime], { - value: lockedAmount - }) - - return { lock } -}) - -export default LockModule diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 56db7de..6059700 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -10,9 +10,9 @@ "compile": "yarn compile:hardhat && yarn compile:forge", "compile:hardhat": "hardhat compile", "compile:forge": "forge compile", - "deploy:lock": "hardhat ignition deploy ignition/modules/Lock.ts", - "deploy:lock-sepolia": "hardhat ignition deploy ignition/modules/Lock.ts --network sepolia", - "verify:lock-sepolia": "hardhat ignition verify sepolia-deployment", + "deploy:FreeForAllExcubia": "hardhat ignition deploy ignition/modules/FreeForAllExcubia.ts", + "deploy:FreeForAllExcubia-sepolia": "hardhat ignition deploy ignition/modules/FreeForAllExcubia.ts --network sepolia", + "verify:FreeForAllExcubia-sepolia": "hardhat ignition verify sepolia-deployment", "test": "yarn test:hardhat && yarn test:forge", "test:hardhat": "hardhat test", "test:forge": "forge test -vvv", diff --git a/packages/contracts/test/FreeForAllExcubia.test.ts b/packages/contracts/test/FreeForAllExcubia.test.ts new file mode 100644 index 0000000..fa6fd0c --- /dev/null +++ b/packages/contracts/test/FreeForAllExcubia.test.ts @@ -0,0 +1,124 @@ +import { expect } from "chai" +import { ethers } from "hardhat" +import { Signer, ZeroAddress, ZeroHash } from "ethers" +import { FreeForAllExcubia, FreeForAllExcubia__factory } from "../typechain-types" + +describe("FreeForAllExcubia", () => { + let FreeForAllExcubiaContract: FreeForAllExcubia__factory + let freeForAllExcubia: FreeForAllExcubia + + let signer: Signer + let signerAddress: string + + let gate: Signer + let gateAddress: string + + before(async () => { + ;[signer, gate] = await ethers.getSigners() + signerAddress = await signer.getAddress() + gateAddress = await gate.getAddress() + + FreeForAllExcubiaContract = await ethers.getContractFactory("FreeForAllExcubia") + freeForAllExcubia = await FreeForAllExcubiaContract.deploy() + }) + + describe("constructor()", () => { + it("Should deploy the FreeForAllExcubia contract correctly", async () => { + expect(freeForAllExcubia).to.not.eq(undefined) + }) + }) + + describe("trait()", () => { + it("should return the trait of the Excubia contract", async () => { + expect(await freeForAllExcubia.trait()).to.be.equal("FreeForAll") + }) + }) + + describe("setGate()", () => { + it("should fail to set the gate when the caller is not the owner", async () => { + const [, notOwnerSigner] = await ethers.getSigners() + + await expect(freeForAllExcubia.connect(notOwnerSigner).setGate(gateAddress)).to.be.revertedWithCustomError( + freeForAllExcubia, + "OwnableUnauthorizedAccount" + ) + }) + + it("should fail to set the gate when the gate address is zero", async () => { + await expect(freeForAllExcubia.setGate(ZeroAddress)).to.be.revertedWithCustomError( + freeForAllExcubia, + "ZeroAddress" + ) + }) + + it("Should set the gate contract address correctly", async () => { + const tx = await freeForAllExcubia.setGate(gateAddress) + const receipt = await tx.wait() + const event = FreeForAllExcubiaContract.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 freeForAllExcubia.gate()).to.eq(gateAddress) + }) + + it("Should fail to set the gate if already set", async () => { + await expect(freeForAllExcubia.setGate(gateAddress)).to.be.revertedWithCustomError( + freeForAllExcubia, + "GateAlreadySet" + ) + }) + }) + + describe("check()", () => { + it("should check", async () => { + // `data` parameter value can be whatever (e.g., ZeroHash default). + await expect(freeForAllExcubia.check(signerAddress, ZeroHash)).to.not.be.reverted + + // check does NOT change the state of the contract (see pass()). + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(await freeForAllExcubia.passedPassersby(signerAddress)).to.be.false + }) + }) + + describe("pass()", () => { + it("should throw when the callee is not the gate", async () => { + await expect( + // `data` parameter value can be whatever (e.g., ZeroHash default). + freeForAllExcubia.connect(signer).pass(signerAddress, ZeroHash) + ).to.be.revertedWithCustomError(freeForAllExcubia, "GateOnly") + }) + + it("should pass", async () => { + // `data` parameter value can be whatever (e.g., ZeroHash default). + const tx = await freeForAllExcubia.connect(gate).pass(signerAddress, ZeroHash) + const receipt = await tx.wait() + const event = FreeForAllExcubiaContract.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) + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + expect(await freeForAllExcubia.passedPassersby(signerAddress)).to.be.true + }) + + it("should prevent to pass twice", async () => { + await expect( + // `data` parameter value can be whatever (e.g., ZeroHash default). + freeForAllExcubia.connect(gate).pass(signerAddress, ZeroHash) + ).to.be.revertedWithCustomError(freeForAllExcubia, "AlreadyPassed") + }) + }) +}) diff --git a/packages/contracts/test/Lock.ts b/packages/contracts/test/Lock.ts deleted file mode 100644 index 61cf3ce..0000000 --- a/packages/contracts/test/Lock.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { time, loadFixture } from "@nomicfoundation/hardhat-toolbox/network-helpers" -import { anyValue } from "@nomicfoundation/hardhat-chai-matchers/withArgs" -import { expect } from "chai" -import hre from "hardhat" - -describe("Lock", () => { - // We define a fixture to reuse the same setup in every test. - // We use loadFixture to run this setup once, snapshot that state, - // and reset Hardhat Network to that snapshot in every test. - async function deployOneYearLockFixture() { - const ONE_YEAR_IN_SECS = 365 * 24 * 60 * 60 - const ONE_GWEI = 1_000_000_000 - - const lockedAmount = ONE_GWEI - const unlockTime = (await time.latest()) + ONE_YEAR_IN_SECS - - // Contracts are deployed using the first signer/account by default - const [owner, otherAccount] = await hre.ethers.getSigners() - - const Lock = await hre.ethers.getContractFactory("Lock") - const lock = await Lock.deploy(unlockTime, { value: lockedAmount }) - - return { lock, unlockTime, lockedAmount, owner, otherAccount } - } - - describe("Deployment", () => { - it("Should set the right unlockTime", async () => { - const { lock, unlockTime } = await loadFixture(deployOneYearLockFixture) - - expect(await lock.unlockTime()).to.equal(unlockTime) - }) - - it("Should set the right owner", async () => { - const { lock, owner } = await loadFixture(deployOneYearLockFixture) - - expect(await lock.owner()).to.equal(owner.address) - }) - - it("Should receive and store the funds to lock", async () => { - const { lock, lockedAmount } = await loadFixture(deployOneYearLockFixture) - - expect(await hre.ethers.provider.getBalance(lock.target)).to.equal(lockedAmount) - }) - - it("Should fail if the unlockTime is not in the future", async () => { - // We don't use the fixture here because we want a different deployment - const latestTime = await time.latest() - const Lock = await hre.ethers.getContractFactory("Lock") - await expect(Lock.deploy(latestTime, { value: 1 })).to.be.revertedWith( - "Unlock time should be in the future" - ) - }) - }) - - describe("Withdrawals", () => { - describe("Validations", () => { - it("Should revert with the right error if called too soon", async () => { - const { lock } = await loadFixture(deployOneYearLockFixture) - - await expect(lock.withdraw()).to.be.revertedWith("You can't withdraw yet") - }) - - it("Should revert with the right error if called from another account", async () => { - const { lock, unlockTime, otherAccount } = await loadFixture(deployOneYearLockFixture) - - // We can increase the time in Hardhat Network - await time.increaseTo(unlockTime) - - // We use lock.connect() to send a transaction from another account - await expect(lock.connect(otherAccount).withdraw()).to.be.revertedWithCustomError( - lock, - "OwnableUnauthorizedAccount" - ) - }) - - it("Shouldn't fail if the unlockTime has arrived and the owner calls it", async () => { - const { lock, unlockTime } = await loadFixture(deployOneYearLockFixture) - - // Transactions are sent using the first signer by default - await time.increaseTo(unlockTime) - - await expect(lock.withdraw()).not.to.be.reverted - }) - }) - - describe("Events", () => { - it("Should emit an event on withdrawals", async () => { - const { lock, unlockTime, lockedAmount } = await loadFixture(deployOneYearLockFixture) - - await time.increaseTo(unlockTime) - - await expect(lock.withdraw()).to.emit(lock, "Withdrawal").withArgs(lockedAmount, anyValue) // We accept any value as `when` arg - }) - }) - - describe("Transfers", () => { - it("Should transfer the funds to the owner", async () => { - const { lock, unlockTime, lockedAmount, owner } = await loadFixture(deployOneYearLockFixture) - - await time.increaseTo(unlockTime) - - await expect(lock.withdraw()).to.changeEtherBalances([owner, lock], [lockedAmount, -lockedAmount]) - }) - }) - }) -})