diff --git a/config/.solhintignore b/config/.solhintignore index 865c2bf8..6f1b4b27 100644 --- a/config/.solhintignore +++ b/config/.solhintignore @@ -3,8 +3,10 @@ node_modules/ src/clones src/test/ src/shim/ -src/interfaces/IDelegationRegistry.sol src/lib/ERC1155.sol +src/interfaces/IDelegationRegistry.sol +src/interfaces/ITransferValidator.sol + test/ lib/ \ No newline at end of file diff --git a/src/clones/ERC1155ContractMetadataCloneable.sol b/src/clones/ERC1155ContractMetadataCloneable.sol index 9c4e78ae..a55d12b5 100644 --- a/src/clones/ERC1155ContractMetadataCloneable.sol +++ b/src/clones/ERC1155ContractMetadataCloneable.sol @@ -9,6 +9,10 @@ import { ERC1155ConduitPreapproved } from "../lib/ERC1155ConduitPreapproved.sol"; +import { ITransferValidator } from "../interfaces/ITransferValidator.sol"; + +import { TokenTransferValidator } from "../lib/TokenTransferValidator.sol"; + import { ERC1155 } from "solady/src/tokens/ERC1155.sol"; import { ERC2981 } from "solady/src/tokens/ERC2981.sol"; @@ -30,6 +34,7 @@ import { */ contract ERC1155ContractMetadataCloneable is ERC1155ConduitPreapproved, + TokenTransferValidator, ERC2981, Ownable, IERC1155ContractMetadata, @@ -306,6 +311,38 @@ contract ERC1155ContractMetadataCloneable is return _baseURI; } + /// @dev Override this function to return true if `_beforeTokenTransfer` is used. + function _useBeforeTokenTransfer() internal view virtual override returns (bool) { + return true; + } + + /** + * @dev Hook that is called before any token transfer. + * This includes minting and burning. + */ + function _beforeTokenTransfer( + address from, + address to, + uint256[] memory ids, + uint256[] memory amounts, + bytes memory /* data */ + ) internal virtual override { + if (from != address(0) && to != address(0)) { + // Call the transfer validator if one is set. + if (_transferValidator != address(0)) { + for (uint256 i = 0; i < ids.length; i++) { + ITransferValidator(_transferValidator).validateTransfer( + msg.sender, + from, + to, + ids[i], + amounts[i] + ); + } + } + } + } + /** * @notice Returns whether the interface is supported. * diff --git a/src/clones/ERC1155SeaDropCloneable.sol b/src/clones/ERC1155SeaDropCloneable.sol index 4ace3f01..4ddc742a 100644 --- a/src/clones/ERC1155SeaDropCloneable.sol +++ b/src/clones/ERC1155SeaDropCloneable.sol @@ -45,42 +45,6 @@ contract ERC1155SeaDropCloneable is ERC1155SeaDropContractOffererCloneable { ); } - /** - * @dev Auto-approve the conduit after mint or transfer. - * - * @custom:param from The address to transfer from. - * @param to The address to transfer to. - * @custom:param ids The token ids to transfer. - * @custom:param amounts The quantities to transfer. - * @custom:param data The data to pass if receiver is a contract. - */ - function _afterTokenTransfer( - address /* from */, - address to, - uint256[] memory /* ids */, - uint256[] memory /* amounts */, - bytes memory /* data */ - ) internal virtual override { - // Auto-approve the conduit. - if (to != address(0) && !isApprovedForAll(to, _CONDUIT)) { - _setApprovalForAll(to, _CONDUIT, true); - } - } - - /** - * @dev Override this function to return true if `_afterTokenTransfer` is - * used. The is to help the compiler avoid producing dead bytecode. - */ - function _useAfterTokenTransfer() - internal - view - virtual - override - returns (bool) - { - return true; - } - /** * @notice Burns a token, restricted to the owner or approved operator, * and must have sufficient balance. diff --git a/src/interfaces/ITransferValidator.sol b/src/interfaces/ITransferValidator.sol new file mode 100644 index 00000000..6d5ba6a1 --- /dev/null +++ b/src/interfaces/ITransferValidator.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.17; + +interface ITransferValidator { + /// @notice Ensure that a transfer has been authorized for a specific tokenId. + function validateTransfer( + address caller, + address from, + address to, + uint256 tokenId + ) external view; + + /// @notice Ensure that a transfer has been authorized for a specific amount of + /// a specific tokenId, and reduce the transferable amount remaining. + function validateTransfer( + address caller, + address from, + address to, + uint256 tokenId, + uint256 amount + ) external; +} diff --git a/src/lib/ERC1155ContractMetadata.sol b/src/lib/ERC1155ContractMetadata.sol index 7f3eea9f..b5e01ab8 100644 --- a/src/lib/ERC1155ContractMetadata.sol +++ b/src/lib/ERC1155ContractMetadata.sol @@ -9,6 +9,10 @@ import { ERC1155ConduitPreapproved } from "../lib/ERC1155ConduitPreapproved.sol"; +import { ITransferValidator } from "../interfaces/ITransferValidator.sol"; + +import { TokenTransferValidator } from "./TokenTransferValidator.sol"; + import { ERC1155 } from "solady/src/tokens/ERC1155.sol"; import { ERC2981 } from "solady/src/tokens/ERC2981.sol"; @@ -26,6 +30,7 @@ import { Ownable } from "solady/src/auth/Ownable.sol"; */ contract ERC1155ContractMetadata is ERC1155ConduitPreapproved, + TokenTransferValidator, ERC2981, Ownable, IERC1155ContractMetadata @@ -304,6 +309,46 @@ contract ERC1155ContractMetadata is return _baseURI; } + /** + * @notice Set the transfer validator. Only callable by the token owner. + */ + function setTransferValidator(address newValidator) external onlyOwner { + // Set the new transfer validator. + _setTransferValidator(newValidator); + } + + /// @dev Override this function to return true if `_beforeTokenTransfer` is used. + function _useBeforeTokenTransfer() internal view virtual override returns (bool) { + return true; + } + + /** + * @dev Hook that is called before any token transfer. + * This includes minting and burning. + */ + function _beforeTokenTransfer( + address from, + address to, + uint256[] memory ids, + uint256[] memory amounts, + bytes memory /* data */ + ) internal virtual override { + if (from != address(0) && to != address(0)) { + // Call the transfer validator if one is set. + if (_transferValidator != address(0)) { + for (uint256 i = 0; i < ids.length; i++) { + ITransferValidator(_transferValidator).validateTransfer( + msg.sender, + from, + to, + ids[i], + amounts[i] + ); + } + } + } + } + /** * @notice Returns whether the interface is supported. * diff --git a/src/lib/ERC721ContractMetadata.sol b/src/lib/ERC721ContractMetadata.sol index a94962a5..5c4989a7 100644 --- a/src/lib/ERC721ContractMetadata.sol +++ b/src/lib/ERC721ContractMetadata.sol @@ -7,6 +7,10 @@ import { import { ERC721AConduitPreapproved } from "./ERC721AConduitPreapproved.sol"; +import { ITransferValidator } from "../interfaces/ITransferValidator.sol"; + +import { TokenTransferValidator } from "./TokenTransferValidator.sol"; + import { ERC721A } from "ERC721A/ERC721A.sol"; import { Ownable } from "solady/src/auth/Ownable.sol"; @@ -24,6 +28,7 @@ import { ERC2981 } from "solady/src/tokens/ERC2981.sol"; */ contract ERC721ContractMetadata is ERC721AConduitPreapproved, + TokenTransferValidator, ERC2981, Ownable, IERC721ContractMetadata @@ -272,6 +277,37 @@ contract ERC721ContractMetadata is return string.concat(theBaseURI, _toString(tokenId)); } + /** + * @notice Set the transfer validator. Only callable by the token owner. + */ + function setTransferValidator(address newValidator) external onlyOwner { + // Set the new transfer validator. + _setTransferValidator(newValidator); + } + + /** + * @dev Hook that is called before any token transfer. + * This includes minting and burning. + */ + function _beforeTokenTransfers( + address from, + address to, + uint256 startTokenId, + uint256 /* quantity */ + ) internal virtual override { + if (from != address(0) && to != address(0)) { + // Call the transfer validator if one is set. + if (_transferValidator != address(0)) { + ITransferValidator(_transferValidator).validateTransfer( + msg.sender, + from, + to, + startTokenId + ); + } + } + } + /** * @notice Returns whether the interface is supported. * diff --git a/src/lib/TokenTransferValidator.sol b/src/lib/TokenTransferValidator.sol new file mode 100644 index 00000000..bd6f8283 --- /dev/null +++ b/src/lib/TokenTransferValidator.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.17; + +/** + * @title TokenTransferValidator + * @notice Functionality to use a transfer validator. + */ +contract TokenTransferValidator { + /// @dev Store the transfer validator. The null address means no transfer validator is set. + address internal _transferValidator; + + /// @notice Emit an event when the transfer validator is updated. + event TransferValidatorUpdated(address oldValidator, address newValidator); + + /// @notice Revert with an error if the transfer validator is being set to the same address. + error SameTransferValidator(); + + /// @notice Returns the currently active transfer validator. + /// The null address means no transfer validator is set. + function getTransferValidator() external view returns (address) { + return _transferValidator; + } + + /// @notice Set the transfer validator. + /// The external method that uses this must include access control. + function _setTransferValidator(address newValidator) internal { + address oldValidator = _transferValidator; + if (oldValidator == newValidator) { + revert SameTransferValidator(); + } + _transferValidator = newValidator; + emit TransferValidatorUpdated(oldValidator, newValidator); + } +} diff --git a/src/test/MockTransferValidator.sol b/src/test/MockTransferValidator.sol new file mode 100644 index 00000000..78f16dba --- /dev/null +++ b/src/test/MockTransferValidator.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: Unlicense +pragma solidity ^0.8.17; + +import { ITransferValidator } from "../interfaces/ITransferValidator.sol"; + +contract MockTransferValidator is ITransferValidator { + bool internal _revertOnValidate; + + constructor(bool revertOnValidate) { + _revertOnValidate = revertOnValidate; + } + + function validateTransfer( + address, + /* caller */ + address, + /* from */ + address, + /* to */ + uint256 /* tokenId */ + ) external view { + if (_revertOnValidate) { + revert("MockTransferValidator: always reverts"); + } + } + + function validateTransfer( + address, /* caller */ + address, /* from */ + address, /* to */ + uint256, /* tokenId */ + uint256 /* amount */ + ) external view { + if (_revertOnValidate) { + revert("MockTransferValidator: always reverts"); + } + } +} diff --git a/test/foundry/TokenTransferValidator.t.sol b/test/foundry/TokenTransferValidator.t.sol new file mode 100644 index 00000000..d79464f7 --- /dev/null +++ b/test/foundry/TokenTransferValidator.t.sol @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import { SeaDrop721Test } from "./utils/SeaDrop721Test.sol"; + +import { ERC721SeaDrop } from "seadrop/ERC721SeaDrop.sol"; + +import { ERC1155SeaDrop } from "seadrop/ERC1155SeaDrop.sol"; + +import { MockTransferValidator } from "seadrop/test/MockTransferValidator.sol"; + +import { Ownable } from "solady/src/auth/Ownable.sol"; + +contract ERC721SeaDropWithMint is ERC721SeaDrop { + constructor( + address allowedConfigurer, + address allowedSeaport, + string memory name, + string memory symbol + ) ERC721SeaDrop(allowedConfigurer, allowedSeaport, name, symbol) {} + + function mint(address to, uint256 amount) public onlyOwner { + _mint(to, amount); + } +} + +contract ERC1155SeaDropWithMint is ERC1155SeaDrop { + constructor( + address allowedConfigurer, + address allowedSeaport, + string memory name_, + string memory symbol_ + ) ERC1155SeaDrop(allowedConfigurer, allowedSeaport, name_, symbol_) {} + + function mint( + address to, + uint256 id, + uint256 amount + ) public onlyOwner { + _mint(to, id, amount, ""); + } +} + +contract TokenTransferValidatorTest is SeaDrop721Test { + MockTransferValidator transferValidatorAlwaysSucceeds = + new MockTransferValidator(false); + MockTransferValidator transferValidatorAlwaysReverts = + new MockTransferValidator(true); + + ERC721SeaDropWithMint token721; + ERC1155SeaDropWithMint token1155; + + event TransferValidatorUpdated(address oldValidator, address newValidator); + + function setUp() public override { + super.setUp(); + + token721 = new ERC721SeaDropWithMint(address(0), allowedSeaport, "", ""); + token721.setMaxSupply(10); + + token1155 = new ERC1155SeaDropWithMint(address(0), allowedSeaport, "", ""); + token1155.setMaxSupply(1, 10); + token1155.setMaxSupply(2, 10); + } + + function testERC721OnlyOwnerCanSetTransferValidator() public { + assertEq(token721.getTransferValidator(), address(0)); + + vm.prank(address(token721)); + vm.expectRevert(Ownable.Unauthorized.selector); + token721.setTransferValidator(address(transferValidatorAlwaysSucceeds)); + + token721.setTransferValidator(address(transferValidatorAlwaysSucceeds)); + assertEq( + token721.getTransferValidator(), + address(transferValidatorAlwaysSucceeds) + ); + } + + function testERC1155OnlyOwnerCanSetTransferValidator() public { + assertEq(token1155.getTransferValidator(), address(0)); + + vm.prank(address(token1155)); + vm.expectRevert(Ownable.Unauthorized.selector); + token1155.setTransferValidator( + address(transferValidatorAlwaysSucceeds) + ); + + token1155.setTransferValidator( + address(transferValidatorAlwaysSucceeds) + ); + assertEq( + token1155.getTransferValidator(), + address(transferValidatorAlwaysSucceeds) + ); + } + + function testERC721TransferValidatorIsCalledOnTransfer() public { + token721.mint(address(this), 2); + + vm.expectEmit(true, true, true, true); + emit TransferValidatorUpdated( + address(0), + address(transferValidatorAlwaysSucceeds) + ); + token721.setTransferValidator(address(transferValidatorAlwaysSucceeds)); + token721.safeTransferFrom(address(this), msg.sender, 1); + + vm.expectEmit(true, true, true, true); + emit TransferValidatorUpdated( + address(transferValidatorAlwaysSucceeds), + address(transferValidatorAlwaysReverts) + ); + token721.setTransferValidator(address(transferValidatorAlwaysReverts)); + vm.expectRevert("MockTransferValidator: always reverts"); + token721.safeTransferFrom(address(this), msg.sender, 2); + + // When set to null address, transfer should succeed without calling the validator + vm.expectEmit(true, true, true, true); + emit TransferValidatorUpdated( + address(transferValidatorAlwaysReverts), + address(0) + ); + token721.setTransferValidator(address(0)); + token721.safeTransferFrom(address(this), msg.sender, 2); + } + + function testERC1155TransferValidatorIsCalledOnTransfer() public { + token1155.mint(address(this), 1, 10); + token1155.mint(address(this), 2, 10); + + vm.expectEmit(true, true, true, true); + emit TransferValidatorUpdated( + address(0), + address(transferValidatorAlwaysSucceeds) + ); + token1155.setTransferValidator( + address(transferValidatorAlwaysSucceeds) + ); + token1155.safeTransferFrom(address(this), msg.sender, 1, 1, ""); + uint256[] memory ids = new uint256[](2); + uint256[] memory amounts = new uint256[](2); + ids[0] = 1; + ids[1] = 2; + amounts[0] = 2; + amounts[1] = 2; + token1155.safeBatchTransferFrom( + address(this), + msg.sender, + ids, + amounts, + "" + ); + + vm.expectEmit(true, true, true, true); + emit TransferValidatorUpdated( + address(transferValidatorAlwaysSucceeds), + address(transferValidatorAlwaysReverts) + ); + token1155.setTransferValidator(address(transferValidatorAlwaysReverts)); + vm.expectRevert("MockTransferValidator: always reverts"); + token1155.safeTransferFrom(address(this), msg.sender, 1, 1, ""); + vm.expectRevert("MockTransferValidator: always reverts"); + token1155.safeBatchTransferFrom( + address(this), + msg.sender, + ids, + amounts, + "" + ); + + // When set to null address, transfer should succeed without calling the validator + vm.expectEmit(true, true, true, true); + emit TransferValidatorUpdated( + address(transferValidatorAlwaysReverts), + address(0) + ); + token1155.setTransferValidator(address(0)); + token1155.safeTransferFrom(address(this), msg.sender, 1, 1, ""); + token1155.safeBatchTransferFrom( + address(this), + msg.sender, + ids, + amounts, + "" + ); + } +}