diff --git a/.github/workflows/slither.yml b/.github/workflows/slither.yml new file mode 100644 index 00000000..267fec6b --- /dev/null +++ b/.github/workflows/slither.yml @@ -0,0 +1,50 @@ +name: Slither Analysis + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + slither: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Use Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "pnpm" + + - name: Install + run: | + pnpm install --frozen-lockfile --prefer-offline + + - name: Build + run: | + pnpm run build + working-directory: packages/contracts + + - name: Run Slither + uses: crytic/slither-action@v0.4.0 + continue-on-error: true + id: slither + with: + sarif: results.sarif + fail-on: none + ignore-compile: true + node-version: 20 + target: "packages/contracts/" + + - name: Upload SARIF file + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: ${{ steps.slither.outputs.sarif }} diff --git a/packages/contracts/.solcover.js b/packages/contracts/.solcover.js index 2885b4d1..f7a22964 100644 --- a/packages/contracts/.solcover.js +++ b/packages/contracts/.solcover.js @@ -1,4 +1,5 @@ -const { buildPoseidonT3, buildPoseidonT4, buildPoseidonT5, buildPoseidonT6 } = require("maci-contracts"); +const { poseidonContract } = require("circomlibjs"); +const hre = require("hardhat"); const fs = require("fs"); const path = require("path"); @@ -8,6 +9,15 @@ const PATHS = [ path.resolve(__dirname, "..", "typechain-types"), ]; +const buildPoseidon = async (numInputs) => { + await hre.overwriteArtifact(`PoseidonT${numInputs + 1}`, poseidonContract.createCode(numInputs)); +}; + +const buildPoseidonT3 = () => buildPoseidon(2); +const buildPoseidonT4 = () => buildPoseidon(3); +const buildPoseidonT5 = () => buildPoseidon(4); +const buildPoseidonT6 = () => buildPoseidon(5); + module.exports = { onPreCompile: async () => { await Promise.all( diff --git a/packages/contracts/contracts/interfaces/ICommon.sol b/packages/contracts/contracts/interfaces/ICommon.sol new file mode 100644 index 00000000..7d9638be --- /dev/null +++ b/packages/contracts/contracts/interfaces/ICommon.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @title ICommon +/// @notice Interface that contains common things for all the contracts +interface ICommon { + /// @notice custom errors + error InvalidAddress(); + error InvalidInput(); + error InvalidIndex(); + error ValidationError(); +} diff --git a/packages/contracts/contracts/interfaces/IRecipientRegistry.sol b/packages/contracts/contracts/interfaces/IRecipientRegistry.sol index 4b87b5d1..c6b74988 100644 --- a/packages/contracts/contracts/interfaces/IRecipientRegistry.sol +++ b/packages/contracts/contracts/interfaces/IRecipientRegistry.sol @@ -21,8 +21,6 @@ interface IRecipientRegistry { /// @notice Custom errors error MaxRecipientsReached(); - error InvalidIndex(); - error InvalidInput(); /// @notice Get a registry metadata url /// @return The metadata url in bytes32 format diff --git a/packages/contracts/contracts/interfaces/IRegistryManager.sol b/packages/contracts/contracts/interfaces/IRegistryManager.sol new file mode 100644 index 00000000..7a6420da --- /dev/null +++ b/packages/contracts/contracts/interfaces/IRegistryManager.sol @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import { IRecipientRegistry } from "./IRecipientRegistry.sol"; + +/// @title IRegistryManager +/// @notice An interface for a registry manager. Allows to manage requests for Registry. +interface IRegistryManager { + /// @notice Enum representing request type + enum RequestType { + Add, + Change, + Remove + } + + /// @notice Enum representing request status + enum Status { + Pending, + Approved, + Rejected + } + + /// @notice Request data + struct Request { + /// @notice index (optional) + uint256 index; + /// @notice registry address + address registry; + /// @notice request type + RequestType requestType; + /// @notice request status + Status status; + /// @notice recipient data + IRecipientRegistry.Recipient recipient; + } + + /// @notice Events + event RequestSent( + address indexed registry, + RequestType indexed requestType, + address indexed recipient, + uint256 index, + bytes32 id, + bytes32 metadataUrl + ); + event RequestApproved( + address indexed registry, + RequestType indexed requestType, + address indexed recipient, + uint256 index, + bytes32 id, + bytes32 metadataUrl + ); + event RequestRejected( + address indexed registry, + RequestType indexed requestType, + address indexed recipient, + uint256 index, + bytes32 id, + bytes32 metadataUrl + ); + + /// @notice Custom errors + error OperationError(); + + /// @notice Send the request to the Registry + /// @param request user request + function process(Request calldata request) external; + + /// @notice Approve the request and call registry function + /// @param index The index of the request + function approve(uint256 index) external; + + /// @notice Reject the request + /// @param index The index of the request + function reject(uint256 index) external; + + /// @notice Get a request + /// @param index The index of the request + /// @return request The request to the registry + function getRequest(uint256 index) external view returns (Request memory request); + + /// @notice Get the number of requests + /// @return The number of requests + function requestCount() external view returns (uint256); +} diff --git a/packages/contracts/contracts/mocks/MockRegistry.sol b/packages/contracts/contracts/mocks/MockRegistry.sol new file mode 100644 index 00000000..22b328f1 --- /dev/null +++ b/packages/contracts/contracts/mocks/MockRegistry.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import { BaseRegistry } from "../registry/BaseRegistry.sol"; + +/// @title MockRegistry +/// @notice Mock registry contract +contract MockRegistry is BaseRegistry { + /// @notice Create a new instance of the registry contract + /// @param max The maximum number of projects that can be registered + /// @param url The metadata url + /// @param ownerAddress The owner address + constructor(uint256 max, bytes32 url, address ownerAddress) payable BaseRegistry(max, url, ownerAddress) {} +} diff --git a/packages/contracts/contracts/registry/BaseRegistry.sol b/packages/contracts/contracts/registry/BaseRegistry.sol index 8c98aa20..e5df1a64 100644 --- a/packages/contracts/contracts/registry/BaseRegistry.sol +++ b/packages/contracts/contracts/registry/BaseRegistry.sol @@ -1,11 +1,14 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; + +import { ICommon } from "../interfaces/ICommon.sol"; import { IRecipientRegistry } from "../interfaces/IRecipientRegistry.sol"; /// @title BaseRegistry /// @notice Base contract for a registry -abstract contract BaseRegistry is IRecipientRegistry { +abstract contract BaseRegistry is Ownable, IRecipientRegistry, ICommon { /// @notice The storage of recipients mapping(uint256 => Recipient) internal recipients; @@ -19,11 +22,12 @@ abstract contract BaseRegistry is IRecipientRegistry { bytes32 public immutable metadataUrl; /// @notice Create a new instance of the registry contract - /// @param _maxRecipients The maximum number of recipients that can be registered - /// @param _metadataUrl The metadata url - constructor(uint256 _maxRecipients, bytes32 _metadataUrl) payable { - maxRecipients = _maxRecipients; - metadataUrl = _metadataUrl; + /// @param max The maximum number of recipients that can be registered + /// @param url The metadata url + /// @param ownerAddress The owner address + constructor(uint256 max, bytes32 url, address ownerAddress) payable Ownable(ownerAddress) { + maxRecipients = max; + metadataUrl = url; } /// @inheritdoc IRecipientRegistry @@ -32,7 +36,7 @@ abstract contract BaseRegistry is IRecipientRegistry { } /// @inheritdoc IRecipientRegistry - function addRecipient(Recipient calldata recipient) public virtual override returns (uint256) { + function addRecipient(Recipient calldata recipient) public virtual override onlyOwner returns (uint256) { uint256 index = recipientCount; if (index >= maxRecipients) { @@ -52,7 +56,7 @@ abstract contract BaseRegistry is IRecipientRegistry { } /// @inheritdoc IRecipientRegistry - function removeRecipient(uint256 index) public virtual override { + function removeRecipient(uint256 index) public virtual override onlyOwner { if (index >= recipientCount) { revert InvalidIndex(); } @@ -66,7 +70,7 @@ abstract contract BaseRegistry is IRecipientRegistry { } /// @inheritdoc IRecipientRegistry - function changeRecipient(uint256 index, Recipient calldata recipient) public virtual override { + function changeRecipient(uint256 index, Recipient calldata recipient) public virtual override onlyOwner { if (index >= recipientCount) { revert InvalidIndex(); } diff --git a/packages/contracts/contracts/registry/EASRegistry.sol b/packages/contracts/contracts/registry/EASRegistry.sol index eee927fd..57a13eda 100644 --- a/packages/contracts/contracts/registry/EASRegistry.sol +++ b/packages/contracts/contracts/registry/EASRegistry.sol @@ -1,25 +1,31 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; -import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; - import { IEAS } from "../interfaces/IEAS.sol"; import { BaseRegistry } from "./BaseRegistry.sol"; -contract EASRegistry is Ownable, BaseRegistry, IEAS { +/// @title EASRegistry +/// @notice EAS registry contract +contract EASRegistry is BaseRegistry, IEAS { /// @notice The EAS contract IEAS public immutable eas; /// @notice Create a new instance of the registry contract - /// @param _maxRecipients The maximum number of projects that can be registered - /// @param _metadataUrl The metadata url - /// @param _eas The EAS address + /// @param max The maximum number of projects that can be registered + /// @param url The metadata url + /// @param easAddress The EAS address + /// @param ownerAddress The owner address constructor( - uint256 _maxRecipients, - bytes32 _metadataUrl, - address _eas - ) payable Ownable(msg.sender) BaseRegistry(_maxRecipients, _metadataUrl) { - eas = IEAS(_eas); + uint256 max, + bytes32 url, + address easAddress, + address ownerAddress + ) payable BaseRegistry(max, url, ownerAddress) { + if (easAddress == address(0)) { + revert InvalidAddress(); + } + + eas = IEAS(easAddress); } /// @notice Add multiple recipients to the registry @@ -36,23 +42,6 @@ contract EASRegistry is Ownable, BaseRegistry, IEAS { } } - /// @inheritdoc BaseRegistry - function addRecipient(Recipient calldata recipient) public override onlyOwner returns (uint256) { - return super.addRecipient(recipient); - } - - /// @notice Edit the address of a project - /// @param index The index of the project to edit - /// @param recipient The new recipient - function changeRecipient(uint256 index, Recipient calldata recipient) public override onlyOwner { - super.changeRecipient(index, recipient); - } - - /// @inheritdoc BaseRegistry - function removeRecipient(uint256 index) public override onlyOwner { - super.removeRecipient(index); - } - /// @inheritdoc IEAS function getAttestation(bytes32 id) public view override returns (Attestation memory) { return eas.getAttestation(id); diff --git a/packages/contracts/contracts/registryManager/EASRegistryManager.sol b/packages/contracts/contracts/registryManager/EASRegistryManager.sol new file mode 100644 index 00000000..4c41f582 --- /dev/null +++ b/packages/contracts/contracts/registryManager/EASRegistryManager.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import { IEAS } from "../interfaces/IEAS.sol"; +import { RegistryManager } from "./RegistryManager.sol"; + +/// @title EASRegistryManager +/// @notice Contract that allows to use send, approve, reject requests to EASRegistry. +contract EASRegistryManager is RegistryManager { + /// @notice custom errors + error NotYourAttestation(); + + /// @notice EAS + IEAS public immutable eas; + + /// @notice Initialize EASRegistryManager + /// @param easAddress EAS contract address + constructor(address easAddress) payable { + if (easAddress == address(0)) { + revert InvalidAddress(); + } + + eas = IEAS(easAddress); + } + + /// @notice Check recipient has an EAS attestation + /// @param request request to the registry + modifier onlyWithAttestation(Request memory request) { + if (request.requestType != RequestType.Change) { + _; + return; + } + + IEAS.Attestation memory attestation = eas.getAttestation(request.recipient.id); + + if (attestation.recipient != request.recipient.recipient) { + revert NotYourAttestation(); + } + + _; + } + + /// @inheritdoc RegistryManager + function process( + Request calldata request + ) public virtual override isValidRequest(request) onlyWithAttestation(request) { + super.process(request); + } +} diff --git a/packages/contracts/contracts/registryManager/RegistryManager.sol b/packages/contracts/contracts/registryManager/RegistryManager.sol new file mode 100644 index 00000000..b343cf77 --- /dev/null +++ b/packages/contracts/contracts/registryManager/RegistryManager.sol @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; + +import { ICommon } from "../interfaces/ICommon.sol"; +import { IOwnable } from "../interfaces/IOwnable.sol"; +import { IRecipientRegistry } from "../interfaces/IRecipientRegistry.sol"; +import { IRegistryManager } from "../interfaces/IRegistryManager.sol"; + +/// @title RegistryManager +/// @notice Contract that allows to use send, approve, reject requests to RecipientRegistry. +contract RegistryManager is Ownable, IRegistryManager, ICommon { + /// @notice requests + mapping(uint256 => Request) internal requests; + + /// @inheritdoc IRegistryManager + uint256 public requestCount; + + /// @notice Initialize registry manager + constructor() payable Ownable(msg.sender) {} + + /// @notice Check if request is valid + modifier isValidRequest(Request calldata request) { + if (request.registry == address(0)) { + revert ValidationError(); + } + + if (request.recipient.recipient == address(0)) { + revert ValidationError(); + } + + if (IOwnable(request.registry).owner() != address(this)) { + revert ValidationError(); + } + + uint256 count = IRecipientRegistry(request.registry).recipientCount(); + bool withIndex = request.requestType == RequestType.Change || request.requestType == RequestType.Remove; + + if (request.index >= count && withIndex) { + revert ValidationError(); + } + + _; + } + + /// @notice Check if request is pending and exists + /// @param index Request index + modifier isPending(uint256 index) { + if (index >= requestCount || requests[index].status != Status.Pending) { + revert OperationError(); + } + + _; + } + + /// @inheritdoc IRegistryManager + function process(Request calldata request) public virtual override isValidRequest(request) { + requests[requestCount] = request; + requests[requestCount].status = Status.Pending; + + unchecked { + requestCount++; + } + + emit RequestSent( + request.registry, + request.requestType, + request.recipient.recipient, + request.index, + request.recipient.id, + request.recipient.metadataUrl + ); + } + + /// @inheritdoc IRegistryManager + function approve(uint256 index) public virtual override onlyOwner isPending(index) { + Request memory request = requests[index]; + IRecipientRegistry registry = IRecipientRegistry(request.registry); + + requests[index].status = Status.Approved; + + emit RequestApproved( + request.registry, + request.requestType, + request.recipient.recipient, + request.index, + request.recipient.id, + request.recipient.metadataUrl + ); + + if (request.requestType == RequestType.Change) { + registry.changeRecipient(request.index, request.recipient); + } else if (request.requestType == RequestType.Remove) { + registry.removeRecipient(request.index); + } else { + registry.addRecipient(request.recipient); + } + } + + /// @inheritdoc IRegistryManager + function reject(uint256 index) public virtual override onlyOwner isPending(index) { + Request storage request = requests[index]; + request.status = Status.Rejected; + + emit RequestRejected( + request.registry, + request.requestType, + request.recipient.recipient, + request.index, + request.recipient.id, + request.recipient.metadataUrl + ); + } + + /// @inheritdoc IRegistryManager + function getRequest(uint256 index) public view virtual override returns (Request memory request) { + request = requests[index]; + } +} diff --git a/packages/contracts/tests/EASRegistry.test.ts b/packages/contracts/tests/EASRegistry.test.ts index cb395a7f..f2abf1d6 100644 --- a/packages/contracts/tests/EASRegistry.test.ts +++ b/packages/contracts/tests/EASRegistry.test.ts @@ -2,7 +2,7 @@ import { expect } from "chai"; import { encodeBytes32String, Signer, ZeroAddress } from "ethers"; import { getSigners, deployContract } from "maci-contracts"; -import { EASRegistry, MockEAS } from "../typechain-types"; +import { EASRegistry, MockEAS, ICommon__factory as ICommonFactory } from "../typechain-types"; describe("EASRegistry", () => { let registry: EASRegistry; @@ -26,7 +26,19 @@ describe("EASRegistry", () => { mockEAS = await deployContract("MockEAS", owner, true, ownerAddress, schema, userAddress); - registry = await deployContract("EASRegistry", owner, true, maxRecipients, metadataUrl, await mockEAS.getAddress()); + await expect( + deployContract("EASRegistry", owner, true, maxRecipients, metadataUrl, ZeroAddress, ownerAddress), + ).to.be.revertedWithCustomError({ interface: ICommonFactory.createInterface() }, "InvalidAddress"); + + registry = await deployContract( + "EASRegistry", + owner, + true, + maxRecipients, + metadataUrl, + await mockEAS.getAddress(), + ownerAddress, + ); }); it("should allow the owner to add a recipient", async () => { diff --git a/packages/contracts/tests/EASRegistryManager.test.ts b/packages/contracts/tests/EASRegistryManager.test.ts new file mode 100644 index 00000000..bf442c7b --- /dev/null +++ b/packages/contracts/tests/EASRegistryManager.test.ts @@ -0,0 +1,162 @@ +import { expect } from "chai"; +import { encodeBytes32String, Signer, ZeroAddress } from "ethers"; +import { getSigners, deployContract } from "maci-contracts"; + +import { ERegistryManagerRequestStatus, ERegistryManagerRequestType } from "../ts"; +import { MockRegistry, EASRegistryManager, MockEAS, ICommon__factory as ICommonFactory } from "../typechain-types"; + +describe("EASRegistryManager", () => { + let registryManager: EASRegistryManager; + let mockEAS: MockEAS; + let mockRegistry: MockRegistry; + let owner: Signer; + let user: Signer; + + let ownerAddress: string; + let userAddress: string; + + const schema = "0xfdcfdad2dbe7489e0ce56b260348b7f14e8365a8a325aef9834818c00d46b31b"; + const attestation = "0x0000000000000000000000000000000000000000000000000000000000000000"; + const newAttestation = "0x0000000000000000000000000000000000000000000000000000000000000001"; + const metadataUrl = encodeBytes32String("url"); + const maxRecipients = 5; + + before(async () => { + [owner, user] = await getSigners(); + [ownerAddress, userAddress] = await Promise.all([owner.getAddress(), user.getAddress()]); + + mockEAS = await deployContract("MockEAS", owner, true, ownerAddress, schema, userAddress); + + registryManager = await deployContract("EASRegistryManager", owner, true, await mockEAS.getAddress()); + + await expect(deployContract("EASRegistryManager", owner, true, ZeroAddress)).to.be.revertedWithCustomError( + { interface: ICommonFactory.createInterface() }, + "InvalidAddress", + ); + + mockRegistry = await deployContract( + "MockRegistry", + owner, + true, + maxRecipients, + metadataUrl, + await registryManager.getAddress(), + ); + + await registryManager.connect(user).process({ + index: 0, + registry: await mockRegistry.getAddress(), + requestType: ERegistryManagerRequestType.Add, + status: ERegistryManagerRequestStatus.Pending, + recipient: { + id: attestation, + recipient: ownerAddress, + metadataUrl, + }, + }); + }); + + it("should not allow non-owner to approve requests to the registry", async () => { + await expect(registryManager.connect(user).approve(0)).to.be.revertedWithCustomError( + registryManager, + "OwnableUnauthorizedAccount", + ); + }); + + it("should allow owner to approve requests to the registry", async () => { + const addRequest = await registryManager.getRequest(0); + + expect(addRequest.status).to.equal(ERegistryManagerRequestStatus.Pending); + + await expect(registryManager.connect(owner).approve(0)) + .to.emit(registryManager, "RequestApproved") + .withArgs( + addRequest.registry, + addRequest.requestType, + addRequest.recipient.recipient, + addRequest.index, + addRequest.recipient.id, + addRequest.recipient.metadataUrl, + ); + + const changeRequest = { + index: 0, + registry: await mockRegistry.getAddress(), + requestType: ERegistryManagerRequestType.Change, + status: ERegistryManagerRequestStatus.Pending, + recipient: { + id: attestation, + recipient: userAddress, + metadataUrl, + }, + }; + + await expect(registryManager.connect(user).process(changeRequest)) + .to.emit(registryManager, "RequestSent") + .withArgs( + changeRequest.registry, + changeRequest.requestType, + changeRequest.recipient.recipient, + changeRequest.index, + changeRequest.recipient.id, + changeRequest.recipient.metadataUrl, + ); + + await expect(registryManager.connect(owner).approve(1)) + .to.emit(registryManager, "RequestApproved") + .withArgs( + changeRequest.registry, + changeRequest.requestType, + changeRequest.recipient.recipient, + changeRequest.index, + changeRequest.recipient.id, + changeRequest.recipient.metadataUrl, + ); + + const [updatedAddRequest, updatedChangeRequest, recipient, recipientCount] = await Promise.all([ + registryManager.getRequest(0), + registryManager.getRequest(1), + mockRegistry.getRecipient(0), + mockRegistry.recipientCount(), + ]); + + expect(updatedAddRequest.status).to.equal(ERegistryManagerRequestStatus.Approved); + expect(updatedChangeRequest.status).to.equal(ERegistryManagerRequestStatus.Approved); + expect(recipient.id).to.equal(changeRequest.recipient.id); + expect(recipient.recipient).to.equal(changeRequest.recipient.recipient); + expect(recipient.metadataUrl).to.equal(changeRequest.recipient.metadataUrl); + expect(recipientCount).to.equal(1); + }); + + it("should not allow to send requests to the registry with invalid request", async () => { + await expect( + registryManager.connect(owner).process({ + index: 1, + registry: ZeroAddress, + requestType: ERegistryManagerRequestType.Change, + status: ERegistryManagerRequestStatus.Rejected, + recipient: { + id: newAttestation, + recipient: ZeroAddress, + metadataUrl, + }, + }), + ).to.be.revertedWithCustomError(registryManager, "ValidationError"); + }); + + it("should not allow to send requests to the registry with invalid attestation", async () => { + await expect( + registryManager.connect(owner).process({ + index: 0, + registry: await mockRegistry.getAddress(), + requestType: ERegistryManagerRequestType.Change, + status: ERegistryManagerRequestStatus.Rejected, + recipient: { + id: newAttestation, + recipient: ownerAddress, + metadataUrl, + }, + }), + ).to.be.revertedWithCustomError(registryManager, "NotYourAttestation"); + }); +}); diff --git a/packages/contracts/tests/RegistryManager.test.ts b/packages/contracts/tests/RegistryManager.test.ts new file mode 100644 index 00000000..d171e1cb --- /dev/null +++ b/packages/contracts/tests/RegistryManager.test.ts @@ -0,0 +1,380 @@ +import { expect } from "chai"; +import { encodeBytes32String, Signer, ZeroAddress } from "ethers"; +import { getSigners, deployContract } from "maci-contracts"; + +import { ERegistryManagerRequestStatus, ERegistryManagerRequestType } from "../ts"; +import { RegistryManager, MockRegistry } from "../typechain-types"; + +describe("RegistryManager", () => { + let registryManager: RegistryManager; + let mockRegistry: MockRegistry; + let owner: Signer; + let user: Signer; + + let ownerAddress: string; + let userAddress: string; + + const attestation = "0x0000000000000000000000000000000000000000000000000000000000000000"; + const metadataUrl = encodeBytes32String("url"); + const maxRecipients = 5; + + before(async () => { + [owner, user] = await getSigners(); + [ownerAddress, userAddress] = await Promise.all([owner.getAddress(), user.getAddress()]); + + registryManager = await deployContract("RegistryManager", owner, true); + + mockRegistry = await deployContract( + "MockRegistry", + owner, + true, + maxRecipients, + metadataUrl, + await registryManager.getAddress(), + ); + }); + + it("should not allow user to send invalid requests to the registry", async () => { + await expect( + registryManager.connect(user).process({ + index: 0, + registry: ZeroAddress, + requestType: ERegistryManagerRequestType.Add, + status: ERegistryManagerRequestStatus.Pending, + recipient: { + id: attestation, + recipient: ownerAddress, + metadataUrl, + }, + }), + ).to.be.revertedWithCustomError(registryManager, "ValidationError"); + + await expect( + registryManager.connect(user).process({ + index: 0, + registry: await mockRegistry.getAddress(), + requestType: ERegistryManagerRequestType.Add, + status: ERegistryManagerRequestStatus.Pending, + recipient: { + id: attestation, + recipient: ZeroAddress, + metadataUrl, + }, + }), + ).to.be.revertedWithCustomError(registryManager, "ValidationError"); + + await expect( + registryManager.connect(user).process({ + index: 1, + registry: await mockRegistry.getAddress(), + requestType: ERegistryManagerRequestType.Change, + status: ERegistryManagerRequestStatus.Pending, + recipient: { + id: attestation, + recipient: userAddress, + metadataUrl, + }, + }), + ).to.be.revertedWithCustomError(registryManager, "ValidationError"); + + await expect( + registryManager.connect(user).process({ + index: 1, + registry: await mockRegistry.getAddress(), + requestType: ERegistryManagerRequestType.Remove, + status: ERegistryManagerRequestStatus.Pending, + recipient: { + id: attestation, + recipient: userAddress, + metadataUrl, + }, + }), + ).to.be.revertedWithCustomError(registryManager, "ValidationError"); + + const unknownRegistry = await deployContract("MockRegistry", owner, true, maxRecipients, metadataUrl, userAddress); + + await expect( + registryManager.connect(user).process({ + index: 0, + registry: await unknownRegistry.getAddress(), + requestType: ERegistryManagerRequestType.Add, + status: ERegistryManagerRequestStatus.Pending, + recipient: { + id: attestation, + recipient: userAddress, + metadataUrl, + }, + }), + ).to.be.revertedWithCustomError(registryManager, "ValidationError"); + + expect(await registryManager.requestCount()).to.equal(0); + }); + + it("should allow user to send requests to the registry", async () => { + const addRequest = { + index: 0, + registry: await mockRegistry.getAddress(), + requestType: ERegistryManagerRequestType.Add, + status: ERegistryManagerRequestStatus.Pending, + recipient: { + id: attestation, + recipient: ownerAddress, + metadataUrl, + }, + }; + + await expect(registryManager.connect(user).process(addRequest)) + .to.emit(registryManager, "RequestSent") + .withArgs( + addRequest.registry, + addRequest.requestType, + addRequest.recipient.recipient, + addRequest.index, + addRequest.recipient.id, + addRequest.recipient.metadataUrl, + ); + + expect(await registryManager.requestCount()).to.equal(1); + }); + + it("should not allow non-owner to approve requests to the registry", async () => { + await expect(registryManager.connect(user).approve(0)).to.be.revertedWithCustomError( + registryManager, + "OwnableUnauthorizedAccount", + ); + }); + + it("should not allow non-owner to reject requests to the registry", async () => { + await expect(registryManager.connect(user).reject(0)).to.be.revertedWithCustomError( + registryManager, + "OwnableUnauthorizedAccount", + ); + }); + + it("should not allow to approve requests to the registry with invalid index", async () => { + await expect(registryManager.connect(owner).approve(9000)).to.be.revertedWithCustomError( + registryManager, + "OperationError", + ); + }); + + it("should not allow to reject requests to the registry with invalid index", async () => { + await expect(registryManager.connect(owner).reject(9000)).to.be.revertedWithCustomError( + registryManager, + "OperationError", + ); + }); + + it("should allow owner to approve requests to the registry", async () => { + const addRequest = await registryManager.getRequest(0); + + expect(addRequest.status).to.equal(ERegistryManagerRequestStatus.Pending); + + await expect(registryManager.connect(owner).approve(0)) + .to.emit(registryManager, "RequestApproved") + .withArgs( + addRequest.registry, + addRequest.requestType, + addRequest.recipient.recipient, + addRequest.index, + addRequest.recipient.id, + addRequest.recipient.metadataUrl, + ); + + const changeRequest = { + index: 0, + registry: await mockRegistry.getAddress(), + requestType: ERegistryManagerRequestType.Change, + status: ERegistryManagerRequestStatus.Pending, + recipient: { + id: attestation, + recipient: userAddress, + metadataUrl, + }, + }; + + await expect(registryManager.connect(user).process(changeRequest)) + .to.emit(registryManager, "RequestSent") + .withArgs( + changeRequest.registry, + changeRequest.requestType, + changeRequest.recipient.recipient, + changeRequest.index, + changeRequest.recipient.id, + changeRequest.recipient.metadataUrl, + ); + + await expect(registryManager.connect(owner).approve(1)) + .to.emit(registryManager, "RequestApproved") + .withArgs( + changeRequest.registry, + changeRequest.requestType, + changeRequest.recipient.recipient, + changeRequest.index, + changeRequest.recipient.id, + changeRequest.recipient.metadataUrl, + ); + + const [updatedAddRequest, updatedChangeRequest, recipient, recipientCount] = await Promise.all([ + registryManager.getRequest(0), + registryManager.getRequest(1), + mockRegistry.getRecipient(0), + mockRegistry.recipientCount(), + ]); + + expect(updatedAddRequest.status).to.equal(ERegistryManagerRequestStatus.Approved); + expect(updatedChangeRequest.status).to.equal(ERegistryManagerRequestStatus.Approved); + expect(recipient.id).to.equal(changeRequest.recipient.id); + expect(recipient.recipient).to.equal(changeRequest.recipient.recipient); + expect(recipient.metadataUrl).to.equal(changeRequest.recipient.metadataUrl); + expect(recipientCount).to.equal(1); + }); + + it("should not allow to approve requests to the registry twice", async () => { + await expect(registryManager.connect(owner).approve(0)).to.be.revertedWithCustomError( + registryManager, + "OperationError", + ); + + await expect(registryManager.connect(owner).approve(1)).to.be.revertedWithCustomError( + registryManager, + "OperationError", + ); + }); + + it("should allow owner to reject requests to the registry", async () => { + const addRequest = { + index: 0, + registry: await mockRegistry.getAddress(), + requestType: ERegistryManagerRequestType.Add, + status: ERegistryManagerRequestStatus.Pending, + recipient: { + id: attestation, + recipient: ownerAddress, + metadataUrl, + }, + }; + + const changeRequest = { + index: 0, + registry: await mockRegistry.getAddress(), + requestType: ERegistryManagerRequestType.Change, + status: ERegistryManagerRequestStatus.Pending, + recipient: { + id: attestation, + recipient: userAddress, + metadataUrl, + }, + }; + + await expect(registryManager.connect(user).process(addRequest)) + .to.emit(registryManager, "RequestSent") + .withArgs( + addRequest.registry, + addRequest.requestType, + addRequest.recipient.recipient, + addRequest.index, + addRequest.recipient.id, + addRequest.recipient.metadataUrl, + ); + + await expect(registryManager.connect(user).process(changeRequest)) + .to.emit(registryManager, "RequestSent") + .withArgs( + changeRequest.registry, + changeRequest.requestType, + changeRequest.recipient.recipient, + changeRequest.index, + changeRequest.recipient.id, + changeRequest.recipient.metadataUrl, + ); + + await expect(registryManager.connect(owner).reject(2)) + .to.emit(registryManager, "RequestRejected") + .withArgs( + addRequest.registry, + addRequest.requestType, + addRequest.recipient.recipient, + addRequest.index, + addRequest.recipient.id, + addRequest.recipient.metadataUrl, + ); + + await expect(registryManager.connect(owner).reject(3)) + .to.emit(registryManager, "RequestRejected") + .withArgs( + changeRequest.registry, + changeRequest.requestType, + changeRequest.recipient.recipient, + changeRequest.index, + changeRequest.recipient.id, + changeRequest.recipient.metadataUrl, + ); + + const [updatedAddRequest, updatedChangeRequest] = await Promise.all([ + registryManager.getRequest(2), + registryManager.getRequest(3), + ]); + + expect(updatedAddRequest.status).to.equal(ERegistryManagerRequestStatus.Rejected); + expect(updatedChangeRequest.status).to.equal(ERegistryManagerRequestStatus.Rejected); + }); + + it("should not allow to reject requests to the registry twice", async () => { + await expect(registryManager.connect(owner).reject(2)).to.be.revertedWithCustomError( + registryManager, + "OperationError", + ); + + await expect(registryManager.connect(owner).reject(3)).to.be.revertedWithCustomError( + registryManager, + "OperationError", + ); + }); + + it("should allow to approve remove request to the registry", async () => { + const removeRequest = { + index: 0, + registry: await mockRegistry.getAddress(), + requestType: ERegistryManagerRequestType.Remove, + status: ERegistryManagerRequestStatus.Pending, + recipient: { + id: attestation, + recipient: userAddress, + metadataUrl, + }, + }; + + await expect(registryManager.connect(user).process(removeRequest)) + .to.emit(registryManager, "RequestSent") + .withArgs( + removeRequest.registry, + removeRequest.requestType, + removeRequest.recipient.recipient, + removeRequest.index, + removeRequest.recipient.id, + removeRequest.recipient.metadataUrl, + ); + + const count = await registryManager.requestCount(); + + await expect(registryManager.connect(owner).approve(count - 1n)) + .to.emit(registryManager, "RequestApproved") + .withArgs( + removeRequest.registry, + removeRequest.requestType, + removeRequest.recipient.recipient, + removeRequest.index, + removeRequest.recipient.id, + removeRequest.recipient.metadataUrl, + ); + + const [updatedRemoveRequest, recipientCount] = await Promise.all([ + registryManager.getRequest(count - 1n), + mockRegistry.recipientCount(), + ]); + + expect(updatedRemoveRequest.status).to.equal(ERegistryManagerRequestStatus.Approved); + expect(recipientCount).to.equal(0); + }); +}); diff --git a/packages/contracts/ts/constants.ts b/packages/contracts/ts/constants.ts new file mode 100644 index 00000000..9cd97716 --- /dev/null +++ b/packages/contracts/ts/constants.ts @@ -0,0 +1,17 @@ +/** + * Enum representing request type + */ +export enum ERegistryManagerRequestType { + Add, + Change, + Remove, +} + +/** + * Enum representing request status + */ +export enum ERegistryManagerRequestStatus { + Pending, + Approved, + Rejected, +} diff --git a/packages/contracts/ts/index.ts b/packages/contracts/ts/index.ts index cb0ff5c3..98027a6c 100644 --- a/packages/contracts/ts/index.ts +++ b/packages/contracts/ts/index.ts @@ -1 +1 @@ -export {}; +export { ERegistryManagerRequestType, ERegistryManagerRequestStatus } from "./constants";