From df18d8442b765289b4b72fb8a6937ac0aa72bd98 Mon Sep 17 00:00:00 2001 From: Seb Date: Sun, 15 Dec 2024 22:00:23 -0800 Subject: [PATCH] Add Implementation to Tokenizer Module and OwnableERC20 (#144) * feat: tokenizer module * fix: inherit IERC165 & additional checks in tokenizer * fix: use `isExpiredNow` in license registry * tests: add tests and final changes * chore: linting * fix: add BUSL license & move whitelist dispute flag --- .../modules/tokenizer/IOwnableERC20.sol | 36 ++ .../modules/tokenizer/ITokenizerModule.sol | 40 +++ contracts/lib/Errors.sol | 43 +++ contracts/modules/tokenizer/OwnableERC20.sol | 79 +++++ .../modules/tokenizer/OwnerableERC20.sol | 13 - .../modules/tokenizer/TokenizerModule.sol | 130 +++++++- script/utils/DeployHelper.sol | 62 +++- test/modules/tokenizer/OwnableERC20.t.sol | 85 +++++ test/modules/tokenizer/TokenizerModule.t.sol | 311 ++++++++++++++++++ test/utils/BaseTest.t.sol | 13 +- 10 files changed, 787 insertions(+), 25 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 create mode 100644 test/modules/tokenizer/OwnableERC20.t.sol create mode 100644 test/modules/tokenizer/TokenizerModule.t.sol diff --git a/contracts/interfaces/modules/tokenizer/IOwnableERC20.sol b/contracts/interfaces/modules/tokenizer/IOwnableERC20.sol new file mode 100644 index 0000000..01a8b84 --- /dev/null +++ b/contracts/interfaces/modules/tokenizer/IOwnableERC20.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.26; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; + +/// @title Ownable ERC20 Interface +/// @notice Interface for the Ownable ERC20 token +interface IOwnableERC20 is IERC20, IERC165 { + /// @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(address ipId, 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 the ip id to whom this fractionalized token belongs to + function ipId() external view returns (address); +} diff --git a/contracts/interfaces/modules/tokenizer/ITokenizerModule.sol b/contracts/interfaces/modules/tokenizer/ITokenizerModule.sol new file mode 100644 index 0000000..6c0a60d --- /dev/null +++ b/contracts/interfaces/modules/tokenizer/ITokenizerModule.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: BUSL-1.1 +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); + + /// @notice Returns the fractionalized token for an IP + /// @param ipId The address of the IP + /// @return token The address of the token + function getFractionalizedToken(address ipId) external view returns (address token); + + /// @notice Checks if a token template is whitelisted + /// @param tokenTemplate The address of the token template + /// @return allowed The whitelisting status (true if whitelisted, false if not) + function isWhitelistedTokenTemplate(address tokenTemplate) external view returns (bool allowed); +} diff --git a/contracts/lib/Errors.sol b/contracts/lib/Errors.sol index 16eb419..0674cd4 100644 --- a/contracts/lib/Errors.sol +++ b/contracts/lib/Errors.sol @@ -92,4 +92,47 @@ 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); + + //////////////////////////////////////////////////////////////////////////// + // OwnableERC20 // + //////////////////////////////////////////////////////////////////////////// + + /// @notice Zero ip id provided. + error OwnableERC20__ZeroIpId(); + + //////////////////////////////////////////////////////////////////////////// + // TokenizerModule // + //////////////////////////////////////////////////////////////////////////// + /// @notice Zero license registry provided. + error TokenizerModule__ZeroLicenseRegistry(); + + /// @notice Zero dispute module provided. + error TokenizerModule__ZeroDisputeModule(); + + /// @notice Zero token template provided. + error TokenizerModule__ZeroTokenTemplate(); + + /// @notice Zero protocol access manager provided. + error TokenizerModule__ZeroProtocolAccessManager(); + + /// @notice Token template is not supported. + /// @param tokenTemplate The address of the token template that is not supported + error TokenizerModule__UnsupportedOwnableERC20(address tokenTemplate); + + /// @notice IP is disputed. + /// @param ipId The address of the disputed IP + error TokenizerModule__DisputedIpId(address ipId); + + /// @notice Token template is not whitelisted. + /// @param tokenTemplate The address of the token template + error TokenizerModule__TokenTemplateNotWhitelisted(address tokenTemplate); + + /// @notice IP is expired. + /// @param ipId The address of the expired IP + error TokenizerModule__IpExpired(address ipId); + + /// @notice IP is already tokenized. + /// @param ipId The address of the already tokenized IP + /// @param token The address of the fractionalized token for the IP + error TokenizerModule__IpAlreadyTokenized(address ipId, address token); } diff --git a/contracts/modules/tokenizer/OwnableERC20.sol b/contracts/modules/tokenizer/OwnableERC20.sol new file mode 100644 index 0000000..73942e4 --- /dev/null +++ b/contracts/modules/tokenizer/OwnableERC20.sol @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.26; + +import { IERC165 } from "@openzeppelin/contracts/interfaces/IERC165.sol"; +import { OwnableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +// solhint-disable-next-line max-line-length +import { ERC20CappedUpgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC20CappedUpgradeable.sol"; + +import { Errors } from "../../lib/Errors.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 { + /// @dev Storage structure for the OwnableERC20 + /// @param ipId The ip id to whom this fractionalized token belongs to + /// @custom:storage-location erc7201:story-protocol-periphery.OwnableERC20 + struct OwnableERC20Storage { + address ipId; + } + + // solhint-disable-next-line max-line-length + // keccak256(abi.encode(uint256(keccak256("story-protocol-periphery.OwnableERC20")) - 1)) & ~bytes32(uint256(0xff)); + bytes32 private constant OwnableERC20StorageLocation = + 0xc4b74d5382372ff8ada6effed0295109822b72fe030fc4cd981ca0e25adfab00; + + /// @notice The upgradable beacon of this contract + address public immutable UPGRADABLE_BEACON; + + constructor(address _upgradableBeacon) { + UPGRADABLE_BEACON = _upgradableBeacon; + _disableInitializers(); + } + + /// @notice Initializes the token + /// @param initData The initialization data + function initialize(address ipId, bytes memory initData) external virtual initializer { + if (ipId == address(0)) revert Errors.OwnableERC20__ZeroIpId(); + + InitData memory initData = abi.decode(initData, (InitData)); + + __ERC20Capped_init(initData.cap); + __ERC20_init(initData.name, initData.symbol); + __Ownable_init(initData.initialOwner); + + OwnableERC20Storage storage $ = _getOwnableERC20Storage(); + $.ipId = ipId; + } + + /// @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 the ip id to whom this fractionalized token belongs to + function ipId() external view returns (address) { + return _getOwnableERC20Storage().ipId; + } + + /// @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 || interfaceId == type(IERC165).interfaceId; + } + + /// @dev Returns the storage struct of OwnableERC20. + function _getOwnableERC20Storage() private pure returns (OwnableERC20Storage storage $) { + assembly { + $.slot := OwnableERC20StorageLocation + } + } +} 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..58acef6 100644 --- a/contracts/modules/tokenizer/TokenizerModule.sol +++ b/contracts/modules/tokenizer/TokenizerModule.sol @@ -2,37 +2,159 @@ pragma solidity 0.8.26; import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; +import { BeaconProxy } from "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol"; import { ERC165Checker } from "@openzeppelin/contracts/utils/introspection/ERC165Checker.sol"; +import { UUPSUpgradeable } from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import { ReentrancyGuardUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; import { BaseModule } from "@storyprotocol/core/modules/BaseModule.sol"; +import { IIPAccount } from "@storyprotocol/core/interfaces/IIPAccount.sol"; import { AccessControlled } from "@storyprotocol/core/access/AccessControlled.sol"; +import { IPAccountStorageOps } from "@storyprotocol/core/lib/IPAccountStorageOps.sol"; +import { ILicenseRegistry } from "@storyprotocol/core/interfaces/registries/ILicenseRegistry.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, + ReentrancyGuardUpgradeable, + 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 + /// @param fractionalizedTokens Mapping of IP IDs to their fractionalized tokens + /// @custom:storage-location erc7201:story-protocol-periphery.TokenizerModule + struct TokenizerModuleStorage { + mapping(address => bool) isWhitelistedTokenTemplate; + mapping(address ipId => address token) fractionalizedTokens; + } + + /// 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; + + /// @notice Returns the protocol-wide license registry + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + ILicenseRegistry public immutable LICENSE_REGISTRY; /// @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 licenseRegistry, address disputeModule ) AccessControlled(accessController, ipAssetRegistry) { + if (licenseRegistry == address(0)) revert Errors.TokenizerModule__ZeroLicenseRegistry(); + if (disputeModule == address(0)) revert Errors.TokenizerModule__ZeroDisputeModule(); + + LICENSE_REGISTRY = ILicenseRegistry(licenseRegistry); DISPUTE_MODULE = IDisputeModule(disputeModule); + _disableInitializers(); + } + + /// @notice Initializes the TokenizerModule + /// @param protocolAccessManager The address of the protocol access manager + function initialize(address protocolAccessManager) external initializer { + if (protocolAccessManager == address(0)) revert Errors.TokenizerModule__ZeroProtocolAccessManager(); + + __ReentrancyGuard_init(); + __UUPSUpgradeable_init(); + __ProtocolPausable_init(protocolAccessManager); + } + + /// @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__UnsupportedOwnableERC20(tokenTemplate); + + TokenizerModuleStorage storage $ = _getTokenizerModuleStorage(); + $.isWhitelistedTokenTemplate[tokenTemplate] = allowed; + + emit TokenTemplateWhitelisted(tokenTemplate, allowed); } - function whitelistTokenTemplate(address tokenTemplate, bool allowed) external {} + /// @notice Tokenizes (fractionalizes) 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) nonReentrant returns (address token) { + if (DISPUTE_MODULE.isIpTagged(ipId)) revert Errors.TokenizerModule__DisputedIpId(ipId); + if (LICENSE_REGISTRY.isExpiredNow(ipId)) revert Errors.TokenizerModule__IpExpired(ipId); + + TokenizerModuleStorage storage $ = _getTokenizerModuleStorage(); + address existingToken = $.fractionalizedTokens[ipId]; + if (existingToken != address(0)) revert Errors.TokenizerModule__IpAlreadyTokenized(ipId, existingToken); + if (!$.isWhitelistedTokenTemplate[tokenTemplate]) + revert Errors.TokenizerModule__TokenTemplateNotWhitelisted(tokenTemplate); + + token = address( + new BeaconProxy( + IOwnableERC20(tokenTemplate).upgradableBeacon(), + abi.encodeWithSelector(IOwnableERC20.initialize.selector, ipId, initData) + ) + ); - function tokenize(address ipId, address tokenTemplate, bytes calldata initData) external verifyPermission(ipId) {} + $.fractionalizedTokens[ipId] = token; + emit IPTokenized(ipId, token); + } + + /// @notice Returns the fractionalized token for an IP + /// @param ipId The address of the IP + /// @return token The address of the token (0 address if IP has not been tokenized) + function getFractionalizedToken(address ipId) external view returns (address token) { + return _getTokenizerModuleStorage().fractionalizedTokens[ipId]; + } + + /// @notice Checks if a token template is whitelisted + /// @param tokenTemplate The address of the token template + /// @return allowed The whitelisting status (true if whitelisted, false if not) + function isWhitelistedTokenTemplate(address tokenTemplate) external view returns (bool allowed) { + return _getTokenizerModuleStorage().isWhitelistedTokenTemplate[tokenTemplate]; + } + + /// @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 {} } diff --git a/script/utils/DeployHelper.sol b/script/utils/DeployHelper.sol index b12e163..5f24170 100644 --- a/script/utils/DeployHelper.sol +++ b/script/utils/DeployHelper.sol @@ -43,6 +43,8 @@ import { RoyaltyWorkflows } from "../../contracts/workflows/RoyaltyWorkflows.sol import { RoyaltyTokenDistributionWorkflows } from "../../contracts/workflows/RoyaltyTokenDistributionWorkflows.sol"; import { StoryBadgeNFT } from "../../contracts/story-nft/StoryBadgeNFT.sol"; import { OrgStoryNFTFactory } from "../../contracts/story-nft/OrgStoryNFTFactory.sol"; +import { OwnableERC20 } from "../../contracts/modules/tokenizer/OwnableERC20.sol"; +import { TokenizerModule } from "../../contracts/modules/tokenizer/TokenizerModule.sol"; // script import { BroadcastManager } from "./BroadcastManager.s.sol"; @@ -94,6 +96,11 @@ contract DeployHelper is address internal defaultOrgStoryNftTemplate; address internal defaultOrgStoryNftBeacon; + // Tokenizer Module + TokenizerModule internal tokenizerModule; + address internal ownableERC20Template; + address internal ownableERC20Beacon; + // DeployHelper variable bool internal writeDeploys; @@ -152,14 +159,14 @@ contract DeployHelper is deployer = mockDeployer; _deployMockCoreContracts(); _configureMockCoreContracts(); - _deployWorkflowContracts(); - _configureWorkflowContracts(); + _deployPeripheryContracts(); + _configurePeripheryContracts(); } else { // production deployment _readStoryProtocolCoreAddresses(); // StoryProtocolCoreAddressManager.s.sol _beginBroadcast(); // BroadcastManager.s.sol - _deployWorkflowContracts(); - _configureWorkflowContracts(); + _deployPeripheryContracts(); + _configurePeripheryContracts(); // Check deployment configuration. if (spgNftBeacon.owner() != address(registrationWorkflows)) @@ -293,7 +300,7 @@ contract DeployHelper is } } - function _deployWorkflowContracts() private { + function _deployPeripheryContracts() private { address impl = address(0); // Periphery workflow contracts @@ -452,12 +459,53 @@ contract DeployHelper is ) ); _postdeploy("SPGNFTBeacon", address(spgNftBeacon)); + + // Tokenizer Module + _predeploy("TokenizerModule"); + impl = address(new TokenizerModule(address(accessController), address(ipAssetRegistry), address(licenseRegistry), address(disputeModule))); + tokenizerModule = TokenizerModule( + TestProxyHelper.deployUUPSProxy( + create3Deployer, + _getSalt(type(TokenizerModule).name), + impl, + abi.encodeCall(TokenizerModule.initialize, address(protocolAccessManager)) + ) + ); + impl = address(0); + _postdeploy("TokenizerModule", address(tokenizerModule)); + + // OwnableERC20 template + _predeploy("OwnableERC20Template"); + ownableERC20Template = address(new OwnableERC20( + _getDeployedAddress("OwnableERC20Beacon") + )); + _postdeploy("OwnableERC20Template", ownableERC20Template); + + // Upgradeable Beacon for OwnableERC20Template + _predeploy("OwnableERC20Beacon"); + ownableERC20Beacon = address(UpgradeableBeacon( + create3Deployer.deploy( + _getSalt("OwnableERC20Beacon"), + abi.encodePacked(type(UpgradeableBeacon).creationCode, abi.encode(ownableERC20Template, deployer)) + ) + )); + _postdeploy("OwnableERC20Beacon", address(ownableERC20Beacon)); + + require( + UpgradeableBeacon(ownableERC20Beacon).implementation() == address(ownableERC20Template), + "DeployHelper: Invalid beacon implementation" + ); + require( + OwnableERC20(ownableERC20Template).upgradableBeacon() == address(ownableERC20Beacon), + "DeployHelper: Invalid beacon address in template" + ); } - function _configureWorkflowContracts() private { + function _configurePeripheryContracts() private { // Transfer ownership of beacon proxy to RegistrationWorkflows spgNftBeacon.transferOwnership(address(registrationWorkflows)); - + tokenizerModule.whitelistTokenTemplate(address(ownableERC20Template), true); + moduleRegistry.registerModule("TOKENIZER_MODULE", address(tokenizerModule)); // more configurations may be added here } diff --git a/test/modules/tokenizer/OwnableERC20.t.sol b/test/modules/tokenizer/OwnableERC20.t.sol new file mode 100644 index 0000000..6784cb5 --- /dev/null +++ b/test/modules/tokenizer/OwnableERC20.t.sol @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: BUSL-1.1 +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 { BeaconProxy } from "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol"; + +import { IOwnableERC20 } from "../../../contracts/interfaces/modules/tokenizer/IOwnableERC20.sol"; +import { OwnableERC20 } from "../../../contracts/modules/tokenizer/OwnableERC20.sol"; + +import { BaseTest } from "../../utils/BaseTest.t.sol"; + +contract OwnableERC20Test is BaseTest { + OwnableERC20 internal testOwnableERC20; + + function setUp() public override { + super.setUp(); + + testOwnableERC20 = OwnableERC20( + address( + new BeaconProxy( + address(ownableERC20Beacon), + abi.encodeWithSelector( + IOwnableERC20.initialize.selector, + address(0x111), + abi.encode( + IOwnableERC20.InitData({ cap: 1000, name: "Test", symbol: "TEST", initialOwner: u.admin }) + ) + ) + ) + ) + ); + } + + function test_OwnableERC20_initialize() public { + assertEq(testOwnableERC20.name(), "Test"); + assertEq(testOwnableERC20.symbol(), "TEST"); + assertEq(testOwnableERC20.owner(), u.admin); + assertEq(testOwnableERC20.cap(), 1000); + assertEq(testOwnableERC20.ipId(), address(0x111)); + } + + function test_OwnableERC20_mint() public { + vm.startPrank(u.admin); + testOwnableERC20.mint(u.admin, 100); + testOwnableERC20.mint(u.alice, 100); + testOwnableERC20.mint(u.bob, 100); + vm.stopPrank(); + + assertEq(testOwnableERC20.totalSupply(), 300); + assertEq(testOwnableERC20.balanceOf(u.admin), 100); + assertEq(testOwnableERC20.balanceOf(u.alice), 100); + assertEq(testOwnableERC20.balanceOf(u.bob), 100); + } + + function test_OwnableERC20_mint_revert_NotOwner() public { + vm.startPrank(u.alice); + vm.expectRevert(abi.encodeWithSelector(OwnableUpgradeable.OwnableUnauthorizedAccount.selector, u.alice)); + testOwnableERC20.mint(u.alice, 100); + vm.stopPrank(); + + vm.startPrank(u.bob); + vm.expectRevert(abi.encodeWithSelector(OwnableUpgradeable.OwnableUnauthorizedAccount.selector, u.bob)); + testOwnableERC20.mint(u.bob, 100); + vm.stopPrank(); + } + + function test_OwnableERC20_revert_ERC20ExceededCap() public { + vm.startPrank(u.admin); + vm.expectRevert(abi.encodeWithSelector(ERC20CappedUpgradeable.ERC20ExceededCap.selector, 1001, 1000)); + testOwnableERC20.mint(u.admin, 1001); + vm.stopPrank(); + + vm.startPrank(u.admin); + testOwnableERC20.mint(u.alice, 500); + testOwnableERC20.mint(u.bob, 500); + vm.stopPrank(); + + vm.startPrank(u.admin); + vm.expectRevert(abi.encodeWithSelector(ERC20CappedUpgradeable.ERC20ExceededCap.selector, 2000, 1000)); + testOwnableERC20.mint(u.admin, 1000); + vm.stopPrank(); + } +} diff --git a/test/modules/tokenizer/TokenizerModule.t.sol b/test/modules/tokenizer/TokenizerModule.t.sol new file mode 100644 index 0000000..1e20fbe --- /dev/null +++ b/test/modules/tokenizer/TokenizerModule.t.sol @@ -0,0 +1,311 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.26; + +import { Licensing } from "@storyprotocol/core/lib/Licensing.sol"; +import { Errors as CoreErrors } from "@storyprotocol/core/lib/Errors.sol"; +import { IIPAccount } from "@storyprotocol/core/interfaces/IIPAccount.sol"; +import { IPAccountStorageOps } from "@storyprotocol/core/lib/IPAccountStorageOps.sol"; +import { PILTerms } from "@storyprotocol/core/interfaces/modules/licensing/IPILicenseTemplate.sol"; + +import { Errors } from "../../../contracts/lib/Errors.sol"; +import { WorkflowStructs } from "../../../contracts/lib/WorkflowStructs.sol"; +import { OwnableERC20 } from "../../../contracts/modules/tokenizer/OwnableERC20.sol"; +import { IOwnableERC20 } from "../../../contracts/interfaces/modules/tokenizer/IOwnableERC20.sol"; +import { ITokenizerModule } from "../../../contracts/interfaces/modules/tokenizer/ITokenizerModule.sol"; + +import { BaseTest } from "../../utils/BaseTest.t.sol"; + +contract TokenizerModuleTest is BaseTest { + using IPAccountStorageOps for IIPAccount; + + function setUp() public override { + super.setUp(); + } + + function test_TokenizerModule_whitelistTokenTemplate() public { + address tokenTemplate1 = address(new OwnableERC20(address(ownableERC20Beacon))); + address tokenTemplate2 = address(new OwnableERC20(address(ownableERC20Beacon))); + address tokenTemplate3 = address(new OwnableERC20(address(ownableERC20Beacon))); + + vm.startPrank(u.admin); + vm.expectEmit(true, true, true, true); + emit ITokenizerModule.TokenTemplateWhitelisted(tokenTemplate1, true); + tokenizerModule.whitelistTokenTemplate(tokenTemplate1, true); + vm.expectEmit(true, true, true, true); + emit ITokenizerModule.TokenTemplateWhitelisted(tokenTemplate2, true); + tokenizerModule.whitelistTokenTemplate(tokenTemplate2, true); + vm.expectEmit(true, true, true, true); + emit ITokenizerModule.TokenTemplateWhitelisted(tokenTemplate3, true); + tokenizerModule.whitelistTokenTemplate(tokenTemplate3, true); + vm.stopPrank(); + + assertTrue(tokenizerModule.isWhitelistedTokenTemplate(tokenTemplate1)); + assertTrue(tokenizerModule.isWhitelistedTokenTemplate(tokenTemplate2)); + assertTrue(tokenizerModule.isWhitelistedTokenTemplate(tokenTemplate3)); + } + + function test_TokenizerModule_revert_whitelistTokenTemplate_UnsupportedERC20() public { + vm.startPrank(u.admin); + vm.expectRevert( + abi.encodeWithSelector(Errors.TokenizerModule__UnsupportedOwnableERC20.selector, address(spgNftImpl)) + ); + tokenizerModule.whitelistTokenTemplate(address(spgNftImpl), true); + vm.expectRevert( + abi.encodeWithSelector(Errors.TokenizerModule__UnsupportedOwnableERC20.selector, address(mockToken)) + ); + tokenizerModule.whitelistTokenTemplate(address(mockToken), true); + vm.expectRevert( + abi.encodeWithSelector(Errors.TokenizerModule__UnsupportedOwnableERC20.selector, address(mockNft)) + ); + tokenizerModule.whitelistTokenTemplate(address(mockNft), true); + vm.stopPrank(); + } + + function test_TokenizerModule_tokenize() public { + mockToken.mint(address(this), 3 * 10 ** mockToken.decimals()); + mockToken.approve(address(spgNftPublic), 3 * 10 ** mockToken.decimals()); + + (address ipId1, ) = registrationWorkflows.mintAndRegisterIp({ + spgNftContract: address(spgNftPublic), + recipient: u.alice, + ipMetadata: ipMetadataDefault, + allowDuplicates: true + }); + + (address ipId2, ) = registrationWorkflows.mintAndRegisterIp({ + spgNftContract: address(spgNftPublic), + recipient: u.bob, + ipMetadata: ipMetadataEmpty, + allowDuplicates: true + }); + + (address ipId3, ) = registrationWorkflows.mintAndRegisterIp({ + spgNftContract: address(spgNftPublic), + recipient: u.carl, + ipMetadata: ipMetadataDefault, + allowDuplicates: true + }); + + vm.prank(u.alice); + vm.expectEmit(true, false, false, false); + emit ITokenizerModule.IPTokenized(ipId1, address(0)); + OwnableERC20 token1 = OwnableERC20( + tokenizerModule.tokenize( + ipId1, + address(ownableERC20Template), + abi.encode(IOwnableERC20.InitData({ cap: 1000, name: "Test1", symbol: "T1", initialOwner: u.alice })) + ) + ); + + vm.prank(u.bob); + vm.expectEmit(true, false, false, false); + emit ITokenizerModule.IPTokenized(ipId2, address(0)); + OwnableERC20 token2 = OwnableERC20( + tokenizerModule.tokenize( + ipId2, + address(ownableERC20Template), + abi.encode(IOwnableERC20.InitData({ cap: 1000000, name: "Test2", symbol: "T2", initialOwner: u.bob })) + ) + ); + + vm.prank(u.carl); + vm.expectEmit(true, false, false, false); + emit ITokenizerModule.IPTokenized(ipId3, address(0)); + OwnableERC20 token3 = OwnableERC20( + tokenizerModule.tokenize( + ipId3, + address(ownableERC20Template), + abi.encode(IOwnableERC20.InitData({ cap: 99999, name: "Test3", symbol: "T3", initialOwner: u.carl })) + ) + ); + + assertEq(tokenizerModule.getFractionalizedToken(ipId1), address(token1)); + assertEq(tokenizerModule.getFractionalizedToken(ipId2), address(token2)); + assertEq(tokenizerModule.getFractionalizedToken(ipId3), address(token3)); + + assertEq(token1.name(), "Test1"); + assertEq(token1.symbol(), "T1"); + assertEq(token1.cap(), 1000); + assertEq(token1.ipId(), ipId1); + assertEq(token1.owner(), u.alice); + + assertEq(token2.name(), "Test2"); + assertEq(token2.symbol(), "T2"); + assertEq(token2.cap(), 1000000); + assertEq(token2.ipId(), ipId2); + assertEq(token2.owner(), u.bob); + + assertEq(token3.name(), "Test3"); + assertEq(token3.symbol(), "T3"); + assertEq(token3.cap(), 99999); + assertEq(token3.ipId(), ipId3); + assertEq(token3.owner(), u.carl); + } + + function test_TokenizerModule_revert_tokenize_DisputedIpId() public { + vm.prank(u.admin); + disputeModule.whitelistDisputeTag("PLAGIARISM", true); + + mockToken.mint(address(this), 1 * 10 ** mockToken.decimals()); + mockToken.approve(address(spgNftPublic), 1 * 10 ** mockToken.decimals()); + (address ipId, ) = registrationWorkflows.mintAndRegisterIp({ + spgNftContract: address(spgNftPublic), + recipient: u.alice, + ipMetadata: ipMetadataDefault, + allowDuplicates: true + }); + + uint256 disputeId = disputeModule.raiseDispute({ + targetIpId: ipId, + disputeEvidenceHash: bytes32(0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef), + targetTag: "PLAGIARISM", + data: "" + }); + + vm.prank(u.admin); + disputeModule.setDisputeJudgement(disputeId, true, ""); + + vm.prank(u.alice); + vm.expectRevert(abi.encodeWithSelector(Errors.TokenizerModule__DisputedIpId.selector, ipId)); + tokenizerModule.tokenize( + ipId, + address(ownableERC20Template), + abi.encode(IOwnableERC20.InitData({ cap: 1000, name: "Test1", symbol: "T1", initialOwner: u.alice })) + ); + } + + function test_TokenizerModule_revert_tokenize_IpNotRegistered() public { + address ipId = ipAssetRegistry.ipId(block.chainid, address(spgNftPublic), 1); + vm.prank(u.alice); + vm.expectRevert(abi.encodeWithSelector(CoreErrors.AccessControlled__NotIpAccount.selector, ipId)); + tokenizerModule.tokenize( + ipId, + address(ownableERC20Template), + abi.encode(IOwnableERC20.InitData({ cap: 1000, name: "Test1", symbol: "T1", initialOwner: u.alice })) + ); + } + + function test_TokenizerModule_revert_tokenize_callerNotIpOwner() public { + mockToken.mint(address(this), 1 * 10 ** mockToken.decimals()); + mockToken.approve(address(spgNftPublic), 1 * 10 ** mockToken.decimals()); + (address ipId, ) = registrationWorkflows.mintAndRegisterIp({ + spgNftContract: address(spgNftPublic), + recipient: u.alice, + ipMetadata: ipMetadataDefault, + allowDuplicates: true + }); + + vm.prank(u.bob); + vm.expectRevert( + abi.encodeWithSelector( + CoreErrors.AccessController__PermissionDenied.selector, + ipId, + u.bob, + address(tokenizerModule), + tokenizerModule.tokenize.selector + ) + ); + tokenizerModule.tokenize( + ipId, + address(ownableERC20Template), + abi.encode(IOwnableERC20.InitData({ cap: 1000, name: "Test1", symbol: "T1", initialOwner: u.bob })) + ); + } + + function test_TokenizerModule_revert_tokenize_IpExpired() public { + WorkflowStructs.LicenseTermsData[] memory termsData = new WorkflowStructs.LicenseTermsData[](1); + termsData[0] = WorkflowStructs.LicenseTermsData({ + terms: PILTerms({ + transferable: true, + royaltyPolicy: address(royaltyPolicyLAP), + defaultMintingFee: 0, + expiration: 10 days, + commercialUse: true, + commercialAttribution: true, + commercializerChecker: address(0), + commercializerCheckerData: "", + commercialRevShare: 0, + commercialRevCeiling: 0, + derivativesAllowed: true, + derivativesAttribution: true, + derivativesApproval: false, + derivativesReciprocal: true, + derivativeRevCeiling: 0, + currency: address(mockToken), + uri: "" + }), + licensingConfig: Licensing.LicensingConfig({ + isSet: false, + mintingFee: 0, + licensingHook: address(0), + hookData: "", + commercialRevShare: 0, + disabled: false, + expectMinimumGroupRewardShare: 0, + expectGroupRewardPool: address(0) + }) + }); + + mockToken.mint(address(this), 2 * 10 ** mockToken.decimals()); + mockToken.approve(address(spgNftPublic), 2 * 10 ** mockToken.decimals()); + (address ipId1, , uint256[] memory licenseIds) = licenseAttachmentWorkflows.mintAndRegisterIpAndAttachPILTerms({ + spgNftContract: address(spgNftPublic), + recipient: u.alice, + ipMetadata: ipMetadataDefault, + licenseTermsData: termsData, + allowDuplicates: true + }); + + address[] memory parentIpIds = new address[](1); + parentIpIds[0] = ipId1; + (address ipId2, ) = derivativeWorkflows.mintAndRegisterIpAndMakeDerivative({ + spgNftContract: address(spgNftPublic), + derivData: WorkflowStructs.MakeDerivative({ + parentIpIds: parentIpIds, + licenseTemplate: address(pilTemplate), + licenseTermsIds: licenseIds, + royaltyContext: "", + maxMintingFee: 0, + maxRts: 0 + }), + recipient: u.alice, + ipMetadata: ipMetadataDefault, + allowDuplicates: true + }); + + vm.warp(11 days); + vm.prank(u.alice); + vm.expectRevert(abi.encodeWithSelector(Errors.TokenizerModule__IpExpired.selector, ipId2)); + tokenizerModule.tokenize( + ipId2, + address(ownableERC20Template), + abi.encode(IOwnableERC20.InitData({ cap: 1000, name: "Test1", symbol: "T1", initialOwner: u.alice })) + ); + } + + function test_TokenizerModule_revert_tokenize_IpAlreadyTokenized() public { + mockToken.mint(address(this), 1 * 10 ** mockToken.decimals()); + mockToken.approve(address(spgNftPublic), 1 * 10 ** mockToken.decimals()); + (address ipId, ) = registrationWorkflows.mintAndRegisterIp({ + spgNftContract: address(spgNftPublic), + recipient: u.alice, + ipMetadata: ipMetadataDefault, + allowDuplicates: true + }); + + vm.prank(u.alice); + address token = tokenizerModule.tokenize( + ipId, + address(ownableERC20Template), + abi.encode(IOwnableERC20.InitData({ cap: 1000, name: "Test1", symbol: "T1", initialOwner: u.alice })) + ); + + vm.prank(u.alice); + vm.expectRevert(abi.encodeWithSelector(Errors.TokenizerModule__IpAlreadyTokenized.selector, ipId, token)); + tokenizerModule.tokenize( + ipId, + address(ownableERC20Template), + abi.encode(IOwnableERC20.InitData({ cap: 1000, name: "Test1", symbol: "T1", initialOwner: u.alice })) + ); + } +} diff --git a/test/utils/BaseTest.t.sol b/test/utils/BaseTest.t.sol index 793ffc5..4608ab3 100644 --- a/test/utils/BaseTest.t.sol +++ b/test/utils/BaseTest.t.sol @@ -11,6 +11,7 @@ import { ICoreMetadataModule } from "@storyprotocol/core/interfaces/modules/meta import { IIPAccount } from "@storyprotocol/core/interfaces/IIPAccount.sol"; import { ILicensingModule } from "@storyprotocol/core/interfaces/modules/licensing/ILicensingModule.sol"; import { MetaTx } from "@storyprotocol/core/lib/MetaTx.sol"; +import { MockArbitrationPolicy } from "@storyprotocol/test/mocks/dispute/MockArbitrationPolicy.sol"; import { MockIPGraph } from "@storyprotocol/test/mocks/MockIPGraph.sol"; // contracts @@ -120,6 +121,7 @@ contract BaseTest is Test, DeployHelper { licenseAttachmentWorkflows.setNftContractBeacon(address(spgNftBeacon)); registrationWorkflows.setNftContractBeacon(address(spgNftBeacon)); royaltyTokenDistributionWorkflows.setNftContractBeacon(address(spgNftBeacon)); + vm.stopPrank(); } @@ -168,9 +170,18 @@ contract BaseTest is Test, DeployHelper { ); vm.stopPrank(); + vm.startPrank(u.admin); // whitelist mockToken as a royalty token - vm.prank(u.admin); royaltyModule.whitelistRoyaltyToken(address(mockToken), true); + + // whitelist mockArbitrationPolicy as an arbitration policy + address mockArbitrationPolicy = address( + new MockArbitrationPolicy(address(disputeModule), address(mockToken), 0) + ); + disputeModule.whitelistArbitrationPolicy(mockArbitrationPolicy, true); + disputeModule.setBaseArbitrationPolicy(mockArbitrationPolicy); + disputeModule.setArbitrationRelayer(mockArbitrationPolicy, address(u.admin)); + vm.stopPrank(); } function _setupIPMetadata() internal {