From 58f023cb4a5ee81617bc8a02a3d2087e125e5a36 Mon Sep 17 00:00:00 2001 From: Kingter <83567446+kingster-will@users.noreply.github.com> Date: Mon, 14 Oct 2024 21:48:00 -0700 Subject: [PATCH] Introduce IP Registration Fee Switch and Governance Controls (#271) --- .../registries/IIPAssetRegistry.sol | 36 +++++++ contracts/lib/Errors.sol | 3 + contracts/registries/IPAssetRegistry.sol | 57 +++++++++- test/foundry/registries/IPAssetRegistry.t.sol | 100 ++++++++++++++++++ 4 files changed, 195 insertions(+), 1 deletion(-) diff --git a/contracts/interfaces/registries/IIPAssetRegistry.sol b/contracts/interfaces/registries/IIPAssetRegistry.sol index 710f40cfa..cab79cae3 100644 --- a/contracts/interfaces/registries/IIPAssetRegistry.sol +++ b/contracts/interfaces/registries/IIPAssetRegistry.sol @@ -24,6 +24,30 @@ interface IIPAssetRegistry is IIPAccountRegistry { uint256 registrationDate ); + /// @notice Emits when an IP registration fee is paid. + /// @param payer The address of the account that paid the fee. + /// @param treasury The address of the treasury that received the fee. + /// @param feeToken The address of the token used to pay the fee. + /// @param amount The amount of the fee paid. + event IPRegistrationFeePaid( + address indexed payer, + address indexed treasury, + address indexed feeToken, + uint96 amount + ); + + /// @notice Emits when an IP registration fee is set. + /// @param treasury The address of the treasury that will receive the fee. + /// @param feeToken The address of the token used to pay the fee. + /// @param feeAmount The amount of the fee. + event RegistrationFeeSet(address indexed treasury, address indexed feeToken, uint96 feeAmount); + + /// @notice Sets the registration fee for IP assets. + /// @param treasury The address of the treasury that will receive the fee. + /// @param feeToken The address of the token used to pay the fee. + /// @param feeAmount The amount of the fee. + function setRegistrationFee(address treasury, address feeToken, uint96 feeAmount) external; + /// @notice Tracks the total number of IP assets in existence. function totalSupply() external view returns (uint256); @@ -46,4 +70,16 @@ interface IIPAssetRegistry is IIPAccountRegistry { /// @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); + + /// @notice Retrieves the treasury address for IP assets. + /// @return treasury The address of the treasury. + function getTreasury() external view returns (address); + + /// @notice Retrieves the registration fee token for IP assets. + /// @return feeToken The address of the token used to pay the fee. + function getFeeToken() external view returns (address); + + /// @notice Retrieves the registration fee amount for IP assets. + /// @return feeAmount The amount of the fee. + function getFeeAmount() external view returns (uint96); } diff --git a/contracts/lib/Errors.sol b/contracts/lib/Errors.sol index 33341287d..46f1ab24b 100644 --- a/contracts/lib/Errors.sol +++ b/contracts/lib/Errors.sol @@ -155,6 +155,9 @@ library Errors { /// @notice The NFT token id does not exist or invalid. error IPAssetRegistry__InvalidToken(address contractAddress, uint256 tokenId); + /// @notice Zero address provided for IP Asset Registry. + error IPAssetRegistry__ZeroAddress(string name); + //////////////////////////////////////////////////////////////////////////// // License Registry // //////////////////////////////////////////////////////////////////////////// diff --git a/contracts/registries/IPAssetRegistry.sol b/contracts/registries/IPAssetRegistry.sol index 5010b9d4f..80172a73a 100644 --- a/contracts/registries/IPAssetRegistry.sol +++ b/contracts/registries/IPAssetRegistry.sol @@ -6,6 +6,8 @@ import { IERC721Metadata } from "@openzeppelin/contracts/token/ERC721/extensions import { ERC165Checker } from "@openzeppelin/contracts/utils/introspection/ERC165Checker.sol"; import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; import { UUPSUpgradeable } from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import { IIPAccount } from "../interfaces/IIPAccount.sol"; import { GroupIPAssetRegistry } from "./GroupIPAssetRegistry.sol"; @@ -34,12 +36,20 @@ contract IPAssetRegistry is using ERC165Checker for address; using Strings for *; using IPAccountStorageOps for IIPAccount; + using SafeERC20 for IERC20; /// @dev Storage structure for the IPAssetRegistry /// @notice Tracks the total number of IP assets in existence. + /// @param totalSupply The total number of IP assets registered. + /// @param treasury The address of the treasury that receives registration fees. + /// @param feeToken The address of the token used to pay registration fees. + /// @param feeAmount The amount of the registration fee. /// @custom:storage-location erc7201:story-protocol.IPAssetRegistry struct IPAssetRegistryStorage { uint256 totalSupply; + address treasury; + address feeToken; + uint96 feeAmount; } // keccak256(abi.encode(uint256(keccak256("story-protocol.IPAssetRegistry")) - 1)) & ~bytes32(uint256(0xff)); @@ -80,6 +90,17 @@ contract IPAssetRegistry is } function _register(uint256 chainid, address tokenContract, uint256 tokenId) internal override returns (address id) { + IPAssetRegistryStorage storage $ = _getIPAssetRegistryStorage(); + + // Pay registration fee + uint96 feeAmount = $.feeAmount; + if (feeAmount > 0) { + address feeToken = $.feeToken; + address treasury = $.treasury; + IERC20(feeToken).safeTransferFrom(msg.sender, treasury, uint256(feeAmount)); + emit IPRegistrationFeePaid(msg.sender, treasury, feeToken, feeAmount); + } + id = _registerIpAccount(chainid, tokenContract, tokenId); IIPAccount ipAccount = IIPAccount(payable(id)); @@ -93,11 +114,27 @@ contract IPAssetRegistry is ipAccount.setString("URI", uri); ipAccount.setUint256("REGISTRATION_DATE", registrationDate); - _getIPAssetRegistryStorage().totalSupply++; + $.totalSupply++; emit IPRegistered(id, chainid, tokenContract, tokenId, name, uri, registrationDate); } + /// @notice Sets the registration fee for IP assets. + /// @param treasury The address of the treasury that will receive the fee. + /// @param feeToken The address of the token used to pay the fee. + /// @param feeAmount The amount of the fee. + function setRegistrationFee(address treasury, address feeToken, uint96 feeAmount) external restricted { + if (feeAmount > 0) { + if (treasury == address(0)) revert Errors.IPAssetRegistry__ZeroAddress("treasury"); + if (feeToken == address(0)) revert Errors.IPAssetRegistry__ZeroAddress("feeToken"); + } + IPAssetRegistryStorage storage $ = _getIPAssetRegistryStorage(); + $.feeToken = feeToken; + $.feeAmount = feeAmount; + $.treasury = treasury; + emit RegistrationFeeSet(treasury, feeToken, feeAmount); + } + /// @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. @@ -120,6 +157,24 @@ contract IPAssetRegistry is return _getIPAssetRegistryStorage().totalSupply; } + /// @notice Retrieves the treasury address for IP assets. + /// @return treasury The address of the treasury. + function getTreasury() external view returns (address) { + return _getIPAssetRegistryStorage().treasury; + } + + /// @notice Retrieves the registration fee token for IP assets. + /// @return feeToken The address of the token used to pay the fee. + function getFeeToken() external view returns (address) { + return _getIPAssetRegistryStorage().feeToken; + } + + /// @notice Retrieves the registration fee amount for IP assets. + /// @return feeAmount The amount of the fee. + function getFeeAmount() external view returns (uint96) { + return _getIPAssetRegistryStorage().feeAmount; + } + /// @dev Retrieves the name and URI of from IP NFT. function _getNameAndUri( uint256 chainid, diff --git a/test/foundry/registries/IPAssetRegistry.t.sol b/test/foundry/registries/IPAssetRegistry.t.sol index 6ebac8784..5474f3227 100644 --- a/test/foundry/registries/IPAssetRegistry.t.sol +++ b/test/foundry/registries/IPAssetRegistry.t.sol @@ -32,6 +32,8 @@ contract IPAssetRegistryTest is BaseTest { uint256 public tokenId; address public ipId; + error ERC20InsufficientAllowance(address spender, uint256 allowance, uint256 needed); + /// @notice Initializes the IP asset registry testing contract. function setUp() public virtual override { super.setUp(); @@ -77,6 +79,104 @@ contract IPAssetRegistryTest is BaseTest { assertEq(IIPAccount(payable(ipId)).getUint256(address(registry), "REGISTRATION_DATE"), block.timestamp); } + function test_IPAssetRegistry_SetRegisterFee() public { + address treasury = address(0x123); + vm.prank(u.admin); + vm.expectEmit(); + emit IIPAssetRegistry.RegistrationFeeSet(treasury, address(erc20), 1000); + registry.setRegistrationFee(treasury, address(erc20), 1000); + + assertEq(registry.getTreasury(), treasury, "Treasury not set"); + assertEq(registry.getFeeToken(), address(erc20), "Fee token not set"); + assertEq(registry.getFeeAmount(), 1000, "Fee amount not set"); + } + + function test_IPAssetRegistry_revert_SetRegisterFeeZeroTreasury() public { + vm.expectRevert(abi.encodeWithSelector(Errors.IPAssetRegistry__ZeroAddress.selector, "treasury")); + vm.prank(u.admin); + registry.setRegistrationFee(address(0), address(erc20), 1000); + } + + function test_IPAssetRegistry_revert_SetRegisterFeeZeroTokenAddress() public { + vm.expectRevert(abi.encodeWithSelector(Errors.IPAssetRegistry__ZeroAddress.selector, "feeToken")); + vm.prank(u.admin); + registry.setRegistrationFee(address(0x123), address(0), 1000); + } + + function test_IPAssetRegistry_resetRegisterFeeBackToZero() public { + address treasury = address(0x123); + vm.prank(u.admin); + vm.expectEmit(); + emit IIPAssetRegistry.RegistrationFeeSet(treasury, address(erc20), 1000); + registry.setRegistrationFee(treasury, address(erc20), 1000); + + assertEq(registry.getTreasury(), treasury, "Treasury not set"); + assertEq(registry.getFeeToken(), address(erc20), "Fee token not set"); + assertEq(registry.getFeeAmount(), 1000, "Fee amount not set"); + + vm.prank(u.admin); + vm.expectEmit(); + emit IIPAssetRegistry.RegistrationFeeSet(address(0), address(0), 0); + registry.setRegistrationFee(address(0), address(0), 0); + + assertEq(registry.getTreasury(), address(0), "Treasury not reset"); + assertEq(registry.getFeeToken(), address(0), "Fee token not reset"); + assertEq(registry.getFeeAmount(), 0, "Fee amount not reset"); + } + + /// @notice Tests registration of IP permissionlessly. + function test_IPAssetRegistry_RegisterWithPayRegisterFee() public { + address treasury = address(0x123); + vm.prank(u.admin); + vm.expectEmit(); + emit IIPAssetRegistry.RegistrationFeeSet(treasury, address(erc20), 1000); + registry.setRegistrationFee(treasury, address(erc20), 1000); + + erc20.mint(alice, 1000); + vm.prank(alice); + erc20.approve(address(registry), 1000); + + uint256 totalSupply = registry.totalSupply(); + + string memory name = string.concat(block.chainid.toString(), ": Ape #99"); + vm.expectEmit(true, true, true, true); + emit IIPAssetRegistry.IPRegistrationFeePaid(alice, treasury, address(erc20), 1000); + emit IIPAssetRegistry.IPRegistered( + ipId, + block.chainid, + tokenAddress, + tokenId, + name, + "https://storyprotocol.xyz/erc721/99", + block.timestamp + ); + vm.prank(alice); + registry.register(block.chainid, 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. + function test_IPAssetRegistry_revert_RegisterNotEnoughRegisterFee() public { + address treasury = address(0x123); + vm.prank(u.admin); + vm.expectEmit(); + emit IIPAssetRegistry.RegistrationFeeSet(treasury, address(erc20), 1000); + registry.setRegistrationFee(treasury, address(erc20), 1000); + + erc20.mint(alice, 100); + vm.prank(alice); + erc20.approve(address(registry), 100); + + vm.expectRevert(abi.encodeWithSelector(ERC20InsufficientAllowance.selector, address(registry), 100, 1000)); + vm.prank(alice); + registry.register(block.chainid, tokenAddress, tokenId); + } + /// @notice Tests registration of IP permissionlessly for IPAccount already created. function test_IPAssetRegistry_RegisterPermissionless_IPAccountAlreadyExist() public { uint256 totalSupply = registry.totalSupply();