From 9a927f0e1eed7494bdd31de62a5eb7b096e6e7a3 Mon Sep 17 00:00:00 2001 From: spengrah Date: Wed, 29 Mar 2023 21:57:28 -0500 Subject: [PATCH 1/7] prevent unworn tophats from being unlinked --- src/Hats.sol | 20 ++++- src/Interfaces/HatsErrors.sol | 4 + src/Interfaces/IHats.sol | 2 +- test/Hats.t.sol | 139 +++++++++++++++++++++++++++++++++- 4 files changed, 156 insertions(+), 9 deletions(-) diff --git a/src/Hats.sol b/src/Hats.sol index f7d3223..71343ad 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 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 wearer; + // since we cannot search the entire address space for a wearer, we require the caller to provide the wearer + if (!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 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..c6e656a 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, diff --git a/test/Hats.t.sol b/test/Hats.t.sol index 4716450..74fd2c6 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,143 @@ 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); + } } contract MalformedInputsTests is TestSetup2 { From 121032a9710acf5303a303821eb53d4182caf9eb Mon Sep 17 00:00:00 2001 From: spengrah Date: Wed, 29 Mar 2023 21:59:00 -0500 Subject: [PATCH 2/7] add hat property getters --- src/Hats.sol | 28 ++++++++++++++++++++++++++++ src/Interfaces/IHats.sol | 6 ++++++ 2 files changed, 34 insertions(+) diff --git a/src/Hats.sol b/src/Hats.sol index 71343ad..1be7658 100644 --- a/src/Hats.sol +++ b/src/Hats.sol @@ -1032,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 @@ -1130,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 max supply for this hat + 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/IHats.sol b/src/Interfaces/IHats.sol index c6e656a..bcd1f15 100644 --- a/src/Interfaces/IHats.sol +++ b/src/Interfaces/IHats.sol @@ -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); From 1775232817aacb4e8a5c08256184f65be36d228e Mon Sep 17 00:00:00 2001 From: spengrah Date: Thu, 30 Mar 2023 11:08:17 -0500 Subject: [PATCH 3/7] update unlinking docs in readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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). From 0256f36ddb42c2eb4da263c4199c1a05dd154289 Mon Sep 17 00:00:00 2001 From: spengrah Date: Thu, 30 Mar 2023 20:54:23 -0500 Subject: [PATCH 4/7] prevent unlinking of tophats worn by address(0) --- src/Hats.sol | 6 +++--- test/Hats.t.sol | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/Hats.sol b/src/Hats.sol index 1be7658..37742e3 100644 --- a/src/Hats.sol +++ b/src/Hats.sol @@ -752,7 +752,7 @@ contract Hats is IHats, ERC1155, HatsIdUtilities { /** * @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 wearer, which can occur if... + * @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) @@ -763,9 +763,9 @@ contract Hats is IHats, ERC1155, HatsIdUtilities { uint256 fullTopHatId = uint256(_topHatDomain) << 224; // (256 - TOPHAT_ADDRESS_SPACE); _checkAdmin(fullTopHatId); - // prevent unlinking if the topHat has no wearer; + // prevent unlinking if the topHat has no non-zero earer // since we cannot search the entire address space for a wearer, we require the caller to provide the wearer - if (!isWearerOfHat(_wearer, fullTopHatId)) revert HatsErrors.InvalidUnlink(); + if (_wearer == address(0) || !isWearerOfHat(_wearer, fullTopHatId)) revert HatsErrors.InvalidUnlink(); // execute the unlink delete linkedTreeAdmins[_topHatDomain]; diff --git a/test/Hats.t.sol b/test/Hats.t.sol index 74fd2c6..feb0c89 100644 --- a/test/Hats.t.sol +++ b/test/Hats.t.sol @@ -2178,6 +2178,46 @@ contract LinkHatsTests is TestSetup2 { 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 { From e482eb2527e9d5c955dd20b25a6504b2c503c04b Mon Sep 17 00:00:00 2001 From: spengrah Date: Fri, 31 Mar 2023 08:56:08 -0500 Subject: [PATCH 5/7] natspec tweak and comment typo fix --- src/Hats.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Hats.sol b/src/Hats.sol index 37742e3..3adae8c 100644 --- a/src/Hats.sol +++ b/src/Hats.sol @@ -763,7 +763,7 @@ contract Hats is IHats, ERC1155, HatsIdUtilities { uint256 fullTopHatId = uint256(_topHatDomain) << 224; // (256 - TOPHAT_ADDRESS_SPACE); _checkAdmin(fullTopHatId); - // prevent unlinking if the topHat has no non-zero earer + // 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(); @@ -1153,7 +1153,7 @@ contract Hats is IHats, ERC1155, HatsIdUtilities { /// @notice Gets the max supply for a hat /// @param _hatId The hat whose max supply we're looking for - /// @return maxSupply The max supply for this hat + /// @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; } From bf45aecc6a92cdce625ab13bbe567d4388d2f735 Mon Sep 17 00:00:00 2001 From: spengrah Date: Fri, 31 Mar 2023 10:02:53 -0500 Subject: [PATCH 6/7] deploy v1.0 --- script/Hats.s.sol | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/script/Hats.s.sol b/script/Hats.s.sol index dda0251..c7e0ed1 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 { + 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 { From c899496f547a3d70bb46d578021e37b57162437f Mon Sep 17 00:00:00 2001 From: spengrah Date: Fri, 31 Mar 2023 10:04:25 -0500 Subject: [PATCH 7/7] fix formatting --- script/Hats.s.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/Hats.s.sol b/script/Hats.s.sol index c7e0ed1..e7c2500 100644 --- a/script/Hats.s.sol +++ b/script/Hats.s.sol @@ -11,7 +11,7 @@ contract DeployHats is Script { bytes32 internal constant SALT = bytes32(abi.encode(0x4a75)); // ~ H(4) A(a) T(7) S(5) - function run() external { + function run() external { // set up deployer uint256 privKey = vm.envUint("PRIVATE_KEY"); address deployer = vm.rememberKey(privKey);