From 3d4f2801c46afebc14c83c0840e70292ff6b2d0e Mon Sep 17 00:00:00 2001 From: Adam Gall Date: Tue, 25 Jun 2024 15:01:48 -0400 Subject: [PATCH 1/4] Create DecentHats contract, and some helpers --- contracts/DecentHats.sol | 36 +++++++++++++++++++++++++++++ contracts/interfaces/hats/IHats.sol | 23 ++++++++++++++++++ contracts/mock/MockHats.sol | 13 +++++++++++ 3 files changed, 72 insertions(+) create mode 100644 contracts/DecentHats.sol create mode 100644 contracts/interfaces/hats/IHats.sol create mode 100644 contracts/mock/MockHats.sol diff --git a/contracts/DecentHats.sol b/contracts/DecentHats.sol new file mode 100644 index 00000000..9d32b837 --- /dev/null +++ b/contracts/DecentHats.sol @@ -0,0 +1,36 @@ +//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 { + IHats public hats; + address public keyValuePairs; + + constructor(IHats _hats, address _keyValuePairs) { + hats = _hats; + keyValuePairs = _keyValuePairs; + } + + function createAndDeclareTree(string memory _details, string memory _imageURI) public returns (bool success) { + uint256 topHatId = hats.mintTopHat(msg.sender, _details, _imageURI); + + string[] memory keys = new string[](1); + keys[0] = "hatsTreeId"; + + string[] memory values = new string[](1); + values[0] = Strings.toString(topHatId); + + success = IAvatar(msg.sender).execTransactionFromModule( + keyValuePairs, + 0, + abi.encodeWithSignature("updateValues(string[],string[])", keys, values), + Enum.Operation.Call + ); + + return success; + } +} diff --git a/contracts/interfaces/hats/IHats.sol b/contracts/interfaces/hats/IHats.sol new file mode 100644 index 00000000..36e3b609 --- /dev/null +++ b/contracts/interfaces/hats/IHats.sol @@ -0,0 +1,23 @@ +// 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); +} \ No newline at end of file diff --git a/contracts/mock/MockHats.sol b/contracts/mock/MockHats.sol new file mode 100644 index 00000000..9562663f --- /dev/null +++ b/contracts/mock/MockHats.sol @@ -0,0 +1,13 @@ +// 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++; + } +} From 5dedf7f171b65e2bb6fb1679a80016ea689d6615 Mon Sep 17 00:00:00 2001 From: Adam Gall Date: Tue, 25 Jun 2024 15:09:43 -0400 Subject: [PATCH 2/4] Create tests for CreateTopHat --- test/DecentHats.test.ts | 225 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 test/DecentHats.test.ts diff --git a/test/DecentHats.test.ts b/test/DecentHats.test.ts new file mode 100644 index 00000000..5fac4f93 --- /dev/null +++ b/test/DecentHats.test.ts @@ -0,0 +1,225 @@ +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 dao1: SignerWithAddress; + let dao2: 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; + [, dao1, dao2] = 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", [ + [dao1.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: [dao1], + }); + }); + + 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", + ["", ""] + ), + signers: [dao1], + }); + }); + + 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 a hatsTreeId ValueUpdated event", 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", + ["", ""] + ), + signers: [dao1], + }); + }); + + 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", "1"); + }); + }); + }); + }); +}); From a1ccd7115acd47cd1c416144b1387d0a44313f94 Mon Sep 17 00:00:00 2001 From: Adam Gall Date: Tue, 25 Jun 2024 16:06:39 -0400 Subject: [PATCH 3/4] Add support for creating and minting admin and child hats --- contracts/DecentHats.sol | 74 +++++++++++++++++++++++++---- contracts/interfaces/hats/IHats.sol | 25 ++++++++-- contracts/mock/MockHats.sol | 25 +++++++++- 3 files changed, 109 insertions(+), 15 deletions(-) diff --git a/contracts/DecentHats.sol b/contracts/DecentHats.sol index 9d32b837..2a0c686c 100644 --- a/contracts/DecentHats.sol +++ b/contracts/DecentHats.sol @@ -1,12 +1,22 @@ //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"; +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; @@ -15,8 +25,17 @@ contract DecentHats { keyValuePairs = _keyValuePairs; } - function createAndDeclareTree(string memory _details, string memory _imageURI) public returns (bool success) { - uint256 topHatId = hats.mintTopHat(msg.sender, _details, _imageURI); + 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"; @@ -24,13 +43,50 @@ contract DecentHats { string[] memory values = new string[](1); values[0] = Strings.toString(topHatId); - success = IAvatar(msg.sender).execTransactionFromModule( + IAvatar(msg.sender).execTransactionFromModule( keyValuePairs, 0, - abi.encodeWithSignature("updateValues(string[],string[])", keys, values), + abi.encodeWithSignature( + "updateValues(string[],string[])", + keys, + values + ), Enum.Operation.Call ); - return success; + 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 index 36e3b609..77c621e1 100644 --- a/contracts/interfaces/hats/IHats.sol +++ b/contracts/interfaces/hats/IHats.sol @@ -17,7 +17,24 @@ pragma solidity >=0.8.13; interface IHats { - function mintTopHat(address _target, string memory _details, string memory _imageURI) - external - returns (uint256 topHatId); -} \ No newline at end of file + 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 index 9562663f..783ca697 100644 --- a/contracts/mock/MockHats.sol +++ b/contracts/mock/MockHats.sol @@ -1,13 +1,34 @@ // SPDX-License-Identifier: MIT pragma solidity =0.8.19; -import { IHats } from "../interfaces/hats/IHats.sol"; +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) { + 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; + } } From 62ec3bf86f941b6e730a80729ffc1c064c35839c Mon Sep 17 00:00:00 2001 From: Adam Gall Date: Tue, 25 Jun 2024 16:18:36 -0400 Subject: [PATCH 4/4] Fix tests --- test/DecentHats.test.ts | 66 ++++++++++++++++++++++++++++++++++------- 1 file changed, 55 insertions(+), 11 deletions(-) diff --git a/test/DecentHats.test.ts b/test/DecentHats.test.ts index 5fac4f93..a254bf83 100644 --- a/test/DecentHats.test.ts +++ b/test/DecentHats.test.ts @@ -61,8 +61,7 @@ const executeSafeTransaction = async ({ }; describe("DecentHats", () => { - let dao1: SignerWithAddress; - let dao2: SignerWithAddress; + let dao: SignerWithAddress; let keyValuePairs: KeyValuePairs; let gnosisSafe: GnosisSafeL2; @@ -77,7 +76,7 @@ describe("DecentHats", () => { beforeEach(async () => { const signers = await hre.ethers.getSigners(); const [deployer] = signers; - [, dao1, dao2] = signers; + [, dao] = signers; const hats = await new MockHats__factory(deployer).deploy(); keyValuePairs = await new KeyValuePairs__factory(deployer).deploy(); @@ -94,7 +93,7 @@ describe("DecentHats", () => { const createGnosisSetupCalldata = GnosisSafeL2__factory.createInterface().encodeFunctionData("setup", [ - [dao1.address], + [dao.address], 1, ethers.ZeroAddress, ethers.ZeroHash, @@ -136,7 +135,7 @@ describe("DecentHats", () => { "enableModule", [decentHatsAddress] ), - signers: [dao1], + signers: [dao], }); }); @@ -160,9 +159,41 @@ describe("DecentHats", () => { 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: [dao1], + signers: [dao], }); }); @@ -179,7 +210,7 @@ describe("DecentHats", () => { .withArgs(decentHatsAddress); }); - it("Emits a hatsTreeId ValueUpdated event", async () => { + it("Emits some hatsTreeId ValueUpdated events", async () => { await expect(createAndDeclareTreeTx) .to.emit(keyValuePairs, "ValueUpdated") .withArgs(gnosisSafeAddress, "hatsTreeId", "0"); @@ -195,9 +226,22 @@ describe("DecentHats", () => { transactionData: DecentHats__factory.createInterface().encodeFunctionData( "createAndDeclareTree", - ["", ""] + [ + "", + "", + { + eligibility: ethers.ZeroAddress, + maxSupply: 1, + toggle: ethers.ZeroAddress, + details: "", + imageURI: "", + isMutable: false, + wearer: ethers.ZeroAddress, + }, + [], + ] ), - signers: [dao1], + signers: [dao], }); }); @@ -217,7 +261,7 @@ describe("DecentHats", () => { it("Creates Top Hats with sequential IDs", async () => { await expect(createAndDeclareTreeTx2) .to.emit(keyValuePairs, "ValueUpdated") - .withArgs(gnosisSafeAddress, "hatsTreeId", "1"); + .withArgs(gnosisSafeAddress, "hatsTreeId", "4"); }); }); });