From ef4b0006afae7be6a89fab5f722d12fe4d789019 Mon Sep 17 00:00:00 2001 From: Seb Date: Wed, 9 Oct 2024 11:18:19 -0700 Subject: [PATCH] Introduce Story NFT (#87) * feat: introduce Story NFT * fix(deploy-script): remove test deployment logic * chore: lint & rm extra imports --- contracts/interfaces/story-nft/IERC5192.sol | 22 ++ contracts/interfaces/story-nft/IOrgNFT.sol | 67 ++++ .../interfaces/story-nft/IStoryBadgeNFT.sol | 70 ++++ contracts/interfaces/story-nft/IStoryNFT.sol | 48 +++ .../interfaces/story-nft/IStoryNFTFactory.sol | 148 ++++++++ contracts/story-nft/BaseStoryNFT.sol | 163 ++++++++ contracts/story-nft/OrgNFT.sol | 190 ++++++++++ contracts/story-nft/StoryBadgeNFT.sol | 159 ++++++++ contracts/story-nft/StoryNFTFactory.sol | 292 +++++++++++++++ script/deployment/StoryNFT.s.sol | 31 ++ script/utils/DeployHelper.sol | 123 +++++- test/story-nft/OrgNFT.t.sol | 95 +++++ test/story-nft/StoryBadgeNFT.t.sol | 228 ++++++++++++ test/story-nft/StoryNFTFactory.t.sol | 350 ++++++++++++++++++ test/utils/BaseTest.t.sol | 84 ++++- test/workflows/DerivativeWorkflows.t.sol | 14 - 16 files changed, 2057 insertions(+), 27 deletions(-) create mode 100644 contracts/interfaces/story-nft/IERC5192.sol create mode 100644 contracts/interfaces/story-nft/IOrgNFT.sol create mode 100644 contracts/interfaces/story-nft/IStoryBadgeNFT.sol create mode 100644 contracts/interfaces/story-nft/IStoryNFT.sol create mode 100644 contracts/interfaces/story-nft/IStoryNFTFactory.sol create mode 100644 contracts/story-nft/BaseStoryNFT.sol create mode 100644 contracts/story-nft/OrgNFT.sol create mode 100644 contracts/story-nft/StoryBadgeNFT.sol create mode 100644 contracts/story-nft/StoryNFTFactory.sol create mode 100644 script/deployment/StoryNFT.s.sol create mode 100644 test/story-nft/OrgNFT.t.sol create mode 100644 test/story-nft/StoryBadgeNFT.t.sol create mode 100644 test/story-nft/StoryNFTFactory.t.sol diff --git a/contracts/interfaces/story-nft/IERC5192.sol b/contracts/interfaces/story-nft/IERC5192.sol new file mode 100644 index 0000000..34038f7 --- /dev/null +++ b/contracts/interfaces/story-nft/IERC5192.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +/// @title Minimal Soulbound NFT Interface +/// @notice Minimal interface for soulbinding EIP-721 NFTs +interface IERC5192 { + /// @notice Emitted when the locking status is changed to locked. + /// @dev If a token is minted and the status is locked, this event should be emitted. + /// @param tokenId The identifier for a token. + event Locked(uint256 tokenId); + + /// @notice Emitted when the locking status is changed to unlocked. + /// @dev If a token is minted and the status is unlocked, this event should be emitted. + /// @param tokenId The identifier for a token. + event Unlocked(uint256 tokenId); + + /// @notice Returns the locking status of an Soulbound Token + /// @dev SBTs assigned to zero address are considered invalid, and queries + /// about them do throw. + /// @param tokenId The identifier for an SBT. + function locked(uint256 tokenId) external view returns (bool); +} diff --git a/contracts/interfaces/story-nft/IOrgNFT.sol b/contracts/interfaces/story-nft/IOrgNFT.sol new file mode 100644 index 0000000..b0207ba --- /dev/null +++ b/contracts/interfaces/story-nft/IOrgNFT.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { IERC721Metadata } from "@openzeppelin/contracts/token/ERC721/extensions/IERC721Metadata.sol"; + +/// @title Organization NFT Interface +/// @notice Each organization token represents a Story ecosystem project. +/// The root organization token represents Story. +/// Each organization token register as a IP on Story and is a derivative of the root organization IP. +interface IOrgNFT is IERC721Metadata { + //////////////////////////////////////////////////////////////////////////// + // Errors // + //////////////////////////////////////////////////////////////////////////// + /// @notice Caller is not the StoryNFTFactory contract. + /// @param caller The address of the caller. + /// @param storyNftFactory The address of the `StoryNFTFactory` contract. + error OrgNFT__CallerNotStoryNFTFactory(address caller, address storyNftFactory); + + /// @notice Root organization NFT has already been minted. + error OrgNFT__RootOrgNftAlreadyMinted(); + + /// @notice Root organization NFT has not been minted yet (`mintRootOrgNft` has not been called). + error OrgNFT__RootOrgNftNotMinted(); + + /// @notice Zero address provided as a param to OrgNFT functions. + error OrgNFT__ZeroAddressParam(); + + //////////////////////////////////////////////////////////////////////////// + // Events // + //////////////////////////////////////////////////////////////////////////// + /// @notice Emitted when a organization token minted. + /// @param recipient The address of the recipient of the organization token. + /// @param orgNft The address of the organization NFT. + /// @param tokenId The ID of the minted organization token. + /// @param orgIpId The ID of the organization IP. + event OrgNFTMinted(address recipient, address orgNft, uint256 tokenId, address orgIpId); + + //////////////////////////////////////////////////////////////////////////// + // Functions // + //////////////////////////////////////////////////////////////////////////// + /// @notice Mints the root organization token and register it as an IP. + /// @param recipient The address of the recipient of the root organization token. + /// @param tokenURI The URI of the root organization token. + /// @return rootOrgTokenId The ID of the root organization token. + /// @return rootOrgIpId The ID of the root organization IP. + function mintRootOrgNft( + address recipient, + string memory tokenURI + ) external returns (uint256 rootOrgTokenId, address rootOrgIpId); + + /// @notice Mints a organization token, register it as an IP, + /// and makes the IP as a derivative of the root organization IP. + /// @param recipient The address of the recipient of the minted organization token. + /// @param tokenURI The URI of the minted organization token. + /// @return orgTokenId The ID of the minted organization token. + /// @return orgIpId The ID of the organization IP. + function mintOrgNft( + address recipient, + string memory tokenURI + ) external returns (uint256 orgTokenId, address orgIpId); + + /// @notice Returns the ID of the root organization IP. + function getRootOrgIpId() external view returns (address); + + /// @notice Returns the total supply of OrgNFT. + function totalSupply() external view returns (uint256); +} diff --git a/contracts/interfaces/story-nft/IStoryBadgeNFT.sol b/contracts/interfaces/story-nft/IStoryBadgeNFT.sol new file mode 100644 index 0000000..2a1d9f4 --- /dev/null +++ b/contracts/interfaces/story-nft/IStoryBadgeNFT.sol @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { IERC721Metadata } from "@openzeppelin/contracts/token/ERC721/extensions/IERC721Metadata.sol"; + +import { IERC5192 } from "./IERC5192.sol"; +import { IStoryNFT } from "./IStoryNFT.sol"; + +/// @title Story Badge NFT Interface +/// @notice A Story Badge NFT is a soulbound NFT that has an unified token URI for all tokens. +interface IStoryBadgeNFT is IStoryNFT, IERC5192, IERC721Metadata { + //////////////////////////////////////////////////////////////////////////// + // Errors // + //////////////////////////////////////////////////////////////////////////// + /// @notice Invalid whitelist signature. + error StoryBadgeNFT__InvalidSignature(); + + /// @notice The provided whitelist signature is already used. + error StoryBadgeNFT__SignatureAlreadyUsed(); + + /// @notice Badges are soulbound, cannot be transferred. + error StoryBadgeNFT__TransferLocked(); + + /// @notice Zero address provided as a param to StoryBadgeNFT functions. + error StoryBadgeNFT__ZeroAddressParam(); + + //////////////////////////////////////////////////////////////////////////// + // Structs // + //////////////////////////////////////////////////////////////////////////// + /// @notice Struct for custom data for initializing the StoryBadgeNFT contract. + /// @param tokenURI The token URI for all the badges (follows OpenSea metadata standard). + /// @param signer The signer of the whitelist signatures. + struct CustomInitParams { + string tokenURI; + address signer; + } + + //////////////////////////////////////////////////////////////////////////// + // Events // + //////////////////////////////////////////////////////////////////////////// + /// @notice Emitted when a badge NFT is minted. + /// @param recipient The address of the recipient of the badge NFT. + /// @param tokenId The token ID of the minted badge NFT. + /// @param ipId The ID of the badge NFT IP. + event StoryBadgeNFTMinted(address recipient, uint256 tokenId, address ipId); + + /// @notice Emitted when the signer is updated. + /// @param signer The new signer address. + event StoryBadgeNFTSignerUpdated(address signer); + + //////////////////////////////////////////////////////////////////////////// + // Functions // + //////////////////////////////////////////////////////////////////////////// + /// @notice Mints a badge for the given recipient, registers it as an IP, + /// and makes it a derivative of the organization IP. + /// @param recipient The address of the recipient of the badge NFT. + /// @param signature The signature from the whitelist signer. This signautre is genreated by having the whitelist + /// signer sign the caller's address (msg.sender) for this `mint` function. + /// @return tokenId The token ID of the minted badge NFT. + /// @return ipId The ID of the badge NFT IP. + function mint(address recipient, bytes calldata signature) external returns (uint256 tokenId, address ipId); + + /// @notice Updates the whitelist signer. + /// @param signer_ The new whitelist signer address. + function setSigner(address signer_) external; + + /// @notice Updates the unified token URI for all badges. + /// @param tokenURI_ The new token URI. + function setTokenURI(string memory tokenURI_) external; +} diff --git a/contracts/interfaces/story-nft/IStoryNFT.sol b/contracts/interfaces/story-nft/IStoryNFT.sol new file mode 100644 index 0000000..41a472c --- /dev/null +++ b/contracts/interfaces/story-nft/IStoryNFT.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { IERC721 } from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; + +/// @title IStoryNFT +/// @notice Interface for StoryNFT contracts. +interface IStoryNFT is IERC721 { + //////////////////////////////////////////////////////////////////////////// + // Errors // + //////////////////////////////////////////////////////////////////////////// + /// @notice Zero address provided as a param to BaseStoryNFT constructor. + error BaseStoryNFT__ZeroAddressParam(); + + //////////////////////////////////////////////////////////////////////////// + // Structs // + //////////////////////////////////////////////////////////////////////////// + /// @notice Struct for initializing StoryNFT contracts. + /// @param owner The address of the owner of this collection. + /// @param name The name of the collection. + /// @param symbol The symbol of the collection. + /// @param contractURI The contract URI of the collection (follows OpenSea contract-level metadata standard). + /// @param baseURI The base URI of the collection (see {ERC721URIStorage-tokenURI} for how it is used). + /// @param customInitData Custom data to initialize the StoryNFT. + struct StoryNftInitParams { + address owner; + string name; + string symbol; + string contractURI; + string baseURI; + bytes customInitData; + } + + //////////////////////////////////////////////////////////////////////////// + // Functions // + //////////////////////////////////////////////////////////////////////////// + /// @notice Initializes the StoryNFT. + /// @param orgTokenId_ The token ID of the organization NFT. + /// @param orgIpId_ The ID of the organization IP. + /// @param initParams The initialization parameters for StoryNFT {see {StoryNftInitParams}}. + function initialize(uint256 orgTokenId_, address orgIpId_, StoryNftInitParams calldata initParams) external; + + /// @notice Returns the current total supply of the collection. + function totalSupply() external view returns (uint256); + + /// @notice Returns the contract URI of the collection (follows OpenSea contract-level metadata standard). + function contractURI() external view returns (string memory); +} diff --git a/contracts/interfaces/story-nft/IStoryNFTFactory.sol b/contracts/interfaces/story-nft/IStoryNFTFactory.sol new file mode 100644 index 0000000..8050f56 --- /dev/null +++ b/contracts/interfaces/story-nft/IStoryNFTFactory.sol @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { IStoryNFT } from "./IStoryNFT.sol"; + +/// @title Story NFT Factory Interface +/// @notice Story NFT Factory is the entrypoint for creating new Story NFT collections. +interface IStoryNFTFactory { + //////////////////////////////////////////////////////////////////////////// + // Errors // + //////////////////////////////////////////////////////////////////////////// + /// @notice Invalid signature provided to StoryNFTFactory functions. + /// @param signature The signature that is invalid. + error StoryNFTFactory__InvalidSignature(bytes signature); + + /// @notice NftTemplate is not whitelisted to be used as a StoryNFT. + /// @param nftTemplate The NFT template that is not whitelisted. + error StoryNFTFactory__NftTemplateNotWhitelisted(address nftTemplate); + + /// @notice Organization is already deployed by the StoryNFTFactory. + /// @param orgName The name of the organization that is already deployed. + /// @param deployedStoryNft The address of the already deployed StoryNFT for the organization. + error StoryNFTFactory__OrgAlreadyDeployed(string orgName, address deployedStoryNft); + + /// @notice Organization is not found in the StoryNFTFactory. + /// @param orgName The name of the organization that is not found. + error StoryNFTFactory__OrgNotFoundByOrgName(string orgName); + + /// @notice Organization is not found in the StoryNFTFactory. + /// @param orgTokenId The token ID of the organization that is not found. + error StoryNFTFactory__OrgNotFoundByOrgTokenId(uint256 orgTokenId); + + /// @notice Organization is not found in the StoryNFTFactory. + /// @param orgIpId The ID of the organization IP that is not found. + error StoryNFTFactory__OrgNotFoundByOrgIpId(address orgIpId); + + /// @notice Signature is already used to deploy a StoryNFT. + /// @param signature The signature that is already used. + error StoryNFTFactory__SignatureAlreadyUsed(bytes signature); + + /// @notice BaseStoryNFT is not supported by the StoryNFTFactory. + /// @param tokenContract The address of the token contract that does not implement IStoryNFT. + error StoryNFTFactory__UnsupportedIStoryNFT(address tokenContract); + + /// @notice Zero address provided as a param to StoryNFTFactory functions. + error StoryNFTFactory__ZeroAddressParam(); + + //////////////////////////////////////////////////////////////////////////// + // Events // + //////////////////////////////////////////////////////////////////////////// + /// @notice Emitted when the default StoryNFT template is updated. + /// @param defaultStoryNftTemplate The new default StoryNFT template. + event StoryNFTFactoryDefaultStoryNftTemplateUpdated(address defaultStoryNftTemplate); + + /// @notice Emitted when a new orgnization NFT is minted and a new StoryNFT associated with it is deployed. + /// @param orgName The name of the organization. + /// @param orgNft The address of the organization NFT. + /// @param orgTokenId The token ID of the organization NFT. + /// @param orgIpId The ID of the organization IP. + /// @param storyNft The address of the deployed StoryNFT. + event StoryNftDeployed(string orgName, address orgNft, uint256 orgTokenId, address orgIpId, address storyNft); + + /// @notice Emitted when the signer of the StoryNFTFactory is updated. + /// @param signer The new signer of the StoryNFTFactory. + event StoryNFTFactorySignerUpdated(address signer); + + /// @notice Emitted when a new Story NFT template is whitelisted. + /// @param nftTemplate The new Story NFT template that is whitelisted to be used in StoryNFTFactory. + event StoryNFTFactoryNftTemplateWhitelisted(address nftTemplate); + + //////////////////////////////////////////////////////////////////////////// + // Functions // + //////////////////////////////////////////////////////////////////////////// + /// @notice Mints a new organization NFT and deploys (creates a clone of) `storyNftTemplate` as the StoryNFT + /// associated with the new organization NFT. + /// @param storyNftTemplate The address of a whitelisted StoryNFT template to be cloned. + /// @param orgNftRecipient The address of the recipient of the organization NFT. + /// @param orgName The name of the organization. + /// @param orgTokenURI The token URI of the organization NFT. + /// @param signature The signature from the StoryNFTFactory's whitelist signer. This signautre is genreated by + /// having the whitelist signer sign the caller's address (msg.sender) for this `deployStoryNft` function. + /// @param storyNftInitParams The initialization parameters for StoryNFT {see {IStoryNFT-StoryNftInitParams}}. + /// @return orgNft The address of the organization NFT. + /// @return orgTokenId The token ID of the organization NFT. + /// @return orgIpId The ID of the organization IP. + /// @return storyNft The address of the dployed StoryNFT + function deployStoryNft( + address storyNftTemplate, + address orgNftRecipient, + string calldata orgName, + string calldata orgTokenURI, + bytes calldata signature, + IStoryNFT.StoryNftInitParams calldata storyNftInitParams + ) external returns (address orgNft, uint256 orgTokenId, address orgIpId, address storyNft); + + /// @notice Mints a new organization NFT and deploys (creates a clone of) `storyNftTemplate` as the StoryNFT + /// associated with the new organization NFT. + /// @dev Enforced to be only callable by the protocol admin in governance. + /// @param storyNftTemplate The address of a whitelisted StoryNFT template to be cloned. + /// @param orgNftRecipient The address of the recipient of the organization NFT. + /// @param orgName The name of the organization. + /// @param orgTokenURI The token URI of the organization NFT. + /// @param storyNftInitParams The initialization parameters for StoryNFT {see {IStoryNFT-StoryNftInitParams}}. + /// @param isRootOrg Whether the organization is the root organization. + /// @return orgNft The address of the organization NFT. + /// @return orgTokenId The token ID of the organization NFT. + /// @return orgIpId The ID of the organization IP. + /// @return storyNft The address of the dployed StoryNFT + function deployStoryNftByAdmin( + address storyNftTemplate, + address orgNftRecipient, + string calldata orgName, + string calldata orgTokenURI, + IStoryNFT.StoryNftInitParams calldata storyNftInitParams, + bool isRootOrg + ) external returns (address orgNft, uint256 orgTokenId, address orgIpId, address storyNft); + + /// @notice Sets the default StoryNFT template of the StoryNFTFactory. + /// @param defaultStoryNftTemplate The new default StoryNFT template. + function setDefaultStoryNftTemplate(address defaultStoryNftTemplate) external; + + /// @notice Sets the signer of the StoryNFTFactory. + /// @param signer The new signer of the StoryNFTFactory. + function setSigner(address signer) external; + + /// @notice Whitelists a new StoryNFT template. + /// @param storyNftTemplate The new StoryNFT template to be whitelisted. + function whitelistNftTemplate(address storyNftTemplate) external; + + /// @notice Returns the default StoryNFT template address. + function getDefaultStoryNftTemplate() external view returns (address); + + /// @notice Returns the address of the StoryNFT for a given organization name. + /// @param orgName The name of the organization. + function getStoryNftAddressByOrgName(string calldata orgName) external view returns (address); + + /// @notice Returns the address of the StoryNFT for a given organization token ID. + /// @param orgTokenId The token ID of the organization. + function getStoryNftAddressByOrgTokenId(uint256 orgTokenId) external view returns (address); + + /// @notice Returns the address of the StoryNFT for a given organization IP ID. + /// @param orgIpId The ID of the organization IP. + function getStoryNftAddressByOrgIpId(address orgIpId) external view returns (address); + + /// @notice Returns whether a given StoryNFT template is whitelisted. + /// @param storyNftTemplate The address of the StoryNFT template. + function isNftTemplateWhitelisted(address storyNftTemplate) external view returns (bool); +} diff --git a/contracts/story-nft/BaseStoryNFT.sol b/contracts/story-nft/BaseStoryNFT.sol new file mode 100644 index 0000000..bc72c92 --- /dev/null +++ b/contracts/story-nft/BaseStoryNFT.sol @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import { ERC721 } from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import { ERC721URIStorage } from "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol"; +import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; +import { IIPAssetRegistry } from "@story-protocol/protocol-core/contracts/interfaces/registries/IIPAssetRegistry.sol"; +/*solhint-disable-next-line max-line-length*/ +import { ILicensingModule } from "@story-protocol/protocol-core/contracts/interfaces/modules/licensing/ILicensingModule.sol"; + +import { IStoryNFT } from "../interfaces/story-nft/IStoryNFT.sol"; + +/// @title Base Story NFT +/// @notice Base StoryNFT that implements the core functionality needed for a StoryNFT. +/// To create a new custom StoryNFT, inherit from this contract and override the required functions. +/// Note: the new StoryNFT must be whitelisted in `StoryNFTFactory` by the Story governance in order +/// to use the Story NFT Factory features. +abstract contract BaseStoryNFT is IStoryNFT, ERC721URIStorage, Ownable, Initializable { + /// @notice Story Proof-of-Creativity IP Asset Registry address. + IIPAssetRegistry public immutable IP_ASSET_REGISTRY; + + /// @notice Story Proof-of-Creativity Licensing Module address. + ILicensingModule public immutable LICENSING_MODULE; + + /// @notice Organization NFT address (see {OrgNFT}). + address public immutable ORG_NFT; + + /// @notice Associated Organization NFT token ID. + uint256 public orgTokenId; + + /// @notice Associated Organization IP ID. + address public orgIpId; + + /// @dev Name of the collection. + string private _name; + + /// @dev Symbol of the collection. + string private _symbol; + + /// @dev Contract URI of the collection (follows OpenSea contract-level metadata standard). + string private _contractURI; + + /// @dev Base URI of the collection (see {ERC721URIStorage-tokenURI} for how it is used). + string private _baseURI_; + + /// @dev Current total supply of the collection. + uint256 private _totalSupply; + + constructor(address ipAssetRegistry, address licensingModule, address orgNft) ERC721("", "") Ownable(msg.sender) { + if (ipAssetRegistry == address(0) || licensingModule == address(0) || orgNft == address(0)) + revert BaseStoryNFT__ZeroAddressParam(); + IP_ASSET_REGISTRY = IIPAssetRegistry(ipAssetRegistry); + LICENSING_MODULE = ILicensingModule(licensingModule); + ORG_NFT = orgNft; + } + + /// @notice Initializes the StoryNFT + /// @param orgTokenId_ The token ID of the organization NFT. + /// @param orgIpId_ The ID of the organization IP. + /// @param initParams The initialization parameters for StoryNFT {see {IStoryNFT-StoryNftInitParams}}. + function initialize( + uint256 orgTokenId_, + address orgIpId_, + StoryNftInitParams calldata initParams + ) public virtual initializer { + if (initParams.owner == address(0) || orgIpId_ == address(0)) revert BaseStoryNFT__ZeroAddressParam(); + + orgTokenId = orgTokenId_; + orgIpId = orgIpId_; + + _name = initParams.name; + _symbol = initParams.symbol; + _contractURI = initParams.contractURI; + _baseURI_ = initParams.baseURI; + + _transferOwnership(initParams.owner); + _customize(initParams.customInitData); + } + + /// @notice Mints a new token and registers as an IP asset without specifying a tokenURI. + /// @param recipient The address to mint the token to. + /// @return tokenId The ID of the minted token. + /// @return ipId The ID of the newly created IP. + function _mintAndRegisterIp(address recipient) internal virtual returns (uint256 tokenId, address ipId) { + (tokenId, ipId) = _mintAndRegisterIp(recipient, ""); + } + + /// @notice Mints a new token and registers as an IP asset. + /// @param recipient The address to mint the token to. + /// @param tokenURI_ The token URI of the token (see {ERC721URIStorage-tokenURI} for how it is used). + /// @return tokenId The ID of the minted token. + /// @return ipId The ID of the newly created IP. + function _mintAndRegisterIp( + address recipient, + string memory tokenURI_ + ) internal virtual returns (uint256 tokenId, address ipId) { + tokenId = _totalSupply++; + _safeMint(recipient, tokenId); + _setTokenURI(tokenId, tokenURI_); + ipId = IP_ASSET_REGISTRY.register(block.chainid, address(this), tokenId); + } + + /// @notice Register `ipId` as a derivative of `parentIpIds` under `licenseTemplate` with `licenseTermsIds`. + /// @param ipId The ID of the IP to be registered as a derivative. + /// @param parentIpIds The IDs of the parent IPs. + /// @param licenseTemplate The address of the license template. + /// @param licenseTermsIds The IDs of the license terms. + /// @param royaltyContext The royalty context, should be empty for Royalty Policy LAP. + function _makeDerivative( + address ipId, + address[] memory parentIpIds, + address licenseTemplate, + uint256[] memory licenseTermsIds, + bytes memory royaltyContext + ) internal virtual { + LICENSING_MODULE.registerDerivative({ + childIpId: ipId, + parentIpIds: parentIpIds, + licenseTermsIds: licenseTermsIds, + licenseTemplate: licenseTemplate, + royaltyContext: royaltyContext + }); + } + + /// @notice IERC165 interface support. + function supportsInterface( + bytes4 interfaceId + ) public view virtual override(ERC721URIStorage, IERC165) returns (bool) { + return interfaceId == type(IStoryNFT).interfaceId || super.supportsInterface(interfaceId); + } + + /// @notice Returns the name of the collection. + function name() public view override returns (string memory) { + return _name; + } + + /// @notice Returns the symbol of the collection. + function symbol() public view override returns (string memory) { + return _symbol; + } + + /// @notice Returns the current total supply of the collection. + function totalSupply() public view returns (uint256) { + return _totalSupply; + } + + /// @notice Returns the contract URI of the collection (follows OpenSea contract-level metadata standard). + function contractURI() external view virtual returns (string memory) { + return _contractURI; + } + + /// @notice Initializes the StoryNFT with custom data, required to be overridden by the inheriting contracts. + /// @dev This function is called by `initialize` function. + /// @param customInitData The custom data to initialize the StoryNFT. + function _customize(bytes memory customInitData) internal virtual; + + /// @notice Returns the base URI of the collection (see {ERC721URIStorage-tokenURI} for how it is used). + function _baseURI() internal view virtual override returns (string memory) { + return _baseURI_; + } +} diff --git a/contracts/story-nft/OrgNFT.sol b/contracts/story-nft/OrgNFT.sol new file mode 100644 index 0000000..9a668c7 --- /dev/null +++ b/contracts/story-nft/OrgNFT.sol @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +// solhint-disable-next-line max-line-length +import { AccessManagedUpgradeable } from "@openzeppelin/contracts-upgradeable/access/manager/AccessManagedUpgradeable.sol"; +import { ERC721Holder } from "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; +// solhint-disable-next-line max-line-length +import { ERC721URIStorageUpgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721URIStorageUpgradeable.sol"; +import { IERC165 } from "@openzeppelin/contracts/utils/introspection/ERC165.sol"; +import { UUPSUpgradeable } from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import { IIPAssetRegistry } from "@storyprotocol/core/interfaces/registries/IIPAssetRegistry.sol"; +// solhint-disable-next-line max-line-length +import { ILicensingModule } from "@story-protocol/protocol-core/contracts/interfaces/modules/licensing/ILicensingModule.sol"; + +import { IOrgNFT } from "../interfaces/story-nft/IOrgNFT.sol"; + +/// @title Organization NFT +/// @notice Each organization token represents a Story ecosystem project. +/// The root organization token represents Story. +/// Each organization token register as a IP on Story and is a derivative of the root organization IP. +contract OrgNFT is IOrgNFT, ERC721URIStorageUpgradeable, AccessManagedUpgradeable, UUPSUpgradeable, ERC721Holder { + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + + /// @notice Story Proof-of-Creativity IP Asset Registry address. + IIPAssetRegistry public immutable IP_ASSET_REGISTRY; + + /// @notice Story Proof-of-Creativity Licensing Module address. + ILicensingModule public immutable LICENSING_MODULE; + + /// @notice License template address. + address public immutable LICENSE_TEMPLATE; + + /// @notice License terms ID. + uint256 public immutable LICENSE_TERMS_ID; + + /// @notice Story NFT Factory address. + address public immutable STORY_NFT_FACTORY; + + /// @dev Storage structure for the OrgNFT + /// @custom:storage-location erc7201:story-protocol-periphery.OrgNFT + /// @param totalSupply The current total supply of the organization tokens. + /// @param rootOrgIpId The ID of the root organization IP. + struct OrgNFTStorage { + uint256 totalSupply; + address rootOrgIpId; + } + + // keccak256(abi.encode(uint256(keccak256("story-protocol-periphery.OrgNFT")) - 1)) & ~bytes32(uint256(0xff)); + bytes32 private constant OrgNFTStorageLocation = 0xa4a36278839a4db2ab2cd96ad705f696fd1f52c0a329c48dd114f7acbbc8db00; + + modifier onlyStoryNFTFactory() { + if (msg.sender != address(STORY_NFT_FACTORY)) { + revert OrgNFT__CallerNotStoryNFTFactory(msg.sender, STORY_NFT_FACTORY); + } + _; + } + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor( + address ipAssetRegistry, + address licensingModule, + address storyNftFactory, + address licenseTemplate, + uint256 licenseTermsId + ) { + if ( + ipAssetRegistry == address(0) || + licensingModule == address(0) || + storyNftFactory == address(0) || + licenseTemplate == address(0) + ) revert OrgNFT__ZeroAddressParam(); + + IP_ASSET_REGISTRY = IIPAssetRegistry(ipAssetRegistry); + LICENSING_MODULE = ILicensingModule(licensingModule); + STORY_NFT_FACTORY = storyNftFactory; + LICENSE_TEMPLATE = licenseTemplate; + LICENSE_TERMS_ID = licenseTermsId; + + _disableInitializers(); + } + + /// @notice Initializer for this implementation contract. + /// @param accessManager The address of the protocol admin contract. + function initialize(address accessManager) public initializer { + if (accessManager == address(0)) { + revert OrgNFT__ZeroAddressParam(); + } + __ERC721_init("Organization NFT", "OrgNFT"); + __AccessManaged_init(accessManager); + __UUPSUpgradeable_init(); + } + + /// @notice Mints the root organization token and register it as an IP. + /// @dev This function is only callable by the StoryNFTFactory contract. + /// @param recipient The address of the recipient of the root organization token. + /// @param tokenURI_ The URI of the root organization token. + /// @return rootOrgTokenId The ID of the root organization token. + /// @return rootOrgIpId The ID of the root organization IP. + function mintRootOrgNft( + address recipient, + string memory tokenURI_ + ) external onlyStoryNFTFactory returns (uint256 rootOrgTokenId, address rootOrgIpId) { + OrgNFTStorage storage $ = _getOrgNFTStorage(); + if ($.rootOrgIpId != address(0)) revert OrgNFT__RootOrgNftAlreadyMinted(); + + (rootOrgTokenId, rootOrgIpId) = _mintAndRegisterIp(recipient, tokenURI_); + $.rootOrgIpId = rootOrgIpId; + } + + /// @notice Mints a organization token, register it as an IP, + /// and makes the IP as a derivative of the root organization IP. + /// @dev This function is only callable by the StoryNFTFactory contract. + /// @param recipient The address of the recipient of the minted organization token. + /// @param tokenURI_ The URI of the minted organization token. + /// @return orgTokenId The ID of the minted organization token. + /// @return orgIpId The ID of the organization IP. + function mintOrgNft( + address recipient, + string memory tokenURI_ + ) external onlyStoryNFTFactory returns (uint256 orgTokenId, address orgIpId) { + OrgNFTStorage storage $ = _getOrgNFTStorage(); + if ($.rootOrgIpId == address(0)) revert OrgNFT__RootOrgNftNotMinted(); + + // Mint the organization token and register it as an IP. + (orgTokenId, orgIpId) = _mintAndRegisterIp(address(this), tokenURI_); + + address[] memory parentIpIds = new address[](1); + uint256[] memory licenseTermsIds = new uint256[](1); + parentIpIds[0] = $.rootOrgIpId; + licenseTermsIds[0] = LICENSE_TERMS_ID; + + // Register the organization IP as a derivative of the root organization IP. + LICENSING_MODULE.registerDerivative({ + childIpId: orgIpId, + parentIpIds: parentIpIds, + licenseTermsIds: licenseTermsIds, + licenseTemplate: LICENSE_TEMPLATE, + royaltyContext: "" + }); + + _safeTransfer(address(this), recipient, orgTokenId); + } + + /// @notice Mints a organization token and register it as an IP. + /// @param recipient The address of the recipient of the minted organization token. + /// @param tokenURI_ The URI of the minted organization token. + /// @return orgTokenId The ID of the minted organization token. + /// @return orgIpId The ID of the organization IP. + function _mintAndRegisterIp( + address recipient, + string memory tokenURI_ + ) private returns (uint256 orgTokenId, address orgIpId) { + OrgNFTStorage storage $ = _getOrgNFTStorage(); + orgTokenId = $.totalSupply++; + _safeMint(recipient, orgTokenId); + _setTokenURI(orgTokenId, tokenURI_); + orgIpId = IP_ASSET_REGISTRY.register(block.chainid, address(this), orgTokenId); + + emit OrgNFTMinted(recipient, address(this), orgTokenId, orgIpId); + } + + /// @notice Returns the current total supply of the organization tokens. + function totalSupply() external view returns (uint256) { + return _getOrgNFTStorage().totalSupply; + } + + /// @notice Returns the ID of the root organization IP. + function getRootOrgIpId() external view returns (address) { + return _getOrgNFTStorage().rootOrgIpId; + } + + /// @notice IERC165 interface support. + function supportsInterface( + bytes4 interfaceId + ) public view virtual override(ERC721URIStorageUpgradeable, IERC165) returns (bool) { + return interfaceId == type(IOrgNFT).interfaceId || super.supportsInterface(interfaceId); + } + + /// @dev Returns the storage struct of OrgNFT. + function _getOrgNFTStorage() private pure returns (OrgNFTStorage storage $) { + assembly { + $.slot := OrgNFTStorageLocation + } + } + + /// @dev Hook to authorize the upgrade according to UUPSUpgradeable + /// @dev Enforced to be only callable by the protocol admin in governance. + /// @param newImplementation The address of the new implementation + function _authorizeUpgrade(address newImplementation) internal override restricted {} +} diff --git a/contracts/story-nft/StoryBadgeNFT.sol b/contracts/story-nft/StoryBadgeNFT.sol new file mode 100644 index 0000000..fa595cd --- /dev/null +++ b/contracts/story-nft/StoryBadgeNFT.sol @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { ERC721 } from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import { ERC721Holder } from "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol"; +import { ERC721URIStorage } from "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol"; +import { IERC721 } from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import { IERC721Metadata } from "@openzeppelin/contracts/token/ERC721/extensions/IERC721Metadata.sol"; +import { MessageHashUtils } from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; +import { SignatureChecker } from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; + +import { BaseStoryNFT } from "./BaseStoryNFT.sol"; +import { IStoryBadgeNFT } from "../interfaces/story-nft/IStoryBadgeNFT.sol"; + +/// @title Story Badge NFT +/// @notice A Story Badge is a soulbound NFT that has an unified token URI for all tokens. +contract StoryBadgeNFT is IStoryBadgeNFT, BaseStoryNFT, ERC721Holder { + using MessageHashUtils for bytes32; + + /// @notice Story Proof-of-Creativity PILicense Template address. + address public immutable PIL_TEMPLATE; + + /// @notice Story Proof-of-Creativity default license terms ID. + uint256 public immutable DEFAULT_LICENSE_TERMS_ID; + + /// @notice Signer of the whitelist signatures. + address private _signer; + + /// @notice The unified token URI for all tokens. + string private _tokenURI; + + /// @notice Mapping of signatures to booleans indicating whether they have been used. + mapping(bytes signature => bool used) private _usedSignatures; + + constructor( + address ipAssetRegistry, + address licensingModule, + address orgNft, + address pilTemplate, + uint256 defaultLicenseTermsId + ) BaseStoryNFT(ipAssetRegistry, licensingModule, orgNft) { + if ( + ipAssetRegistry == address(0) || + licensingModule == address(0) || + pilTemplate == address(0) || + orgNft == address(0) + ) revert StoryBadgeNFT__ZeroAddressParam(); + + PIL_TEMPLATE = pilTemplate; + DEFAULT_LICENSE_TERMS_ID = defaultLicenseTermsId; + + _disableInitializers(); + } + + /// @notice Returns true if the token is locked. + /// @dev This is a placeholder function to satisfy the ERC5192 interface. + /// @return bool Always true. + function locked(uint256 tokenId) external pure returns (bool) { + return true; + } + + /// @notice Mints a badge for the given recipient, registers it as an IP, + /// and makes it a derivative of the organization IP. + /// @param recipient The address of the recipient of the badge. + /// @param signature The signature from the whitelist signer. This signautre is genreated by having the whitelist + /// signer sign the caller's address (msg.sender) for this `mint` function. + /// @return tokenId The token ID of the minted badge NFT. + /// @return ipId The ID of the badge NFT IP. + function mint(address recipient, bytes calldata signature) external returns (uint256 tokenId, address ipId) { + // The given signature must not have been used + if (_usedSignatures[signature]) revert StoryBadgeNFT__SignatureAlreadyUsed(); + + // The given signature must be valid + bytes32 digest = keccak256(abi.encodePacked(msg.sender)).toEthSignedMessageHash(); + if (!SignatureChecker.isValidSignatureNow(_signer, digest, signature)) revert StoryBadgeNFT__InvalidSignature(); + + // Mint the badge and register it as an IP + (tokenId, ipId) = _mintAndRegisterIp(address(this), _tokenURI); + + address[] memory parentIpIds = new address[](1); + uint256[] memory licenseTermsIds = new uint256[](1); + parentIpIds[0] = orgIpId; + licenseTermsIds[0] = DEFAULT_LICENSE_TERMS_ID; + + // Make the badge a derivative of the organization IP + _makeDerivative(ipId, parentIpIds, PIL_TEMPLATE, licenseTermsIds, ""); + + // Transfer the badge to the recipient + _safeTransfer(address(this), recipient, tokenId); + + // Mark the signature as used + _usedSignatures[signature] = true; + + emit StoryBadgeNFTMinted(recipient, tokenId, ipId); + } + + /// @notice Updates the whitelist signer. + /// @param signer_ The new whitelist signer address. + function setSigner(address signer_) external onlyOwner { + _signer = signer_; + emit StoryBadgeNFTSignerUpdated(signer_); + } + + /// @notice Updates the unified token URI for all badges. + /// @param tokenURI_ The new token URI. + function setTokenURI(string memory tokenURI_) external onlyOwner { + _tokenURI = tokenURI_; + emit BatchMetadataUpdate(0, totalSupply()); + } + + /// @notice Returns the token URI for the given token ID. + /// @param tokenId The token ID. + /// @return The unified token URI for all badges. + function tokenURI(uint256 tokenId) public view override(ERC721URIStorage, IERC721Metadata) returns (string memory) { + return _tokenURI; + } + + /// @notice Initializes the StoryBadgeNFT with custom data (see {IStoryBadgeNFT-CustomInitParams}). + /// @dev This function is called by BaseStoryNFT's `initialize` function. + /// @param customInitData The custom data to initialize the StoryBadgeNFT. + function _customize(bytes memory customInitData) internal override { + CustomInitParams memory customParams = abi.decode(customInitData, (CustomInitParams)); + if (customParams.signer == address(0)) revert StoryBadgeNFT__ZeroAddressParam(); + + _tokenURI = customParams.tokenURI; + _signer = customParams.signer; + } + + /// @notice Returns the base URI + /// @return empty string + function _baseURI() internal pure override returns (string memory) { + return ""; + } + + //////////////////////////////////////////////////////////////////////////// + // Locked Functions // + //////////////////////////////////////////////////////////////////////////// + + function approve(address to, uint256 tokenId) public pure override(ERC721, IERC721) { + revert StoryBadgeNFT__TransferLocked(); + } + + function setApprovalForAll(address operator, bool approved) public pure override(ERC721, IERC721) { + revert StoryBadgeNFT__TransferLocked(); + } + + function transferFrom(address from, address to, uint256 tokenId) public pure override(ERC721, IERC721) { + revert StoryBadgeNFT__TransferLocked(); + } + + function safeTransferFrom( + address from, + address to, + uint256 tokenId, + bytes memory data + ) public pure override(ERC721, IERC721) { + revert StoryBadgeNFT__TransferLocked(); + } +} diff --git a/contracts/story-nft/StoryNFTFactory.sol b/contracts/story-nft/StoryNFTFactory.sol new file mode 100644 index 0000000..c190090 --- /dev/null +++ b/contracts/story-nft/StoryNFTFactory.sol @@ -0,0 +1,292 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +// solhint-disable-next-line max-line-length +import { AccessManagedUpgradeable } from "@openzeppelin/contracts-upgradeable/access/manager/AccessManagedUpgradeable.sol"; +import { Clones } from "@openzeppelin/contracts/proxy/Clones.sol"; +import { ERC165Checker } from "@openzeppelin/contracts/utils/introspection/ERC165Checker.sol"; +import { MessageHashUtils } from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; +import { SignatureChecker } from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; +import { UUPSUpgradeable } from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; + +import { IStoryNFT } from "../interfaces/story-nft/IStoryNFT.sol"; +import { IOrgNFT } from "../interfaces/story-nft/IOrgNFT.sol"; +import { IStoryNFTFactory } from "../interfaces/story-nft/IStoryNFTFactory.sol"; + +/// @title StoryNFTFactory +/// @notice StoryNFTFactory is the entrypoint for creating new Story NFT collections. +contract StoryNFTFactory is IStoryNFTFactory, AccessManagedUpgradeable, UUPSUpgradeable { + using ERC165Checker for address; + using MessageHashUtils for bytes32; + + /// @notice Story Proof-of-Creativity IP Asset Registry address. + address public immutable IP_ASSET_REGISTRY; + + /// @notice Story Proof-of-Creativity Licensing Module address. + address public immutable LICENSING_MODULE; + + /// @notice Story Proof-of-Creativity PILicense Template address. + address public immutable PIL_TEMPLATE; + + /// @notice Story Proof-of-Creativity default license terms ID. + uint256 public immutable DEFAULT_LICENSE_TERMS_ID; + + /// @notice Organization NFT address. + IOrgNFT public immutable ORG_NFT; + + /// @dev Storage structure for the StoryNFTFactory + /// @custom:storage-location erc7201:story-protocol-periphery.StoryNFTFactory + /// @param signer The address of the StoryNFTFactory's whitelist signer. + /// @param defaultStoryNftTemplate The address of the default StoryNFT template. + /// @param deployedStoryNftsByOrgName A mapping of organization names to their corresponding StoryNFT addresses. + /// @param deployedStoryNftsByOrgTokenId A mapping of organization token IDs to their corresponding StoryNFT addresses. + /// @param deployedStoryNftsByOrgIpId A mapping of organization IP IDs to their corresponding StoryNFT addresses. + /// @param usedSignatures A mapping of signatures to booleans indicating whether they have been used. + /// @param whitelistedNftTemplates A mapping of StoryNFT templates to booleans indicating whether + /// they are whitelisted. + struct StoryNFTFactoryStorage { + address signer; + address defaultStoryNftTemplate; + mapping(string orgName => address storyNft) deployedStoryNftsByOrgName; + mapping(uint256 orgTokenId => address storyNft) deployedStoryNftsByOrgTokenId; + mapping(address orgIpId => address storyNft) deployedStoryNftsByOrgIpId; + mapping(bytes signature => bool used) usedSignatures; + mapping(address storyNftTemplate => bool isWhitelisted) whitelistedNftTemplates; + } + + // solhint-disable-next-line max-line-length + // keccak256(abi.encode(uint256(keccak256("story-protocol-periphery.StoryNFTFactory")) - 1)) & ~bytes32(uint256(0xff)); + bytes32 private constant StoryNFTFactoryStorageLocation = + 0xf790322fec2c69d950299f25bd2b4e4f8b183652054d59cf2f75df434f22df00; + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor( + address ipAssetRegistry, + address licensingModule, + address pilTemplate, + uint256 defaultLicenseTermsId, + address orgNft + ) { + if ( + ipAssetRegistry == address(0) || + licensingModule == address(0) || + pilTemplate == address(0) || + orgNft == address(0) + ) revert StoryNFTFactory__ZeroAddressParam(); + + IP_ASSET_REGISTRY = ipAssetRegistry; + LICENSING_MODULE = licensingModule; + PIL_TEMPLATE = pilTemplate; + DEFAULT_LICENSE_TERMS_ID = defaultLicenseTermsId; + ORG_NFT = IOrgNFT(orgNft); + + _disableInitializers(); + } + + /// @dev Initializes the contract. + /// @param accessManager The address of the protocol access manager. + /// @param defaultStoryNftTemplate The address of the default StoryNFT template. + /// @param signer The address of the StoryNFTFactory's whitelist signer. + function initialize(address accessManager, address defaultStoryNftTemplate, address signer) external initializer { + if (accessManager == address(0) || defaultStoryNftTemplate == address(0)) + revert StoryNFTFactory__ZeroAddressParam(); + __AccessManaged_init(accessManager); + __UUPSUpgradeable_init(); + + StoryNFTFactoryStorage storage $ = _getStoryNFTFactoryStorage(); + $.signer = signer; + $.defaultStoryNftTemplate = defaultStoryNftTemplate; + $.whitelistedNftTemplates[defaultStoryNftTemplate] = true; + } + + /// @notice Mints a new organization NFT and deploys (creates a clone of) `storyNftTemplate` as the StoryNFT + /// associated with the new organization NFT. + /// @param storyNftTemplate The address of a whitelisted StoryNFT template to be cloned. + /// @param orgNftRecipient The address of the recipient of the organization NFT. + /// @param orgName The name of the organization. + /// @param orgTokenURI The token URI of the organization NFT. + /// @param signature The signature from the StoryNFTFactory's whitelist signer. This signautre is genreated by + /// having the whitelist signer sign the caller's address (msg.sender) for this `deployStoryNft` function. + /// @param storyNftInitParams The initialization data for the StoryNFT (see {IStoryBadgeNFT-InitParams}). + /// @return orgNft The address of the organization NFT. + /// @return orgTokenId The token ID of the organization NFT. + /// @return orgIpId The ID of the organization IP. + /// @return storyNft The address of the dployed StoryNFT + function deployStoryNft( + address storyNftTemplate, + address orgNftRecipient, + string calldata orgName, + string calldata orgTokenURI, + bytes calldata signature, + IStoryNFT.StoryNftInitParams calldata storyNftInitParams + ) external returns (address orgNft, uint256 orgTokenId, address orgIpId, address storyNft) { + StoryNFTFactoryStorage storage $ = _getStoryNFTFactoryStorage(); + + // The given story NFT template must be whitelisted + if (!$.whitelistedNftTemplates[storyNftTemplate]) + revert StoryNFTFactory__NftTemplateNotWhitelisted(storyNftTemplate); + + // The given signature must not have been used + if ($.usedSignatures[signature]) revert StoryNFTFactory__SignatureAlreadyUsed(signature); + + // The given organization name must not have been used + if ($.deployedStoryNftsByOrgName[orgName] != address(0)) + revert StoryNFTFactory__OrgAlreadyDeployed(orgName, $.deployedStoryNftsByOrgName[orgName]); + + // The signature must be valid + bytes32 hash = keccak256(abi.encodePacked(msg.sender)).toEthSignedMessageHash(); + if (!SignatureChecker.isValidSignatureNow($.signer, hash, signature)) + revert StoryNFTFactory__InvalidSignature(signature); + + // Mint the organization NFT and register it as an IP + (orgTokenId, orgIpId) = ORG_NFT.mintOrgNft(orgNftRecipient, orgTokenURI); + + orgNft = address(ORG_NFT); + + // Clones the story NFT template and initializes it + storyNft = Clones.clone(storyNftTemplate); + IStoryNFT(storyNft).initialize(orgTokenId, orgIpId, storyNftInitParams); + + // Stores the deployed story NFT address + $.deployedStoryNftsByOrgName[orgName] = storyNft; + $.deployedStoryNftsByOrgTokenId[orgTokenId] = storyNft; + $.deployedStoryNftsByOrgIpId[orgIpId] = storyNft; + + // Mark the signature as used + $.usedSignatures[signature] = true; + + emit StoryNftDeployed(orgName, orgNft, orgTokenId, orgIpId, storyNft); + } + + /// @notice Mints a new organization NFT and deploys (creates a clone of) `storyNftTemplate` as the StoryNFT + /// associated with the new organization NFT. + /// @dev Enforced to be only callable by the protocol admin in governance. + /// @param storyNftTemplate The address of a whitelisted StoryNFT template to be cloned. + /// @param orgNftRecipient The address of the recipient of the organization NFT. + /// @param orgName The name of the organization. + /// @param orgTokenURI The token URI of the organization NFT. + /// @param storyNftInitParams The initialization data for the StoryNFT (see {IStoryBadgeNFT-InitParams}). + /// @param isRootOrg Whether the organization is the root organization. + /// @return orgNft The address of the organization NFT. + /// @return orgTokenId The token ID of the organization NFT. + /// @return orgIpId The ID of the organization IP. + /// @return storyNft The address of the dployed StoryNFT + function deployStoryNftByAdmin( + address storyNftTemplate, + address orgNftRecipient, + string calldata orgName, + string calldata orgTokenURI, + IStoryNFT.StoryNftInitParams calldata storyNftInitParams, + bool isRootOrg + ) external restricted returns (address orgNft, uint256 orgTokenId, address orgIpId, address storyNft) { + StoryNFTFactoryStorage storage $ = _getStoryNFTFactoryStorage(); + + // The given story NFT template must be whitelisted + if (!$.whitelistedNftTemplates[storyNftTemplate]) + revert StoryNFTFactory__NftTemplateNotWhitelisted(storyNftTemplate); + + // The given organization name must not have been used + if ($.deployedStoryNftsByOrgName[orgName] != address(0)) + revert StoryNFTFactory__OrgAlreadyDeployed(orgName, $.deployedStoryNftsByOrgName[orgName]); + + // Mint the organization NFT and register it as an IP + if (isRootOrg) { + (orgTokenId, orgIpId) = ORG_NFT.mintRootOrgNft(orgNftRecipient, orgTokenURI); + } else { + (orgTokenId, orgIpId) = ORG_NFT.mintOrgNft(orgNftRecipient, orgTokenURI); + } + + orgNft = address(ORG_NFT); + + // Clones the story NFT template and initializes it + storyNft = Clones.clone(storyNftTemplate); + IStoryNFT(storyNft).initialize(orgTokenId, orgIpId, storyNftInitParams); + + // Stores the deployed story NFT address + $.deployedStoryNftsByOrgName[orgName] = storyNft; + $.deployedStoryNftsByOrgTokenId[orgTokenId] = storyNft; + $.deployedStoryNftsByOrgIpId[orgIpId] = storyNft; + + emit StoryNftDeployed(orgName, orgNft, orgTokenId, orgIpId, storyNft); + } + + /// @notice Sets the default StoryNFT template of the StoryNFTFactory. + /// @dev Enforced to be only callable by the protocol admin in governance. + /// @param defaultStoryNftTemplate The new default StoryNFT template. + function setDefaultStoryNftTemplate(address defaultStoryNftTemplate) external restricted { + if (defaultStoryNftTemplate == address(0)) revert StoryNFTFactory__ZeroAddressParam(); + if (!defaultStoryNftTemplate.supportsInterface(type(IStoryNFT).interfaceId)) + revert StoryNFTFactory__UnsupportedIStoryNFT(defaultStoryNftTemplate); + + _getStoryNFTFactoryStorage().whitelistedNftTemplates[defaultStoryNftTemplate] = true; + _getStoryNFTFactoryStorage().defaultStoryNftTemplate = defaultStoryNftTemplate; + emit StoryNFTFactoryDefaultStoryNftTemplateUpdated(defaultStoryNftTemplate); + } + + /// @notice Sets the signer of the StoryNFTFactory. + /// @dev Enforced to be only callable by the protocol admin in governance. + /// @param signer The new signer of the StoryNFTFactory. + function setSigner(address signer) external restricted { + if (signer == address(0)) revert StoryNFTFactory__ZeroAddressParam(); + _getStoryNFTFactoryStorage().signer = signer; + emit StoryNFTFactorySignerUpdated(signer); + } + + /// @notice Whitelists a new StoryNFT template. + /// @dev Enforced to be only callable by the protocol admin in governance. + /// @param storyNftTemplate The new StoryNFT template to be whitelisted. + function whitelistNftTemplate(address storyNftTemplate) external restricted { + if (storyNftTemplate == address(0)) revert StoryNFTFactory__ZeroAddressParam(); + + // The given story NFT template must implement IStoryNFT + if (!storyNftTemplate.supportsInterface(type(IStoryNFT).interfaceId)) + revert StoryNFTFactory__UnsupportedIStoryNFT(storyNftTemplate); + + _getStoryNFTFactoryStorage().whitelistedNftTemplates[storyNftTemplate] = true; + emit StoryNFTFactoryNftTemplateWhitelisted(storyNftTemplate); + } + + /// @notice Returns the address of the default StoryNFT template. + function getDefaultStoryNftTemplate() external view returns (address) { + return _getStoryNFTFactoryStorage().defaultStoryNftTemplate; + } + + /// @notice Returns the address of the StoryNFT for a given organization name. + /// @param orgName The name of the organization. + function getStoryNftAddressByOrgName(string calldata orgName) external view returns (address storyNft) { + storyNft = _getStoryNFTFactoryStorage().deployedStoryNftsByOrgName[orgName]; + if (storyNft == address(0)) revert StoryNFTFactory__OrgNotFoundByOrgName(orgName); + } + + /// @notice Returns the address of the StoryNFT for a given organization token ID. + /// @param orgTokenId The token ID of the organization. + function getStoryNftAddressByOrgTokenId(uint256 orgTokenId) external view returns (address storyNft) { + storyNft = _getStoryNFTFactoryStorage().deployedStoryNftsByOrgTokenId[orgTokenId]; + if (storyNft == address(0)) revert StoryNFTFactory__OrgNotFoundByOrgTokenId(orgTokenId); + } + + /// @notice Returns the address of the StoryNFT for a given organization IP ID. + /// @param orgIpId The ID of the organization IP. + function getStoryNftAddressByOrgIpId(address orgIpId) external view returns (address storyNft) { + storyNft = _getStoryNFTFactoryStorage().deployedStoryNftsByOrgIpId[orgIpId]; + if (storyNft == address(0)) revert StoryNFTFactory__OrgNotFoundByOrgIpId(orgIpId); + } + + /// @notice Returns whether a given StoryNFT template is whitelisted. + /// @param storyNftTemplate The address of the StoryNFT template. + function isNftTemplateWhitelisted(address storyNftTemplate) external view returns (bool) { + return _getStoryNFTFactoryStorage().whitelistedNftTemplates[storyNftTemplate]; + } + + /// @dev Returns the storage struct of StoryNFTFactory. + function _getStoryNFTFactoryStorage() private pure returns (StoryNFTFactoryStorage storage $) { + assembly { + $.slot := StoryNFTFactoryStorageLocation + } + } + + /// @dev Hook to authorize the upgrade according to UUPSUpgradeable + /// @dev Enforced to be only callable by the protocol admin in governance. + /// @param newImplementation The address of the new implementation + function _authorizeUpgrade(address newImplementation) internal override restricted {} +} diff --git a/script/deployment/StoryNFT.s.sol b/script/deployment/StoryNFT.s.sol new file mode 100644 index 0000000..f39b636 --- /dev/null +++ b/script/deployment/StoryNFT.s.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { ILicenseRegistry } from "@storyprotocol/core/interfaces/registries/ILicenseRegistry.sol"; + +import { DeployHelper } from "../utils/DeployHelper.sol"; + +contract StoryNFT is DeployHelper { + address internal CREATE3_DEPLOYER = 0x384a891dFDE8180b054f04D66379f16B7a678Ad6; + uint256 private constant CREATE3_DEFAULT_SEED = 1234567890; + constructor() DeployHelper(CREATE3_DEPLOYER) {} + + function run() public override { + create3SaltSeed = CREATE3_DEFAULT_SEED; + writeDeploys = true; + + _readStoryProtocolCoreAddresses(); + (address defaultLicenseTemplate, uint256 defaultLicenseTermsId) = + ILicenseRegistry(licenseRegistryAddr).getDefaultLicenseTerms(); + address storyNftFactorySigner = vm.envAddress("STORY_NFT_FACTORY_SIGNER"); + + _deployAndConfigStoryNftContracts({ + licenseTemplate_: defaultLicenseTemplate, + licenseTermsId_: defaultLicenseTermsId, + storyNftFactorySigner: storyNftFactorySigner, + isTest: false + }); + + _writeDeployment(); + } +} diff --git a/script/utils/DeployHelper.sol b/script/utils/DeployHelper.sol index 7876f09..7edd151 100644 --- a/script/utils/DeployHelper.sol +++ b/script/utils/DeployHelper.sol @@ -32,17 +32,22 @@ import { RoyaltyPolicyLRP } from "@storyprotocol/core/modules/royalty/policies/L import { StorageLayoutChecker } from "@storyprotocol/script/utils/upgrades/StorageLayoutCheck.s.sol"; // contracts +import { IStoryNFT } from "../../contracts/interfaces/story-nft/IStoryNFT.sol"; import { SPGNFT } from "../../contracts/SPGNFT.sol"; import { DerivativeWorkflows } from "../../contracts/workflows/DerivativeWorkflows.sol"; import { GroupingWorkflows } from "../../contracts/workflows/GroupingWorkflows.sol"; import { LicenseAttachmentWorkflows } from "../../contracts/workflows/LicenseAttachmentWorkflows.sol"; +import { OrgNFT } from "../../contracts/story-nft/OrgNFT.sol"; import { RegistrationWorkflows } from "../../contracts/workflows/RegistrationWorkflows.sol"; import { RoyaltyWorkflows } from "../../contracts/workflows/RoyaltyWorkflows.sol"; +import { StoryBadgeNFT } from "../../contracts/story-nft/StoryBadgeNFT.sol"; +import { StoryNFTFactory } from "../../contracts/story-nft/StoryNFTFactory.sol"; // script import { BroadcastManager } from "./BroadcastManager.s.sol"; import { JsonDeploymentHandler } from "./JsonDeploymentHandler.s.sol"; import { StoryProtocolCoreAddressManager } from "./StoryProtocolCoreAddressManager.sol"; +import { StoryProtocolPeripheryAddressManager } from "./StoryProtocolPeripheryAddressManager.sol"; import { StringUtil } from "./StringUtil.sol"; // test @@ -53,7 +58,8 @@ contract DeployHelper is BroadcastManager, StorageLayoutChecker, JsonDeploymentHandler, - StoryProtocolCoreAddressManager + StoryProtocolCoreAddressManager, + StoryProtocolPeripheryAddressManager { using StringUtil for uint256; using stdJson for string; @@ -79,8 +85,14 @@ contract DeployHelper is RegistrationWorkflows internal registrationWorkflows; RoyaltyWorkflows internal royaltyWorkflows; + // StoryNFT + StoryNFTFactory internal storyNftFactory; + OrgNFT internal orgNft; + StoryBadgeNFT internal rootStoryNft; + address internal defaultStoryNftTemplate; + // DeployHelper variable - bool private writeDeploys; + bool internal writeDeploys; // Mock Core Contracts AccessController internal accessController; @@ -135,14 +147,14 @@ contract DeployHelper is deployer = mockDeployer; _deployMockCoreContracts(); _configureMockCoreContracts(); - _deployPeripheryContracts(); - _configurePeripheryContracts(); + _deployWorkflowContracts(); + _configureWorkflowContracts(); } else { // production deployment _readStoryProtocolCoreAddresses(); // StoryProtocolCoreAddressManager.s.sol _beginBroadcast(); // BroadcastManager.s.sol - _deployPeripheryContracts(); - _configurePeripheryContracts(); + _deployWorkflowContracts(); + _configureWorkflowContracts(); // Check deployment configuration. if (spgNftBeacon.owner() != address(registrationWorkflows)) @@ -160,7 +172,98 @@ contract DeployHelper is } } - function _deployPeripheryContracts() private { + function _deployAndConfigStoryNftContracts( + address licenseTemplate_, + uint256 licenseTermsId_, + address storyNftFactorySigner, + bool isTest + ) internal { + if (!isTest) { + _readStoryProtocolCoreAddresses(); // StoryProtocolCoreAddressManager.s.sol + _readStoryProtocolPeripheryAddresses(); // StoryProtocolPeripheryAddressManager.s.sol + _beginBroadcast(); // BroadcastManager.s.sol + + if (writeDeploys) { + _writeAddress("DerivativeWorkflows", address(derivativeWorkflowsAddr)); + _writeAddress("GroupingWorkflows", address(groupingWorkflowsAddr)); + _writeAddress("LicenseAttachmentWorkflows", address(licenseAttachmentWorkflowsAddr)); + _writeAddress("RegistrationWorkflows", address(registrationWorkflowsAddr)); + _writeAddress("RoyaltyWorkflows", address(royaltyWorkflowsAddr)); + _writeAddress("SPGNFTBeacon", address(spgNftBeaconAddr)); + _writeAddress("SPGNFTImpl", address(spgNftImplAddr)); + } + } + address impl = address(0); + + // OrgNFT + _predeploy("OrgNFT"); + impl = address( + new OrgNFT( + ipAssetRegistryAddr, + licensingModuleAddr, + _getDeployedAddress(type(StoryNFTFactory).name), + licenseTemplate_, + licenseTermsId_ + ) + ); + orgNft = OrgNFT( + TestProxyHelper.deployUUPSProxy( + create3Deployer, + _getSalt(type(OrgNFT).name), + impl, + abi.encodeCall(OrgNFT.initialize, protocolAccessManagerAddr) + ) + ); + impl = address(0); + _postdeploy("OrgNFT", address(orgNft)); + + // Default StoryNFT template + _predeploy("DefaultStoryNftTemplate"); + defaultStoryNftTemplate = address(new StoryBadgeNFT( + ipAssetRegistryAddr, + licensingModuleAddr, + address(orgNft), + pilTemplateAddr, + licenseTermsId_ + )); + _postdeploy("DefaultStoryNftTemplate", defaultStoryNftTemplate); + + // StoryNFTFactory + _predeploy("StoryNFTFactory"); + impl = address( + new StoryNFTFactory( + ipAssetRegistryAddr, + licensingModuleAddr, + licenseTemplate_, + licenseTermsId_, + address(orgNft) + ) + ); + storyNftFactory = StoryNFTFactory( + TestProxyHelper.deployUUPSProxy( + create3Deployer, + _getSalt(type(StoryNFTFactory).name), + impl, + abi.encodeCall( + StoryNFTFactory.initialize, + ( + protocolAccessManagerAddr, + defaultStoryNftTemplate, + storyNftFactorySigner + ) + ) + ) + ); + impl = address(0); + _postdeploy("StoryNFTFactory", address(storyNftFactory)); + + if (!isTest) { + if (writeDeploys) _writeDeployment(); + _endBroadcast(); + } + } + + function _deployWorkflowContracts() private { address impl = address(0); // Periphery workflow contracts @@ -296,7 +399,7 @@ contract DeployHelper is _postdeploy("SPGNFTBeacon", address(spgNftBeacon)); } - function _configurePeripheryContracts() private { + function _configureWorkflowContracts() private { // Transfer ownership of beacon proxy to RegistrationWorkflows spgNftBeacon.transferOwnership(address(registrationWorkflows)); @@ -697,7 +800,7 @@ contract DeployHelper is // set up default license terms pilTemplate.registerLicenseTerms(PILFlavors.nonCommercialSocialRemixing()); licenseRegistry.registerLicenseTemplate(address(pilTemplate)); - licenseRegistry.setDefaultLicenseTerms(address(pilTemplate), 0); + licenseRegistry.setDefaultLicenseTerms(address(pilTemplate), PILFlavors.getNonCommercialSocialRemixingId(pilTemplate)); royaltyModule.whitelistRoyaltyPolicy(address(royaltyPolicyLAP), true); royaltyModule.whitelistRoyaltyPolicy(address(royaltyPolicyLRP), true); @@ -706,7 +809,7 @@ contract DeployHelper is } /// @dev get the salt for the contract deployment with CREATE3 - function _getSalt(string memory name) private view returns (bytes32 salt) { + function _getSalt(string memory name) internal view returns (bytes32 salt) { salt = keccak256(abi.encode(name, create3SaltSeed)); } diff --git a/test/story-nft/OrgNFT.t.sol b/test/story-nft/OrgNFT.t.sol new file mode 100644 index 0000000..ebfa072 --- /dev/null +++ b/test/story-nft/OrgNFT.t.sol @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +// external +import { IERC721 } from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import { IERC721Metadata } from "@openzeppelin/contracts/token/ERC721/extensions/IERC721Metadata.sol"; + +// contracts +import { IOrgNFT } from "../../contracts/interfaces/story-nft/IOrgNFT.sol"; +import { OrgNFT } from "../../contracts/story-nft/OrgNFT.sol"; + +// test +import { BaseTest } from "../utils/BaseTest.t.sol"; +import { TestProxyHelper } from "../utils/TestProxyHelper.t.sol"; + +contract OrgNFTTest is BaseTest { + function setUp() public override { + super.setUp(); + } + + function test_OrgNFT_initialize() public { + address testOrgNftImpl = address( + new OrgNFT({ + ipAssetRegistry: address(ipAssetRegistry), + licensingModule: address(licensingModule), + storyNftFactory: address(storyNftFactory), + licenseTemplate: address(pilTemplate), + licenseTermsId: 1 + }) + ); + + OrgNFT testOrgNft = OrgNFT( + TestProxyHelper.deployUUPSProxy( + testOrgNftImpl, + abi.encodeCall(OrgNFT.initialize, address(protocolAccessManager)) + ) + ); + + assertEq(address(testOrgNft.IP_ASSET_REGISTRY()), address(ipAssetRegistry)); + assertEq(address(testOrgNft.LICENSING_MODULE()), address(licensingModule)); + assertEq(address(testOrgNft.STORY_NFT_FACTORY()), address(storyNftFactory)); + assertEq(testOrgNft.LICENSE_TEMPLATE(), address(pilTemplate)); + assertEq(testOrgNft.LICENSE_TERMS_ID(), 1); + + assertEq(testOrgNft.name(), "Organization NFT"); + assertEq(testOrgNft.symbol(), "OrgNFT"); + assertEq(testOrgNft.authority(), address(protocolAccessManager)); + } + + function test_OrgNFT_revert_initialize_ZeroAddress() public { + vm.expectRevert(IOrgNFT.OrgNFT__ZeroAddressParam.selector); + OrgNFT testOrgNft = new OrgNFT({ + ipAssetRegistry: address(ipAssetRegistry), + licensingModule: address(licensingModule), + storyNftFactory: address(storyNftFactory), + licenseTemplate: address(0), + licenseTermsId: 1 + }); + + address testOrgNftImpl = address( + new OrgNFT({ + ipAssetRegistry: address(ipAssetRegistry), + licensingModule: address(licensingModule), + storyNftFactory: address(storyNftFactory), + licenseTemplate: address(pilTemplate), + licenseTermsId: 1 + }) + ); + + vm.expectRevert(IOrgNFT.OrgNFT__ZeroAddressParam.selector); + testOrgNft = OrgNFT( + TestProxyHelper.deployUUPSProxy(testOrgNftImpl, abi.encodeCall(OrgNFT.initialize, address(0))) + ); + } + + function test_OrgNFT_interfaceSupport() public { + assertTrue(IOrgNFT(address(orgNft)).supportsInterface(type(IOrgNFT).interfaceId)); + assertTrue(orgNft.supportsInterface(type(IERC721).interfaceId)); + assertTrue(orgNft.supportsInterface(type(IERC721Metadata).interfaceId)); + } + + function test_OrgNFT_revert_mintOrgNft_CallerIsNotStoryNftFactory() public { + vm.startPrank(u.bob); + vm.expectRevert( + abi.encodeWithSelector(IOrgNFT.OrgNFT__CallerNotStoryNFTFactory.selector, u.bob, address(storyNftFactory)) + ); + orgNft.mintRootOrgNft(u.bob, "test"); + + vm.expectRevert( + abi.encodeWithSelector(IOrgNFT.OrgNFT__CallerNotStoryNFTFactory.selector, u.bob, address(storyNftFactory)) + ); + orgNft.mintOrgNft(u.bob, "test"); + vm.stopPrank(); + } +} diff --git a/test/story-nft/StoryBadgeNFT.t.sol b/test/story-nft/StoryBadgeNFT.t.sol new file mode 100644 index 0000000..4919b5b --- /dev/null +++ b/test/story-nft/StoryBadgeNFT.t.sol @@ -0,0 +1,228 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +// external +import { IERC721 } from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import { IERC721Metadata } from "@openzeppelin/contracts/token/ERC721/extensions/IERC721Metadata.sol"; +import { IIPAccount } from "@storyprotocol/core/interfaces/IIPAccount.sol"; +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; + +// contracts +import { BaseStoryNFT } from "../../contracts/story-nft/BaseStoryNFT.sol"; +import { IStoryBadgeNFT } from "../../contracts/interfaces/story-nft/IStoryBadgeNFT.sol"; +import { IStoryNFT } from "../../contracts/interfaces/story-nft/IStoryNFT.sol"; +import { StoryBadgeNFT } from "../../contracts/story-nft/StoryBadgeNFT.sol"; + +// test +import { BaseTest } from "../utils/BaseTest.t.sol"; +import { TestProxyHelper } from "../utils/TestProxyHelper.t.sol"; + +contract StoryBadgeNFTTest is BaseTest { + function setUp() public override { + super.setUp(); + } + + function test_StoryBadgeNFT_initialize() public { + address testStoryBadgeNftImpl = address( + new StoryBadgeNFT({ + ipAssetRegistry: address(ipAssetRegistry), + licensingModule: address(licensingModule), + orgNft: address(orgNft), + pilTemplate: address(pilTemplate), + defaultLicenseTermsId: 1 + }) + ); + + string memory tokenURI = "Test Token URI"; + + bytes memory storyBadgeNftCustomInitParams = abi.encode( + IStoryBadgeNFT.CustomInitParams({ tokenURI: tokenURI, signer: rootStoryNftSigner }) + ); + + IStoryNFT.StoryNftInitParams memory storyBadgeNftInitParams = IStoryNFT.StoryNftInitParams({ + owner: rootStoryNftOwner, + name: "Test Badge", + symbol: "TB", + contractURI: "Test Contract URI", + baseURI: "", + customInitData: storyBadgeNftCustomInitParams + }); + + StoryBadgeNFT testStoryBadgeNft = StoryBadgeNFT( + TestProxyHelper.deployUUPSProxy( + testStoryBadgeNftImpl, + abi.encodeCall(IStoryNFT.initialize, (0, address(1), storyBadgeNftInitParams)) + ) + ); + + assertEq(testStoryBadgeNft.ORG_NFT(), address(orgNft)); + assertEq(address(BaseStoryNFT(address(testStoryBadgeNft)).IP_ASSET_REGISTRY()), address(ipAssetRegistry)); + assertEq(address(BaseStoryNFT(address(testStoryBadgeNft)).LICENSING_MODULE()), address(licensingModule)); + assertEq(testStoryBadgeNft.PIL_TEMPLATE(), address(pilTemplate)); + assertEq(testStoryBadgeNft.DEFAULT_LICENSE_TERMS_ID(), 1); + assertEq(testStoryBadgeNft.name(), "Test Badge"); + assertEq(testStoryBadgeNft.symbol(), "TB"); + assertEq(testStoryBadgeNft.contractURI(), "Test Contract URI"); + assertEq(testStoryBadgeNft.tokenURI(0), tokenURI); + assertEq(testStoryBadgeNft.owner(), rootStoryNftOwner); + assertEq(testStoryBadgeNft.totalSupply(), 0); + assertTrue(testStoryBadgeNft.locked(0)); + } + + function test_StoryBadgeNFT_revert_initialize_ZeroAddress() public { + vm.expectRevert(IStoryNFT.BaseStoryNFT__ZeroAddressParam.selector); + StoryBadgeNFT testStoryBadgeNft = new StoryBadgeNFT( + address(ipAssetRegistry), + address(licensingModule), + address(0), + address(pilTemplate), + 1 + ); + + address testStoryBadgeNftImpl = address( + new StoryBadgeNFT({ + ipAssetRegistry: address(ipAssetRegistry), + licensingModule: address(licensingModule), + orgNft: address(orgNft), + pilTemplate: address(pilTemplate), + defaultLicenseTermsId: 1 + }) + ); + + string memory tokenURI = "Test Token URI"; + + bytes memory storyBadgeNftCustomInitParams = abi.encode( + IStoryBadgeNFT.CustomInitParams({ + tokenURI: tokenURI, + signer: address(0) // Should revert + }) + ); + + IStoryNFT.StoryNftInitParams memory storyBadgeNftInitParams = IStoryNFT.StoryNftInitParams({ + owner: rootStoryNftOwner, + name: "Test Badge", + symbol: "TB", + contractURI: "Test Contract URI", + baseURI: "", + customInitData: storyBadgeNftCustomInitParams + }); + + vm.expectRevert(IStoryBadgeNFT.StoryBadgeNFT__ZeroAddressParam.selector); + testStoryBadgeNft = StoryBadgeNFT( + TestProxyHelper.deployUUPSProxy( + testStoryBadgeNftImpl, + abi.encodeCall(IStoryNFT.initialize, (0, address(1), storyBadgeNftInitParams)) + ) + ); + } + + function test_StoryBadgeNFT_interfaceSupport() public { + assertTrue(BaseStoryNFT(rootStoryNft).supportsInterface(type(IStoryNFT).interfaceId)); + assertTrue(BaseStoryNFT(rootStoryNft).supportsInterface(type(IERC721).interfaceId)); + assertTrue(BaseStoryNFT(rootStoryNft).supportsInterface(type(IERC721Metadata).interfaceId)); + } + + function test_StoryBadgeNFT_mint() public { + bytes memory signature = _signAddress(rootStoryNftSignerSk, u.carl); + + uint256 totalSupplyBefore = rootStoryNft.totalSupply(); + vm.startPrank(u.carl); + (uint256 tokenId, address ipId) = rootStoryNft.mint(u.carl, signature); + vm.stopPrank(); + + assertEq(rootStoryNft.ownerOf(tokenId), u.carl); + assertTrue(ipAssetRegistry.isRegistered(ipId)); + assertEq(rootStoryNft.tokenURI(tokenId), "Test Token URI"); + assertEq(rootStoryNft.totalSupply(), totalSupplyBefore + 1); + (address licenseTemplateChild, uint256 licenseTermsIdChild) = licenseRegistry.getAttachedLicenseTerms(ipId, 0); + (address licenseTemplateParent, uint256 licenseTermsIdParent) = licenseRegistry.getAttachedLicenseTerms( + rootStoryNft.orgIpId(), + 0 + ); + assertEq(licenseTemplateParent, licenseTemplateChild); + assertEq(licenseTermsIdParent, licenseTermsIdChild); + assertEq(IIPAccount(payable(ipId)).owner(), u.carl); + + assertParentChild({ + parentIpId: rootStoryNft.orgIpId(), + childIpId: ipId, + expectedParentCount: 1, + expectedParentIndex: 0 + }); + } + + function test_StoryBadgeNFT_setSigner() public { + vm.prank(rootStoryNftOwner); + rootStoryNft.setSigner(u.bob); + + bytes memory signature = _signAddress(sk.bob, u.carl); + + vm.prank(u.carl); + rootStoryNft.mint(u.carl, signature); + + vm.prank(rootStoryNftOwner); + rootStoryNft.setSigner(rootStoryNftSigner); + } + + function test_StoryBadgeNFT_setTokenURI() public { + assertEq(rootStoryNft.tokenURI(0), "Test Token URI"); + + vm.prank(rootStoryNftOwner); + rootStoryNft.setTokenURI("New Token URI"); + + assertEq(rootStoryNft.tokenURI(0), "New Token URI"); + } + + function test_StoryBadgeNFT_revert_setSigner_CallerIsNotOwner() public { + vm.startPrank(u.carl); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, u.carl)); + rootStoryNft.setSigner(u.carl); + vm.stopPrank(); + } + + function test_StoryBadgeNFT_revert_setTokenURI_CallerIsNotOwner() public { + vm.startPrank(u.carl); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, u.carl)); + rootStoryNft.setTokenURI("New Token URI"); + vm.stopPrank(); + } + + function test_StoryBadgeNFT_revert_mint_SignatureAlreadyUsed() public { + bytes memory signature = _signAddress(rootStoryNftSignerSk, u.carl); + + vm.startPrank(u.carl); + rootStoryNft.mint(u.carl, signature); + vm.expectRevert(IStoryBadgeNFT.StoryBadgeNFT__SignatureAlreadyUsed.selector); + rootStoryNft.mint(u.carl, signature); + vm.stopPrank(); + } + + function test_StoryBadgeNFT_revert_mint_InvalidSignature() public { + bytes memory signature = _signAddress(sk.carl, u.carl); + + vm.startPrank(u.carl); + vm.expectRevert(IStoryBadgeNFT.StoryBadgeNFT__InvalidSignature.selector); + rootStoryNft.mint(u.carl, signature); + vm.stopPrank(); + } + + function test_StoryBadgeNFT_revert_TransferLocked() public { + bytes memory signature = _signAddress(rootStoryNftSignerSk, u.carl); + + vm.startPrank(u.carl); + (uint256 tokenId, address ipId) = rootStoryNft.mint(u.carl, signature); + + vm.expectRevert(IStoryBadgeNFT.StoryBadgeNFT__TransferLocked.selector); + rootStoryNft.approve(u.bob, tokenId); + + vm.expectRevert(IStoryBadgeNFT.StoryBadgeNFT__TransferLocked.selector); + rootStoryNft.setApprovalForAll(u.bob, true); + + vm.expectRevert(IStoryBadgeNFT.StoryBadgeNFT__TransferLocked.selector); + rootStoryNft.transferFrom(u.carl, u.bob, tokenId); + + vm.expectRevert(IStoryBadgeNFT.StoryBadgeNFT__TransferLocked.selector); + rootStoryNft.safeTransferFrom(u.carl, u.bob, tokenId); + vm.stopPrank(); + } +} diff --git a/test/story-nft/StoryNFTFactory.t.sol b/test/story-nft/StoryNFTFactory.t.sol new file mode 100644 index 0000000..d808c57 --- /dev/null +++ b/test/story-nft/StoryNFTFactory.t.sol @@ -0,0 +1,350 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +// external +import { IAccessManaged } from "@openzeppelin/contracts/access/manager/IAccessManaged.sol"; +import { IIPAccount } from "@storyprotocol/core/interfaces/IIPAccount.sol"; +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; + +// contracts +import { IOrgNFT } from "../../contracts/interfaces/story-nft/IOrgNFT.sol"; +import { IStoryBadgeNFT } from "../../contracts/interfaces/story-nft/IStoryBadgeNFT.sol"; +import { IStoryNFT } from "../../contracts/interfaces/story-nft/IStoryNFT.sol"; +import { IStoryNFTFactory } from "../../contracts/interfaces/story-nft/IStoryNFTFactory.sol"; +import { StoryNFTFactory } from "../../contracts/story-nft/StoryNFTFactory.sol"; + +// test +import { BaseTest } from "../utils/BaseTest.t.sol"; +import { TestProxyHelper } from "../utils/TestProxyHelper.t.sol"; + +contract StoryNFTFactoryTest is BaseTest { + string private orgName; + string private orgTokenURI; + string private storyNftName; + string private storyNftSymbol; + string private storyNftContractURI; + string private storyNftBaseURI; + string private storyNftTokenURI; + IStoryNFT.StoryNftInitParams private storyNftInitParams; + + function setUp() public override { + super.setUp(); + + orgName = "Carl's Org"; + orgTokenURI = "Carl's Org Token URI"; + storyNftName = "Carl's StoryBadge"; + storyNftSymbol = "CSB"; + storyNftContractURI = "Carl's StoryBadge Contract URI"; + storyNftBaseURI = ""; + storyNftTokenURI = "Carl's StoryBadge Token URI"; + + storyNftInitParams = IStoryNFT.StoryNftInitParams({ + owner: u.carl, + name: storyNftName, + symbol: storyNftSymbol, + contractURI: storyNftContractURI, + baseURI: storyNftBaseURI, + customInitData: abi.encode(IStoryBadgeNFT.CustomInitParams({ tokenURI: storyNftTokenURI, signer: u.carl })) + }); + } + + function test_StoryNFTFactory_initialize() public { + address testStoryNftFactoryImpl = address( + new StoryNFTFactory({ + ipAssetRegistry: address(ipAssetRegistry), + licensingModule: address(licensingModule), + pilTemplate: address(pilTemplate), + defaultLicenseTermsId: 1, + orgNft: address(orgNft) + }) + ); + + StoryNFTFactory testStoryNftFactory = StoryNFTFactory( + TestProxyHelper.deployUUPSProxy( + testStoryNftFactoryImpl, + abi.encodeCall( + StoryNFTFactory.initialize, + (address(protocolAccessManager), address(defaultStoryNftTemplate), address(storyNftFactorySigner)) + ) + ) + ); + + assertEq(testStoryNftFactory.IP_ASSET_REGISTRY(), address(ipAssetRegistry)); + assertEq(testStoryNftFactory.LICENSING_MODULE(), address(licensingModule)); + assertEq(testStoryNftFactory.PIL_TEMPLATE(), address(pilTemplate)); + assertEq(testStoryNftFactory.DEFAULT_LICENSE_TERMS_ID(), 1); + assertEq(address(testStoryNftFactory.ORG_NFT()), address(orgNft)); + assertEq(testStoryNftFactory.getDefaultStoryNftTemplate(), address(defaultStoryNftTemplate)); + assertEq(testStoryNftFactory.authority(), address(protocolAccessManager)); + } + + function test_StoryNFTFactory_deployStoryNft() public { + uint256 totalSupplyBefore = IOrgNFT(orgNft).totalSupply(); + + vm.startPrank(u.carl); + (address orgNft, uint256 orgTokenId, address orgIpId, address storyNft) = storyNftFactory.deployStoryNft({ + storyNftTemplate: defaultStoryNftTemplate, + orgNftRecipient: u.carl, + orgName: orgName, + orgTokenURI: orgTokenURI, + signature: _signAddress(storyNftFactorySignerSk, u.carl), + storyNftInitParams: storyNftInitParams + }); + + assertEq(IOrgNFT(orgNft).totalSupply(), totalSupplyBefore + 1); + assertEq(IOrgNFT(orgNft).ownerOf(orgTokenId), u.carl); + assertEq(IOrgNFT(orgNft).tokenURI(orgTokenId), orgTokenURI); + assertTrue(ipAssetRegistry.isRegistered(orgIpId)); + assertEq(Ownable(storyNft).owner(), u.carl); + assertEq(IStoryBadgeNFT(storyNft).name(), storyNftName); + assertEq(IStoryBadgeNFT(storyNft).symbol(), storyNftSymbol); + assertEq(IStoryBadgeNFT(storyNft).contractURI(), storyNftContractURI); + assertEq(IStoryBadgeNFT(storyNft).tokenURI(0), storyNftTokenURI); + (address licenseTemplateChild, uint256 licenseTermsIdChild) = licenseRegistry.getAttachedLicenseTerms( + orgIpId, + 0 + ); + (address licenseTemplateParent, uint256 licenseTermsIdParent) = licenseRegistry.getAttachedLicenseTerms( + rootStoryNft.orgIpId(), + 0 + ); + assertEq(licenseTemplateParent, licenseTemplateChild); + assertEq(licenseTermsIdParent, licenseTermsIdChild); + assertEq(IIPAccount(payable(orgIpId)).owner(), u.carl); + assertParentChild({ + parentIpId: rootStoryNft.orgIpId(), + childIpId: orgIpId, + expectedParentCount: 1, + expectedParentIndex: 0 + }); + } + + function test_StoryNFTFactory_deployStoryNftByAdmin() public { + uint256 totalSupplyBefore = IOrgNFT(orgNft).totalSupply(); + + vm.startPrank(u.admin); + (address orgNft, uint256 orgTokenId, address orgIpId, address storyNft) = storyNftFactory + .deployStoryNftByAdmin({ + storyNftTemplate: defaultStoryNftTemplate, + orgNftRecipient: u.carl, + orgName: orgName, + orgTokenURI: orgTokenURI, + storyNftInitParams: storyNftInitParams, + isRootOrg: false + }); + + assertEq(IOrgNFT(orgNft).totalSupply(), totalSupplyBefore + 1); + assertEq(IOrgNFT(orgNft).ownerOf(orgTokenId), u.carl); + assertEq(IOrgNFT(orgNft).tokenURI(orgTokenId), orgTokenURI); + assertTrue(ipAssetRegistry.isRegistered(orgIpId)); + assertEq(Ownable(storyNft).owner(), u.carl); + assertEq(IStoryBadgeNFT(storyNft).name(), storyNftName); + assertEq(IStoryBadgeNFT(storyNft).symbol(), storyNftSymbol); + assertEq(IStoryBadgeNFT(storyNft).contractURI(), storyNftContractURI); + assertEq(IStoryBadgeNFT(storyNft).tokenURI(0), storyNftTokenURI); + (address licenseTemplateChild, uint256 licenseTermsIdChild) = licenseRegistry.getAttachedLicenseTerms( + orgIpId, + 0 + ); + (address licenseTemplateParent, uint256 licenseTermsIdParent) = licenseRegistry.getAttachedLicenseTerms( + rootStoryNft.orgIpId(), + 0 + ); + assertEq(licenseTemplateParent, licenseTemplateChild); + assertEq(licenseTermsIdParent, licenseTermsIdChild); + assertEq(IIPAccount(payable(orgIpId)).owner(), u.carl); + assertParentChild({ + parentIpId: rootStoryNft.orgIpId(), + childIpId: orgIpId, + expectedParentCount: 1, + expectedParentIndex: 0 + }); + } + + function test_StoryNFTFactory_setDefaultStoryNftTemplate() public { + assertEq(storyNftFactory.getDefaultStoryNftTemplate(), defaultStoryNftTemplate); + + vm.prank(u.admin); + storyNftFactory.setDefaultStoryNftTemplate(address(rootStoryNft)); + assertEq(storyNftFactory.getDefaultStoryNftTemplate(), address(rootStoryNft)); + + vm.prank(u.admin); + storyNftFactory.setDefaultStoryNftTemplate(address(defaultStoryNftTemplate)); + assertEq(storyNftFactory.getDefaultStoryNftTemplate(), address(defaultStoryNftTemplate)); + } + + function test_StoryNFTFactory_setSigner() public { + vm.prank(u.admin); + storyNftFactory.setSigner(u.bob); + + vm.prank(u.carl); + storyNftFactory.deployStoryNft({ + storyNftTemplate: address(defaultStoryNftTemplate), + orgNftRecipient: u.carl, + orgName: orgName, + orgTokenURI: orgTokenURI, + signature: _signAddress(sk.bob, u.carl), + storyNftInitParams: storyNftInitParams + }); + + vm.prank(u.admin); + storyNftFactory.setSigner(storyNftFactorySigner); + } + + function test_StoryNFTFactory_whitelistNftTemplate() public { + assertFalse(storyNftFactory.isNftTemplateWhitelisted(address(rootStoryNft))); + vm.prank(u.admin); + storyNftFactory.whitelistNftTemplate(address(rootStoryNft)); + assertTrue(storyNftFactory.isNftTemplateWhitelisted(address(rootStoryNft))); + } + + function test_StoryNFTFactory_getStoryNftAddress() public { + vm.startPrank(u.carl); + (address orgNft, uint256 orgTokenId, address orgIpId, address storyNft) = storyNftFactory.deployStoryNft({ + storyNftTemplate: defaultStoryNftTemplate, + orgNftRecipient: u.carl, + orgName: orgName, + orgTokenURI: orgTokenURI, + signature: _signAddress(storyNftFactorySignerSk, u.carl), + storyNftInitParams: storyNftInitParams + }); + + assertEq(storyNftFactory.getStoryNftAddressByOrgName(orgName), address(storyNft)); + assertEq(storyNftFactory.getStoryNftAddressByOrgTokenId(orgTokenId), address(storyNft)); + assertEq(storyNftFactory.getStoryNftAddressByOrgIpId(orgIpId), address(storyNft)); + } + + function test_StoryNFTFactory_revert_initialize_ZeroAddress() public { + vm.expectRevert(IStoryNFTFactory.StoryNFTFactory__ZeroAddressParam.selector); + StoryNFTFactory testStoryNftFactory = new StoryNFTFactory({ + ipAssetRegistry: address(ipAssetRegistry), + licensingModule: address(0), + pilTemplate: address(pilTemplate), + defaultLicenseTermsId: 1, + orgNft: address(orgNft) + }); + + address testStoryNftFactoryImpl = address( + new StoryNFTFactory({ + ipAssetRegistry: address(ipAssetRegistry), + licensingModule: address(licensingModule), + pilTemplate: address(pilTemplate), + defaultLicenseTermsId: 1, + orgNft: address(orgNft) + }) + ); + + vm.expectRevert(IStoryNFTFactory.StoryNFTFactory__ZeroAddressParam.selector); + testStoryNftFactory = StoryNFTFactory( + TestProxyHelper.deployUUPSProxy( + testStoryNftFactoryImpl, + abi.encodeCall( + StoryNFTFactory.initialize, + (address(protocolAccessManager), address(0), address(storyNftFactorySigner)) + ) + ) + ); + } + + function test_StoryNFTFactory_revert_setDefaultStoryNftTemplate() public { + vm.prank(u.carl); + vm.expectRevert(abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, u.carl)); + storyNftFactory.setDefaultStoryNftTemplate(defaultStoryNftTemplate); + + vm.startPrank(u.admin); + vm.expectRevert(IStoryNFTFactory.StoryNFTFactory__ZeroAddressParam.selector); + storyNftFactory.setDefaultStoryNftTemplate(address(0)); + + vm.expectRevert( + abi.encodeWithSelector(IStoryNFTFactory.StoryNFTFactory__UnsupportedIStoryNFT.selector, address(orgNft)) + ); + storyNftFactory.setDefaultStoryNftTemplate(address(orgNft)); + vm.stopPrank(); + } + + function test_StoryNFTFactory_revert_whitelistNftTemplate_ZeroAddress() public { + vm.prank(u.carl); + vm.expectRevert(abi.encodeWithSelector(IAccessManaged.AccessManagedUnauthorized.selector, u.carl)); + storyNftFactory.whitelistNftTemplate(defaultStoryNftTemplate); + + vm.startPrank(u.admin); + vm.expectRevert(IStoryNFTFactory.StoryNFTFactory__ZeroAddressParam.selector); + storyNftFactory.whitelistNftTemplate(address(0)); + + vm.expectRevert( + abi.encodeWithSelector(IStoryNFTFactory.StoryNFTFactory__UnsupportedIStoryNFT.selector, address(orgNft)) + ); + storyNftFactory.whitelistNftTemplate(address(orgNft)); + vm.stopPrank(); + } + + function test_StoryNFTFactory_revert_deployStoryNft() public { + vm.prank(u.carl); + vm.expectRevert( + abi.encodeWithSelector( + IStoryNFTFactory.StoryNFTFactory__NftTemplateNotWhitelisted.selector, + address(rootStoryNft) + ) + ); + storyNftFactory.deployStoryNft({ + storyNftTemplate: address(rootStoryNft), + orgNftRecipient: u.carl, + orgName: orgName, + orgTokenURI: orgTokenURI, + signature: _signAddress(storyNftFactorySignerSk, u.carl), + storyNftInitParams: storyNftInitParams + }); + + bytes memory signature = _signAddress(storyNftFactorySignerSk, u.carl); + vm.startPrank(u.carl); + (, , , address storyNft) = storyNftFactory.deployStoryNft({ + storyNftTemplate: address(defaultStoryNftTemplate), + orgNftRecipient: u.carl, + orgName: orgName, + orgTokenURI: orgTokenURI, + signature: signature, + storyNftInitParams: storyNftInitParams + }); + vm.expectRevert( + abi.encodeWithSelector(IStoryNFTFactory.StoryNFTFactory__SignatureAlreadyUsed.selector, signature) + ); + storyNftFactory.deployStoryNft({ + storyNftTemplate: address(defaultStoryNftTemplate), + orgNftRecipient: u.carl, + orgName: orgName, + orgTokenURI: orgTokenURI, + signature: signature, + storyNftInitParams: storyNftInitParams + }); + vm.stopPrank(); + + vm.prank(u.bob); + vm.expectRevert( + abi.encodeWithSelector( + IStoryNFTFactory.StoryNFTFactory__OrgAlreadyDeployed.selector, + orgName, + address(storyNft) + ) + ); + storyNftFactory.deployStoryNft({ + storyNftTemplate: address(defaultStoryNftTemplate), + orgNftRecipient: u.carl, + orgName: orgName, + orgTokenURI: orgTokenURI, + signature: _signAddress(storyNftFactorySignerSk, u.bob), + storyNftInitParams: storyNftInitParams + }); + + signature = _signAddress(storyNftFactorySignerSk, u.bob); + vm.prank(u.alice); + vm.expectRevert(abi.encodeWithSelector(IStoryNFTFactory.StoryNFTFactory__InvalidSignature.selector, signature)); + storyNftFactory.deployStoryNft({ + storyNftTemplate: address(defaultStoryNftTemplate), + orgNftRecipient: u.alice, + orgName: "Alice's Org", + orgTokenURI: orgTokenURI, + signature: signature, + storyNftInitParams: storyNftInitParams + }); + } +} diff --git a/test/utils/BaseTest.t.sol b/test/utils/BaseTest.t.sol index 431e536..d60ef6b 100644 --- a/test/utils/BaseTest.t.sol +++ b/test/utils/BaseTest.t.sol @@ -17,6 +17,9 @@ import { MockIPGraph } from "@storyprotocol/test/mocks/MockIPGraph.sol"; // contracts import { ISPGNFT } from "../../contracts/interfaces/ISPGNFT.sol"; import { WorkflowStructs } from "../../contracts/lib/WorkflowStructs.sol"; +import { IStoryBadgeNFT } from "../../contracts/interfaces/story-nft/IStoryBadgeNFT.sol"; +import { IStoryNFT } from "../../contracts/interfaces/story-nft/IStoryNFT.sol"; +import { StoryBadgeNFT } from "../../contracts/story-nft/StoryBadgeNFT.sol"; // script import { DeployHelper } from "../../script/utils/DeployHelper.sol"; @@ -28,20 +31,27 @@ import { Users, UserSecretKeys, UsersLib } from "../utils/Users.t.sol"; /// @title Base Test Contract contract BaseTest is Test, DeployHelper { + using MessageHashUtils for bytes32; + /// @dev Users struct to abstract away user management when testing Users internal u; /// @dev UserSecretKeys struct to abstract away user secret keys when testing UserSecretKeys internal sk; - /// @dev User roles + /// @dev User roles for workflow tests address internal caller; // function caller address internal minter; // minter of the mock collections address internal feeRecipient; // fee recipient of the mock collections - - /// @dev Minter's secret key uint256 internal minterSk; + /// @dev User roles for story NFT tests + address internal storyNftFactorySigner; + address internal rootStoryNftSigner; + address internal rootStoryNftOwner; + uint256 internal storyNftFactorySignerSk; + uint256 internal rootStoryNftSignerSk; + /// @dev Create3 deployer address address internal CREATE3_DEPLOYER = address(new Create3Deployer()); uint256 internal CREATE3_DEFAULT_SEED = 1234567890; @@ -80,6 +90,9 @@ contract BaseTest is Test, DeployHelper { // setup test IPMetadata _setupIPMetadata(); + + // deploy and set up story NFT contracts + _setupStoryNftContracts(); } function _setupUsers() internal { @@ -180,6 +193,50 @@ contract BaseTest is Test, DeployHelper { }); } + function _setupStoryNftContracts() internal { + storyNftFactorySigner = u.alice; + rootStoryNftSigner = u.alice; + rootStoryNftOwner = u.admin; + storyNftFactorySignerSk = sk.alice; + rootStoryNftSignerSk = sk.alice; + + (address defaultLicenseTemplate, uint256 defaultLicenseTermsId) = licenseRegistry.getDefaultLicenseTerms(); + string memory rootOrgName = "Test Root Org"; + string memory rootOrgTokenURI = "Test Token URI"; + + bytes memory rootStoryNftCustomInitParams = abi.encode( + IStoryBadgeNFT.CustomInitParams({ tokenURI: rootOrgTokenURI, signer: rootStoryNftSigner }) + ); + + IStoryNFT.StoryNftInitParams memory rootStoryNftInitParams = IStoryNFT.StoryNftInitParams({ + owner: rootStoryNftOwner, + name: "Test Org Badge", + symbol: "TOB", + contractURI: "Test Contract URI", + baseURI: "", + customInitData: rootStoryNftCustomInitParams + }); + + vm.startPrank(u.admin); + _deployAndConfigStoryNftContracts({ + licenseTemplate_: defaultLicenseTemplate, + licenseTermsId_: defaultLicenseTermsId, + storyNftFactorySigner: storyNftFactorySigner, + isTest: true + }); + + (, , , address rootStoryNftAddr) = storyNftFactory.deployStoryNftByAdmin({ + storyNftTemplate: defaultStoryNftTemplate, + orgNftRecipient: rootStoryNftOwner, + orgName: rootOrgName, + orgTokenURI: rootOrgTokenURI, + storyNftInitParams: rootStoryNftInitParams, + isRootOrg: true + }); + rootStoryNft = StoryBadgeNFT(rootStoryNftAddr); + vm.stopPrank(); + } + /*////////////////////////////////////////////////////////////////////////// MODIFIERS //////////////////////////////////////////////////////////////////////////*/ @@ -361,10 +418,31 @@ contract BaseTest is Test, DeployHelper { signature = abi.encodePacked(r, s, v); } + /// @dev Uses `signerSk` to sign `addr` and return the signature. + function _signAddress(uint256 signerSk, address addr) internal view returns (bytes memory signature) { + bytes32 digest = keccak256(abi.encodePacked(addr)).toEthSignedMessageHash(); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerSk, digest); + signature = abi.encodePacked(r, s, v); + } + /// @dev Assert metadata for the IP. function assertMetadata(address ipId, WorkflowStructs.IPMetadata memory expectedMetadata) internal view { assertEq(coreMetadataViewModule.getMetadataURI(ipId), expectedMetadata.ipMetadataURI); assertEq(coreMetadataViewModule.getMetadataHash(ipId), expectedMetadata.ipMetadataHash); assertEq(coreMetadataViewModule.getNftMetadataHash(ipId), expectedMetadata.nftMetadataHash); } + + /// @dev Assert parent and derivative relationship. + function assertParentChild( + address parentIpId, + address childIpId, + uint256 expectedParentCount, + uint256 expectedParentIndex + ) internal view { + assertTrue(licenseRegistry.hasDerivativeIps(parentIpId)); + assertTrue(licenseRegistry.isDerivativeIp(childIpId)); + assertTrue(licenseRegistry.isParentIp({ parentIpId: parentIpId, childIpId: childIpId })); + assertEq(licenseRegistry.getParentIpCount(childIpId), expectedParentCount); + assertEq(licenseRegistry.getParentIp(childIpId, expectedParentIndex), parentIpId); + } } diff --git a/test/workflows/DerivativeWorkflows.t.sol b/test/workflows/DerivativeWorkflows.t.sol index 9948f08..aed5fa8 100644 --- a/test/workflows/DerivativeWorkflows.t.sol +++ b/test/workflows/DerivativeWorkflows.t.sol @@ -387,18 +387,4 @@ contract DerivativeWorkflowsTest is BaseTest { expectedParentIndex: 0 }); } - - /// @dev Assert parent and derivative relationship. - function assertParentChild( - address parentIpId, - address childIpId, - uint256 expectedParentCount, - uint256 expectedParentIndex - ) internal view { - assertTrue(licenseRegistry.hasDerivativeIps(parentIpId)); - assertTrue(licenseRegistry.isDerivativeIp(childIpId)); - assertTrue(licenseRegistry.isParentIp({ parentIpId: parentIpId, childIpId: childIpId })); - assertEq(licenseRegistry.getParentIpCount(childIpId), expectedParentCount); - assertEq(licenseRegistry.getParentIp(childIpId, expectedParentIndex), parentIpId); - } }