From 7e61975bab592855f5189f1e053bc91212d5d75d Mon Sep 17 00:00:00 2001 From: Sebastian Liu Date: Sat, 14 Dec 2024 22:50:00 -0800 Subject: [PATCH] feat: tokenizer module --- .../modules/tokenizer/IOwnableERC20.sol | 36 ++++++ .../modules/tokenizer/ITokenizerModule.sol | 30 +++++ contracts/lib/Errors.sol | 25 ++++ contracts/modules/tokenizer/OwnableERC20.sol | 47 ++++++++ .../modules/tokenizer/OwnerableERC20.sol | 13 --- .../modules/tokenizer/TokenizerModule.sol | 107 +++++++++++++++++- 6 files changed, 240 insertions(+), 18 deletions(-) create mode 100644 contracts/interfaces/modules/tokenizer/IOwnableERC20.sol create mode 100644 contracts/interfaces/modules/tokenizer/ITokenizerModule.sol create mode 100644 contracts/modules/tokenizer/OwnableERC20.sol delete mode 100644 contracts/modules/tokenizer/OwnerableERC20.sol diff --git a/contracts/interfaces/modules/tokenizer/IOwnableERC20.sol b/contracts/interfaces/modules/tokenizer/IOwnableERC20.sol new file mode 100644 index 0000000..da86324 --- /dev/null +++ b/contracts/interfaces/modules/tokenizer/IOwnableERC20.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/// @title Ownable ERC20 Interface +/// @notice Interface for the Ownable ERC20 token +interface IOwnableERC20 is IERC20 { + /// @notice Struct for the initialization data + /// @param name The name of the token + /// @param symbol The symbol of the token + /// @param cap The cap of the token + /// @param initialOwner The initial owner of the token + struct InitData { + string name; + string symbol; + uint256 cap; + address initialOwner; + } + + /// @notice Initializes the token + /// @param initData The initialization data + function initialize(bytes memory initData) external; + + /// @notice Mints tokens + /// @param to The address to mint tokens to + /// @param amount The amount of tokens to mint + function mint(address to, uint256 amount) external; + + /// @notice Returns the upgradable beacon + function upgradableBeacon() external view returns (address); + + /// @notice Returns whether the contract supports an interface + /// @param interfaceId The interface ID + function supportsInterface(bytes4 interfaceId) external view returns (bool); +} diff --git a/contracts/interfaces/modules/tokenizer/ITokenizerModule.sol b/contracts/interfaces/modules/tokenizer/ITokenizerModule.sol new file mode 100644 index 0000000..078fa09 --- /dev/null +++ b/contracts/interfaces/modules/tokenizer/ITokenizerModule.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { IModule } from "@storyprotocol/core/interfaces/modules/base/IModule.sol"; + +/// @title Tokenizer Module Interface +/// @notice Interface for the Tokenizer Module +interface ITokenizerModule is IModule { + /// @notice Event emitted when a token template is whitelisted + /// @param tokenTemplate The address of the token template + /// @param allowed The whitelisting status + event TokenTemplateWhitelisted(address tokenTemplate, bool allowed); + + /// @notice Event emitted when an IP is tokenized + /// @param ipId The address of the IP + /// @param token The address of the token + event IPTokenized(address ipId, address token); + + /// @notice Whitelists a token template + /// @param tokenTemplate The address of the token template + /// @param allowed The whitelisting status + function whitelistTokenTemplate(address tokenTemplate, bool allowed) external; + + /// @notice Tokenizes an IP + /// @param ipId The address of the IP + /// @param tokenTemplate The address of the token template + /// @param initData The initialization data for the token + /// @return token The address of the newly created token + function tokenize(address ipId, address tokenTemplate, bytes calldata initData) external returns (address token); +} diff --git a/contracts/lib/Errors.sol b/contracts/lib/Errors.sol index 16eb419..c0b5314 100644 --- a/contracts/lib/Errors.sol +++ b/contracts/lib/Errors.sol @@ -92,4 +92,29 @@ library Errors { /// @param tokenId The ID of the original NFT that was first minted with this metadata hash. /// @param nftMetadataHash The hash of the NFT metadata that caused the duplication error. error SPGNFT__DuplicatedNFTMetadataHash(address spgNftContract, uint256 tokenId, bytes32 nftMetadataHash); + + //////////////////////////////////////////////////////////////////////////// + // TokenizerModule // + //////////////////////////////////////////////////////////////////////////// + + /// @notice Zero dispute module provided. + error TokenizerModule__ZeroDisputeModule(); + + /// @notice Zero token template provided. + error TokenizerModule__ZeroTokenTemplate(); + + /// @notice Token template is not supported. + error TokenizerModule__UnsupportedERC20(address tokenTemplate); + + /// @notice IP is disputed. + error TokenizerModule__DisputedIpId(address ipId); + + /// @notice Token template is not whitelisted. + error TokenizerModule__TokenTemplateNotWhitelisted(address tokenTemplate); + + /// @notice IP is not registered. + error TokenizerModule__IpNotRegistered(address ipId); + + /// @notice IP is expired. + error TokenizerModule__IpExpired(address ipId); } diff --git a/contracts/modules/tokenizer/OwnableERC20.sol b/contracts/modules/tokenizer/OwnableERC20.sol new file mode 100644 index 0000000..c9b23bd --- /dev/null +++ b/contracts/modules/tokenizer/OwnableERC20.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +// solhint-disable-next-line max-line-length +import { ERC20CappedUpgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20CappedUpgradeable.sol"; +import { OwnableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; + +import { IOwnableERC20 } from "../../interfaces/modules/tokenizer/IOwnableERC20.sol"; + +/// @title OwnableERC20 +/// @notice A capped ERC20 token with an owner that can mint tokens. +contract OwnableERC20 is IOwnableERC20, ERC20CappedUpgradeable, OwnableUpgradeable { + address public immutable UPGRADABLE_BEACON; + + constructor(address _upgradableBeacon) { + UPGRADABLE_BEACON = _upgradableBeacon; + _disableInitializers(); + } + + /// @notice Initializes the token + /// @param initData The initialization data + function initialize(bytes memory initData) external virtual initializer { + InitData memory initData = abi.decode(initData, (InitData)); + + __ERC20Capped_init(initData.cap); + __ERC20_init(initData.name, initData.symbol); + __Ownable_init(initData.initialOwner); + } + + /// @notice Mints tokens to the specified address. + /// @param to The address to mint tokens to. + /// @param amount The amount of tokens to mint. + function mint(address to, uint256 amount) external virtual onlyOwner { + _mint(to, amount); + } + + /// @notice Returns the upgradable beacon + function upgradableBeacon() external view returns (address) { + return UPGRADABLE_BEACON; + } + + /// @notice Returns whether the contract supports an interface + /// @param interfaceId The interface ID + function supportsInterface(bytes4 interfaceId) public view virtual returns (bool) { + return interfaceId == type(IOwnableERC20).interfaceId; + } +} diff --git a/contracts/modules/tokenizer/OwnerableERC20.sol b/contracts/modules/tokenizer/OwnerableERC20.sol deleted file mode 100644 index df21a30..0000000 --- a/contracts/modules/tokenizer/OwnerableERC20.sol +++ /dev/null @@ -1,13 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 -pragma solidity 0.8.26; - -import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; - -contract OwnerableERC20 is ERC20 { - constructor() ERC20("MockERC20", "MERC20") {} - - // can only mint by owner - function mint(address to, uint256 amount) external { - _mint(to, amount); - } -} diff --git a/contracts/modules/tokenizer/TokenizerModule.sol b/contracts/modules/tokenizer/TokenizerModule.sol index 74eb33e..90d48de 100644 --- a/contracts/modules/tokenizer/TokenizerModule.sol +++ b/contracts/modules/tokenizer/TokenizerModule.sol @@ -1,38 +1,135 @@ -// SPDX-License-Identifier: BUSL-1.1 +// SPDX-License-Identifier: MIT pragma solidity 0.8.26; import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; import { ERC165Checker } from "@openzeppelin/contracts/utils/introspection/ERC165Checker.sol"; +import { BeaconProxy } from "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol"; +import { UUPSUpgradeable } from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import { BaseModule } from "@storyprotocol/core/modules/BaseModule.sol"; import { AccessControlled } from "@storyprotocol/core/access/AccessControlled.sol"; +import { IIPAccount } from "@storyprotocol/core/interfaces/IIPAccount.sol"; +import { IPAccountStorageOps } from "@storyprotocol/core/lib/IPAccountStorageOps.sol"; import { IDisputeModule } from "@storyprotocol/core/interfaces/modules/dispute/IDisputeModule.sol"; +import { ProtocolPausableUpgradeable } from "@storyprotocol/core/pause/ProtocolPausableUpgradeable.sol"; + +import { IOwnableERC20 } from "../../interfaces/modules/tokenizer/IOwnableERC20.sol"; +import { Errors } from "../../lib/Errors.sol"; +import { ITokenizerModule } from "../../interfaces/modules/tokenizer/ITokenizerModule.sol"; /// @title Tokenizer Module /// @notice Tokenizer module is the main entry point for the IPA Tokenization and Fractionalization. /// It is responsible for: /// - Tokenize an IPA /// - Whitelist ERC20 Token Templates -contract TokenizerModule is BaseModule, AccessControlled { - using ERC165Checker for address; +contract TokenizerModule is + ITokenizerModule, + BaseModule, + AccessControlled, + ProtocolPausableUpgradeable, + UUPSUpgradeable +{ using Strings for *; + using ERC165Checker for address; + using IPAccountStorageOps for IIPAccount; + + /// @dev Storage structure for the TokenizerModule + /// @param isWhitelistedTokenTemplate Mapping of token templates to their whitelisting status + /// @custom:storage-location erc7201:story-protocol-periphery.TokenizerModule + struct TokenizerModuleStorage { + mapping(address => bool) isWhitelistedTokenTemplate; + } + + /// solhint-disable-next-line max-line-length + /// keccak256(abi.encode(uint256(keccak256("story-protocol-periphery.TokenizerModule")) - 1)) & ~bytes32(uint256(0xff)); + bytes32 private constant TokenizerModuleStorageLocation = + 0xef271c298b3e9574aa43cf546463b750863573b31e3d16f477ffc6f522452800; + + bytes32 public constant EXPIRATION_TIME = "EXPIRATION_TIME"; /// @notice Returns the protocol-wide dispute module + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable IDisputeModule public immutable DISPUTE_MODULE; + /// @custom:oz-upgrades-unsafe-allow constructor constructor( address accessController, address ipAssetRegistry, address disputeModule ) AccessControlled(accessController, ipAssetRegistry) { + if (disputeModule == address(0)) revert Errors.TokenizerModule__ZeroDisputeModule(); + DISPUTE_MODULE = IDisputeModule(disputeModule); } - function whitelistTokenTemplate(address tokenTemplate, bool allowed) external {} + /// @notice Whitelists a token template + /// @param tokenTemplate The address of the token template + /// @param allowed The whitelisting status + function whitelistTokenTemplate(address tokenTemplate, bool allowed) external restricted { + if (tokenTemplate == address(0)) revert Errors.TokenizerModule__ZeroTokenTemplate(); + if (!tokenTemplate.supportsInterface(type(IOwnableERC20).interfaceId)) + revert Errors.TokenizerModule__UnsupportedERC20(tokenTemplate); + + TokenizerModuleStorage storage $ = _getTokenizerModuleStorage(); + $.isWhitelistedTokenTemplate[tokenTemplate] = allowed; + + emit TokenTemplateWhitelisted(tokenTemplate, allowed); + } + + /// @notice Tokenizes an IP + /// @param ipId The address of the IP + /// @param tokenTemplate The address of the token template + /// @param initData The initialization data for the token + /// @return token The address of the newly created token + function tokenize( + address ipId, + address tokenTemplate, + bytes calldata initData + ) external verifyPermission(ipId) returns (address token) { + if (DISPUTE_MODULE.isIpTagged(ipId)) revert Errors.TokenizerModule__DisputedIpId(ipId); + if (!IP_ASSET_REGISTRY.isRegistered(ipId)) revert Errors.TokenizerModule__IpNotRegistered(ipId); + if (_isExpiredNow(ipId)) revert Errors.TokenizerModule__IpExpired(ipId); + + TokenizerModuleStorage storage $ = _getTokenizerModuleStorage(); + if (!$.isWhitelistedTokenTemplate[tokenTemplate]) + revert Errors.TokenizerModule__TokenTemplateNotWhitelisted(tokenTemplate); + + token = address( + new BeaconProxy( + IOwnableERC20(tokenTemplate).upgradableBeacon(), + abi.encodeWithSelector(IOwnableERC20.initialize.selector, initData) + ) + ); - function tokenize(address ipId, address tokenTemplate, bytes calldata initData) external verifyPermission(ipId) {} + emit IPTokenized(ipId, token); + } + + /// @dev Check if an IP is expired now + /// @param ipId The address of the IP + function _isExpiredNow(address ipId) internal view returns (bool) { + uint256 expireTime = _getExpireTime(ipId); + return expireTime != 0 && expireTime < block.timestamp; + } + /// @dev Get the expiration time of an IP + /// @param ipId The address of the IP + function _getExpireTime(address ipId) internal view returns (uint256) { + return IIPAccount(payable(ipId)).getUint256(EXPIRATION_TIME); + } + + /// @dev Returns the name of the module function name() external pure override returns (string memory) { return "TOKENIZER_MODULE"; } + + /// @dev Returns the storage struct of TokenizerModule. + function _getTokenizerModuleStorage() private pure returns (TokenizerModuleStorage storage $) { + assembly { + $.slot := TokenizerModuleStorageLocation + } + } + + /// @dev Hook to authorize the upgrade according to UUPSUpgradeable + /// @param newImplementation The address of the new implementation + function _authorizeUpgrade(address newImplementation) internal override restricted {} }