diff --git a/contracts/DecentHats.sol b/contracts/DecentHats.sol new file mode 100644 index 00000000..2a0c686c --- /dev/null +++ b/contracts/DecentHats.sol @@ -0,0 +1,92 @@ +//SPDX-License-Identifier: MIT +pragma solidity =0.8.19; + +import {Enum} from "@gnosis.pm/zodiac/contracts/core/Module.sol"; +import {IAvatar} from "@gnosis.pm/zodiac/contracts/interfaces/IAvatar.sol"; +import {IHats} from "./interfaces/hats/IHats.sol"; +import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; + +contract DecentHats { + struct Hat { + address eligibility; + uint32 maxSupply; + address toggle; + string details; + string imageURI; + bool isMutable; + address wearer; + } + + IHats public hats; + address public keyValuePairs; + + constructor(IHats _hats, address _keyValuePairs) { + hats = _hats; + keyValuePairs = _keyValuePairs; + } + + function createAndDeclareTree( + string memory _topHatDetails, + string memory _topHatImageURI, + Hat calldata _adminHat, + Hat[] calldata _hats + ) public { + uint256 topHatId = hats.mintTopHat( + msg.sender, + _topHatDetails, + _topHatImageURI + ); + + string[] memory keys = new string[](1); + keys[0] = "hatsTreeId"; + + string[] memory values = new string[](1); + values[0] = Strings.toString(topHatId); + + IAvatar(msg.sender).execTransactionFromModule( + keyValuePairs, + 0, + abi.encodeWithSignature( + "updateValues(string[],string[])", + keys, + values + ), + Enum.Operation.Call + ); + + uint256 adminHatId = hats.createHat( + topHatId, + _adminHat.details, + _adminHat.maxSupply, + _adminHat.eligibility, + _adminHat.toggle, + _adminHat.isMutable, + _adminHat.imageURI + ); + + if (_adminHat.wearer != address(0)) { + hats.mintHat(adminHatId, _adminHat.wearer); + } + + for (uint256 i = 0; i < _hats.length; ) { + Hat memory hat = _hats[i]; + uint256 hatId = hats.createHat( + adminHatId, + hat.details, + hat.maxSupply, + hat.eligibility, + hat.toggle, + hat.isMutable, + hat.imageURI + ); + + if (hat.wearer != address(0)) { + hats.mintHat(hatId, hat.wearer); + } + + unchecked { + ++i; + } + } + } +} diff --git a/contracts/interfaces/hats/IHats.sol b/contracts/interfaces/hats/IHats.sol new file mode 100644 index 00000000..77c621e1 --- /dev/null +++ b/contracts/interfaces/hats/IHats.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: AGPL-3.0 +// Copyright (C) 2023 Haberdasher Labs +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +pragma solidity >=0.8.13; + +interface IHats { + function mintTopHat( + address _target, + string memory _details, + string memory _imageURI + ) external returns (uint256 topHatId); + + function createHat( + uint256 _admin, + string calldata _details, + uint32 _maxSupply, + address _eligibility, + address _toggle, + bool _mutable, + string calldata _imageURI + ) external returns (uint256 newHatId); + + function mintHat( + uint256 _hatId, + address _wearer + ) external returns (bool success); +} diff --git a/contracts/mock/MockHats.sol b/contracts/mock/MockHats.sol new file mode 100644 index 00000000..783ca697 --- /dev/null +++ b/contracts/mock/MockHats.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT +pragma solidity =0.8.19; + +import {IHats} from "../interfaces/hats/IHats.sol"; + +contract MockHats is IHats { + uint256 count = 0; + + function mintTopHat( + address, + string memory, + string memory + ) external returns (uint256 topHatId) { + topHatId = count; + count++; + } + + function createHat( + uint256, + string calldata, + uint32, + address, + address, + bool, + string calldata + ) external returns (uint256 newHatId) { + newHatId = count; + count++; + } + + function mintHat(uint256, address) external pure returns (bool success) { + success = true; + } +} diff --git a/test/DecentHats.test.ts b/test/DecentHats.test.ts new file mode 100644 index 00000000..a254bf83 --- /dev/null +++ b/test/DecentHats.test.ts @@ -0,0 +1,269 @@ +import { + GnosisSafeL2, + GnosisSafeL2__factory, + DecentHats__factory, + KeyValuePairs, + KeyValuePairs__factory, + MockHats__factory, +} from "../typechain-types"; + +import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers"; +import { expect } from "chai"; +import { ethers } from "ethers"; +import hre from "hardhat"; + +import { + getGnosisSafeL2Singleton, + getGnosisSafeProxyFactory, +} from "./GlobalSafeDeployments.test"; +import { + buildSafeTransaction, + buildSignatureBytes, + predictGnosisSafeAddress, + safeSignTypedData, +} from "./helpers"; + +const executeSafeTransaction = async ({ + safe, + to, + transactionData, + signers, +}: { + safe: GnosisSafeL2; + to: string; + transactionData: string; + signers: SignerWithAddress[]; +}) => { + const safeTx = buildSafeTransaction({ + to, + data: transactionData, + nonce: await safe.nonce(), + }); + + const sigs = await Promise.all( + signers.map(async (signer) => await safeSignTypedData(signer, safe, safeTx)) + ); + + const tx = await safe.execTransaction( + safeTx.to, + safeTx.value, + safeTx.data, + safeTx.operation, + safeTx.safeTxGas, + safeTx.baseGas, + safeTx.gasPrice, + safeTx.gasToken, + safeTx.refundReceiver, + buildSignatureBytes(sigs) + ); + + return tx; +}; + +describe("DecentHats", () => { + let dao: SignerWithAddress; + + let keyValuePairs: KeyValuePairs; + let gnosisSafe: GnosisSafeL2; + + let gnosisSafeAddress: string; + let decentHatsAddress: string; + + const saltNum = BigInt( + `0x${Buffer.from(ethers.randomBytes(32)).toString("hex")}` + ); + + beforeEach(async () => { + const signers = await hre.ethers.getSigners(); + const [deployer] = signers; + [, dao] = signers; + + const hats = await new MockHats__factory(deployer).deploy(); + keyValuePairs = await new KeyValuePairs__factory(deployer).deploy(); + const decentHats = await new DecentHats__factory(deployer).deploy( + await hats.getAddress(), + await keyValuePairs.getAddress() + ); + decentHatsAddress = await decentHats.getAddress(); + + const gnosisSafeProxyFactory = getGnosisSafeProxyFactory(); + const gnosisSafeL2Singleton = getGnosisSafeL2Singleton(); + const gnosisSafeL2SingletonAddress = + await gnosisSafeL2Singleton.getAddress(); + + const createGnosisSetupCalldata = + GnosisSafeL2__factory.createInterface().encodeFunctionData("setup", [ + [dao.address], + 1, + ethers.ZeroAddress, + ethers.ZeroHash, + ethers.ZeroAddress, + ethers.ZeroAddress, + 0, + ethers.ZeroAddress, + ]); + + const predictedGnosisSafeAddress = await predictGnosisSafeAddress( + createGnosisSetupCalldata, + saltNum, + gnosisSafeL2SingletonAddress, + gnosisSafeProxyFactory + ); + gnosisSafeAddress = predictedGnosisSafeAddress; + + await gnosisSafeProxyFactory.createProxyWithNonce( + gnosisSafeL2SingletonAddress, + createGnosisSetupCalldata, + saltNum + ); + + gnosisSafe = GnosisSafeL2__factory.connect( + predictedGnosisSafeAddress, + deployer + ); + }); + + describe("DecentHats as a Module", () => { + let enableModuleTx: ethers.ContractTransactionResponse; + + beforeEach(async () => { + enableModuleTx = await executeSafeTransaction({ + safe: gnosisSafe, + to: gnosisSafeAddress, + transactionData: + GnosisSafeL2__factory.createInterface().encodeFunctionData( + "enableModule", + [decentHatsAddress] + ), + signers: [dao], + }); + }); + + it("Emits an ExecutionSuccess event", async () => { + await expect(enableModuleTx).to.emit(gnosisSafe, "ExecutionSuccess"); + }); + + it("Emits an EnabledModule event", async () => { + await expect(enableModuleTx) + .to.emit(gnosisSafe, "EnabledModule") + .withArgs(decentHatsAddress); + }); + + describe("Creating a new Top Hat and Tree", () => { + let createAndDeclareTreeTx: ethers.ContractTransactionResponse; + + beforeEach(async () => { + createAndDeclareTreeTx = await executeSafeTransaction({ + safe: gnosisSafe, + to: decentHatsAddress, + transactionData: + DecentHats__factory.createInterface().encodeFunctionData( + "createAndDeclareTree", + [ + "", + "", + { + eligibility: ethers.ZeroAddress, + maxSupply: 1, + toggle: ethers.ZeroAddress, + details: "", + imageURI: "", + isMutable: false, + wearer: ethers.ZeroAddress, + }, + [ + { + eligibility: ethers.ZeroAddress, + maxSupply: 1, + toggle: ethers.ZeroAddress, + details: "", + imageURI: "", + isMutable: false, + wearer: ethers.ZeroAddress, + }, + { + eligibility: ethers.ZeroAddress, + maxSupply: 1, + toggle: ethers.ZeroAddress, + details: "", + imageURI: "", + isMutable: false, + wearer: ethers.ZeroAddress, + }, + ], + ] + ), + signers: [dao], + }); + }); + + it("Emits an ExecutionSuccess event", async () => { + await expect(createAndDeclareTreeTx).to.emit( + gnosisSafe, + "ExecutionSuccess" + ); + }); + + it("Emits an ExecutionFromModuleSuccess event", async () => { + await expect(createAndDeclareTreeTx) + .to.emit(gnosisSafe, "ExecutionFromModuleSuccess") + .withArgs(decentHatsAddress); + }); + + it("Emits some hatsTreeId ValueUpdated events", async () => { + await expect(createAndDeclareTreeTx) + .to.emit(keyValuePairs, "ValueUpdated") + .withArgs(gnosisSafeAddress, "hatsTreeId", "0"); + }); + + describe("Multiple calls", () => { + let createAndDeclareTreeTx2: ethers.ContractTransactionResponse; + + beforeEach(async () => { + createAndDeclareTreeTx2 = await executeSafeTransaction({ + safe: gnosisSafe, + to: decentHatsAddress, + transactionData: + DecentHats__factory.createInterface().encodeFunctionData( + "createAndDeclareTree", + [ + "", + "", + { + eligibility: ethers.ZeroAddress, + maxSupply: 1, + toggle: ethers.ZeroAddress, + details: "", + imageURI: "", + isMutable: false, + wearer: ethers.ZeroAddress, + }, + [], + ] + ), + signers: [dao], + }); + }); + + it("Emits an ExecutionSuccess event", async () => { + await expect(createAndDeclareTreeTx2).to.emit( + gnosisSafe, + "ExecutionSuccess" + ); + }); + + it("Emits an ExecutionFromModuleSuccess event", async () => { + await expect(createAndDeclareTreeTx2) + .to.emit(gnosisSafe, "ExecutionFromModuleSuccess") + .withArgs(decentHatsAddress); + }); + + it("Creates Top Hats with sequential IDs", async () => { + await expect(createAndDeclareTreeTx2) + .to.emit(keyValuePairs, "ValueUpdated") + .withArgs(gnosisSafeAddress, "hatsTreeId", "4"); + }); + }); + }); + }); +});