diff --git a/README.md b/README.md index 2a0c7e1..f2cf66f 100644 --- a/README.md +++ b/README.md @@ -380,7 +380,7 @@ In these cases, Hat trees can be grafted onto other trees. This is done via a re 2. The hat to which it is linked becomes its new admin; it is no longer its own admin 3. On linking, the linked topHat can be assigned eligibility and/or toggle modules like any other hat -Linked Hat trees can also be unlinked by the tree root from its linked admin, via `Hats.unlinkTopHatFromTree`. This causes the tree root to regain its status as a top hat and to once again become its own admin. Any eligibility or toggle modules added on linking are cleared. +Linked Hat trees can also be unlinked by the tree root from its linked admin, via `Hats.unlinkTopHatFromTree`. This causes the tree root to regain its status as a top hat and to once again become its own admin. Any eligibility or toggle modules added on linking are cleared. Note that unlinking is only allowed if the tree root is active and has an eligible wearer. ⚠️ **CAUTION**: Be careful when nesting multiple Hat trees. If the nested linkages become too long, the higher level admins may lose control of the lowest level Hats because admin actions at that distance may cost-prohibitive or even exceed the gas limit. Best practice is to not attach external authorities (e.g. via token gating) to Hats in trees that are more than ~10 nested trees deep (varies by network). diff --git a/script/Hats.s.sol b/script/Hats.s.sol index dda0251..e7c2500 100644 --- a/script/Hats.s.sol +++ b/script/Hats.s.sol @@ -7,42 +7,35 @@ import { Hats } from "../src/Hats.sol"; contract DeployHats is Script { string public constant baseImageURI = "ipfs://bafybeigcimbqwfajsnhoq7fqnbdllz7kye7cpdy3adj2sob3wku2llu5bi"; - string public constant name = "Hats Protocol v1"; // increment this each deployment + string public constant name = "Hats Protocol v1.0"; // increment this each deployment bytes32 internal constant SALT = bytes32(abi.encode(0x4a75)); // ~ H(4) A(a) T(7) S(5) function run() external { + // set up deployer uint256 privKey = vm.envUint("PRIVATE_KEY"); - address deployer = vm.rememberKey(privKey); + // log deployer data console2.log("Deployer: ", deployer); console2.log("Deployer Nonce: ", vm.getNonce(deployer)); vm.startBroadcast(deployer); - // deploy Hats + // deploy Hats to a deterministic address via CREATE2 Hats hats = new Hats{ salt: SALT }(name, baseImageURI); - // mint Hats Protocol Governance topHat - // Note: This topHat is not connected to any protocol authorities. The protocol is fully permissionless and not upgradeable. - hats.mintTopHat( - 0x2D785497c6C8ce3f4cCff4937D321C37e80705E8, // hatsprotocol.eth - "Hats Protocol Governance", - baseImageURI - ); - vm.stopBroadcast(); - + // log deployment data console2.log("Salt: ", vm.toString(SALT)); console2.log("Hats contract: ", address(hats)); } - // forge script script/Hats.s.sol:DeployHats -f ethereum - // forge script script/Hats.s.sol:DeployHats -f ethereum --broadcast --verify + // forge script script/Hats.s.sol:DeployHats -f mainnet + // forge script script/Hats.s.sol:DeployHats -f mainnet --broadcast --verify // forge script script/Hats.s.sol:DeployHats --rpc-url http://localhost:8545 --broadcast - // forge verify-contract --chain-id 1 --num-of-optimizations 10000 --watch --constructor-args $(cast abi-encode "constructor(string,string)" "Hats Protocol v1" "ipfs://bafybeigcimbqwfajsnhoq7fqnbdllz7kye7cpdy3adj2sob3wku2llu5bi") --compiler-version v0.8.17 0x850f3384829D7bab6224D141AFeD9A559d745E3D src/Hats.sol:Hats --etherscan-api-key $ETHERSCAN_KEY + // forge verify-contract --chain-id 1 --num-of-optimizations 10000 --watch --constructor-args $(cast abi-encode "constructor(string,string)" "Hats Protocol v1.0" "ipfs://bafybeigcimbqwfajsnhoq7fqnbdllz7kye7cpdy3adj2sob3wku2llu5bi") --compiler-version v0.8.17 0x9D2dfd6066d5935267291718E8AA16C8Ab729E9d src/Hats.sol:Hats --etherscan-api-key $ETHERSCAN_KEY } contract DeployHatsAndMintTopHat is Script { diff --git a/src/Hats.sol b/src/Hats.sol index f7d3223..3adae8c 100644 --- a/src/Hats.sol +++ b/src/Hats.sol @@ -750,14 +750,26 @@ contract Hats is IHats, ERC1155, HatsIdUtilities { _linkTopHatToTree(_topHatDomain, _newAdminHat, _eligibility, _toggle, _details, _imageURI); } - /// @notice Unlink a Tree from the parent tree - /// @dev This can only be called by an admin of the tree root - /// @param _topHatDomain The 32 bit domain of the topHat to unlink - function unlinkTopHatFromTree(uint32 _topHatDomain) external { + /** + * @notice Unlink a Tree from the parent tree + * @dev This can only be called by an admin of the tree root. Fails if the topHat to unlink has no non-zero wearer, which can occur if... + * - It's wearer is in badStanding + * - It has been revoked from its wearer (and possibly burned)˘ + * - It is not active (ie toggled off) + * @param _topHatDomain The 32 bit domain of the topHat to unlink + * @param _wearer The current wearer of the topHat to unlink + */ + function unlinkTopHatFromTree(uint32 _topHatDomain, address _wearer) external { uint256 fullTopHatId = uint256(_topHatDomain) << 224; // (256 - TOPHAT_ADDRESS_SPACE); _checkAdmin(fullTopHatId); + // prevent unlinking if the topHat has no non-zero wearer + // since we cannot search the entire address space for a wearer, we require the caller to provide the wearer + if (_wearer == address(0) || !isWearerOfHat(_wearer, fullTopHatId)) revert HatsErrors.InvalidUnlink(); + + // execute the unlink delete linkedTreeAdmins[_topHatDomain]; + // remove the request — ensures all linkages are initialized by unique requests delete linkedTreeRequests[_topHatDomain]; // reset eligibility and storage to defaults for unlinked top hats @@ -1020,6 +1032,13 @@ contract Hats is IHats, ERC1155, HatsIdUtilities { } } + /// @notice Checks the active status of a hat + /// @param _hatId The id of the hat + /// @return active Whether the hat is active + function isActive(uint256 _hatId) external view returns (bool active) { + active = _isActive(_hats[_hatId], _hatId); + } + /// @notice Internal function to retrieve a hat's status from storage /// @dev reads the 0th bit of the hat's config /// @param _hat The hat object @@ -1118,6 +1137,27 @@ contract Hats is IHats, ERC1155, HatsIdUtilities { supply = _hats[_hatId].supply; } + /// @notice Gets the eligibility module for a hat + /// @param _hatId The hat whose eligibility module we're looking for + /// @return eligibility The eligibility module for this hat + function getHatEligibilityModule(uint256 _hatId) external view returns (address eligibility) { + eligibility = _hats[_hatId].eligibility; + } + + /// @notice Gets the toggle module for a hat + /// @param _hatId The hat whose toggle module we're looking for + /// @return toggle The toggle module for this hat + function getHatToggleModule(uint256 _hatId) external view returns (address toggle) { + toggle = _hats[_hatId].toggle; + } + + /// @notice Gets the max supply for a hat + /// @param _hatId The hat whose max supply we're looking for + /// @return maxSupply The maximum possible quantity of this hat that could be minted + function getHatMaxSupply(uint256 _hatId) external view returns (uint32 maxSupply) { + maxSupply = _hats[_hatId].maxSupply; + } + /// @notice Gets the imageURI for a given hat /// @dev If this hat does not have an imageURI set, recursively get the imageURI from /// its admin diff --git a/src/Interfaces/HatsErrors.sol b/src/Interfaces/HatsErrors.sol index 46db4be..b592529 100644 --- a/src/Interfaces/HatsErrors.sol +++ b/src/Interfaces/HatsErrors.sol @@ -72,6 +72,10 @@ interface HatsErrors { /// @notice Emitted when attempting to link a tophat without a request error LinkageNotRequested(); + /// @notice Emitted when attempting to unlink a tophat that does not have a wearer + /// @dev This ensures that unlinking never results in a bricked tophat + error InvalidUnlink(); + /// @notice Emmited when attempting to change a hat's eligibility or toggle module to the zero address error ZeroAddress(); diff --git a/src/Interfaces/IHats.sol b/src/Interfaces/IHats.sol index 1d341a7..bcd1f15 100644 --- a/src/Interfaces/IHats.sol +++ b/src/Interfaces/IHats.sol @@ -92,7 +92,7 @@ interface IHats is IHatsIdUtilities, HatsErrors, HatsEvents { string calldata _imageURI ) external; - function unlinkTopHatFromTree(uint32 _topHatId) external; + function unlinkTopHatFromTree(uint32 _topHatId, address _wearer) external; function relinkTopHatWithinTree( uint32 _topHatDomain, @@ -130,6 +130,12 @@ interface IHats is IHatsIdUtilities, HatsErrors, HatsEvents { function isEligible(address _wearer, uint256 _hatId) external view returns (bool eligible); + function getHatEligibilityModule(uint256 _hatId) external view returns (address eligibility); + + function getHatToggleModule(uint256 _hatId) external view returns (address toggle); + + function getHatMaxSupply(uint256 _hatId) external view returns (uint32 maxSupply); + function hatSupply(uint256 _hatId) external view returns (uint32 supply); function getImageURIForHat(uint256 _hatId) external view returns (string memory _uri); diff --git a/test/Hats.t.sol b/test/Hats.t.sol index 4716450..feb0c89 100644 --- a/test/Hats.t.sol +++ b/test/Hats.t.sol @@ -1977,12 +1977,12 @@ contract LinkHatsTests is TestSetup2 { assertEq(hats.linkedTreeRequests(secondTopHatDomain), 0); vm.expectRevert(abi.encodeWithSelector(HatsErrors.NotAdmin.selector, address(this), secondTopHatId)); - hats.unlinkTopHatFromTree(secondTopHatDomain); + hats.unlinkTopHatFromTree(secondTopHatDomain, thirdWearer); vm.prank(secondWearer); vm.expectEmit(true, true, true, true); emit TopHatLinked(secondTopHatDomain, 0); - hats.unlinkTopHatFromTree(secondTopHatDomain); + hats.unlinkTopHatFromTree(secondTopHatDomain, thirdWearer); assertEq(hats.isTopHat(secondTopHatId), true); } @@ -2002,7 +2002,7 @@ contract LinkHatsTests is TestSetup2 { hats.requestLinkTopHatToTree(secondTopHatDomain, treeB); // tree A unlinks the tophat - hats.unlinkTopHatFromTree(secondTopHatDomain); + hats.unlinkTopHatFromTree(secondTopHatDomain, thirdWearer); // admin B should not be able to rug the tree by approving the link without the tree's permission vm.expectRevert(HatsErrors.LinkageNotRequested.selector); @@ -2041,12 +2041,183 @@ contract LinkHatsTests is TestSetup2 { assertFalse(status); // modules values reset on unlink + // first need to toggle back on + vm.mockCall(address(101), abi.encodeWithSignature("getHatStatus(uint256)", secondTopHatId), abi.encode(true)); + (,,,,,,,, status) = hats.viewHat(secondTopHatId); + assertTrue(status); vm.prank(topHatWearer); - hats.unlinkTopHatFromTree(secondTopHatDomain); + hats.unlinkTopHatFromTree(secondTopHatDomain, thirdWearer); (,,, eligibility, toggle,,,,) = hats.viewHat(secondTopHatId); assertEq(eligibility, address(0)); assertEq(toggle, address(0)); } + + function testAdminCanBurnAndRemintLinkedTopHat() public { + // request + vm.prank(thirdWearer); + hats.requestLinkTopHatToTree(secondTopHatDomain, secondHatId); + // approve + vm.prank(topHatWearer); + hats.approveLinkTopHatToTree(secondTopHatDomain, secondHatId, _eligibility, address(0), "", ""); + + // mock wearer ineligible + vm.mockCall( + _eligibility, + abi.encodeWithSignature("getWearerStatus(address,uint256)", thirdWearer, secondTopHatId), + abi.encode(false, true) + ); + assertFalse(hats.isEligible(thirdWearer, secondTopHatId)); + // burn the hat + hats.checkHatWearerStatus(secondTopHatId, thirdWearer); + + // remint + vm.expectEmit(true, true, true, true); + emit TransferSingle(topHatWearer, address(0), address(99), secondTopHatId, 1); + vm.prank(topHatWearer); + hats.mintHat(secondTopHatId, address(99)); + } + + function testAdminCannotTransferLinkedTopHat() public { + // request + vm.prank(thirdWearer); + hats.requestLinkTopHatToTree(secondTopHatDomain, secondHatId); + // approve + vm.prank(topHatWearer); + hats.approveLinkTopHatToTree(secondTopHatDomain, secondHatId, address(0), address(0), "", ""); + + // attempt transfer + vm.prank(topHatWearer); + vm.expectRevert(HatsErrors.Immutable.selector); + hats.transferHat(secondTopHatId, thirdWearer, address(99)); + } + + function testAdminCannotUnlinkInactivefTopHat() public { + // request + vm.prank(thirdWearer); + hats.requestLinkTopHatToTree(secondTopHatDomain, secondHatId); + // approve + vm.prank(topHatWearer); + hats.approveLinkTopHatToTree(secondTopHatDomain, secondHatId, address(0), address(101), "", ""); + + // toggle off linked tophat + vm.mockCall(address(101), abi.encodeWithSignature("getHatStatus(uint256)", secondTopHatId), abi.encode(false)); + hats.checkHatStatus(secondTopHatId); + (,,,,,,,, bool status) = hats.viewHat(secondTopHatId); + assertFalse(status); + + // attempt unlink + vm.prank(topHatWearer); + vm.expectRevert(HatsErrors.InvalidUnlink.selector); + hats.unlinkTopHatFromTree(secondTopHatDomain, thirdWearer); + } + + function testAdminCannotUnlinkBurnedTopHat() public { + // request + vm.prank(thirdWearer); + hats.requestLinkTopHatToTree(secondTopHatDomain, secondHatId); + // approve + vm.prank(topHatWearer); + hats.approveLinkTopHatToTree(secondTopHatDomain, secondHatId, _eligibility, address(0), "", ""); + + // mock wearer ineligible + vm.mockCall( + _eligibility, + abi.encodeWithSignature("getWearerStatus(address,uint256)", thirdWearer, secondTopHatId), + abi.encode(false, true) + ); + assertFalse(hats.isEligible(thirdWearer, secondTopHatId)); + // burn the hat + hats.checkHatWearerStatus(secondTopHatId, thirdWearer); + + // attempt unlink + vm.prank(topHatWearer); + vm.expectRevert(HatsErrors.InvalidUnlink.selector); + hats.unlinkTopHatFromTree(secondTopHatDomain, thirdWearer); + } + + function testAdminCannotUnlinkRevokedTopHat() public { + // request + vm.prank(thirdWearer); + hats.requestLinkTopHatToTree(secondTopHatDomain, secondHatId); + // approve + vm.prank(topHatWearer); + hats.approveLinkTopHatToTree(secondTopHatDomain, secondHatId, _eligibility, address(0), "", ""); + + // mock wearer ineligible + vm.mockCall( + _eligibility, + abi.encodeWithSignature("getWearerStatus(address,uint256)", thirdWearer, secondTopHatId), + abi.encode(false, true) + ); + assertFalse(hats.isEligible(thirdWearer, secondTopHatId)); + + // attempt unlink + vm.prank(topHatWearer); + vm.expectRevert(HatsErrors.InvalidUnlink.selector); + hats.unlinkTopHatFromTree(secondTopHatDomain, thirdWearer); + } + + function testAdminCannotUnlinkTopHatWhenWearerIsInBadStanding() public { + // request + vm.prank(thirdWearer); + hats.requestLinkTopHatToTree(secondTopHatDomain, secondHatId); + // approve + vm.prank(topHatWearer); + hats.approveLinkTopHatToTree(secondTopHatDomain, secondHatId, _eligibility, address(0), "", ""); + + // mock wearer ineligible + vm.mockCall( + _eligibility, + abi.encodeWithSignature("getWearerStatus(address,uint256)", thirdWearer, secondTopHatId), + abi.encode(true, false) + ); + assertFalse(hats.isEligible(thirdWearer, secondTopHatId)); + + // attempt unlink + vm.prank(topHatWearer); + vm.expectRevert(HatsErrors.InvalidUnlink.selector); + hats.unlinkTopHatFromTree(secondTopHatDomain, thirdWearer); + } + + function testAdminCannotUnlinkTopHatWornByZeroAddress() public { + // request + vm.prank(thirdWearer); + hats.requestLinkTopHatToTree(secondTopHatDomain, secondHatId); + // approve + vm.prank(topHatWearer); + hats.approveLinkTopHatToTree(secondTopHatDomain, secondHatId, _eligibility, address(0), "", ""); + + // revoke top hat + vm.prank(_eligibility); + hats.setHatWearerStatus(secondTopHatId, thirdWearer, false, true); + + // remint it to address(0) + vm.prank(topHatWearer); + hats.mintHat(secondTopHatId, address(0)); + + // attempt unlink + vm.prank(topHatWearer); + vm.expectRevert(HatsErrors.InvalidUnlink.selector); + hats.unlinkTopHatFromTree(secondTopHatDomain, address(0)); + } + + function testAdminCannotUnlinkRenouncedTopHat() public { + // request + vm.prank(thirdWearer); + hats.requestLinkTopHatToTree(secondTopHatDomain, secondHatId); + // approve + vm.prank(topHatWearer); + hats.approveLinkTopHatToTree(secondTopHatDomain, secondHatId, _eligibility, address(0), "", ""); + + // the tophat is renounced + vm.prank(thirdWearer); + hats.renounceHat(secondTopHatId); + + // attempt unlink + vm.prank(topHatWearer); + vm.expectRevert(HatsErrors.InvalidUnlink.selector); + hats.unlinkTopHatFromTree(secondTopHatDomain, address(0)); + } } contract MalformedInputsTests is TestSetup2 {