From 401c71fa19b086385f0c555cc7387408e5d74a80 Mon Sep 17 00:00:00 2001 From: iain nash Date: Wed, 13 Mar 2024 22:54:19 -0400 Subject: [PATCH] update tests and pr --- src/ERC721Drop.sol | 326 ++++++-------------- src/interfaces/ITransferHookExtension.sol | 14 +- src/storage/ERC721TransferHookStorageV1.sol | 9 +- test/ERC721Drop.t.sol | 41 +++ test/utils/MockTransferHook.sol | 51 +++ 5 files changed, 202 insertions(+), 239 deletions(-) create mode 100644 test/utils/MockTransferHook.sol diff --git a/src/ERC721Drop.sol b/src/ERC721Drop.sol index a92b438..6a9d1f9 100644 --- a/src/ERC721Drop.sol +++ b/src/ERC721Drop.sol @@ -42,7 +42,6 @@ import {ERC721DropStorageV1} from "./storage/ERC721DropStorageV1.sol"; import {ERC721DropStorageV2} from "./storage/ERC721DropStorageV2.sol"; import {ERC721TransferHookStorageV1, TransferHookStorage} from "./storage/ERC721TransferHookStorageV1.sol"; - /** * @notice ZORA NFT Base contract for Drops and Editions * @@ -111,10 +110,7 @@ contract ERC721Drop is /// @notice Only a given role has access or admin /// @param role role to check for alongside the admin role modifier onlyRoleOrAdmin(bytes32 role) { - if ( - !hasRole(DEFAULT_ADMIN_ROLE, _msgSender()) && - !hasRole(role, _msgSender()) - ) { + if (!hasRole(DEFAULT_ADMIN_ROLE, _msgSender()) && !hasRole(role, _msgSender())) { revert Access_MissingRoleOrAdmin(role); } @@ -131,15 +127,11 @@ contract ERC721Drop is } function _presaleActive() internal view returns (bool) { - return - salesConfig.presaleStart <= block.timestamp && - salesConfig.presaleEnd > block.timestamp; + return salesConfig.presaleStart <= block.timestamp && salesConfig.presaleEnd > block.timestamp; } function _publicSaleActive() internal view returns (bool) { - return - salesConfig.publicSaleStart <= block.timestamp && - salesConfig.publicSaleEnd > block.timestamp; + return salesConfig.publicSaleStart <= block.timestamp && salesConfig.publicSaleEnd > block.timestamp; } /// @notice Presale active @@ -258,17 +250,8 @@ contract ERC721Drop is /// @notice Connects this contract to the factory upgrade gate /// @param newImplementation proposed new upgrade implementation /// @dev Only can be called by admin - function _authorizeUpgrade(address newImplementation) - internal - override - onlyAdmin - { - if ( - !factoryUpgradeGate.isValidUpgradePath({ - _newImpl: newImplementation, - _currentImpl: _getImplementation() - }) - ) { + function _authorizeUpgrade(address newImplementation) internal override onlyAdmin { + if (!factoryUpgradeGate.isValidUpgradePath({_newImpl: newImplementation, _currentImpl: _getImplementation()})) { revert Admin_InvalidUpgradeAddress(newImplementation); } } @@ -299,28 +282,16 @@ contract ERC721Drop is /// @dev Get royalty information for token /// @param _salePrice Sale price for the token - function royaltyInfo(uint256, uint256 _salePrice) - external - view - override - returns (address receiver, uint256 royaltyAmount) - { + function royaltyInfo(uint256, uint256 _salePrice) external view override returns (address receiver, uint256 royaltyAmount) { if (config.fundsRecipient == address(0)) { return (config.fundsRecipient, 0); } - return ( - config.fundsRecipient, - (_salePrice * config.royaltyBPS) / 10_000 - ); + return (config.fundsRecipient, (_salePrice * config.royaltyBPS) / 10_000); } /// @notice Sale details /// @return IERC721Drop.SaleDetails sale information details - function saleDetails() - external - view - returns (IERC721Drop.SaleDetails memory) - { + function saleDetails() external view returns (IERC721Drop.SaleDetails memory) { return IERC721Drop.SaleDetails({ publicSaleActive: _publicSaleActive(), @@ -339,17 +310,11 @@ contract ERC721Drop is /// @dev Number of NFTs the user has minted per address /// @param minter to get counts for - function mintedPerAddress(address minter) - external - view - override - returns (IERC721Drop.AddressMintDetails memory) - { + function mintedPerAddress(address minter) external view override returns (IERC721Drop.AddressMintDetails memory) { return IERC721Drop.AddressMintDetails({ presaleMints: presaleMintsByAddress[minter], - publicMints: _numberMinted(minter) - - presaleMintsByAddress[minter], + publicMints: _numberMinted(minter) - presaleMintsByAddress[minter], totalMints: _numberMinted(minter) }); } @@ -358,12 +323,7 @@ contract ERC721Drop is /// Still requires approval for module /// @param nftOwner owner of the nft /// @param operator operator wishing to transfer/burn/etc the NFTs - function isApprovedForAll(address nftOwner, address operator) - public - view - override(IERC721Upgradeable, ERC721AUpgradeable) - returns (bool) - { + function isApprovedForAll(address nftOwner, address operator) public view override(IERC721Upgradeable, ERC721AUpgradeable) returns (bool) { if (operator == zoraERC721TransferHelper) { return true; } @@ -372,11 +332,7 @@ contract ERC721Drop is /// @notice ZORA fee is fixed now per mint /// @dev Gets the zora fee for amount of withdraw - function zoraFeeForAmount(uint256 quantity) - public - view - returns (address payable recipient, uint256 fee) - { + function zoraFeeForAmount(uint256 quantity) public view returns (address payable recipient, uint256 fee) { recipient = ZORA_MINT_FEE_RECIPIENT; fee = ZORA_MINT_FEE * quantity; } @@ -448,13 +404,7 @@ contract ERC721Drop is /// @notice Purchase a quantity of tokens /// @param quantity quantity to purchase /// @return tokenId of the first token minted - function purchase(uint256 quantity) - external - payable - nonReentrant - onlyPublicSaleActive - returns (uint256) - { + function purchase(uint256 quantity) external payable nonReentrant onlyPublicSaleActive returns (uint256) { return _handleMintWithRewards(msg.sender, quantity, "", address(0)); } @@ -462,13 +412,7 @@ contract ERC721Drop is /// @param quantity quantity to purchase /// @param comment comment to include in the IERC721Drop.Sale event /// @return tokenId of the first token minted - function purchaseWithComment(uint256 quantity, string calldata comment) - external - payable - nonReentrant - onlyPublicSaleActive - returns (uint256) - { + function purchaseWithComment(uint256 quantity, string calldata comment) external payable nonReentrant onlyPublicSaleActive returns (uint256) { return _handleMintWithRewards(msg.sender, quantity, comment, address(0)); } @@ -477,13 +421,11 @@ contract ERC721Drop is /// @param quantity quantity to purchase /// @param comment optional comment to include in the IERC721Drop.Sale event (leave blank for no comment) /// @return tokenId of the first token minted - function purchaseWithRecipient(address recipient, uint256 quantity, string calldata comment) - external - payable - nonReentrant - onlyPublicSaleActive - returns (uint256) - { + function purchaseWithRecipient( + address recipient, + uint256 quantity, + string calldata comment + ) external payable nonReentrant onlyPublicSaleActive returns (uint256) { return _handleMintWithRewards(recipient, quantity, comment, address(0)); } @@ -493,14 +435,12 @@ contract ERC721Drop is /// @param comment comment to include in the IERC721Drop.Sale event /// @param mintReferral The finder of the mint /// @return tokenId of the first token minted - function mintWithRewards(address recipient, uint256 quantity, string calldata comment, address mintReferral) - external - payable - nonReentrant - canMintTokens(quantity) - onlyPublicSaleActive - returns (uint256) - { + function mintWithRewards( + address recipient, + uint256 quantity, + string calldata comment, + address mintReferral + ) external payable nonReentrant canMintTokens(quantity) onlyPublicSaleActive returns (uint256) { return _handleMintWithRewards(recipient, quantity, comment, mintReferral); } @@ -510,7 +450,14 @@ contract ERC721Drop is uint256 salePrice = salesConfig.publicSalePrice; - _handleRewards(msg.value, quantity, salePrice, config.fundsRecipient != address(0) ? config.fundsRecipient : address(this), createReferral, mintReferral); + _handleRewards( + msg.value, + quantity, + salePrice, + config.fundsRecipient != address(0) ? config.fundsRecipient : address(this), + createReferral, + mintReferral + ); _mintNFTs(recipient, quantity); @@ -528,9 +475,7 @@ contract ERC721Drop is /// @param quantity number of NFTs to mint function _mintNFTs(address to, uint256 quantity) internal { do { - uint256 toMint = quantity > MAX_MINT_BATCH_SIZE - ? MAX_MINT_BATCH_SIZE - : quantity; + uint256 toMint = quantity > MAX_MINT_BATCH_SIZE ? MAX_MINT_BATCH_SIZE : quantity; _mint({to: to, quantity: toMint}); quantity -= toMint; } while (quantity > 0); @@ -602,16 +547,7 @@ contract ERC721Drop is /// @param maxQuantity max quantity that can be purchased via merkle proof # /// @param pricePerToken price that each token is purchased at /// @param merkleProof proof for presale mint - function purchasePresale( - uint256 quantity, - uint256 maxQuantity, - uint256 pricePerToken, - bytes32[] calldata merkleProof - ) - external - payable - returns (uint256) - { + function purchasePresale(uint256 quantity, uint256 maxQuantity, uint256 pricePerToken, bytes32[] calldata merkleProof) external payable returns (uint256) { return purchasePresaleWithRewards(quantity, maxQuantity, pricePerToken, merkleProof, "", address(0)); } @@ -627,13 +563,7 @@ contract ERC721Drop is uint256 pricePerToken, bytes32[] calldata merkleProof, string calldata comment - ) - external - payable - nonReentrant - onlyPresaleActive - returns (uint256) - { + ) external payable nonReentrant onlyPresaleActive returns (uint256) { return purchasePresaleWithRewards(quantity, maxQuantity, pricePerToken, merkleProof, comment, address(0)); } @@ -651,17 +581,11 @@ contract ERC721Drop is bytes32[] calldata merkleProof, string memory comment, address mintReferral - ) - public - payable - nonReentrant - onlyPresaleActive - returns (uint256) - { + ) public payable nonReentrant onlyPresaleActive returns (uint256) { return _handlePurchasePresaleWithRewards(quantity, maxQuantity, pricePerToken, merkleProof, comment, mintReferral); } - function _handlePurchasePresaleWithRewards( + function _handlePurchasePresaleWithRewards( uint256 quantity, uint256 maxQuantity, uint256 pricePerToken, @@ -678,7 +602,14 @@ contract ERC721Drop is _requireCanPurchasePresale(msgSender, quantity, maxQuantity); - _handleRewards(msg.value, quantity, pricePerToken, config.fundsRecipient != address(0) ? config.fundsRecipient : address(this), createReferral, mintReferral); + _handleRewards( + msg.value, + quantity, + pricePerToken, + config.fundsRecipient != address(0) ? config.fundsRecipient : address(this), + createReferral, + mintReferral + ); _mintNFTs(msgSender, quantity); @@ -739,12 +670,7 @@ contract ERC721Drop is /// @notice Mint admin /// @param recipient recipient to mint to /// @param quantity quantity to mint - function adminMint(address recipient, uint256 quantity) - external - onlyRoleOrAdmin(MINTER_ROLE) - canMintTokens(quantity) - returns (uint256) - { + function adminMint(address recipient, uint256 quantity) external onlyRoleOrAdmin(MINTER_ROLE) canMintTokens(quantity) returns (uint256) { _mintNFTs(recipient, quantity); return _lastMintedTokenId(); @@ -796,22 +722,12 @@ contract ERC721Drop is // / \ /// @dev This mints multiple editions to the given list of addresses. /// @param recipients list of addresses to send the newly minted editions to - function adminMintAirdrop(address[] calldata recipients) - external - override - onlyRoleOrAdmin(MINTER_ROLE) - canMintTokens(recipients.length) - returns (uint256) - { + function adminMintAirdrop(address[] calldata recipients) external override onlyRoleOrAdmin(MINTER_ROLE) canMintTokens(recipients.length) returns (uint256) { uint256 atId = _currentIndex; uint256 startAt = atId; unchecked { - for ( - uint256 endAt = atId + recipients.length; - atId < endAt; - atId++ - ) { + for (uint256 endAt = atId + recipients.length; atId < endAt; atId++) { _mintNFTs(recipients[atId - startAt], 1); } } @@ -863,44 +779,44 @@ contract ERC721Drop is /// @dev Set to 0 to disable, address to enable transfer hook. /// @param newTransferHook new transfer hook to receive before token transfer events function setTransferHook(address newTransferHook) public onlyAdmin { - if (newTransferHook == address(0) || ITransferHookExtension(newTransferHook).supportsInterface(type(ITransferHookExtension).interfaceId)) { - _setTransferHook(newTransferHook); - } else { + if (newTransferHook != address(0) && !ITransferHookExtension(newTransferHook).supportsInterface(type(ITransferHookExtension).interfaceId)) { revert InvalidTransferHook(); } + + _setTransferHook(newTransferHook); } /// @notice Handles the internal before token transfer hook /// @param from address transfer is coming from /// @param to address transfer is going to - /// @param tokenId token id for transfer - /// @param amount number of transfers - function _beforeTokenTransfers(address from, address to, uint256 tokenId, uint256 amount) internal override virtual { + /// @param startTokenId token id for transfer + /// @param quantity number of transfers + function _beforeTokenTransfers(address from, address to, uint256 startTokenId, uint256 quantity) internal virtual override { TransferHookStorage storage transferHookStorage = _getTransferHookStorage(); if (transferHookStorage.transferHookExtension != address(0)) { - ITransferHookExtension(transferHookStorage.transferHookExtension).beforeTokenTransfers(from, to, tokenId, amount); + ITransferHookExtension(transferHookStorage.transferHookExtension).beforeTokenTransfers({ + from: from, + to: to, + operator: msg.sender, + startTokenId: startTokenId, + quantity: quantity + }); } - - super._beforeTokenTransfers(from, to, tokenId, amount); + + super._beforeTokenTransfers(from, to, startTokenId, quantity); } /// @notice Set a new metadata renderer /// @param newRenderer new renderer address to use /// @param setupRenderer data to setup new renderer with - function setMetadataRenderer( - IMetadataRenderer newRenderer, - bytes memory setupRenderer - ) external onlyAdmin { + function setMetadataRenderer(IMetadataRenderer newRenderer, bytes memory setupRenderer) external onlyAdmin { config.metadataRenderer = newRenderer; if (setupRenderer.length > 0) { newRenderer.initializeWithData(setupRenderer); } - emit UpdatedMetadataRenderer({ - sender: _msgSender(), - renderer: newRenderer - }); + emit UpdatedMetadataRenderer({sender: _msgSender(), renderer: newRenderer}); _notifyMetadataUpdate(); } @@ -908,13 +824,8 @@ contract ERC721Drop is /// @notice Calls the metadata renderer contract to make an update and uses the EIP4906 event to notify /// @param data raw calldata to call the metadata renderer contract with. /// @dev Only accessible via an admin role - function callMetadataRenderer(bytes memory data) - public - onlyAdmin - returns (bytes memory) - { - (bool success, bytes memory response) = address(config.metadataRenderer) - .call(data); + function callMetadataRenderer(bytes memory data) public onlyAdmin returns (bytes memory) { + (bool success, bytes memory response) = address(config.metadataRenderer).call(data); if (!success) { revert ExternalMetadataRenderer_CallFailed(); } @@ -1014,10 +925,7 @@ contract ERC721Drop is // / \ /// @notice Set a different funds recipient /// @param newRecipientAddress new funds recipient address - function setFundsRecipient(address payable newRecipientAddress) - external - onlyRoleOrAdmin(SALES_MANAGER_ROLE) - { + function setFundsRecipient(address payable newRecipientAddress) external onlyRoleOrAdmin(SALES_MANAGER_ROLE) { // TODO(iain): funds recipient cannot be 0? config.fundsRecipient = newRecipientAddress; emit FundsRecipientChanged(newRecipientAddress, _msgSender()); @@ -1081,22 +989,13 @@ contract ERC721Drop is uint256 funds = address(this).balance; // Payout recipient - (bool successFunds, ) = config.fundsRecipient.call{ - value: funds, - gas: FUNDS_SEND_GAS_LIMIT - }(""); + (bool successFunds, ) = config.fundsRecipient.call{value: funds, gas: FUNDS_SEND_GAS_LIMIT}(""); if (!successFunds) { revert Withdraw_FundsSendFailure(); } // Emit event for indexing - emit FundsWithdrawn( - _msgSender(), - config.fundsRecipient, - funds, - address(0), - 0 - ); + emit FundsWithdrawn(_msgSender(), config.fundsRecipient, funds, address(0), 0); } /// @notice This withdraws ETH from the protocol rewards contract to an address specified by the contract owner. @@ -1113,11 +1012,7 @@ contract ERC721Drop is } function _verifyWithdrawAccess(address msgSender) internal view { - if ( - !hasRole(DEFAULT_ADMIN_ROLE, msgSender) && - !hasRole(SALES_MANAGER_ROLE, msgSender) && - msgSender != config.fundsRecipient - ) { + if (!hasRole(DEFAULT_ADMIN_ROLE, msgSender) && !hasRole(SALES_MANAGER_ROLE, msgSender) && msgSender != config.fundsRecipient) { revert Access_WithdrawNotAllowed(); } } @@ -1164,10 +1059,7 @@ contract ERC721Drop is // | // / \ /// @notice Admin function to finalize and open edition sale - function finalizeOpenEdition() - external - onlyRoleOrAdmin(SALES_MANAGER_ROLE) - { + function finalizeOpenEdition() external onlyRoleOrAdmin(SALES_MANAGER_ROLE) { if (config.editionSize != type(uint64).max) { revert Admin_UnableToFinalizeNotOpenEdition(); } @@ -1186,12 +1078,7 @@ contract ERC721Drop is /// @notice Simple override for owner interface. /// @return user owner address - function owner() - public - view - override(OwnableSkeleton, IERC721Drop) - returns (address) - { + function owner() public view override(OwnableSkeleton, IERC721Drop) returns (address) { return super.owner(); } @@ -1209,12 +1096,7 @@ contract ERC721Drop is /// @notice Token URI Getter, proxies to metadataRenderer /// @param tokenId id of token to get URI for /// @return Token URI - function tokenURI(uint256 tokenId) - public - view - override - returns (string memory) - { + function tokenURI(uint256 tokenId) public view override returns (string memory) { if (!_exists(tokenId)) { revert IERC721AUpgradeable.URIQueryForNonexistentToken(); } @@ -1230,19 +1112,14 @@ contract ERC721Drop is // If we have tokens to notify about if (totalMinted > 0) { - emit BatchMetadataUpdate( - _startTokenId(), - totalMinted + _startTokenId() - ); + emit BatchMetadataUpdate(_startTokenId(), totalMinted + _startTokenId()); } } function _payoutZoraFee(uint256 quantity) internal { // Transfer ZORA fee to recipient (, uint256 zoraFee) = zoraFeeForAmount(quantity); - (bool success, ) = ZORA_MINT_FEE_RECIPIENT.call{value: zoraFee, gas: FUNDS_SEND_GAS_LIMIT}( - "" - ); + (bool success, ) = ZORA_MINT_FEE_RECIPIENT.call{value: zoraFee, gas: FUNDS_SEND_GAS_LIMIT}(""); emit MintFeePayout(zoraFee, ZORA_MINT_FEE_RECIPIENT, success); } @@ -1256,9 +1133,8 @@ contract ERC721Drop is // If max purchase per address == 0 there is no limit. // Any other number, the per address mint limit is that. if ( - salesConfig.maxSalePurchasePerAddress != 0 - && _numberMinted(recipient) + quantity - presaleMintsByAddress[recipient] - > salesConfig.maxSalePurchasePerAddress + salesConfig.maxSalePurchasePerAddress != 0 && + _numberMinted(recipient) + quantity - presaleMintsByAddress[recipient] > salesConfig.maxSalePurchasePerAddress ) { revert Purchase_TooManyForAddress(); } @@ -1272,12 +1148,7 @@ contract ERC721Drop is } } - function _requireMerkleApproval( - address recipient, - uint256 maxQuantity, - uint256 pricePerToken, - bytes32[] calldata merkleProof - ) internal view { + function _requireMerkleApproval(address recipient, uint256 maxQuantity, uint256 pricePerToken, bytes32[] calldata merkleProof) internal view { if ( !MerkleProofUpgradeable.verify( merkleProof, @@ -1298,7 +1169,7 @@ contract ERC721Drop is return; } - address royaltyRecipient = config.fundsRecipient; + address royaltyRecipient = config.fundsRecipient; if (royaltyRecipient == address(0)) { return; } @@ -1327,37 +1198,24 @@ contract ERC721Drop is createReferral = recipient; } - function _emitSaleEvents(address msgSender, address recipient, uint256 quantity, uint256 pricePerToken, uint256 firstMintedTokenId, string memory comment) internal { - emit IERC721Drop.Sale({ - to: recipient, - quantity: quantity, - pricePerToken: pricePerToken, - firstPurchasedTokenId: firstMintedTokenId - }); + function _emitSaleEvents( + address msgSender, + address recipient, + uint256 quantity, + uint256 pricePerToken, + uint256 firstMintedTokenId, + string memory comment + ) internal { + emit IERC721Drop.Sale({to: recipient, quantity: quantity, pricePerToken: pricePerToken, firstPurchasedTokenId: firstMintedTokenId}); if (bytes(comment).length > 0) { - emit IERC721Drop.MintComment({ - sender: msgSender, - tokenContract: address(this), - tokenId: firstMintedTokenId, - quantity: quantity, - comment: comment - }); + emit IERC721Drop.MintComment({sender: msgSender, tokenContract: address(this), tokenId: firstMintedTokenId, quantity: quantity, comment: comment}); } } /// @notice ERC165 supports interface /// @param interfaceId interface id to check if supported - function supportsInterface(bytes4 interfaceId) - public - view - override( - IERC165Upgradeable, - ERC721AUpgradeable, - AccessControlUpgradeable - ) - returns (bool) - { + function supportsInterface(bytes4 interfaceId) public view override(IERC165Upgradeable, ERC721AUpgradeable, AccessControlUpgradeable) returns (bool) { return super.supportsInterface(interfaceId) || type(IOwnable).interfaceId == interfaceId || diff --git a/src/interfaces/ITransferHookExtension.sol b/src/interfaces/ITransferHookExtension.sol index 37f207f..de822a5 100644 --- a/src/interfaces/ITransferHookExtension.sol +++ b/src/interfaces/ITransferHookExtension.sol @@ -1,7 +1,15 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.10; +/// @dev ITransferHookExtension – optional extension to add custom behavior to 721 NFT on Transfer +/// @notice Used for custom functionality and improvements interface ITransferHookExtension { - function beforeTokenTransfers(address from, address to, uint256 startTokenId, uint256 quantity) external; - function supportsInterface(bytes4 interfaceId) external returns (bool); -} \ No newline at end of file + /// @param from Address transfer from + /// @param to Address transfer to + /// @param operator Address operating (calling) the transfer + /// @param startTokenId transfer start token id + /// @param quantity Transfer quantity (from ERC721A) + function beforeTokenTransfers(address from, address to, address operator, uint256 startTokenId, uint256 quantity) external; + /// @notice Used for supportsInterface IERC165 + function supportsInterface(bytes4 interfaceId) external returns (bool); +} diff --git a/src/storage/ERC721TransferHookStorageV1.sol b/src/storage/ERC721TransferHookStorageV1.sol index aa4a26e..7f02878 100644 --- a/src/storage/ERC721TransferHookStorageV1.sol +++ b/src/storage/ERC721TransferHookStorageV1.sol @@ -5,23 +5,28 @@ import {IERC721Drop} from "../interfaces/IERC721Drop.sol"; /// @custom:storage-location erc7201:zora.erc721drop.transferhook struct TransferHookStorage { + /// @notice Extension for transfer hook across the whole contract. Optional – disabled if set to address(0). address transferHookExtension; } +/// @notice Contract to handle the storage of the transfer hook information for 721 contract ERC721TransferHookStorageV1 { + /// @notice Called when an invalid transfer hook is attempted to be set error InvalidTransferHook(); + /// @notice Emitted when a new transfer hook is setup event SetNewTransferHook(address _newTransferHook); // keccak256(abi.encode(uint256(keccak256("zora.erc721drop.transferhook")) - 1)) & ~bytes32(uint256(0xff)); - bytes32 private constant TRANSFER_HOOK_STORAGE_LOCATION = - 0x7dd1076582dd9e0dc6a5073ed536c067f2e92ed46866d3076f6f2d9a5e36b400; + bytes32 private constant TRANSFER_HOOK_STORAGE_LOCATION = 0x7dd1076582dd9e0dc6a5073ed536c067f2e92ed46866d3076f6f2d9a5e36b400; + /// @notice Function to get the current transfer hook storage from its direct storage slot. function _getTransferHookStorage() internal pure returns (TransferHookStorage storage $) { assembly { $.slot := TRANSFER_HOOK_STORAGE_LOCATION } } + /// @notice Internal direct setter for transfer hook. Emits changed event. function _setTransferHook(address _newTransferHook) internal { _getTransferHookStorage().transferHookExtension = _newTransferHook; emit SetNewTransferHook(_newTransferHook); diff --git a/test/ERC721Drop.t.sol b/test/ERC721Drop.t.sol index 0c63043..ef13c6c 100644 --- a/test/ERC721Drop.t.sol +++ b/test/ERC721Drop.t.sol @@ -10,10 +10,12 @@ import {RewardsSettings} from "@zoralabs/protocol-rewards/src/abstract/RewardSpl import {ERC721Drop} from "../src/ERC721Drop.sol"; import {DummyMetadataRenderer} from "./utils/DummyMetadataRenderer.sol"; import {MockUser} from "./utils/MockUser.sol"; +import {MockTransferHookReverts, MockTransferHookWrongInterface, MockTransferHookSavesState} from "./utils/MockTransferHook.sol"; import {IMetadataRenderer} from "../src/interfaces/IMetadataRenderer.sol"; import {IERC721Drop} from "../src/interfaces/IERC721Drop.sol"; import {FactoryUpgradeGate} from "../src/FactoryUpgradeGate.sol"; import {ERC721DropProxy} from "../src/ERC721DropProxy.sol"; +import {ERC721TransferHookStorageV1} from "../src/storage/ERC721TransferHookStorageV1.sol"; contract ERC721DropTest is Test { /// @notice Event emitted when the funds are withdrawn from the minting contract @@ -186,6 +188,45 @@ contract ERC721DropTest is Test { // }); // } + function test_transferHook() public setupZoraNFTBase(4) { + vm.prank(address(10)); + vm.expectRevert(abi.encodeWithSignature("Access_OnlyAdmin()")); + zoraNFTBase.setTransferHook(address(243)); + + vm.startPrank(DEFAULT_OWNER_ADDRESS); + + vm.expectRevert(); + zoraNFTBase.setTransferHook(address(243)); + + MockTransferHookWrongInterface transferHookBadInterface = new MockTransferHookWrongInterface(); + vm.expectRevert(ERC721TransferHookStorageV1.InvalidTransferHook.selector); + zoraNFTBase.setTransferHook(address(transferHookBadInterface)); + + MockTransferHookReverts transferHookReverts = new MockTransferHookReverts(); + zoraNFTBase.setTransferHook(address(transferHookReverts)); + + // expect hook reverts here + vm.expectRevert(); + zoraNFTBase.adminMint(address(this), 1); + + // remove transfer hook + zoraNFTBase.setTransferHook(address(0)); + + // assume mint works + zoraNFTBase.adminMint(DEFAULT_OWNER_ADDRESS, 1); + assertEq(zoraNFTBase.balanceOf(DEFAULT_OWNER_ADDRESS), 1); + + // set transfer hook that sets state + MockTransferHookSavesState transferHookState = new MockTransferHookSavesState(); + zoraNFTBase.setTransferHook(address(transferHookState)); + + zoraNFTBase.adminMint(DEFAULT_OWNER_ADDRESS, 1); + zoraNFTBase.adminMint(DEFAULT_OWNER_ADDRESS, 2); + assertEq(transferHookState.numberTransfers(), 2); + + vm.stopPrank(); + } + function test_IsAdminGetter() public setupZoraNFTBase(1) { assertTrue(zoraNFTBase.isAdmin(DEFAULT_OWNER_ADDRESS)); assertTrue(!zoraNFTBase.isAdmin(address(0x999))); diff --git a/test/utils/MockTransferHook.sol b/test/utils/MockTransferHook.sol new file mode 100644 index 0000000..bd8780e --- /dev/null +++ b/test/utils/MockTransferHook.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.10; + +import {ITransferHookExtension} from "../../src/interfaces/ITransferHookExtension.sol"; + +contract MockTransferHookReverts is ITransferHookExtension { + function beforeTokenTransfers(address, address, address, uint256, uint256) external pure { + revert("AT_HOOK"); + } + + function supportsInterface(bytes4 interfaceId) external pure returns (bool) { + return interfaceId == type(ITransferHookExtension).interfaceId; + } +} + +contract MockTransferHookWrongInterface is ITransferHookExtension { + function beforeTokenTransfers(address, address, address, uint256, uint256) external pure { + revert("AT_HOOK"); + } + + function supportsInterface(bytes4) external pure returns (bool) { + return false; + } +} + +contract MockTransferHookSavesState is ITransferHookExtension { + uint256 public numberTransfers = 0; + struct LastCall { + address from; + address to; + address operator; + uint256 firstTokenId; + uint256 quantity; + } + LastCall internal lastCall; + function beforeTokenTransfers(address from, address to, address operator, uint256 firstTokenId, uint256 quantity) external { + lastCall.from = from; + lastCall.to = to; + lastCall.operator = operator; + lastCall.firstTokenId = firstTokenId; + lastCall.quantity = quantity; + numberTransfers += 1; + } + function getLastCall() public view returns (LastCall memory) { + return lastCall; + } + + function supportsInterface(bytes4 interfaceId) external pure returns (bool) { + return interfaceId == type(ITransferHookExtension).interfaceId; + } +}