From 61049137d2adb38b0949fbe7c258199d54d6df2e Mon Sep 17 00:00:00 2001 From: Sebastian Liu Date: Wed, 2 Oct 2024 15:36:34 -0700 Subject: [PATCH] badge WIP --- contracts/interfaces/story-nft/IERC5192.sol | 22 ++ contracts/interfaces/story-nft/IOrgNFT.sol | 67 +++++ .../interfaces/story-nft/IStoryBadgeNFT.sol | 75 +++++ .../interfaces/story-nft/IStoryNFTFactory.sol | 124 +++++++++ contracts/story-nft/BaseStoryNFT.sol | 155 +++++++++++ contracts/story-nft/OrgNFT.sol | 190 +++++++++++++ contracts/story-nft/StoryBadgeNFT.sol | 182 ++++++++++++ contracts/story-nft/StoryNFTFactory.sol | 262 ++++++++++++++++++ script/deployment/StoryNFT.s.sol | 62 +++++ script/utils/DeployHelper.sol | 129 ++++++++- 10 files changed, 1260 insertions(+), 8 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/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 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..5abce1e --- /dev/null +++ b/contracts/interfaces/story-nft/IStoryBadgeNFT.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { IERC721Metadata } from "@openzeppelin/contracts/token/ERC721/extensions/IERC721Metadata.sol"; + +import { IERC5192 } from "./IERC5192.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 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 initializing the StoryBadgeNFT contract. + /// @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 tokenURI The token URI for all the badges (follows OpenSea metadata standard). + /// @param signer The signer of the whitelist signatures. + struct InitParams { + string name; + string symbol; + string contractURI; + 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/IStoryNFTFactory.sol b/contracts/interfaces/story-nft/IStoryNFTFactory.sol new file mode 100644 index 0000000..24c0351 --- /dev/null +++ b/contracts/interfaces/story-nft/IStoryNFTFactory.sol @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +/// @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__OrgNotFound(string orgName); + + /// @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 BaseStoryNFT. + error StoryNFTFactory__UnsupportedBaseStoryNFT(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 storyNftOwner The address of the owner of the new StoryNFT contract. + /// @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 initData The initialization data for the StoryNFT (see {IStoryBadgeNFT-InitParams} for an example). + /// @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 storyNftOwner, + string calldata orgName, + string calldata orgTokenURI, + bytes calldata signature, + bytes calldata initData + ) external returns (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 storyNftOwner The address of the owner of the new StoryNFT contract. + /// @param orgName The name of the organization. + /// @param orgTokenURI The token URI of the organization NFT. + /// @param initData The initialization data for the StoryNFT (see {IStoryBadgeNFT-InitParams} for an example). + /// @param isRootOrg Whether the organization is the root organization. + /// @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 storyNftOwner, + string calldata orgName, + string calldata orgTokenURI, + bytes calldata initData, + bool isRootOrg + ) external returns (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 getStoryNftAddress(string calldata orgName) external view returns (address); +} diff --git a/contracts/story-nft/BaseStoryNFT.sol b/contracts/story-nft/BaseStoryNFT.sol new file mode 100644 index 0000000..26a6ecb --- /dev/null +++ b/contracts/story-nft/BaseStoryNFT.sol @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +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"; + +/// @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 ERC721URIStorage, Ownable, Initializable { + /// @notice Zero address provided as a param to BaseStoryNFT constructor. + error BaseStoryNFT__ZeroAddressParam(); + + /// @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; + + /// @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) ERC721("", "") Ownable(msg.sender) { + if (ipAssetRegistry == address(0) || licensingModule == address(0)) revert BaseStoryNFT__ZeroAddressParam(); + IP_ASSET_REGISTRY = IIPAssetRegistry(ipAssetRegistry); + LICENSING_MODULE = ILicensingModule(licensingModule); + } + + /// @notice Initializes the StoryNFT, required to be overridden by the inheriting contract. + /// @dev This function is required by `StoryNFTFactory` (see {IStoryNFTFactory-deployStoryNft}). + /// @param owner_ The address of the owner of this collection. + /// @param orgNft_ The address of the organization NFT for this collection (see {OrgNFT}). + /// @param orgTokenId_ The token ID of the organization NFT. + /// @param orgIpId_ The ID of the organization IP. + /// @param initData The data to initialize the collection. + function initialize( + address owner_, + address orgNft_, + uint256 orgTokenId_, + address orgIpId_, + bytes calldata initData + ) public virtual; + + /// @notice Initializes the BaseStoryNFT. + /// @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). + function __BaseStoryNFT_init( + string memory name_, + string memory symbol_, + string memory contractURI_, + string memory baseURI_ + ) internal onlyInitializing { + _name = name_; + _symbol = symbol_; + _contractURI = contractURI_; + _baseURI_ = baseURI_; + _totalSupply = 0; + } + + /// @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) returns (bool) { + return interfaceId == type(BaseStoryNFT).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 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..38dcab5 --- /dev/null +++ b/contracts/story-nft/StoryBadgeNFT.sol @@ -0,0 +1,182 @@ +// 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 Organization NFT address. + address public orgNft; + + /// @notice Associated Organization NFT token ID. + uint256 public orgTokenId; + + /// @notice Associated Organization IP ID. + address public orgIpId; + + /// @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 pilTemplate, + uint256 defaultLicenseTermsId + ) BaseStoryNFT(ipAssetRegistry, licensingModule) { + if (ipAssetRegistry == address(0) || licensingModule == address(0) || pilTemplate == address(0)) + revert StoryBadgeNFT__ZeroAddressParam(); + + PIL_TEMPLATE = pilTemplate; + DEFAULT_LICENSE_TERMS_ID = defaultLicenseTermsId; + } + + /// @notice Initializes the StoryBadgeNFT contract. + /// @param owner_ The address of the owner of this collection. + /// @param orgNft_ The address of the organization NFT (see {OrgNFT}). + /// @param orgTokenId_ The associated organization NFT token ID. + /// @param orgIpId_ The ID of the associated organization IP. + /// @param initData The data to initialize the collection. + function initialize( + address owner_, + address orgNft_, + uint256 orgTokenId_, + address orgIpId_, + bytes calldata initData + ) public override initializer { + InitParams memory initParams = abi.decode(initData, (InitParams)); + if (owner_ == address(0) || orgNft_ == address(0) || orgIpId_ == address(0) || initParams.signer == address(0)) + revert StoryBadgeNFT__ZeroAddressParam(); + + __BaseStoryNFT_init({ + name_: initParams.name, + symbol_: initParams.symbol, + contractURI_: initParams.contractURI, + baseURI_: "" + }); + _transferOwnership(owner_); + + _tokenURI = initParams.tokenURI; + _signer = initParams.signer; + orgNft = orgNft_; + orgTokenId = orgTokenId_; + orgIpId = orgIpId_; + } + + /// @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 Returns the base URI + /// @return empty string + function _baseURI() internal view 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..bb2d1d9 --- /dev/null +++ b/contracts/story-nft/StoryNFTFactory.sol @@ -0,0 +1,262 @@ +// 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 { BaseStoryNFT } from "./BaseStoryNFT.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 deployedStoryNfts A mapping of organization names 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) deployedStoryNfts; + 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 storyNftOwner The address of the owner of the new StoryNFT contract. + /// @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 initData The initialization data for the StoryNFT (see {IStoryBadgeNFT-InitParams} for an example). + /// @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 storyNftOwner, + string calldata orgName, + string calldata orgTokenURI, + bytes calldata signature, + bytes calldata initData + ) external returns (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 ($.deployedStoryNfts[orgName] != address(0)) + revert StoryNFTFactory__OrgAlreadyDeployed(orgName, $.deployedStoryNfts[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(storyNftOwner, orgTokenURI); + + // Clones the story NFT template and initializes it + storyNft = Clones.clone(storyNftTemplate); + BaseStoryNFT(storyNft).initialize({ + owner_: storyNftOwner, + orgNft_: address(ORG_NFT), + orgTokenId_: orgTokenId, + orgIpId_: orgIpId, + initData: initData + }); + + // Stores the deployed story NFT address + $.deployedStoryNfts[orgName] = storyNft; + + emit StoryNftDeployed(orgName, address(ORG_NFT), 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 storyNftOwner The address of the owner of the new StoryNFT contract. + /// @param orgName The name of the organization. + /// @param orgTokenURI The token URI of the organization NFT. + /// @param initData The initialization data for the StoryNFT (see {IStoryBadgeNFT-InitParams} for an example). + /// @param isRootOrg Whether the organization is the root organization. + /// @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 storyNftOwner, + string calldata orgName, + string calldata orgTokenURI, + bytes calldata initData, + bool isRootOrg + ) external restricted returns (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 ($.deployedStoryNfts[orgName] != address(0)) + revert StoryNFTFactory__OrgAlreadyDeployed(orgName, $.deployedStoryNfts[orgName]); + + // Mint the organization NFT and register it as an IP + if (isRootOrg) { + (orgTokenId, orgIpId) = ORG_NFT.mintRootOrgNft(storyNftOwner, orgTokenURI); + } else { + (orgTokenId, orgIpId) = ORG_NFT.mintOrgNft(storyNftOwner, orgTokenURI); + } + + // Clones the story NFT template and initializes it + storyNft = Clones.clone(storyNftTemplate); + BaseStoryNFT(storyNft).initialize({ + owner_: storyNftOwner, + orgNft_: address(ORG_NFT), + orgTokenId_: orgTokenId, + orgIpId_: orgIpId, + initData: initData + }); + + // Stores the deployed story NFT address + $.deployedStoryNfts[orgName] = storyNft; + + emit StoryNftDeployed(orgName, address(ORG_NFT), 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 { + _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 inherit from BaseStoryNFT + if (!storyNftTemplate.supportsInterface(type(BaseStoryNFT).interfaceId)) + revert StoryNFTFactory__UnsupportedBaseStoryNFT(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 getStoryNftAddress(string calldata orgName) external view returns (address storyNft) { + storyNft = _getStoryNFTFactoryStorage().deployedStoryNfts[orgName]; + if (storyNft == address(0)) revert StoryNFTFactory__OrgNotFound(orgName); + } + + /// @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..ac51ee7 --- /dev/null +++ b/script/deployment/StoryNFT.s.sol @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; +/* solhint-disable*/ + +import { Base64 } from "@openzeppelin/contracts/utils/Base64.sol"; +import { ILicenseRegistry } from "@storyprotocol/core/interfaces/registries/ILicenseRegistry.sol"; + +import { IStoryBadgeNFT } from "../../contracts/interfaces/story-nft/IStoryBadgeNFT.sol"; +import { DeployHelper } from "../utils/DeployHelper.sol"; + +contract StoryNFT is DeployHelper { + address internal CREATE3_DEPLOYER = 0x384a891dFDE8180b054f04D66379f16B7a678Ad6; + uint256 private constant CREATE3_DEFAULT_SEED = 49879792553172213211421121323534; + 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"); + address rootOrgOwner = vm.envAddress("ROOT_ORG_OWNER"); + address rootStoryNftSigner = vm.envAddress("ROOT_STORY_NFT_SIGNER"); + string memory rootOrgName = "Test Root Org"; + string memory rootOrgTokenURI = string(abi.encodePacked( + "data:application/json;base64,", + Base64.encode(bytes(string( + abi.encodePacked( + "{", + '"name": "Test Badge",', + '"description": "Test Badge",', + '"external_url": "https://www.story.foundation/",', + '"image": "https://storage.googleapis.com/opensea-prod.appspot.com/puffs/3.png"', + "}" + ) + ))) + )); + + bytes memory rootStoryNftInitData = abi.encode(IStoryBadgeNFT.InitParams({ + name: "Test Org Badge", + symbol: "TOB", + contractURI: "Test Contract URI", + tokenURI: rootOrgTokenURI, + signer: rootStoryNftSigner + })); + + _deployAndConfigStoryNftContracts({ + licenseTemplate_: defaultLicenseTemplate, + licenseTermsId_: defaultLicenseTermsId, + storyNftFactorySigner: storyNftFactorySigner, + rootOrgOwner: rootOrgOwner, + rootOrgName: rootOrgName, + rootOrgTokenURI: rootOrgTokenURI, + rootStoryNftInitData: rootStoryNftInitData, + isTest: false + }); + + _writeDeployment(); + } +} diff --git a/script/utils/DeployHelper.sol b/script/utils/DeployHelper.sol index 7876f09..f1f834e 100644 --- a/script/utils/DeployHelper.sol +++ b/script/utils/DeployHelper.sol @@ -36,13 +36,17 @@ 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 +57,8 @@ contract DeployHelper is BroadcastManager, StorageLayoutChecker, JsonDeploymentHandler, - StoryProtocolCoreAddressManager + StoryProtocolCoreAddressManager, + StoryProtocolPeripheryAddressManager { using StringUtil for uint256; using stdJson for string; @@ -79,8 +84,13 @@ contract DeployHelper is RegistrationWorkflows internal registrationWorkflows; RoyaltyWorkflows internal royaltyWorkflows; + // StoryNFT + StoryNFTFactory internal storyNFTFactory; + OrgNFT internal orgNFT; + StoryBadgeNFT internal rootStoryNft; + // DeployHelper variable - bool private writeDeploys; + bool internal writeDeploys; // Mock Core Contracts AccessController internal accessController; @@ -135,14 +145,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 +170,110 @@ contract DeployHelper is } } - function _deployPeripheryContracts() private { + function _deployAndConfigStoryNftContracts( + address licenseTemplate_, + uint256 licenseTermsId_, + address storyNftFactorySigner, + address rootOrgOwner, + string memory rootOrgName, + string memory rootOrgTokenURI, + bytes memory rootStoryNftInitData, + 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"); + address defaultStoryNftTemplate = address(new StoryBadgeNFT( + ipAssetRegistryAddr, + licensingModuleAddr, + 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)); + + storyNFTFactory.deployStoryNftByAdmin( + defaultStoryNftTemplate, + rootOrgOwner, + rootOrgName, + rootOrgTokenURI, + rootStoryNftInitData, + true + ); + + if (!isTest) { + if (writeDeploys) _writeDeployment(); + _endBroadcast(); + } + } + + function _deployWorkflowContracts() private { address impl = address(0); // Periphery workflow contracts @@ -296,7 +409,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));