diff --git a/contracts/interfaces/registries/IIPAssetRegistry.sol b/contracts/interfaces/registries/IIPAssetRegistry.sol index d8ca1a113..65006244c 100644 --- a/contracts/interfaces/registries/IIPAssetRegistry.sol +++ b/contracts/interfaces/registries/IIPAssetRegistry.sol @@ -36,6 +36,25 @@ interface IIPAssetRegistry is IIPAccountRegistry { bytes metadata ); + // TODO: replace the IPRegistered event with IPRegisteredPermissionless + /// @notice Emits when an IP is officially registered into the protocol. + /// @param ipId The canonical identifier for the IP. + /// @param chainId The chain identifier of where the IP resides. + /// @param tokenContract The token contract address of the IP NFT. + /// @param tokenId The token identifier of the IP. + /// @param name The name of the IP. + /// @param uri The URI of the IP. + /// @param registrationDate The date and time the IP was registered. + event IPRegisteredPermissionless( + address ipId, + uint256 indexed chainId, + address indexed tokenContract, + uint256 indexed tokenId, + string name, + string uri, + uint256 registrationDate + ); + /// @notice Emits when an IP resolver is bound to an IP. /// @param ipId The canonical identifier of the specified IP. /// @param resolver The address of the new resolver bound to the IP. @@ -70,6 +89,12 @@ interface IIPAssetRegistry is IIPAccountRegistry { /// @param approved Whether or not to approve that operator for registration. function setApprovalForAll(address operator, bool approved) external; + /// @notice Registers an NFT as an IP asset. + /// @param tokenContract The address of the NFT. + /// @param tokenId The token identifier of the NFT. + /// @return id The address of the newly registered IP. + function register(address tokenContract, uint256 tokenId) external returns (address id); + /// @notice Registers an NFT as IP, creating a corresponding IP record. /// @param chainId The chain identifier of where the NFT resides. /// @param tokenContract The address of the NFT. diff --git a/contracts/lib/Errors.sol b/contracts/lib/Errors.sol index 7e0415b04..cbadb828a 100644 --- a/contracts/lib/Errors.sol +++ b/contracts/lib/Errors.sol @@ -63,6 +63,15 @@ library Errors { /// @notice The metadata provider is not valid. error IPAssetRegistry__InvalidMetadataProvider(); + /// @notice The NFT token contract is not valid ERC721 contract. + error IPAssetRegistry__UnsupportedIERC721(address contractAddress); + + /// @notice The NFT token contract does not support ERC721Metadata. + error IPAssetRegistry__UnsupportedIERC721Metadata(address contractAddress); + + /// @notice The NFT token id does not exist or invalid. + error IPAssetRegistry__InvalidToken(address contractAddress, uint256 tokenId); + //////////////////////////////////////////////////////////////////////////// // IPResolver /// //////////////////////////////////////////////////////////////////////////// diff --git a/contracts/registries/IPAssetRegistry.sol b/contracts/registries/IPAssetRegistry.sol index 00c12b595..9822b9c7d 100644 --- a/contracts/registries/IPAssetRegistry.sol +++ b/contracts/registries/IPAssetRegistry.sol @@ -2,7 +2,9 @@ pragma solidity 0.8.23; import { IERC721 } from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import { IERC721Metadata } from "@openzeppelin/contracts/token/ERC721/extensions/IERC721Metadata.sol"; import { ERC165Checker } from "@openzeppelin/contracts/utils/introspection/ERC165Checker.sol"; +import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; import { IIPAccount } from "../interfaces/IIPAccount.sol"; import { IIPAssetRegistry } from "../interfaces/registries/IIPAssetRegistry.sol"; @@ -16,6 +18,7 @@ import { IModuleRegistry } from "../interfaces/registries/IModuleRegistry.sol"; import { ILicensingModule } from "../interfaces/modules/licensing/ILicensingModule.sol"; import { IIPAssetRegistry } from "../interfaces/registries/IIPAssetRegistry.sol"; import { Governable } from "../governance/Governable.sol"; +import { IPAccountStorageOps } from "../lib/IPAccountStorageOps.sol"; /// @title IP Asset Registry /// @notice This contract acts as the source of truth for all IP registered in @@ -27,6 +30,10 @@ import { Governable } from "../governance/Governable.sol"; /// IMPORTANT: The IP account address, besides being used for protocol /// auth, is also the canonical IP identifier for the IP NFT. contract IPAssetRegistry is IIPAssetRegistry, IPAccountRegistry, Governable { + using ERC165Checker for address; + using Strings for *; + using IPAccountStorageOps for IIPAccount; + /// @notice The canonical module registry used by the protocol. IModuleRegistry public immutable MODULE_REGISTRY; @@ -70,6 +77,49 @@ contract IPAssetRegistry is IIPAssetRegistry, IPAccountRegistry, Governable { _metadataProvider = IMetadataProviderMigratable(newMetadataProvider); } + /// @notice Registers an NFT as an IP asset. + /// @dev The IP required metadata name and URI are derived from the NFT's metadata. + /// @param tokenContract The address of the NFT. + /// @param tokenId The token identifier of the NFT. + /// @return id The address of the newly registered IP. + function register(address tokenContract, uint256 tokenId) external returns (address id) { + if (!tokenContract.supportsInterface(type(IERC721).interfaceId)) { + revert Errors.IPAssetRegistry__UnsupportedIERC721(tokenContract); + } + + if (IERC721(tokenContract).ownerOf(tokenId) == address(0)) { + revert Errors.IPAssetRegistry__InvalidToken(tokenContract, tokenId); + } + + if (!tokenContract.supportsInterface(type(IERC721Metadata).interfaceId)) { + revert Errors.IPAssetRegistry__UnsupportedIERC721Metadata(tokenContract); + } + + id = registerIpAccount(block.chainid, tokenContract, tokenId); + IIPAccount ipAccount = IIPAccount(payable(id)); + + if (bytes(ipAccount.getString("NAME")).length != 0) { + revert Errors.IPAssetRegistry__AlreadyRegistered(); + } + + string memory name = string.concat( + block.chainid.toString(), + ": ", + IERC721Metadata(tokenContract).name(), + " #", + tokenId.toString() + ); + string memory uri = IERC721Metadata(tokenContract).tokenURI(tokenId); + uint256 registrationDate = block.timestamp; + ipAccount.setString("NAME", name); + ipAccount.setString("URI", uri); + ipAccount.setUint256("REGISTRATION_DATE", registrationDate); + + totalSupply++; + + emit IPRegisteredPermissionless(id, block.chainid, tokenContract, tokenId, name, uri, registrationDate); + } + /// @notice Registers an NFT as IP, creating a corresponding IP record. /// @param chainId The chain identifier of where the NFT resides. /// @param tokenContract The address of the NFT. @@ -146,7 +196,8 @@ contract IPAssetRegistry is IIPAssetRegistry, IPAccountRegistry, Governable { /// @param id The canonical identifier for the IP. /// @return isRegistered Whether the IP was registered into the protocol. function isRegistered(address id) external view returns (bool) { - return _records[id].resolver != address(0); + // TODO: also check the ipAccount has "Name" metadata after clean up permissioned functions. + return _records[id].resolver != address(0) || id.code.length != 0; } /// @notice Gets the resolver bound to an IP based on its ID. diff --git a/contracts/utils/ShortStringOps.sol b/contracts/utils/ShortStringOps.sol index ce8d6b87e..c9914132f 100644 --- a/contracts/utils/ShortStringOps.sol +++ b/contracts/utils/ShortStringOps.sol @@ -2,10 +2,12 @@ pragma solidity 0.8.23; import { ShortString, ShortStrings } from "@openzeppelin/contracts/utils/ShortStrings.sol"; +import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; /// @notice Library for working with Openzeppelin's ShortString data types. library ShortStringOps { using ShortStrings for *; + using Strings for *; /// @dev Compares whether two ShortStrings are equal. function equal(ShortString a, ShortString b) internal pure returns (bool) { @@ -44,4 +46,8 @@ library ShortStringOps { function bytes32ToString(bytes32 b) internal pure returns (string memory) { return ShortString.wrap(b).toString(); } + + function toShortString(uint256 value) internal pure returns (ShortString) { + return value.toString().toShortString(); + } } diff --git a/test/foundry/mocks/token/MockERC721WithoutMetadata.sol b/test/foundry/mocks/token/MockERC721WithoutMetadata.sol new file mode 100644 index 000000000..09211067d --- /dev/null +++ b/test/foundry/mocks/token/MockERC721WithoutMetadata.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: BUSDL-1.1 +pragma solidity 0.8.23; + +import { IERC721, IERC165 } from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; + +contract MockERC721WithoutMetadata is IERC721 { + mapping(uint256 => address) private _owners; + + function mint(address to, uint256 tokenId) external { + _owners[tokenId] = to; + } + + function balanceOf(address owner) external view returns (uint256 balance) { + revert("MockERC721WithoutMetadata: not implemented"); + } + function ownerOf(uint256 tokenId) external view returns (address owner) { + return _owners[tokenId]; + } + function safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata data) external { + revert("MockERC721WithoutMetadata: not implemented"); + } + function safeTransferFrom(address from, address to, uint256 tokenId) external { + revert("MockERC721WithoutMetadata: not implemented"); + } + function transferFrom(address from, address to, uint256 tokenId) external { + revert("MockERC721WithoutMetadata: not implemented"); + } + function approve(address to, uint256 tokenId) external { + revert("MockERC721WithoutMetadata: not implemented"); + } + function setApprovalForAll(address operator, bool approved) external { + revert("MockERC721WithoutMetadata: not implemented"); + } + function getApproved(uint256 tokenId) external view returns (address operator) { + revert("MockERC721WithoutMetadata: not implemented"); + } + function isApprovedForAll(address owner, address operator) external view returns (bool) { + revert("MockERC721WithoutMetadata: not implemented"); + } + function supportsInterface(bytes4 interfaceId) public view virtual returns (bool) { + return interfaceId == type(IERC165).interfaceId || interfaceId == type(IERC721).interfaceId; + } +} diff --git a/test/foundry/registries/IPAssetRegistry.t.sol b/test/foundry/registries/IPAssetRegistry.t.sol index ed86d3839..8dce47d32 100644 --- a/test/foundry/registries/IPAssetRegistry.t.sol +++ b/test/foundry/registries/IPAssetRegistry.t.sol @@ -5,13 +5,22 @@ import { IIPAssetRegistry } from "contracts/interfaces/registries/IIPAssetRegist import { IPAccountChecker } from "contracts/lib/registries/IPAccountChecker.sol"; import { IP } from "contracts/lib/IP.sol"; import { IPAssetRegistry } from "contracts/registries/IPAssetRegistry.sol"; +import { IIPAccountRegistry } from "contracts/interfaces/registries/IIPAccountRegistry.sol"; import { Errors } from "contracts/lib/Errors.sol"; +import { IIPAccount } from "contracts/interfaces/IIPAccount.sol"; +import { IPAccountStorageOps } from "contracts/lib/IPAccountStorageOps.sol"; +import { ShortStrings } from "@openzeppelin/contracts/utils/ShortStrings.sol"; +import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; +import { MockERC721WithoutMetadata } from "test/foundry/mocks/token/MockERC721WithoutMetadata.sol"; import { BaseTest } from "../utils/BaseTest.t.sol"; /// @title IP Asset Registry Testing Contract /// @notice Contract for testing core IP registration. contract IPAssetRegistryTest is BaseTest { + using IPAccountStorageOps for IIPAccount; + using ShortStrings for *; + using Strings for *; // Default IP record attributes. string public constant IP_NAME = "IPAsset"; string public constant IP_DESCRIPTION = "IPs all the way down."; @@ -63,6 +72,98 @@ contract IPAssetRegistryTest is BaseTest { registry.register(block.chainid, tokenAddress, tokenId, resolver, true, metadata); } + /// @notice Tests registration of IP permissionlessly. + function test_IPAssetRegistry_RegisterPermissionless() public { + uint256 totalSupply = registry.totalSupply(); + + assertTrue(!registry.isRegistered(ipId)); + assertTrue(!IPAccountChecker.isRegistered(ipAccountRegistry, block.chainid, tokenAddress, tokenId)); + string memory name = string.concat(block.chainid.toString(), ": Ape #99"); + vm.expectEmit(true, true, true, true); + emit IIPAssetRegistry.IPRegisteredPermissionless( + ipId, + block.chainid, + tokenAddress, + tokenId, + name, + "https://storyprotocol.xyz/erc721/99", + block.timestamp + ); + vm.prank(alice); + registry.register(tokenAddress, tokenId); + + assertEq(totalSupply + 1, registry.totalSupply()); + assertTrue(IPAccountChecker.isRegistered(ipAccountRegistry, block.chainid, tokenAddress, tokenId)); + assertEq(IIPAccount(payable(ipId)).getString(address(registry), "NAME"), name); + assertEq(IIPAccount(payable(ipId)).getString(address(registry), "URI"), "https://storyprotocol.xyz/erc721/99"); + assertEq(IIPAccount(payable(ipId)).getUint256(address(registry), "REGISTRATION_DATE"), block.timestamp); + } + + /// @notice Tests registration of IP permissionlessly for IPAccount already created. + function test_IPAssetRegistry_RegisterPermissionless_IPAccountAlreadyExist() public { + uint256 totalSupply = registry.totalSupply(); + + IIPAccountRegistry(registry).registerIpAccount(block.chainid, tokenAddress, tokenId); + string memory name = string.concat(block.chainid.toString(), ": Ape #99"); + vm.expectEmit(true, true, true, true); + emit IIPAssetRegistry.IPRegisteredPermissionless( + ipId, + block.chainid, + tokenAddress, + tokenId, + name, + "https://storyprotocol.xyz/erc721/99", + block.timestamp + ); + vm.prank(alice); + registry.register(tokenAddress, tokenId); + + assertEq(totalSupply + 1, registry.totalSupply()); + assertTrue(IPAccountChecker.isRegistered(ipAccountRegistry, block.chainid, tokenAddress, tokenId)); + assertEq(IIPAccount(payable(ipId)).getString(address(registry), "NAME"), name); + assertEq(IIPAccount(payable(ipId)).getString(address(registry), "URI"), "https://storyprotocol.xyz/erc721/99"); + assertEq(IIPAccount(payable(ipId)).getUint256(address(registry), "REGISTRATION_DATE"), block.timestamp); + } + + /// @notice Tests registration of the same IP twice. + function test_IPAssetRegistry_revert_RegisterPermissionlessTwice() public { + assertTrue(!registry.isRegistered(ipId)); + assertTrue(!IPAccountChecker.isRegistered(ipAccountRegistry, block.chainid, tokenAddress, tokenId)); + + vm.prank(alice); + registry.register(tokenAddress, tokenId); + + vm.expectRevert(Errors.IPAssetRegistry__AlreadyRegistered.selector); + vm.prank(alice); + registry.register(tokenAddress, tokenId); + } + + /// @notice Tests registration of IP with non ERC721 token. + function test_IPAssetRegistry_revert_InvalidTokenContract() public { + // not an ERC721 contract + vm.expectRevert(abi.encodeWithSelector(Errors.IPAssetRegistry__UnsupportedIERC721.selector, address(0x12345))); + registry.register(address(0x12345), 1); + + // not implemented ERC721Metadata contract + MockERC721WithoutMetadata erc721WithoutMetadata = new MockERC721WithoutMetadata(); + erc721WithoutMetadata.mint(alice, 1); + vm.expectRevert( + abi.encodeWithSelector(Errors.IPAssetRegistry__UnsupportedIERC721Metadata.selector, erc721WithoutMetadata) + ); + registry.register(address(erc721WithoutMetadata), 1); + } + + /// @notice Tests registration of IP with non-exist NFT. + function test_IPAssetRegistry_revert_InvalidNFTToken() public { + MockERC721WithoutMetadata erc721WithoutMetadata = new MockERC721WithoutMetadata(); + erc721WithoutMetadata.mint(alice, 1); + // non exist token id + vm.expectRevert( + abi.encodeWithSelector(Errors.IPAssetRegistry__InvalidToken.selector, erc721WithoutMetadata, 999) + ); + registry.register(address(erc721WithoutMetadata), 999); + } + /// @notice Tests registration of IP assets without licenses. function test_IPAssetRegistry_Register() public { uint256 totalSupply = registry.totalSupply(); @@ -224,4 +325,8 @@ contract IPAssetRegistryTest is BaseTest { }) ); } + + function _toBytes32(address a) internal pure returns (bytes32) { + return bytes32(uint256(uint160(a))); + } }