diff --git a/contracts/interfaces/registries/IIPRecordRegistry.sol b/contracts/interfaces/registries/IIPRecordRegistry.sol new file mode 100644 index 000000000..5519dc517 --- /dev/null +++ b/contracts/interfaces/registries/IIPRecordRegistry.sol @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: UNLICENSED +// See https://github.com/storyprotocol/protocol-contracts/blob/main/StoryProtocol-AlphaTestingAgreement-17942166.3.pdf +pragma solidity ^0.8.21; + +/// @title IP Record Registry Interface +interface IIPRecordRegistry { + + /// @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 address of the IP. + /// @param tokenId The token identifier of the IP. + /// @param resolver The address of the resolver linked to the IP. + event IPRegistered( + address ipId, + uint256 indexed chainId, + address indexed tokenContract, + uint256 indexed tokenId, + address resolver + ); + + /// @notice Emits when an IP account is created for an IP. + /// @param ipId The canonical identifier for the IP. + /// @param chainId The chain identifier of where the IP resides. + /// @param tokenContract The address of the IP. + /// @param tokenId The token identifier of the IP. + event IPAccountSet( + address ipId, + uint256 indexed chainId, + address indexed tokenContract, + uint256 indexed tokenId + ); + + /// @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. + event IPResolverSet( + address ipId, + address resolver + ); + + /// @notice Gets the canonical IP identifier associated with an IP (NFT). + /// @dev This is the same as the address of the IP account bound to the IP. + /// @param chainId The chain identifier of where the IP resides. + /// @param tokenContract The address of the IP. + /// @param tokenId The token identifier of the IP. + /// @return The address of the associated IP account. + function ipId( + uint256 chainId, + address tokenContract, + uint256 tokenId + ) external view returns (address); + + /// @notice Checks whether an IP was registered based on its ID. + /// @param id The canonical identifier for the IP. + /// @return Whether the IP was registered into the protocol. + function isRegistered( + address id + ) external view returns (bool); + + /// @notice Retrieves whether or not an IP was registered. + /// @param chainId The chain identifier of where the IP resides. + /// @param tokenContract The address of the IP. + /// @param tokenId The token identifier of the IP. + /// @return Whether the IP was registered into the protocol. + function isRegistered( + uint256 chainId, + address tokenContract, + uint256 tokenId + ) external view returns (bool); + + /// @notice Gets the resolver bound to an IP based on its ID. + /// @param id The canonical identifier for the IP. + /// @return The IP resolver address if registered, else the zero address. + function resolver( + address id + ) external view returns (address); + + /// @notice Gets the resolver bound to an IP based on its NFT attributes. + /// @param chainId The chain identifier of where the IP resides. + /// @param tokenContract The address of the IP. + /// @param tokenId The token identifier of the IP. + /// @return The IP resolver address if registered, else the zero address. + function resolver( + uint256 chainId, + address tokenContract, + uint256 tokenId + ) external view returns (address); + + /// @notice Registers an NFT as IP, creating a corresponding IP record. + /// @dev This is only callable by an authorized registration module. + /// @param chainId The chain identifier of where the IP resides. + /// @param tokenContract The address of the IP. + /// @param tokenId The token identifier of the IP. + /// @param createAccount Whether to create an IP account in the process. + function register( + uint256 chainId, + address tokenContract, + uint256 tokenId, + address resolverAddr, + bool createAccount + ) external; + + /// @notice Creates the IP account for the specified IP. + /// @param chainId The chain identifier of where the IP resides. + /// @param tokenContract The address of the IP. + /// @param tokenId The token identifier of the IP. + function createIPAccount( + uint256 chainId, + address tokenContract, + uint256 tokenId + ) external returns (address); + + /// @notice Sets the resolver for an IP based on its canonical ID. + /// @param id The canonical ID of the IP. + /// @param resolverAddr The address of the resolver being set. + function setResolver(address id, address resolverAddr) external; + + /// @notice Sets the resolver for an IP based on its NFT attributes. + /// @dev This is only callable by an authorized registration module. + /// @param chainId The chain identifier of where the IP resides. + /// @param tokenContract The address of the IP. + /// @param tokenId The token identifier of the IP. + function setResolver( + uint256 chainId, + address tokenContract, + uint256 tokenId, + address resolver + ) external; + +} diff --git a/contracts/interfaces/resolvers/IIPMetadataResolver.sol b/contracts/interfaces/resolvers/IIPMetadataResolver.sol new file mode 100644 index 000000000..81a74317e --- /dev/null +++ b/contracts/interfaces/resolvers/IIPMetadataResolver.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: UNLICENSED +// See https://github.com/storyprotocol/protocol-contracts/blob/main/StoryProtocol-AlphaTestingAgreement-17942166.3.pdf +pragma solidity ^0.8.21; + +import { IResolver } from "contracts/interfaces/resolvers/IResolver.sol"; +import { IP } from "contracts/lib/IP.sol"; + +/// @notice Resolver Interface +interface IIPMetadataResolver is IResolver { + + /// @notice Fetches core metadata attributed to a specific IP. + function metadata(address ipId) external view returns (IP.Metadata memory); + + /// @notice Fetches the canonical name associated with the specified IP. + /// @param ipId The canonical ID of the specified IP. + function name(address ipId) external view returns (string memory); + + /// @notice Fetches the description associated with the specified IP. + /// @param ipId The canonical ID of the specified IP. + /// @return The string descriptor of the IP. + function description(address ipId) external view returns (string memory); + + /// @notice Fetches the keccak-256 hash associated with the specified IP. + /// @param ipId The canonical ID of the specified IP. + /// @return The bytes32 content hash of the IP. + function hash(address ipId) external view returns (bytes32); + + /// @notice Fetches the date of registration of the IP. + /// @param ipId The canonical ID of the specified IP. + function registrationDate(address ipId) external view returns (uint64); + + /// @notice Fetches the initial registrant of the IP. + /// @param ipId The canonical ID of the specified IP. + function registrant(address ipId) external view returns (address); + + /// @notice Fetches the current owner of the IP. + /// @param ipId The canonical ID of the specified IP. + function owner(address ipId) external view returns (address); + + /// @notice Fetches an IP owner defined URI associated with the IP. + /// @param ipId The canonical ID of the specified IP. + function uri(address ipId) external view returns (string memory); + + /// @notice Sets the core metadata associated with an IP. + /// @param ipId The canonical ID of the specified IP. + /// @param data Metadata to be stored for the IP in the metadata resolver. + function setMetadata(address ipId, IP.MetadataRecord calldata data) external; + + /// @notice Sets the name associated with an IP. + /// @param ipId The canonical ID of the specified IP. + /// @param name The string name to associate with the IP. + function setName(address ipId, string calldata name) external; + + /// @notice Sets the description associated with an IP. + /// @param ipId The canonical ID of the specified IP. + /// @param description The string description to associate with the IP. + function setDescription(address ipId, string calldata description) external; + + /// @notice Sets the keccak-256 hash associated with an IP. + /// @param ipId The canonical ID of the specified IP. + /// @param hash The keccak-256 hash to associate with the IP. + function setHash(address ipId, bytes32 hash) external; + + /// @notice Sets an IP owner defined URI to associate with the IP. + /// @param ipId The canonical ID of the specified IP. + function setURI(address ipId, string calldata uri) external; + +} diff --git a/contracts/interfaces/resolvers/IResolver.sol b/contracts/interfaces/resolvers/IResolver.sol new file mode 100644 index 000000000..ce031d473 --- /dev/null +++ b/contracts/interfaces/resolvers/IResolver.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: UNLICENSED +// See https://github.com/storyprotocol/protocol-contracts/blob/main/StoryProtocol-AlphaTestingAgreement-17942166.3.pdf +pragma solidity ^0.8.21; + +/// @notice Resolver Interface +interface IResolver { + + /// @notice Gets the address of the access controller for the resolver. + function accessController() view external returns (address); + + /// @notice Checks whether the resolver IP interface is supported. + function supportsInterface(bytes4 id) view external returns (bool); + +} diff --git a/contracts/lib/Errors.sol b/contracts/lib/Errors.sol index d3bfa0492..9a8e1182a 100644 --- a/contracts/lib/Errors.sol +++ b/contracts/lib/Errors.sol @@ -1,15 +1,43 @@ -// SPDX-License-Identifier: MIT - -pragma solidity ^0.8.20; +// SPDX-License-Identifier: UNLICENSED +// See https://github.com/storyprotocol/protocol-contracts/blob/main/StoryProtocol-AlphaTestingAgreement-17942166.3.pdf +pragma solidity ^0.8.19; /// @title Errors Library /// @notice Library for all Story Protocol contract errors. library Errors { - + //////////////////////////////////////////////////////////////////////////// + // IPRecordRegistry // + //////////////////////////////////////////////////////////////////////////// + + /// @notice The IP record has already been registered. + error IPRecordRegistry_AlreadyRegistered(); + + /// @notice The IP account has already been created. + error IPRecordRegistry_IPAccountAlreadyCreated(); + + /// @notice The IP record has not yet been registered. + error IPRecordRegistry_NotYetRegistered(); + + /// @notice The specified IP resolver is not valid. + error IPRecordRegistry_ResolverInvalid(); + + /// @notice Caller not authorized to perform the IP registry function call. + error IPRecordRegistry_Unauthorized(); + + //////////////////////////////////////////////////////////////////////////// + // IPResolver /// + //////////////////////////////////////////////////////////////////////////// + + /// @notice The targeted IP does not yet have an IP account. + error IPResolver_InvalidIP(); + + /// @notice Caller not authorized to perform the IP resolver function call. + error IPResolver_Unauthorized(); + //////////////////////////////////////////////////////////////////////////// // LicenseRegistry // //////////////////////////////////////////////////////////////////////////// - + /// @notice Error thrown when a policy is already set for an IP ID. error LicenseRegistry__PolicyAlreadySetForIpId(); error LicenseRegistry__FrameworkNotFound(); @@ -42,5 +70,4 @@ library Errors { error AccessController__SignerIsZeroAddress(); error AccessController__CallerIsNotIPAccount(); error AccessController__PermissionIsNotValid(); - -} \ No newline at end of file +} diff --git a/contracts/lib/IP.sol b/contracts/lib/IP.sol new file mode 100644 index 000000000..341ddef4a --- /dev/null +++ b/contracts/lib/IP.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: UNLICENSED +// See https://github.com/storyprotocol/protocol-contracts/blob/main/StoryProtocol-AlphaTestingAgreement-17942166.3.pdf +pragma solidity ^0.8.21; + +/// @title IP Library +/// @notice Library for constants, structs, and helper functions used for IP. +library IP { + /// @notice Core metadata associated with an IP. + /// @dev This is what is fetched when `metadata()` is called from an IP + /// resolver, and includes aggregated attributes fetched from various + /// modules in addition to that which is stored on the resolver itself. + struct Metadata { + // The current owner of the IP. + address owner; + // The name associated with the IP. + string name; + // A description associated with the IP. + string description; + // A keccak-256 hash of the IP content. + bytes32 hash; + // The date which the IP was registered. + uint64 registrationDate; + // The address of the initial IP registrant. + address registrant; + // The token URI associated with the IP. + string uri; + } + + /// @notice Core metadata exclusively saved by the IP resolver. + /// @dev This only encompasses metadata which is stored on the IP metadata + /// resolver itself, and does not include those attributes which may + /// be fetched from different modules (e.g. the licensing modules). + struct MetadataRecord { + // The name associated with the IP. + string name; + // A description associated with the IP. + string description; + // A keccak-256 hash of the IP content. + bytes32 hash; + // The date which the IP was registered. + uint64 registrationDate; + // The address of the initial IP registrant. + address registrant; + // The token URI associated with the IP. + string uri; + } +} diff --git a/contracts/lib/modules/Module.sol b/contracts/lib/modules/Module.sol new file mode 100644 index 000000000..25bbf6c79 --- /dev/null +++ b/contracts/lib/modules/Module.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: UNLICENSED +// See https://github.com/storyprotocol/protocol-contracts/blob/main/StoryProtocol-AlphaTestingAgreement-17942166.3.pdf +pragma solidity ^0.8.21; + +// String values for core protocol modules. +string constant REGISTRATION_MODULE_KEY = "REGISTRATION_MODULE"; diff --git a/contracts/registries/IPAccountRegistry.sol b/contracts/registries/IPAccountRegistry.sol index 42c925f43..03f2e9e7c 100644 --- a/contracts/registries/IPAccountRegistry.sol +++ b/contracts/registries/IPAccountRegistry.sol @@ -9,10 +9,10 @@ import { IERC6551Registry } from "lib/reference/src/interfaces/IERC6551Registry. /// @notice This contract is responsible for managing the registration and tracking of IP Accounts. /// It leverages a public ERC6551 registry to deploy IPAccount contracts. contract IPAccountRegistry is IIPAccountRegistry { - address internal immutable IP_ACCOUNT_IMPL; - bytes32 internal immutable IP_ACCOUNT_SALT; - address internal immutable ERC6551_PUBLIC_REGISTRY; - address internal immutable ACCESS_CONTROLLER; + address public immutable IP_ACCOUNT_IMPL; + bytes32 public immutable IP_ACCOUNT_SALT; + address public immutable ERC6551_PUBLIC_REGISTRY; + address public immutable ACCESS_CONTROLLER; error NonExistIpAccountImpl(); diff --git a/contracts/registries/IPRecordRegistry.sol b/contracts/registries/IPRecordRegistry.sol new file mode 100644 index 000000000..4ca03ddad --- /dev/null +++ b/contracts/registries/IPRecordRegistry.sol @@ -0,0 +1,189 @@ +// SPDX-License-Identifier: UNLICENSED +// See https://github.com/storyprotocol/protocol-contracts/blob/main/StoryProtocol-AlphaTestingAgreement-17942166.3.pdf +pragma solidity ^0.8.21; + +import { REGISTRATION_MODULE_KEY } from "contracts/lib/modules/Module.sol"; +import { IIPRecordRegistry } from "contracts/interfaces/registries/IIPRecordRegistry.sol"; +import { IIPAccountRegistry } from "contracts/interfaces/registries/IIPAccountRegistry.sol"; +import { IModuleRegistry } from "contracts/interfaces/registries/IModuleRegistry.sol"; +import { Errors } from "contracts/lib/Errors.sol"; + +/// @title IP Record Registry +/// @notice This contract acts as the source of truth for all IP registered in +/// Story Protocol. An IP is identified by its contract address, token +/// id, and coin type, meaning any NFT may be conceptualized as an IP. +/// Once an IP is registered into the protocol, a corresponding IP +/// record is generated, which references an IP resolver for metadata +/// attribution and an IP account for protocol authorization. Only +/// approved registration modules may register IP into this registry. +/// IMPORTANT: The IP account address, besides being used for protocol +/// auth, is also the canonical IP identifier for the IP NFT. +contract IPRecordRegistry is IIPRecordRegistry { + /// @notice Gets the factory contract used for IP account creation. + IIPAccountRegistry public immutable IP_ACCOUNT_REGISTRY; + + /// @notice Gets the protocol-wide module registry. + IModuleRegistry public immutable MODULE_REGISTRY; + + /// @notice Tracks the total number of IP records in existence. + uint256 public totalSupply = 0; + + /// @dev Maps an IP, identified by its IP ID, to a metadata resolver. + mapping(address => address) internal _resolvers; + + /// @notice Restricts calls to only originate from the registration module. + modifier onlyRegistrationModule() { + if (address(MODULE_REGISTRY.getModule(REGISTRATION_MODULE_KEY)) != msg.sender) { + revert Errors.IPRecordRegistry_Unauthorized(); + } + _; + } + + /// @notice Initializes the IP Record Registry. + /// @param moduleRegistry The address of the protocol module registry. + /// @param ipAccountRegistry The address of the IP account registry. + constructor(address moduleRegistry, address ipAccountRegistry) { + IP_ACCOUNT_REGISTRY = IIPAccountRegistry(ipAccountRegistry); + MODULE_REGISTRY = IModuleRegistry(moduleRegistry); + } + + /// @notice Gets the canonical IP identifier associated with an IP NFT. + /// @dev This is equivalent to the address of its bound IP account. + /// @param chainId The chain identifier of where the IP resides. + /// @param tokenContract The address of the IP. + /// @param tokenId The token identifier of the IP. + /// @return The IP's canonical address identifier. + function ipId(uint256 chainId, address tokenContract, uint256 tokenId) public view returns (address) { + return IP_ACCOUNT_REGISTRY.ipAccount(chainId, tokenContract, tokenId); + } + + /// @notice Checks whether an IP was registered based on its ID. + /// @param id The canonical identifier for the IP. + /// @return Whether the IP was registered into the protocol. + function isRegistered(address id) external view returns (bool) { + return _resolvers[id] != address(0); + } + + /// @notice Checks whether an IP was registered based on its NFT attributes. + /// @param chainId The chain identifier of where the NFT resides. + /// @param tokenContract The address of the NFT. + /// @param tokenId The token identifier of the NFT. + /// @return Whether the NFT was registered into the protocol as IP. + function isRegistered(uint256 chainId, address tokenContract, uint256 tokenId) external view returns (bool) { + address id = ipId(chainId, tokenContract, tokenId); + return _resolvers[id] != address(0); + } + + /// @notice Gets the resolver bound to an IP based on its ID. + /// @param id The canonical identifier for the IP. + /// @return The IP resolver address if registered, else the zero address. + function resolver(address id) external view returns (address) { + return _resolvers[id]; + } + + /// @notice Gets the resolver bound to an IP based on its NFT attributes. + /// @param chainId The chain identifier of where the NFT resides. + /// @param tokenContract The address of the NFT. + /// @param tokenId The token identifier of the NFT. + /// @return The IP resolver address if registered, else the zero address. + function resolver(uint256 chainId, address tokenContract, uint256 tokenId) external view returns (address) { + address id = ipId(chainId, tokenContract, tokenId); + return _resolvers[id]; + } + + /// @notice Registers an NFT as an IP, creating a corresponding IP record. + /// @param chainId The chain identifier of where the NFT resides. + /// @param tokenContract The address of the NFT. + /// @param tokenId The token identifier of the NFT. + /// @param createAccount Whether to create an IP account when registering. + function register( + uint256 chainId, + address tokenContract, + uint256 tokenId, + address resolverAddr, + bool createAccount + ) external onlyRegistrationModule { + address id = ipId(chainId, tokenContract, tokenId); + if (_resolvers[id] != address(0)) { + revert Errors.IPRecordRegistry_AlreadyRegistered(); + } + + // This is to emphasize the semantic differences between utilizing the + // IP account as an identifier versus as an account used for auth. + address account = id; + + if (account.code.length == 0 && createAccount) { + _createIPAccount(chainId, tokenContract, tokenId); + } + _setResolver(id, resolverAddr); + totalSupply++; + emit IPRegistered(id, chainId, tokenContract, tokenId, resolverAddr); + } + + /// @notice Creates the IP account for the specified IP. + /// @custom:note For now, we assume that every IP is uniquely tied to an IP + /// account deployed by the IP account registry. However, this + /// may change in the future, hence the distinguishing between + /// IP accounts as identifiers vs. authentication primitives. + /// @param chainId The chain identifier of where the NFT resides. + /// @param tokenContract The address of the NFT. + /// @param tokenId The token identifier of the NFT. + function createIPAccount(uint256 chainId, address tokenContract, uint256 tokenId) external returns (address) { + address account = IP_ACCOUNT_REGISTRY.ipAccount(chainId, tokenContract, tokenId); + // TODO: Finalize disambiguation between IP accounts and IP identifiers. + if (account.code.length != 0) { + revert Errors.IPRecordRegistry_IPAccountAlreadyCreated(); + } + return _createIPAccount(chainId, tokenContract, tokenId); + } + + /// @notice Sets the resolver for an IP based on its NFT attributes. + /// @param chainId The chain identifier of where the NFT resides. + /// @param tokenContract The address of the NFT. + /// @param tokenId The token identifier of the NFT. + /// @param resolverAddr The address of the resolver being set. + function setResolver( + uint256 chainId, + address tokenContract, + uint256 tokenId, + address resolverAddr + ) external onlyRegistrationModule { + address id = ipId(chainId, tokenContract, tokenId); + setResolver(id, resolverAddr); + } + + /// @notice Sets the resolver for an IP based on its canonical ID. + /// @param id The canonical ID of the IP. + /// @param resolverAddr The address of the resolver being set. + function setResolver(address id, address resolverAddr) public onlyRegistrationModule { + if (resolverAddr == address(0)) { + revert Errors.IPRecordRegistry_ResolverInvalid(); + } + // Resolvers may not be set unless the IP was registered into the protocol. + if (_resolvers[id] == address(0)) { + revert Errors.IPRecordRegistry_NotYetRegistered(); + } + _setResolver(id, resolverAddr); + } + + /// @dev Creates an IP account for the specified IP (NFT). + /// @param chainId The chain identifier of where the NFT resides. + /// @param tokenContract The address of the NFT. + /// @param tokenId The token identifier of the NFT. + function _createIPAccount( + uint256 chainId, + address tokenContract, + uint256 tokenId + ) internal returns (address account) { + account = IP_ACCOUNT_REGISTRY.registerIpAccount(chainId, tokenContract, tokenId); + emit IPAccountSet(account, chainId, tokenContract, tokenId); + } + + /// @dev Sets the resolver for the specified IP. + /// @param id The canonical identifier for the specified IP. + /// @param resolverAddr The address of the IP resolver. + function _setResolver(address id, address resolverAddr) internal { + _resolvers[id] = resolverAddr; + emit IPResolverSet(id, resolverAddr); + } +} diff --git a/contracts/resolvers/IPMetadataResolver.sol b/contracts/resolvers/IPMetadataResolver.sol new file mode 100644 index 000000000..c3090c036 --- /dev/null +++ b/contracts/resolvers/IPMetadataResolver.sol @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: UNLICENSED +// See https://github.com/storyprotocol/protocol-contracts/blob/main/StoryProtocol-AlphaTestingAgreement-17942166.3.pdf +pragma solidity ^0.8.21; + +import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; +import { Base64 } from "@openzeppelin/contracts/utils/Base64.sol"; +import { IResolver } from "contracts/interfaces/resolvers/IResolver.sol"; +import { ResolverBase } from "./ResolverBase.sol"; +import { IIPMetadataResolver } from "contracts/interfaces/resolvers/IIPMetadataResolver.sol"; +import { IIPAccount } from "contracts/interfaces/IIPAccount.sol"; +import { Errors } from "contracts/lib/Errors.sol"; +import { IP } from "contracts/lib/IP.sol"; + +/// @title IP Metadata Resolver +/// @notice Canonical IP resolver contract used for Story Protocol. This will +/// likely change to a separate contract that extends IPMetadataResolver +/// in the near future. +contract IPMetadataResolver is IIPMetadataResolver, ResolverBase { + /// @dev Maps IP to their metadata records based on their canonical IDs. + mapping(address => IP.MetadataRecord) public _records; + + /// @notice Initializes the IP metadata resolver. + /// @param accessController The access controller used for IP authorization. + /// @param ipRecordRegistry The address of the IP record registry. + /// @param ipAccountRegistry The address of the IP account registry. + constructor( + address accessController, + address ipRecordRegistry, + address ipAccountRegistry + ) ResolverBase(accessController, ipRecordRegistry, ipAccountRegistry) {} + + /// @notice Fetches all metadata associated with the specified IP. + /// @param ipId The canonical ID of the specified IP. + function metadata(address ipId) public view returns (IP.Metadata memory) { + IP.MetadataRecord memory record = _records[ipId]; + return + IP.Metadata({ + owner: owner(ipId), + name: record.name, + description: record.description, + hash: record.hash, + registrationDate: record.registrationDate, + registrant: record.registrant, + uri: uri(ipId) + }); + } + + /// @notice Fetches the canonical name associated with the specified IP. + /// @param ipId The canonical ID of the specified IP. + function name(address ipId) external view returns (string memory) { + return _records[ipId].name; + } + + /// @notice Fetches the description associated with the specified IP. + /// @param ipId The canonical ID of the specified IP. + /// @return The string descriptor of the IP. + function description(address ipId) external view returns (string memory) { + return _records[ipId].description; + } + + /// @notice Fetches the keccak-256 hash associated with the specified IP. + /// @param ipId The canonical ID of the specified IP. + /// @return The bytes32 content hash of the IP. + function hash(address ipId) external view returns (bytes32) { + return _records[ipId].hash; + } + + /// @notice Fetches the date of registration of the IP. + /// @param ipId The canonical ID of the specified IP. + function registrationDate(address ipId) external view returns (uint64) { + return _records[ipId].registrationDate; + } + + /// @notice Fetches the initial registrant of the IP. + /// @param ipId The canonical ID of the specified IP. + function registrant(address ipId) external view returns (address) { + return _records[ipId].registrant; + } + + /// @notice Fetches the current owner of the IP. + /// @param ipId The canonical ID of the specified IP. + function owner(address ipId) public view returns (address) { + if (!IP_RECORD_REGISTRY.isRegistered(ipId)) { + return address(0); + } + return IIPAccount(payable(ipId)).owner(); + } + + /// @notice Fetches an IP owner defined URI associated with the IP. + /// @param ipId The canonical ID of the specified IP. + function uri(address ipId) public view returns (string memory) { + if (!IP_RECORD_REGISTRY.isRegistered(ipId)) { + return ""; + } + + IP.MetadataRecord memory record = _records[ipId]; + string memory ipUri = record.uri; + + if (bytes(ipUri).length > 0) { + return ipUri; + } + + return _defaultTokenURI(ipId, record); + } + + /// @notice Sets metadata associated with an IP. + /// @param ipId The canonical ID of the specified IP. + /// @param newMetadata The new metadata to set for the IP. + function setMetadata(address ipId, IP.MetadataRecord calldata newMetadata) external onlyAuthorized(ipId) { + _records[ipId] = newMetadata; + } + + /// @notice Sets the name associated with an IP. + /// @param ipId The canonical ID of the specified IP. + /// @param newName The new string name to associate with the IP. + function setName(address ipId, string calldata newName) external onlyAuthorized(ipId) { + _records[ipId].name = newName; + } + + /// @notice Sets the description associated with an IP. + /// @param ipId The canonical ID of the specified IP. + /// @param newDescription The string description to associate with the IP. + function setDescription(address ipId, string calldata newDescription) external onlyAuthorized(ipId) { + _records[ipId].description = newDescription; + } + + /// @notice Sets the keccak-256 hash associated with an IP. + /// @param ipId The canonical ID of the specified IP. + /// @param newHash The keccak-256 hash to associate with the IP. + function setHash(address ipId, bytes32 newHash) external onlyAuthorized(ipId) { + _records[ipId].hash = newHash; + } + + /// @notice Sets an IP owner defined URI to associate with the IP. + /// @param ipId The canonical ID of the specified IP. + /// @param newURI The new token URI to set for the IP. + function setURI(address ipId, string calldata newURI) external onlyAuthorized(ipId) { + _records[ipId].uri = newURI; + } + + /// @notice Checks whether the resolver interface is supported. + /// @param id The resolver interface identifier. + /// @return Whether the resolver interface is supported. + function supportsInterface(bytes4 id) public view virtual override(IResolver, ResolverBase) returns (bool) { + return id == type(IIPMetadataResolver).interfaceId || super.supportsInterface(id); + } + + /// @dev Internal function for generating a default IP URI if not provided. + /// @param ipId The canonical ID of the specified IP. + /// @param record The IP record associated with the IP. + function _defaultTokenURI(address ipId, IP.MetadataRecord memory record) internal view returns (string memory) { + string memory baseJson = string( + /* solhint-disable */ + abi.encodePacked( + '{"name": "IP Asset #', + Strings.toHexString(ipId), + '", "description": "', + record.description, + '", "attributes": [' + ) + /* solhint-enable */ + ); + + string memory ipAttributes = string( + /* solhint-disable */ + abi.encodePacked( + '{"trait_type": "Name", "value": "', + record.name, + '"},' + '{"trait_type": "Owner", "value": "', + Strings.toHexString(uint160(owner(ipId)), 20), + '"},' + '{"trait_type": "Registrant", "value": "', + Strings.toHexString(uint160(record.registrant), 20), + '"},', + '{"trait_type": "Hash", "value": "', + Strings.toHexString(uint256(record.hash), 32), + '"},', + '{"trait_type": "Registration Date", "value": "', + Strings.toString(record.registrationDate), + '"}' + ) + /* solhint-enable */ + ); + + return + string( + abi.encodePacked( + "data:application/json;base64,", + Base64.encode(bytes(string(abi.encodePacked(baseJson, ipAttributes, "]}")))) + ) + ); + } +} diff --git a/contracts/resolvers/ResolverBase.sol b/contracts/resolvers/ResolverBase.sol new file mode 100644 index 000000000..7fb8d9198 --- /dev/null +++ b/contracts/resolvers/ResolverBase.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: UNLICENSED +// See https://github.com/storyprotocol/protocol-contracts/blob/main/StoryProtocol-AlphaTestingAgreement-17942166.3.pdf +pragma solidity ^0.8.21; + +import { IAccessController } from "contracts/interfaces/IAccessController.sol"; +import { IPRecordRegistry } from "contracts/registries/IPRecordRegistry.sol"; +import { IPAccountRegistry } from "contracts/registries/IPAccountRegistry.sol"; +import { IResolver } from "contracts/interfaces/resolvers/IResolver.sol"; +import { Errors } from "contracts/lib/Errors.sol"; + +/// @notice IP Resolver Base Contract +abstract contract ResolverBase is IResolver { + /// @notice Gets the protocol-wide module access controller. + IAccessController public immutable ACCESS_CONTROLLER; + + /// @notice Gets the protocol-wide IP account registry. + IPAccountRegistry public immutable IP_ACCOUNT_REGISTRY; + + /// @notice Gets the protocol-wide IP record registry. + IPRecordRegistry public immutable IP_RECORD_REGISTRY; + + /// @notice Checks if IP identified by ipId is authorized to perform a call. + /// @param ipId The identifier for the IP being authorized. + modifier onlyAuthorized(address ipId) { + if (!ACCESS_CONTROLLER.checkPermission(ipId, msg.sender, address(this), msg.sig)) { + revert Errors.IPResolver_Unauthorized(); + } + _; + } + + /// @notice Initializes the base IP resolver contract. + /// @param controller The address of the module access controller. + /// @param recordRegistry The address of the IP record registry. + /// @param accountRegistry The address of the IP account registry. + constructor(address controller, address recordRegistry, address accountRegistry) { + ACCESS_CONTROLLER = IAccessController(controller); + IP_RECORD_REGISTRY = IPRecordRegistry(recordRegistry); + IP_ACCOUNT_REGISTRY = IPAccountRegistry(accountRegistry); + } + + /// @notice Gets the access controller responsible for resolver auth. + /// @return The address of the access controller. + function accessController() external view returns (address) { + return address(ACCESS_CONTROLLER); + } + + /// @notice Checks whether the resolver interface is supported. + /// @param id The resolver interface identifier. + /// @return Whether the resolver interface is supported. + function supportsInterface(bytes4 id) public view virtual override returns (bool) { + return id == type(IResolver).interfaceId; + } +} diff --git a/test/foundry/IPAccountRegistry.t.sol b/test/foundry/IPAccountRegistry.t.sol index c3679d139..242e8ed94 100644 --- a/test/foundry/IPAccountRegistry.t.sol +++ b/test/foundry/IPAccountRegistry.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.21; +pragma solidity ^0.8.23; import "forge-std/Test.sol"; diff --git a/test/foundry/IPRecordRegistry.t.sol b/test/foundry/IPRecordRegistry.t.sol new file mode 100644 index 000000000..12f96a34e --- /dev/null +++ b/test/foundry/IPRecordRegistry.t.sol @@ -0,0 +1,292 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import { BaseTest } from "./utils/BaseTest.sol"; +import { IModuleRegistry } from "contracts/interfaces/registries/IModuleRegistry.sol"; +import { IIPRecordRegistry } from "contracts/interfaces/registries/IIPRecordRegistry.sol"; +import { IPAccountChecker } from "contracts/lib/registries/IPAccountChecker.sol"; +import { IPAccountRegistry } from "contracts/registries/IPAccountRegistry.sol"; +import { ERC6551Registry } from "lib/reference/src/ERC6551Registry.sol"; +import { IPRecordRegistry } from "contracts/registries/IPRecordRegistry.sol"; +import { IPAccountImpl} from "contracts/IPAccountImpl.sol"; +import { MockAccessController } from "test/foundry/mocks/MockAccessController.sol"; +import { MockModuleRegistry } from "test/foundry/mocks/MockModuleRegistry.sol"; +import { MockERC721 } from "test/foundry/mocks/MockERC721.sol"; +import { Errors } from "contracts/lib/Errors.sol"; + +/// @title IP Record Registry Testing Contract +/// @notice Contract for testing core IP registration. +contract IPRecordRegistryTest is BaseTest { + + /// @notice Placeholder for registration module. + address public registrationModule = vm.addr(0x1337); + + /// @notice Placeholder for test resolver addresses. + address public resolver = vm.addr(0x6969); + address public resolver2 = vm.addr(0x6978); + + /// @notice The IP record registry SUT. + IPRecordRegistry public registry; + + /// @notice The module registry used for protocol module identification. + IModuleRegistry public moduleRegistry; + + /// @notice The IP account registry used for account creation. + IPAccountRegistry public ipAccountRegistry; + + /// @notice Mock NFT address for IP registration testing. + address public tokenAddress; + + /// @notice Mock NFT tokenId for IP registration testing. + uint256 public tokenId; + + /// @notice ERC-6551 public registry. + address public erc6551Registry; + + /// @notice Mock IP account implementation address. + address public ipAccountImpl; + + /// @notice Initializes the IP record registry testing contract. + function setUp() public virtual override { + BaseTest.setUp(); + address accessController = address(new MockAccessController()); + erc6551Registry = address(new ERC6551Registry()); + moduleRegistry = IModuleRegistry( + address(new MockModuleRegistry(registrationModule)) + ); + ipAccountImpl = address(new IPAccountImpl()); + ipAccountRegistry = new IPAccountRegistry( + erc6551Registry, + accessController, + ipAccountImpl + ); + registry = new IPRecordRegistry( + address(moduleRegistry), + address(ipAccountRegistry) + ); + MockERC721 erc721 = new MockERC721(); + tokenAddress = address(erc721); + tokenId = erc721.mint(alice, 99); + } + + /// @notice Tests IP record registry initialization. + function test_IPRecordRegistry_Constructor() public { + assertEq(address(registry.MODULE_REGISTRY()), address(moduleRegistry)); + assertEq(address(registry.IP_ACCOUNT_REGISTRY()), address(ipAccountRegistry)); + } + + /// @notice Tests retrieval of IP canonical IDs. + function test_IPRecordRegistry_IpId() public { + assertEq( + registry.ipId(block.chainid, tokenAddress, tokenId), + _getAccount( + ipAccountImpl, + block.chainid, + tokenAddress, + tokenId, + ipAccountRegistry.IP_ACCOUNT_SALT() + ) + ); + } + + /// @notice Tests registration of IP records. + function test_IPRecordRegistry_Register() public { + uint256 totalSupply = registry.totalSupply(); + address ipId = _getAccount( + ipAccountImpl, + block.chainid, + tokenAddress, + tokenId, + ipAccountRegistry.IP_ACCOUNT_SALT() + ); + + // Ensure unregistered IP preconditions are satisfied. + assertEq(registry.resolver(ipId), address(0)); + assertTrue(!registry.isRegistered(ipId)); + assertTrue(!registry.isRegistered(block.chainid, tokenAddress, tokenId)); + assertTrue(!IPAccountChecker.isRegistered(ipAccountRegistry, block.chainid, tokenAddress, tokenId)); + + // Ensure all expected events are emitted. + vm.expectEmit(true, true, true, true); + emit IIPRecordRegistry.IPAccountSet( + ipId, + block.chainid, + tokenAddress, + tokenId + ); + vm.expectEmit(true, true, true, true); + emit IIPRecordRegistry.IPResolverSet(ipId, resolver); + vm.expectEmit(true, true, true, true); + emit IIPRecordRegistry.IPRegistered( + ipId, + block.chainid, + tokenAddress, + tokenId, + resolver + ); + vm.prank(registrationModule); + registry.register( + block.chainid, + tokenAddress, + tokenId, + resolver, + true + ); + + /// Ensures IP record post-registration conditions are met. + assertEq(registry.resolver(ipId), resolver); + assertEq(totalSupply + 1, registry.totalSupply()); + assertTrue(registry.isRegistered(ipId)); + assertTrue(registry.isRegistered(block.chainid, tokenAddress, tokenId)); + assertTrue(IPAccountChecker.isRegistered(ipAccountRegistry, block.chainid, tokenAddress, tokenId)); + } + + /// @notice Tests registration of IP records with lazy account creation. + function test_IPRecordRegistry_RegisterWithoutAccount() public { + uint256 totalSupply = registry.totalSupply(); + address ipId = _getAccount( + ipAccountImpl, + block.chainid, + tokenAddress, + tokenId, + ipAccountRegistry.IP_ACCOUNT_SALT() + ); + + // Ensure unregistered IP preconditions are satisfied. + assertEq(registry.resolver(ipId), address(0)); + assertTrue(!registry.isRegistered(ipId)); + assertTrue(!IPAccountChecker.isRegistered(ipAccountRegistry, block.chainid, tokenAddress, tokenId)); + + // Ensure all expected events are emitted. + vm.expectEmit(true, true, true, true); + emit IIPRecordRegistry.IPResolverSet(ipId, resolver); + vm.expectEmit(true, true, true, true); + emit IIPRecordRegistry.IPRegistered( + ipId, + block.chainid, + tokenAddress, + tokenId, + resolver + ); + vm.prank(registrationModule); + registry.register( + block.chainid, + tokenAddress, + tokenId, + resolver, + false + ); + + /// Ensures IP record post-registration conditions are met. + assertEq(registry.resolver(ipId), resolver); + assertEq(totalSupply + 1, registry.totalSupply()); + assertTrue(registry.isRegistered(ipId)); + assertTrue(!IPAccountChecker.isRegistered(ipAccountRegistry, block.chainid, tokenAddress, tokenId)); + } + + /// @notice Tests registration of IP records works with existing IP accounts. + function test_IPRecordRegistry_RegisterExistingAccount() public { + address ipId = registry.createIPAccount(block.chainid, tokenAddress, tokenId); + assertTrue(IPAccountChecker.isRegistered(ipAccountRegistry, block.chainid, tokenAddress, tokenId)); + vm.prank(registrationModule); + registry.register( + block.chainid, + tokenAddress, + tokenId, + resolver, + true + ); + + } + + /// @notice Tests registration of IP reverts when an IP has already been registered. + function test_IPRecordRegistry_Register_Reverts_ExistingRegistration() public { + vm.startPrank(registrationModule); + registry.register(block.chainid, tokenAddress, tokenId, resolver, false); + vm.expectRevert(Errors.IPRecordRegistry_AlreadyRegistered.selector); + registry.register(block.chainid, tokenAddress, tokenId, resolver, false); + } + + /// @notice Tests registration of IP reverts if not called by a registration module. + function test_IPRecordRegistry_Register_Reverts_InvalidRegistrationModule() public { + vm.expectRevert(Errors.IPRecordRegistry_Unauthorized.selector); + vm.prank(alice); + registry.register(block.chainid, tokenAddress, tokenId, resolver, false); + } + + /// @notice Tests generic IP account creation works. + function test_IPRecordRegistry_CreateIPAccount() public { + assertTrue(!IPAccountChecker.isRegistered(ipAccountRegistry, block.chainid, tokenAddress, tokenId)); + address ipId = registry.createIPAccount(block.chainid, tokenAddress, tokenId); + assertTrue(IPAccountChecker.isRegistered(ipAccountRegistry, block.chainid, tokenAddress, tokenId)); + } + + /// @notice Tests IP account creation reverts if one already exists. + function test_IPRecordRegistry_CreateIPAccount_Reverts_AlreadyCreated() public { + registry.createIPAccount(block.chainid, tokenAddress, tokenId); + vm.expectRevert(Errors.IPRecordRegistry_IPAccountAlreadyCreated.selector); + registry.createIPAccount(block.chainid, tokenAddress, tokenId); + } + + /// @notice Tests IP resolver setting works. + function test_IPRecordRegistry_SetResolver() public { + address ipId = _getAccount( + ipAccountImpl, + block.chainid, + tokenAddress, + tokenId, + ipAccountRegistry.IP_ACCOUNT_SALT() + ); + vm.startPrank(registrationModule); + registry.register(block.chainid, tokenAddress, tokenId, resolver, false); + + vm.expectEmit(true, true, true, true); + emit IIPRecordRegistry.IPResolverSet( + ipId, + resolver2 + ); + registry.setResolver(block.chainid, tokenAddress, tokenId, resolver2); + assertEq(registry.resolver(block.chainid, tokenAddress, tokenId), resolver2); + + // Check that resolvers can be reassigned. + vm.expectEmit(true, true, true, true); + emit IIPRecordRegistry.IPResolverSet( + ipId, + resolver + ); + registry.setResolver(ipId, resolver); + assertEq(registry.resolver(ipId), resolver); + } + + /// @notice Tests IP resolver setting reverts if an IP is not yet registered. + function test_IPRecordRegistry_SetResolver_Reverts_NotYetRegistered() public { + vm.startPrank(registrationModule); + vm.expectRevert(Errors.IPRecordRegistry_NotYetRegistered.selector); + registry.setResolver(block.chainid, tokenAddress, tokenId, resolver); + } + + /// @notice Tests IP resolver setting reverts if the resolver is invalid. + function test_IPRecordRegistry_SetResolver_Reverts_ResolverInvalid() public { + vm.startPrank(registrationModule); + vm.expectRevert(Errors.IPRecordRegistry_ResolverInvalid.selector); + registry.setResolver(block.chainid, tokenAddress, tokenId, address(0)); + } + + /// @notice Helper function for generating an account address. + function _getAccount( + address impl, + uint256 chainId, + address contractAddress, + uint256 contractId, + bytes32 salt + ) internal view returns (address) { + return ERC6551Registry(erc6551Registry).account( + impl, + salt, + chainId, + contractAddress, + contractId + ); + } + +} diff --git a/test/foundry/ModuleRegistry.t.sol b/test/foundry/ModuleRegistry.t.sol index 8a6bf4041..13cd53a9d 100644 --- a/test/foundry/ModuleRegistry.t.sol +++ b/test/foundry/ModuleRegistry.t.sol @@ -8,7 +8,7 @@ import "contracts/IPAccountImpl.sol"; import "contracts/interfaces/IIPAccount.sol"; import "lib/reference/src/interfaces/IERC6551Account.sol"; import "test/foundry/mocks/MockERC721.sol"; -import "test/foundry/mocks/MockERC6551Registry.sol"; +import { ERC6551Registry } from "lib/reference/src/ERC6551Registry.sol"; import "test/foundry/mocks/MockAccessController.sol"; import "test/foundry/mocks/MockModule.sol"; import "contracts/registries/ModuleRegistry.sol"; @@ -18,7 +18,7 @@ contract ModuleRegistryTest is Test { IPAccountRegistry public registry; IPAccountImpl public implementation; ModuleRegistry public moduleRegistry = new ModuleRegistry(); - MockERC6551Registry public erc6551Registry = new MockERC6551Registry(); + ERC6551Registry public erc6551Registry = new ERC6551Registry(); MockAccessController public accessController = new MockAccessController(); MockModule public module; diff --git a/test/foundry/mocks/MockERC6551Registry.sol b/test/foundry/mocks/MockERC6551Registry.sol deleted file mode 100644 index e762f75d4..000000000 --- a/test/foundry/mocks/MockERC6551Registry.sol +++ /dev/null @@ -1,131 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.21; -import { Create2 } from "@openzeppelin/contracts/utils/Create2.sol"; - -contract MockERC6551Registry { - /** - * @dev The registry SHALL emit the AccountCreated event upon successful account creation - */ - event AccountCreated( - address account, - address indexed implementation, - uint256 chainId, - address indexed tokenContract, - uint256 indexed tokenId, - uint256 salt - ); - - /** - * @dev Creates a token bound account for a non-fungible token - * - * If account has already been created, returns the account address without calling create2 - * - * If initData is not empty and account has not yet been created, calls account with - * provided initData after creation - * - * Emits AccountCreated event - * - * @return the address of the account - */ - function createAccount( - address implementation, - uint256 chainId, - address tokenContract, - uint256 tokenId, - uint256 salt, - bytes calldata initData - ) external returns (address) { - bytes memory code = _getCreationCode( - implementation, - chainId, - tokenContract, - tokenId, - salt - ); - - address _account = Create2.computeAddress(bytes32(salt), keccak256(code)); - - if (_account.code.length != 0) return _account; - - emit AccountCreated(_account, implementation, chainId, tokenContract, tokenId, salt); - - _account = Create2.deploy(0, bytes32(salt), code); - - if (initData.length != 0) { - (bool success, bytes memory result) = _account.call(initData); - if (!success) { - assembly { - revert(add(result, 32), mload(result)) - } - } - } - - return _account; - } - - /** - * @dev Returns the computed token bound account address for a non-fungible token - * - * @return The computed address of the token bound account - */ - function account( - address implementation, - uint256 chainId, - address tokenContract, - uint256 tokenId, - uint256 salt - ) external view returns (address) { - bytes32 bytecodeHash = keccak256( - _getCreationCode( - implementation, - chainId, - tokenContract, - tokenId, - salt - ) - ); - - return Create2.computeAddress(bytes32(salt), bytecodeHash); - } - - function _getCreationCode( - address implementation_, - uint256 chainId_, - address tokenContract_, - uint256 tokenId_, - uint256 salt_ - ) internal pure returns (bytes memory) { - return - // Proxy that delegate call to IPAccountProxy - // | 0x00000000 36 calldatasize cds - // | 0x00000001 3d returndatasize 0 cds - // | 0x00000002 3d returndatasize 0 0 cds - // | 0x00000003 37 calldatacopy - // | 0x00000004 3d returndatasize 0 - // | 0x00000005 3d returndatasize 0 0 - // | 0x00000006 3d returndatasize 0 0 0 - // | 0x00000007 36 calldatasize cds 0 0 0 - // | 0x00000008 3d returndatasize 0 cds 0 0 0 - // | 0x00000009 73bebebebebe. push20 0xbebebebe 0xbebe 0 cds 0 0 0 - // | 0x0000001e 5a gas gas 0xbebe 0 cds 0 0 0 - // | 0x0000001f f4 delegatecall suc 0 - // | 0x00000020 3d returndatasize rds suc 0 - // | 0x00000021 82 dup3 0 rds suc 0 - // | 0x00000022 80 dup1 0 0 rds suc 0 - // | 0x00000023 3e returndatacopy suc 0 - // | 0x00000024 90 swap1 0 suc - // | 0x00000025 3d returndatasize rds 0 suc - // | 0x00000026 91 swap2 suc 0 rds - // | 0x00000027 602b push1 0x2b 0x2b suc 0 rds - // | ,=< 0x00000029 57 jumpi 0 rds - // | | 0x0000002a fd revert - // | `-> 0x0000002b 5b jumpdest 0 rds - // \ 0x0000002c f3 return - abi.encodePacked( - hex"3d60ad80600a3d3981f3363d3d373d3d3d363d73", - implementation_, - hex"5af43d82803e903d91602b57fd5bf3", - abi.encode(salt_, chainId_, tokenContract_, tokenId_) - ); - } -} diff --git a/test/foundry/mocks/MockERC721.sol b/test/foundry/mocks/MockERC721.sol index 1db8755b1..d9e9c5ca3 100644 --- a/test/foundry/mocks/MockERC721.sol +++ b/test/foundry/mocks/MockERC721.sol @@ -1,12 +1,13 @@ // SPDX-License-Identifier: BUSDL-1.1 -pragma solidity ^0.8.13; +pragma solidity ^0.8.23; import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; contract MockERC721 is ERC721 { constructor() ERC721("MockERC721", "M721") {} - function mint(address to, uint256 tokenId) external { + function mint(address to, uint256 tokenId) external returns(uint256) { _safeMint(to, tokenId); + return tokenId; } } diff --git a/test/foundry/mocks/MockModuleRegistry.sol b/test/foundry/mocks/MockModuleRegistry.sol new file mode 100644 index 000000000..c34ba289f --- /dev/null +++ b/test/foundry/mocks/MockModuleRegistry.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: BUSDL-1.1 +pragma solidity ^0.8.23; + +import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; +import { IModuleRegistry } from "contracts/interfaces/registries/IModuleRegistry.sol"; +import { REGISTRATION_MODULE_KEY } from "contracts/lib/modules/Module.sol"; + +/// @title Mock Module Registry Contract +contract MockModuleRegistry { + + address public immutable REGISTRATION_MODULE; + + constructor(address registrationModule) { + REGISTRATION_MODULE = registrationModule; + } + + function getModule(string memory moduleKey) external view returns (address module) { + if (Strings.equal(moduleKey, REGISTRATION_MODULE_KEY)) { + module = REGISTRATION_MODULE; + } + } +} diff --git a/test/foundry/resolvers/IPMetadataResolver.t.sol b/test/foundry/resolvers/IPMetadataResolver.t.sol new file mode 100644 index 000000000..8595c880b --- /dev/null +++ b/test/foundry/resolvers/IPMetadataResolver.t.sol @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; +import { Base64 } from "@openzeppelin/contracts/utils/Base64.sol"; + +import { ResolverBaseTest } from "test/foundry/resolvers/ResolverBase.t.sol"; +import { IPMetadataResolver } from "contracts/resolvers/IPMetadataResolver.sol"; +import { IResolver } from "contracts/interfaces/resolvers/IResolver.sol"; +import { ERC6551Registry } from "lib/reference/src/ERC6551Registry.sol"; +import { IModuleRegistry } from "contracts/interfaces/registries/IModuleRegistry.sol"; +import { IPRecordRegistry } from "contracts/registries/IPRecordRegistry.sol"; +import { IPAccountRegistry } from "contracts/registries/IPAccountRegistry.sol"; +import { MockModuleRegistry } from "test/foundry/mocks/MockModuleRegistry.sol"; +import { IIPRecordRegistry } from "contracts/interfaces/registries/IIPRecordRegistry.sol"; +import { IPAccountImpl} from "contracts/IPAccountImpl.sol"; +import { IIPMetadataResolver } from "contracts/interfaces/resolvers/IIPMetadataResolver.sol"; +import { MockERC721 } from "test/foundry/mocks/MockERC721.sol"; +import { IP } from "contracts/lib/IP.sol"; +import { Errors } from "contracts/lib/Errors.sol"; + +/// @title IP Metadata Resolver Test Contract +/// @notice Tests IP metadata resolver functionality. +contract IPMetadataResolverTest is ResolverBaseTest { + + // Default IP record attributes. + string public constant RECORD_NAME = "IPRecord"; + string public constant RECORD_DESCRIPTION = "IPs all the way down."; + bytes32 public constant RECORD_HASH = ""; + uint64 public constant RECORD_REGISTRATION_DATE = 999999; + string public constant RECORD_URI = "https://storyprotocol.xyz"; + + /// @notice Placeholder for registration module. + address public registrationModule = vm.addr(0x1337); + + /// @notice The IP record registry. + IPRecordRegistry public registry; + + /// @notice The IP account registry. + IPAccountRegistry public ipAccountRegistry; + + /// @notice The token contract SUT. + IIPMetadataResolver public ipResolver; + + /// @notice Mock IP identifier for resolver testing. + address public ipId; + + /// @notice Initializes the base token contract for testing. + function setUp() public virtual override(ResolverBaseTest) { + ResolverBaseTest.setUp(); + ipAccountRegistry = new IPAccountRegistry( + address(new ERC6551Registry()), + address(accessController), + address(new IPAccountImpl()) + ); + registry = new IPRecordRegistry( + address(new MockModuleRegistry(registrationModule)), + address(ipAccountRegistry) + ); + MockERC721 erc721 = new MockERC721(); + vm.prank(alice); + ipResolver = IIPMetadataResolver(_deployResolver()); + uint256 tokenId = erc721.mint(alice, 99); + ipId = registry.ipId(block.chainid, address(erc721), tokenId); + vm.prank(registrationModule); + registry.register( + block.chainid, + address(erc721), + tokenId, + address(ipResolver), + true + ); + } + + /// @notice Tests that the IP resolver interface is supported. + function test_IPMetadataResolver_SupportsInterface() public virtual { + assertTrue(ipResolver.supportsInterface(type(IIPMetadataResolver).interfaceId)); + } + + /// @notice Tests that metadata may be properly set for the resolver. + function test_IPMetadataResolver_SetMetadata() public { + accessController.setPermission(ipId, alice, address(ipResolver), IIPMetadataResolver.setMetadata.selector, 1); + vm.prank(alice); + ipResolver.setMetadata( + ipId, + IP.MetadataRecord({ + name: RECORD_NAME, + description: RECORD_DESCRIPTION, + hash: RECORD_HASH, + registrationDate: RECORD_REGISTRATION_DATE, + registrant: alice, + uri: RECORD_URI + }) + ); + assertEq(ipResolver.name(ipId), RECORD_NAME); + assertEq(ipResolver.description(ipId), RECORD_DESCRIPTION); + assertEq(ipResolver.hash(ipId), RECORD_HASH); + assertEq(ipResolver.registrationDate(ipId), RECORD_REGISTRATION_DATE); + assertEq(ipResolver.registrant(ipId), alice); + assertEq(ipResolver.owner(ipId), alice); + assertEq(ipResolver.uri(ipId), RECORD_URI); + + // Also check the metadata getter returns as expected. + IP.Metadata memory metadata = ipResolver.metadata(ipId); + assertEq(metadata.name, RECORD_NAME); + assertEq(metadata.description, RECORD_DESCRIPTION); + assertEq(metadata.hash, RECORD_HASH); + assertEq(metadata.registrationDate, RECORD_REGISTRATION_DATE); + assertEq(metadata.registrant, alice); + assertEq(metadata.uri, RECORD_URI); + assertEq(metadata.owner, alice); + } + + /// @notice Checks that an unauthorized call to setMetadata reverts. + function test_IPMetadataResolver_SetMetadata_Reverts_Unauthorized() public { + vm.expectRevert(Errors.IPResolver_Unauthorized.selector); + ipResolver.setMetadata( + ipId, + IP.MetadataRecord({ + name: RECORD_NAME, + description: RECORD_DESCRIPTION, + hash: RECORD_HASH, + registrationDate: RECORD_REGISTRATION_DATE, + registrant: alice, + uri: RECORD_URI + }) + ); + } + + /// @notice Tests that the name may be properly set for the resolver. + function test_IPMetadataResolver_SetName() public { + accessController.setPermission(ipId, alice, address(ipResolver), IIPMetadataResolver.setName.selector, 1); + vm.prank(alice); + ipResolver.setName(ipId, RECORD_NAME); + assertEq(RECORD_NAME, ipResolver.name(ipId)); + } + + /// @notice Checks that an unauthorized call to setName reverts. + function test_IPMetadataResolver_SetName_Reverts_Unauthorized() public { + vm.expectRevert(Errors.IPResolver_Unauthorized.selector); + ipResolver.setName(ipId, RECORD_NAME); + } + + /// @notice Tests that the description may be properly set for the resolver. + function test_IPMetadataResolver_SetDescription() public { + accessController.setPermission(ipId, alice, address(ipResolver), IIPMetadataResolver.setDescription.selector, 1); + vm.prank(alice); + ipResolver.setDescription(ipId, RECORD_DESCRIPTION); + assertEq(RECORD_DESCRIPTION, ipResolver.description(ipId)); + } + + /// @notice Checks that an unauthorized call to setDescription reverts. + function test_IPMetadataResolver_SetDescription_Reverts_Unauthorized() public { + vm.expectRevert(Errors.IPResolver_Unauthorized.selector); + ipResolver.setDescription(ipId, RECORD_DESCRIPTION); + } + + /// @notice Tests that the hash may be properly set for the resolver. + function test_IPMetadataResolver_SetHash() public { + accessController.setPermission(ipId, alice, address(ipResolver), IIPMetadataResolver.setHash.selector, 1); + vm.prank(alice); + ipResolver.setHash(ipId, RECORD_HASH); + assertEq(RECORD_HASH, ipResolver.hash(ipId)); + } + + /// @notice Checks that an unauthorized call to setHash reverts. + function test_IPMetadataResolver_SetHash_Reverts_Unauthorized() public { + vm.expectRevert(Errors.IPResolver_Unauthorized.selector); + ipResolver.setHash(ipId, RECORD_HASH); + } + + /// @notice Checks that owner queries return the zero address if there is no + /// IP account attached to the IP or if it was not registered. + function test_IPMetadataResolver_Owner_NonExistent() public { + // TODO: Make more granular testing for the above two conditions. + assertEq(address(0), ipResolver.owner(address(0))); + } + + /// @notice Checks setting token URI works as expected. + function test_IPMetadataResolver_SetTokenURI() public { + accessController.setPermission(ipId, alice, address(ipResolver), IIPMetadataResolver.setURI.selector, 1); + vm.prank(alice); + ipResolver.setURI(ipId, RECORD_URI); + assertEq(ipResolver.uri(ipId), RECORD_URI); + } + + /// @notice Checks the default token URI renders as expected. + function test_IPMetadataResolver_TokenURI_DefaultRender() public { + // Check default empty string value for unregistered IP. + assertEq(ipResolver.uri(address(0)), ""); + + // Check default string value for registered IP. + assertTrue(accessController.checkPermission(ipId, alice, address(ipResolver), IIPMetadataResolver.setMetadata.selector)); + vm.prank(alice); + ipResolver.setMetadata( + ipId, + IP.MetadataRecord({ + name: RECORD_NAME, + description: RECORD_DESCRIPTION, + hash: RECORD_HASH, + registrationDate: RECORD_REGISTRATION_DATE, + registrant: alice, + uri: "" // Blank indicates the default record should be used. + }) + ); + string memory ownerStr = Strings.toHexString(uint160(address(alice))); + string memory ipIdStr = Strings.toHexString(uint160(ipId)); + string memory uriEncoding = string(abi.encodePacked( + '{"name": "IP Asset #', ipIdStr, '", "description": "IPs all the way down.", "attributes": [', + '{"trait_type": "Name", "value": "IPRecord"},', + '{"trait_type": "Owner", "value": "', ownerStr, '"},' + '{"trait_type": "Registrant", "value": "', ownerStr, '"},', + '{"trait_type": "Hash", "value": "0x0000000000000000000000000000000000000000000000000000000000000000"},', + '{"trait_type": "Registration Date", "value": "', Strings.toString(RECORD_REGISTRATION_DATE), '"}', + ']}' + )); + string memory expectedURI = string(abi.encodePacked( + "data:application/json;base64,", + Base64.encode(bytes(string(abi.encodePacked(uriEncoding)))) + )); + assertEq(expectedURI, ipResolver.uri(ipId)); + } + + /// @dev Deploys a new IP Metadata Resolver. + function _deployResolver() internal override returns (address) { + return address( + new IPMetadataResolver( + address(accessController), + address(registry), + address(ipAccountRegistry) + ) + ); + } + +} diff --git a/test/foundry/resolvers/ResolverBase.t.sol b/test/foundry/resolvers/ResolverBase.t.sol new file mode 100644 index 000000000..b3c6995b1 --- /dev/null +++ b/test/foundry/resolvers/ResolverBase.t.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import "forge-std/console2.sol"; + +import { MockAccessController } from "test/foundry/mocks/MockAccessController.sol"; +import { BaseTest } from "test/foundry/utils/BaseTest.sol"; +import { IResolver } from "contracts/interfaces/resolvers/IResolver.sol"; +import { IAccessController } from "contracts/interfaces/IAccessController.sol"; +import { Errors } from "contracts/lib/Errors.sol"; + +/// @title Resolver Base Test Contract +/// @notice Base contract for testing standard resolver functionality. +abstract contract ResolverBaseTest is BaseTest { + + /// @notice The access controller address. + IAccessController public accessController; + + /// @notice The resolver SUT. + IResolver public baseResolver; + + /// @notice Initializes the base ERC20 contract for testing. + function setUp() public virtual override(BaseTest) { + BaseTest.setUp(); + accessController = IAccessController(address(new MockAccessController())); + baseResolver = IResolver(_deployResolver()); + } + + /// @notice Tests that the default resolver constructor runs successfully. + function test_Resolver_Constructor() public { + assertEq(baseResolver.accessController(), address(accessController)); + } + + /// @notice Tests that the base resolver interface is supported. + function test_Resolver_SupportsInterface() public virtual { + assertTrue(baseResolver.supportsInterface(type(IResolver).interfaceId)); + } + + /// @dev Deploys the resolver SUT. + function _deployResolver() internal virtual returns (address); + +} diff --git a/test/foundry/utils/BaseTest.sol b/test/foundry/utils/BaseTest.sol new file mode 100644 index 000000000..e68e5f6ea --- /dev/null +++ b/test/foundry/utils/BaseTest.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import "forge-std/Test.sol"; + +/// @title Base Test Contract +/// @notice This contract provides a set of protocol-unrelated testing utilities +/// that may be extended by testing contracts. +contract BaseTest is Test { + + // Test public keys EOAs for deriving reusable EOA addresses. + uint256 internal alicePk = 0xa11ce; + uint256 internal bobPk = 0xb0b; + uint256 internal calPk = 0xca1; + + // Test EOA addresses that may be reused for testing. + address payable internal alice = payable(vm.addr(alicePk)); + address payable internal bob = payable(vm.addr(bobPk)); + address payable internal cal = payable(vm.addr(calPk)); + + /// @notice Sets up the base test contract. + function setUp() public virtual { + vm.label(alice, "alice"); + vm.label(bob, "bob"); + vm.label(cal, "cal"); + } +}