From c425dbddc1b40f78ce2e87fc2ad5b5eb0d787926 Mon Sep 17 00:00:00 2001 From: Ryan Ghods Date: Mon, 8 Apr 2024 11:48:42 -0700 Subject: [PATCH] add transfer validator contracts --- .../transfer-validated/ICreatorToken.sol | 12 +++ .../transfer-validated/ITransferValidator.sol | 12 +++ .../ERC1155ShipyardTransferValidated.sol | 77 +++++++++++++ .../ERC721ShipyardTransferValidated.sol | 64 +++++++++++ .../lib/TokenTransferValidator.sol | 33 ++++++ .../ERC1155ShipyardTransferValidated.t.sol | 101 ++++++++++++++++++ .../ERC721ShipyardTransferValidated.t.sol | 81 ++++++++++++++ .../mock/MockTransferValidator.sol | 38 +++++++ 8 files changed, 418 insertions(+) create mode 100644 src/interfaces/transfer-validated/ICreatorToken.sol create mode 100644 src/interfaces/transfer-validated/ITransferValidator.sol create mode 100644 src/transfer-validated/ERC1155ShipyardTransferValidated.sol create mode 100644 src/transfer-validated/ERC721ShipyardTransferValidated.sol create mode 100644 src/transfer-validated/lib/TokenTransferValidator.sol create mode 100644 test/transfer-validated/ERC1155ShipyardTransferValidated.t.sol create mode 100644 test/transfer-validated/ERC721ShipyardTransferValidated.t.sol create mode 100644 test/transfer-validated/mock/MockTransferValidator.sol diff --git a/src/interfaces/transfer-validated/ICreatorToken.sol b/src/interfaces/transfer-validated/ICreatorToken.sol new file mode 100644 index 0000000..33da367 --- /dev/null +++ b/src/interfaces/transfer-validated/ICreatorToken.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.17; + +interface ICreatorToken { + event TransferValidatorUpdated(address oldValidator, address newValidator); + + function getTransferValidator() external view returns (address validator); + + function getTransferValidationFunction() external view returns (bytes4 functionSignature, bool isViewFunction); + + function setTransferValidator(address validator) external; +} diff --git a/src/interfaces/transfer-validated/ITransferValidator.sol b/src/interfaces/transfer-validated/ITransferValidator.sol new file mode 100644 index 0000000..d5294bc --- /dev/null +++ b/src/interfaces/transfer-validated/ITransferValidator.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.17; + +interface ITransferValidator721 { + /// @notice Ensure that a transfer has been authorized for a specific tokenId + function validateTransfer(address caller, address from, address to, uint256 tokenId) external view; +} + +interface ITransferValidator1155 { + /// @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/transfer-validated/ERC1155ShipyardTransferValidated.sol b/src/transfer-validated/ERC1155ShipyardTransferValidated.sol new file mode 100644 index 0000000..b2fd0e3 --- /dev/null +++ b/src/transfer-validated/ERC1155ShipyardTransferValidated.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.17; + +import {Ownable} from "solady/src/auth/Ownable.sol"; +import {ERC1155} from "solady/src/tokens/ERC1155.sol"; +import {ERC1155ConduitPreapproved_Solady} from "../tokens/erc1155/ERC1155ConduitPreapproved_Solady.sol"; +import {TokenTransferValidator} from "./lib/TokenTransferValidator.sol"; +import {ICreatorToken} from "../interfaces/transfer-validated/ICreatorToken.sol"; +import {ITransferValidator1155} from "../interfaces/transfer-validated/ITransferValidator.sol"; + +contract ERC1155ShipyardTransferValidated is ERC1155ConduitPreapproved_Solady, TokenTransferValidator, Ownable { + constructor(address initialTransferValidator) ERC1155ConduitPreapproved_Solady() { + // Set the initial contract owner. + _initializeOwner(msg.sender); + + // Set the initial transfer validator. + if (initialTransferValidator != address(0)) { + _setTransferValidator(initialTransferValidator); + } + } + + /// @notice Returns the transfer validation function used. + function getTransferValidationFunction() external pure returns (bytes4 functionSignature, bool isViewFunction) { + functionSignature = ITransferValidator1155.validateTransfer.selector; + isViewFunction = true; + } + + /// @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. + address transferValidator = _transferValidator; + if (transferValidator != address(0)) { + for (uint256 i = 0; i < ids.length; i++) { + ITransferValidator1155(transferValidator).validateTransfer(msg.sender, from, to, ids[i], amounts[i]); + } + } + } + } + + /// @dev Override supportsInterface to additionally return true for ICreatorToken. + function supportsInterface(bytes4 interfaceId) public view virtual override(ERC1155) returns (bool) { + return interfaceId == type(ICreatorToken).interfaceId || ERC1155.supportsInterface(interfaceId); + } + + /// @dev Replace me with the token name. + function name() public view virtual returns (string memory) { + return "ERC1155ShipyardTransferValidated"; + } + + /// @dev Replace me with the token symbol. + function symbol() public view virtual returns (string memory) { + return "ERC1155-S-TV"; + } + + /// @dev Replace me with the token URI. + function uri(uint256 /* id */ ) public view virtual override returns (string memory) { + return ""; + } +} diff --git a/src/transfer-validated/ERC721ShipyardTransferValidated.sol b/src/transfer-validated/ERC721ShipyardTransferValidated.sol new file mode 100644 index 0000000..852410e --- /dev/null +++ b/src/transfer-validated/ERC721ShipyardTransferValidated.sol @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.17; + +import {Ownable} from "solady/src/auth/Ownable.sol"; +import {ERC721} from "solady/src/tokens/ERC721.sol"; +import {ERC721ConduitPreapproved_Solady} from "../tokens/erc721/ERC721ConduitPreapproved_Solady.sol"; +import {TokenTransferValidator} from "./lib/TokenTransferValidator.sol"; +import {ICreatorToken} from "../interfaces/transfer-validated/ICreatorToken.sol"; +import {ITransferValidator721} from "../interfaces/transfer-validated/ITransferValidator.sol"; + +contract ERC721ShipyardTransferValidated is ERC721ConduitPreapproved_Solady, TokenTransferValidator, Ownable { + constructor(address initialTransferValidator) ERC721ConduitPreapproved_Solady() { + // Set the initial contract owner. + _initializeOwner(msg.sender); + + // Set the initial transfer validator. + if (initialTransferValidator != address(0)) { + _setTransferValidator(initialTransferValidator); + } + } + + /// @notice Returns the transfer validation function used. + function getTransferValidationFunction() external pure returns (bytes4 functionSignature, bool isViewFunction) { + functionSignature = ITransferValidator721.validateTransfer.selector; + isViewFunction = false; + } + + /// @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 _beforeTokenTransfer(address from, address to, uint256 id) internal virtual override { + if (from != address(0) && to != address(0)) { + // Call the transfer validator if one is set. + address transferValidator = _transferValidator; + if (transferValidator != address(0)) { + ITransferValidator721(transferValidator).validateTransfer(msg.sender, from, to, id); + } + } + } + + /// @dev Override supportsInterface to additionally return true for ICreatorToken. + function supportsInterface(bytes4 interfaceId) public view virtual override(ERC721) returns (bool) { + return interfaceId == type(ICreatorToken).interfaceId || ERC721.supportsInterface(interfaceId); + } + + /// @dev Replace me with the token name. + function name() public view virtual override returns (string memory) { + return "ERC721ShipyardTransferValidated"; + } + + /// @dev Replace me with the token symbol. + function symbol() public view virtual override returns (string memory) { + return "ERC721-S-TV"; + } + + /// @dev Replace me with the token URI. + function tokenURI(uint256 /* id */ ) public view virtual override returns (string memory) { + return ""; + } +} diff --git a/src/transfer-validated/lib/TokenTransferValidator.sol b/src/transfer-validated/lib/TokenTransferValidator.sol new file mode 100644 index 0000000..360c08c --- /dev/null +++ b/src/transfer-validated/lib/TokenTransferValidator.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.17; + +import {ICreatorToken} from "../../interfaces/transfer-validated/ICreatorToken.sol"; + +/** + * @title TokenTransferValidator + * @notice Functionality to use a transfer validator. + */ +abstract contract TokenTransferValidator is ICreatorToken { + /// @dev Store the transfer validator. The null address means no transfer validator is set. + address internal _transferValidator; + + /// @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/test/transfer-validated/ERC1155ShipyardTransferValidated.t.sol b/test/transfer-validated/ERC1155ShipyardTransferValidated.t.sol new file mode 100644 index 0000000..0dd03ae --- /dev/null +++ b/test/transfer-validated/ERC1155ShipyardTransferValidated.t.sol @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.17; + +import {Test} from "forge-std/Test.sol"; +import {TestPlus} from "solady/test/utils/TestPlus.sol"; +import {Ownable} from "solady/src/auth/Ownable.sol"; +import {ICreatorToken} from "src/interfaces/transfer-validated/ICreatorToken.sol"; +import {ITransferValidator1155} from "src/interfaces/transfer-validated/ITransferValidator.sol"; +import {MockTransferValidator} from "./mock/MockTransferValidator.sol"; +import {ERC1155ShipyardTransferValidated} from "src/transfer-validated/ERC1155ShipyardTransferValidated.sol"; + +contract ERC1155ShipyardTransferValidatedWithMint is ERC1155ShipyardTransferValidated { + constructor(address initialTransferValidator) ERC1155ShipyardTransferValidated(initialTransferValidator) {} + + function mint(address to, uint256 id, uint256 amount) public onlyOwner { + _mint(to, id, amount, ""); + } +} + +contract TestERC1155ShipyardTransferValidated is Test, TestPlus { + MockTransferValidator transferValidatorAlwaysSucceeds = new MockTransferValidator(false); + MockTransferValidator transferValidatorAlwaysReverts = new MockTransferValidator(true); + + event TransferValidatorUpdated(address oldValidator, address newValidator); + + ERC1155ShipyardTransferValidatedWithMint token; + + function setUp() public { + token = new ERC1155ShipyardTransferValidatedWithMint(address(0)); + } + + function testOnlyOwnerCanSetTransferValidator() public { + assertEq(token.getTransferValidator(), address(0)); + + vm.prank(address(token)); + vm.expectRevert(Ownable.Unauthorized.selector); + token.setTransferValidator(address(transferValidatorAlwaysSucceeds)); + + token.setTransferValidator(address(transferValidatorAlwaysSucceeds)); + assertEq(token.getTransferValidator(), address(transferValidatorAlwaysSucceeds)); + } + + function testTransferValidatedSetInConstructor() public { + ERC1155ShipyardTransferValidatedWithMint token2 = + new ERC1155ShipyardTransferValidatedWithMint(address(transferValidatorAlwaysSucceeds)); + + assertEq(token2.getTransferValidator(), address(transferValidatorAlwaysSucceeds)); + } + + function testTransferValidatorIsCalledOnTransfer() public { + token.mint(address(this), 1, 10); + token.mint(address(this), 2, 10); + + vm.expectEmit(true, true, true, true); + emit TransferValidatorUpdated(address(0), address(transferValidatorAlwaysSucceeds)); + token.setTransferValidator(address(transferValidatorAlwaysSucceeds)); + token.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; + token.safeBatchTransferFrom(address(this), msg.sender, ids, amounts, ""); + + vm.expectEmit(true, true, true, true); + emit TransferValidatorUpdated(address(transferValidatorAlwaysSucceeds), address(transferValidatorAlwaysReverts)); + token.setTransferValidator(address(transferValidatorAlwaysReverts)); + vm.expectRevert("MockTransferValidator: always reverts"); + token.safeTransferFrom(address(this), msg.sender, 1, 1, ""); + vm.expectRevert("MockTransferValidator: always reverts"); + token.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)); + token.setTransferValidator(address(0)); + token.safeTransferFrom(address(this), msg.sender, 1, 1, ""); + token.safeBatchTransferFrom(address(this), msg.sender, ids, amounts, ""); + } + + function testGetTransferValidationFunction() public { + (bytes4 functionSignature, bool isViewFunction) = token.getTransferValidationFunction(); + assertEq(functionSignature, ITransferValidator1155.validateTransfer.selector); + assertEq(isViewFunction, true); + } + + function testSupportsInterface() public { + assertEq(token.supportsInterface(type(ICreatorToken).interfaceId), true); + } + + function onERC1155Received( + address, /* operator */ + address, /* from */ + uint256, /* id */ + uint256, /* value */ + bytes calldata /* data */ + ) external pure returns (bytes4) { + return this.onERC1155Received.selector; + } +} diff --git a/test/transfer-validated/ERC721ShipyardTransferValidated.t.sol b/test/transfer-validated/ERC721ShipyardTransferValidated.t.sol new file mode 100644 index 0000000..d8d6e71 --- /dev/null +++ b/test/transfer-validated/ERC721ShipyardTransferValidated.t.sol @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.17; + +import {Test} from "forge-std/Test.sol"; +import {TestPlus} from "solady/test/utils/TestPlus.sol"; +import {Ownable} from "solady/src/auth/Ownable.sol"; +import {ICreatorToken} from "src/interfaces/transfer-validated/ICreatorToken.sol"; +import {ITransferValidator721} from "src/interfaces/transfer-validated/ITransferValidator.sol"; +import {MockTransferValidator} from "./mock/MockTransferValidator.sol"; +import {ERC721ShipyardTransferValidated} from "src/transfer-validated/ERC721ShipyardTransferValidated.sol"; + +contract ERC721ShipyardTransferValidatedWithMint is ERC721ShipyardTransferValidated { + constructor(address initialTransferValidator) ERC721ShipyardTransferValidated(initialTransferValidator) {} + + function mint(address to, uint256 amount) public onlyOwner { + _mint(to, amount); + } +} + +contract TestERC721ShipyardTransferValidated is Test, TestPlus { + MockTransferValidator transferValidatorAlwaysSucceeds = new MockTransferValidator(false); + MockTransferValidator transferValidatorAlwaysReverts = new MockTransferValidator(true); + + event TransferValidatorUpdated(address oldValidator, address newValidator); + + ERC721ShipyardTransferValidatedWithMint token; + + function setUp() public { + token = new ERC721ShipyardTransferValidatedWithMint(address(0)); + } + + function testOnlyOwnerCanSetTransferValidator() public { + assertEq(token.getTransferValidator(), address(0)); + + vm.prank(address(token)); + vm.expectRevert(Ownable.Unauthorized.selector); + token.setTransferValidator(address(transferValidatorAlwaysSucceeds)); + + token.setTransferValidator(address(transferValidatorAlwaysSucceeds)); + assertEq(token.getTransferValidator(), address(transferValidatorAlwaysSucceeds)); + } + + function testTransferValidatedSetInConstructor() public { + ERC721ShipyardTransferValidatedWithMint token2 = + new ERC721ShipyardTransferValidatedWithMint(address(transferValidatorAlwaysSucceeds)); + + assertEq(token2.getTransferValidator(), address(transferValidatorAlwaysSucceeds)); + } + + function testTransferValidatorIsCalledOnTransfer() public { + token.mint(address(this), 1); + token.mint(address(this), 2); + + vm.expectEmit(true, true, true, true); + emit TransferValidatorUpdated(address(0), address(transferValidatorAlwaysSucceeds)); + token.setTransferValidator(address(transferValidatorAlwaysSucceeds)); + token.safeTransferFrom(address(this), msg.sender, 1); + + vm.expectEmit(true, true, true, true); + emit TransferValidatorUpdated(address(transferValidatorAlwaysSucceeds), address(transferValidatorAlwaysReverts)); + token.setTransferValidator(address(transferValidatorAlwaysReverts)); + vm.expectRevert("MockTransferValidator: always reverts"); + token.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)); + token.setTransferValidator(address(0)); + token.safeTransferFrom(address(this), msg.sender, 2); + } + + function testGetTransferValidationFunction() public { + (bytes4 functionSignature, bool isViewFunction) = token.getTransferValidationFunction(); + assertEq(functionSignature, ITransferValidator721.validateTransfer.selector); + assertEq(isViewFunction, false); + } + + function testSupportsInterface() public { + assertEq(token.supportsInterface(type(ICreatorToken).interfaceId), true); + } +} diff --git a/test/transfer-validated/mock/MockTransferValidator.sol b/test/transfer-validated/mock/MockTransferValidator.sol new file mode 100644 index 0000000..7c32486 --- /dev/null +++ b/test/transfer-validated/mock/MockTransferValidator.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: Unlicense +pragma solidity ^0.8.17; + +import {ITransferValidator721, ITransferValidator1155} from "src/interfaces/transfer-validated/ITransferValidator.sol"; + +contract MockTransferValidator is ITransferValidator721, ITransferValidator1155 { + 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"); + } + } +}