diff --git a/packages/nfts/contracts/profile/RegisterProfilePicture.sol b/packages/nfts/contracts/profile/RegisterProfilePicture.sol new file mode 100644 index 0000000000..c885339e9a --- /dev/null +++ b/packages/nfts/contracts/profile/RegisterProfilePicture.sol @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import { UUPSUpgradeable } from + "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import { Ownable2StepUpgradeable } from + "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; + +/// @title A store for trailblazer profile pictures +/// @author Bennett Yogn +/// @dev All function calls are currently implemented without side effects +contract RegisterProfilePicture is Initializable, UUPSUpgradeable, Ownable2StepUpgradeable { + error InvalidNFTContract(address nftContract); + error NotTokenOwner(address nftContract, uint256 tokenId, address caller); + + /// @notice struct of nft contract address and token id + struct ProfilePicture { + address nftContract; + uint256 tokenId; + } + + /// @notice mapping of user id to profile picture + mapping(address user => ProfilePicture pfp) public profilePicture; + + event ProfilePictureSet( + address indexed user, address indexed nftContract, uint256 indexed tokenId + ); + + /// @notice Contract initializer + function initialize() public initializer { + _transferOwnership(_msgSender()); + } + + /// @notice Set the profile picture + /// @param nftContract The address of the nft to set as the profile picture + /// @param tokenId The tokenId of the nft to set as the profile picture + function setPFP(address nftContract, uint256 tokenId) external { + if (IERC721(nftContract).supportsInterface(type(IERC721).interfaceId)) { + // Check if the provided contract address is a valid ERC721 contract + if (IERC721(nftContract).ownerOf(tokenId) != _msgSender()) { + revert NotTokenOwner(nftContract, tokenId, _msgSender()); + } + } else if (IERC1155(nftContract).supportsInterface(type(IERC1155).interfaceId)) { + // Check if the provided contract address is a valid ERC1155 contract + if (IERC1155(nftContract).balanceOf(_msgSender(), tokenId) == 0) { + revert NotTokenOwner(nftContract, tokenId, _msgSender()); + } + } else { + // If the contract does not support ERC721 or ERC1155 interfaces + revert InvalidNFTContract(nftContract); + } + + // Set the PFP + profilePicture[_msgSender()] = ProfilePicture(nftContract, tokenId); + + emit ProfilePictureSet(_msgSender(), nftContract, tokenId); + } + + /// @notice Get the profile picture of a user + /// @param user The address of user + function getProfilePicture(address user) external view returns (string memory) { + ProfilePicture memory userProfilePicture = profilePicture[user]; + + if (IERC721(userProfilePicture.nftContract).supportsInterface(type(IERC721).interfaceId)) { + // ERC721 case: Check ownership before returning the URI + if (IERC721(userProfilePicture.nftContract).ownerOf(userProfilePicture.tokenId) != user) + { + revert NotTokenOwner( + userProfilePicture.nftContract, userProfilePicture.tokenId, user + ); + } + return ERC721(userProfilePicture.nftContract).tokenURI(userProfilePicture.tokenId); + } else if ( + IERC1155(userProfilePicture.nftContract).supportsInterface(type(IERC1155).interfaceId) + ) { + // ERC1155 case: Check ownership before returning the URI + if ( + IERC1155(userProfilePicture.nftContract).balanceOf(user, userProfilePicture.tokenId) + == 0 + ) { + revert NotTokenOwner( + userProfilePicture.nftContract, userProfilePicture.tokenId, user + ); + } + return ERC1155(userProfilePicture.nftContract).uri(userProfilePicture.tokenId); + } else { + // If the contract does not support ERC721 or ERC1155 interfaces + revert InvalidNFTContract(userProfilePicture.nftContract); + } + } + + /// @notice Internal method to authorize an upgrade + function _authorizeUpgrade(address) internal virtual override onlyOwner { } +} diff --git a/packages/nfts/script/profile/Deploy.s.sol b/packages/nfts/script/profile/Deploy.s.sol new file mode 100644 index 0000000000..6fd08ba8ad --- /dev/null +++ b/packages/nfts/script/profile/Deploy.s.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import { UtilsScript } from "./Utils.s.sol"; +import { Script, console } from "forge-std/src/Script.sol"; +import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import { RegisterProfilePicture } from "../../contracts/profile/RegisterProfilePicture.sol"; + +contract DeployScript is Script { + UtilsScript public utils; + string public jsonLocation; + uint256 public deployerPrivateKey; + address public deployerAddress; + + function setUp() public { + utils = new UtilsScript(); + utils.setUp(); + + jsonLocation = utils.getContractJsonLocation(); + deployerPrivateKey = utils.getPrivateKey(); + deployerAddress = utils.getAddress(); + } + + function run() public { + string memory jsonRoot = "root"; + + vm.startBroadcast(deployerPrivateKey); + + // deploy token with empty root + address impl = address(new RegisterProfilePicture()); + address proxy = address( + new ERC1967Proxy( + impl, + abi.encodeCall( + RegisterProfilePicture.initialize, () + ) + ) + ); + + RegisterProfilePicture profile = RegisterProfilePicture(proxy); + + console.log("Deployed TaikoPartyTicket to:", address(profile)); + + string memory finalJson = vm.serializeAddress(jsonRoot, "RegisterProfilePicture", address(profile)); + vm.writeJson(finalJson, jsonLocation); + + vm.stopBroadcast(); + } +} diff --git a/packages/nfts/script/profile/Utils.s.sol b/packages/nfts/script/profile/Utils.s.sol new file mode 100644 index 0000000000..756321175f --- /dev/null +++ b/packages/nfts/script/profile/Utils.s.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import { Script, console } from "forge-std/src/Script.sol"; +import "forge-std/src/StdJson.sol"; + +contract UtilsScript is Script { + using stdJson for string; + + uint256 public chainId; + + string public lowercaseNetworkKey; + string public uppercaseNetworkKey; + + function setUp() public { + // load all network configs + chainId = block.chainid; + + if (chainId == 31_337) { + lowercaseNetworkKey = "localhost"; + uppercaseNetworkKey = "LOCALHOST"; + } else if (chainId == 17_000) { + lowercaseNetworkKey = "holesky"; + uppercaseNetworkKey = "HOLESKY"; + } else if (chainId == 167_001) { + lowercaseNetworkKey = "devnet"; + uppercaseNetworkKey = "DEVNET"; + } else if (chainId == 11_155_111) { + lowercaseNetworkKey = "sepolia"; + uppercaseNetworkKey = "SEPOLIA"; + } else if (chainId == 167_008) { + lowercaseNetworkKey = "katla"; + uppercaseNetworkKey = "KATLA"; + } else if (chainId == 167_000) { + lowercaseNetworkKey = "mainnet"; + uppercaseNetworkKey = "MAINNET"; + } else if (chainId == 167_009) { + lowercaseNetworkKey = "hekla"; + uppercaseNetworkKey = "HEKLA"; + } else { + revert("Unsupported chainId"); + } + } + + function getPrivateKey() public view returns (uint256) { + string memory lookupKey = string.concat(uppercaseNetworkKey, "_PRIVATE_KEY"); + return vm.envUint(lookupKey); + } + + function getAddress() public view returns (address) { + string memory lookupKey = string.concat(uppercaseNetworkKey, "_ADDRESS"); + return vm.envAddress(lookupKey); + } + + function getContractJsonLocation() public view returns (string memory) { + string memory root = vm.projectRoot(); + return string.concat(root, "/deployments/profile/", lowercaseNetworkKey, ".json"); + } + + function run() public { } +} diff --git a/packages/nfts/test/profile/RegisterProfilePicture.t.sol b/packages/nfts/test/profile/RegisterProfilePicture.t.sol new file mode 100644 index 0000000000..4d17c1440a --- /dev/null +++ b/packages/nfts/test/profile/RegisterProfilePicture.t.sol @@ -0,0 +1,266 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import { Test, console } from "forge-std/src/Test.sol"; +import { RegisterProfilePicture } from "../../contracts/profile/RegisterProfilePicture.sol"; +import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import { ERC721Mock, ERC1155Mock, MockInvalidNFT } from "../util/MockTokens.sol"; + +contract RegisterProfilePictureTest is Test { + RegisterProfilePicture public registerProfilePicture; + ERC721Mock public erc721Mock; + ERC1155Mock public erc1155Mock; + MockInvalidNFT public invalidNFT; + + address public owner; + address public user; + address public otherUser; + + uint256 public constant ERC721_TOKEN_ID = 1; + uint256 public constant ERC1155_TOKEN_ID = 2; + + event ProfilePictureSet( + address indexed user, address indexed nftContract, uint256 indexed tokenId + ); + + function setUp() public { + owner = vm.addr(0x1); + user = vm.addr(0x2); + otherUser = vm.addr(0x3); + + vm.startBroadcast(owner); + + RegisterProfilePicture impl = new RegisterProfilePicture(); + ERC1967Proxy proxy = + new ERC1967Proxy(address(impl), abi.encodeCall(RegisterProfilePicture.initialize, ())); + registerProfilePicture = RegisterProfilePicture(address(proxy)); + + erc721Mock = new ERC721Mock(); + erc721Mock.initialize("MockERC721", "M721"); + erc721Mock.mint(user, ERC721_TOKEN_ID); + + erc1155Mock = new ERC1155Mock(); + erc1155Mock.initialize("https://token-cdn-domain/{id}.json"); + erc1155Mock.mint(user, ERC1155_TOKEN_ID, 1, ""); + + invalidNFT = new MockInvalidNFT(); + + vm.stopBroadcast(); + } + + function test_SetPFPWithERC721() public { + vm.startPrank(user); + + registerProfilePicture.setPFP(address(erc721Mock), ERC721_TOKEN_ID); + + (address nftContract, uint256 tokenId) = registerProfilePicture.profilePicture(user); + assertEq(nftContract, address(erc721Mock)); + assertEq(tokenId, ERC721_TOKEN_ID); + + vm.stopPrank(); + } + + function test_SetPFPWithERC1155() public { + vm.startPrank(user); + + registerProfilePicture.setPFP(address(erc1155Mock), ERC1155_TOKEN_ID); + + (address nftContract, uint256 tokenId) = registerProfilePicture.profilePicture(user); + assertEq(nftContract, address(erc1155Mock)); + assertEq(tokenId, ERC1155_TOKEN_ID); + + vm.stopPrank(); + } + + function test_GetProfilePictureERC721() public { + vm.startPrank(user); + registerProfilePicture.setPFP(address(erc721Mock), ERC721_TOKEN_ID); + string memory uri = registerProfilePicture.getProfilePicture(user); + assertEq(uri, erc721Mock.tokenURI(ERC721_TOKEN_ID)); + vm.stopPrank(); + } + + function test_GetProfilePictureERC1155() public { + vm.startPrank(user); + registerProfilePicture.setPFP(address(erc1155Mock), ERC1155_TOKEN_ID); + string memory uri = registerProfilePicture.getProfilePicture(user); + assertEq(uri, erc1155Mock.uri(ERC1155_TOKEN_ID)); + vm.stopPrank(); + } + + function test_CannotSetPFPWithInvalidNFTContract() public { + vm.startPrank(user); + + // Expect any kind of revert when trying to set PFP with invalid NFT contract + vm.expectRevert(); + registerProfilePicture.setPFP(address(invalidNFT), 1); + + vm.stopPrank(); + } + + function test_CannotSetPFPWithNonOwnedERC721() public { + vm.startPrank(otherUser); + + vm.expectRevert( + abi.encodeWithSignature( + "NotTokenOwner(address,uint256,address)", + address(erc721Mock), + ERC721_TOKEN_ID, + otherUser + ) + ); + registerProfilePicture.setPFP(address(erc721Mock), ERC721_TOKEN_ID); + + vm.stopPrank(); + } + + function test_CannotSetPFPWithNonOwnedERC1155() public { + vm.startPrank(otherUser); + + vm.expectRevert( + abi.encodeWithSignature( + "NotTokenOwner(address,uint256,address)", + address(erc1155Mock), + ERC1155_TOKEN_ID, + otherUser + ) + ); + registerProfilePicture.setPFP(address(erc1155Mock), ERC1155_TOKEN_ID); + + vm.stopPrank(); + } + + function test_CannotGetProfilePictureAfterTransferERC721() public { + vm.startPrank(user); + + registerProfilePicture.setPFP(address(erc721Mock), ERC721_TOKEN_ID); + erc721Mock.transferFrom(user, otherUser, ERC721_TOKEN_ID); + + vm.expectRevert( + abi.encodeWithSignature( + "NotTokenOwner(address,uint256,address)", address(erc721Mock), ERC721_TOKEN_ID, user + ) + ); + registerProfilePicture.getProfilePicture(user); + + vm.stopPrank(); + } + + function test_CannotGetProfilePictureAfterTransferERC1155() public { + vm.startPrank(user); + + registerProfilePicture.setPFP(address(erc1155Mock), ERC1155_TOKEN_ID); + erc1155Mock.safeTransferFrom(user, otherUser, ERC1155_TOKEN_ID, 1, ""); + + vm.expectRevert( + abi.encodeWithSignature( + "NotTokenOwner(address,uint256,address)", + address(erc1155Mock), + ERC1155_TOKEN_ID, + user + ) + ); + registerProfilePicture.getProfilePicture(user); + + vm.stopPrank(); + } + + function test_ChangeProfilePicture() public { + vm.startPrank(user); + + registerProfilePicture.setPFP(address(erc721Mock), ERC721_TOKEN_ID); + + registerProfilePicture.setPFP(address(erc1155Mock), ERC1155_TOKEN_ID); + + (address nftContract, uint256 tokenId) = registerProfilePicture.profilePicture(user); + assertEq(nftContract, address(erc1155Mock)); + assertEq(tokenId, ERC1155_TOKEN_ID); + + vm.stopPrank(); + } + + function test_upgrade() public { + // Step 1: Deploy the initial implementation (v1) using the proxy + vm.startPrank(owner); + + // Deploy v1 implementation and proxy pointing to v1 + address implV1 = address(new RegisterProfilePicture()); + ERC1967Proxy proxy = new ERC1967Proxy(implV1, abi.encodeWithSignature("initialize()")); + RegisterProfilePicture tokenV1 = RegisterProfilePicture(address(proxy)); + + vm.stopPrank(); + + // Step 2: Interact with v1 + vm.startPrank(user); + tokenV1.setPFP(address(erc721Mock), ERC721_TOKEN_ID); + + // Verify that the PFP was set correctly + string memory uri = tokenV1.getProfilePicture(user); + assertEq(uri, erc721Mock.tokenURI(ERC721_TOKEN_ID)); + vm.stopPrank(); + + // Step 3: Upgrade to v2 + vm.startPrank(owner); + address implV2 = address(new RegisterProfilePicture()); + tokenV1.upgradeToAndCall(implV2, ""); + + // Verify that the previous state is still relevant after the upgrade + RegisterProfilePicture tokenV2 = RegisterProfilePicture(address(proxy)); + uri = tokenV2.getProfilePicture(user); + assertEq(uri, erc721Mock.tokenURI(ERC721_TOKEN_ID)); + vm.stopPrank(); + + // Step 4: Test setting and retrieving PFP with ERC1155 after the upgrade + vm.startPrank(user); + tokenV2.setPFP(address(erc1155Mock), ERC1155_TOKEN_ID); + + uri = tokenV2.getProfilePicture(user); + assertEq(uri, erc1155Mock.uri(ERC1155_TOKEN_ID)); + vm.stopPrank(); + } + + function test_upgrade_throw() public { + // Step 1: Deploy the initial implementation (v1) using the proxy + vm.startPrank(owner); + + // Deploy v1 implementation and proxy pointing to v1 + address implV1 = address(new RegisterProfilePicture()); + ERC1967Proxy proxy = new ERC1967Proxy(implV1, abi.encodeWithSignature("initialize()")); + RegisterProfilePicture tokenV1 = RegisterProfilePicture(address(proxy)); + + vm.stopPrank(); + + // Step 2: Interact with v1 + vm.startPrank(user); + tokenV1.setPFP(address(erc721Mock), ERC721_TOKEN_ID); + + // Verify that the PFP was set correctly + string memory uri = tokenV1.getProfilePicture(user); + assertEq(uri, erc721Mock.tokenURI(ERC721_TOKEN_ID)); + + // Attempt to upgrade as a non-owner and expect a revert + address implV2 = address(new RegisterProfilePicture()); + vm.expectRevert(); + tokenV1.upgradeToAndCall(implV2, ""); + vm.stopPrank(); + + // Step 3: Upgrade to v2 by the owner + vm.startPrank(owner); + implV2 = address(new RegisterProfilePicture()); + tokenV1.upgradeToAndCall(implV2, ""); + + // Verify that the previous state is still relevant after the upgrade + RegisterProfilePicture tokenV2 = RegisterProfilePicture(address(proxy)); + uri = tokenV2.getProfilePicture(user); + assertEq(uri, erc721Mock.tokenURI(ERC721_TOKEN_ID)); + vm.stopPrank(); + + // Step 4: Test setting and retrieving PFP with ERC1155 after the upgrade + vm.startPrank(user); + tokenV2.setPFP(address(erc1155Mock), ERC1155_TOKEN_ID); + + uri = tokenV2.getProfilePicture(user); + assertEq(uri, erc1155Mock.uri(ERC1155_TOKEN_ID)); + vm.stopPrank(); + } +} diff --git a/packages/nfts/test/util/MockTokens.sol b/packages/nfts/test/util/MockTokens.sol new file mode 100644 index 0000000000..7f7192475d --- /dev/null +++ b/packages/nfts/test/util/MockTokens.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import "@openzeppelin/contracts-upgradeable/token/ERC721/ERC721Upgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC1155/ERC1155Upgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; + +contract ERC721Mock is Initializable, ERC721Upgradeable { + function initialize(string memory name, string memory symbol) public initializer { + __ERC721_init(name, symbol); + } + + function mint(address to, uint256 tokenId) public { + _mint(to, tokenId); + } + + function burn(uint256 tokenId) public { + _burn(tokenId); + } +} + +contract ERC1155Mock is Initializable, ERC1155Upgradeable { + function initialize(string memory uri) public initializer { + __ERC1155_init(uri); + } + + function mint(address to, uint256 id, uint256 amount, bytes memory data) public { + _mint(to, id, amount, data); + } + + function mintBatch( + address to, + uint256[] memory ids, + uint256[] memory amounts, + bytes memory data + ) + public + { + _mintBatch(to, ids, amounts, data); + } + + function burn(address from, uint256 id, uint256 amount) public { + _burn(from, id, amount); + } + + function burnBatch(address from, uint256[] memory ids, uint256[] memory amounts) public { + _burnBatch(from, ids, amounts); + } +} + +contract MockInvalidNFT { +// This contract doesn't implement any NFT interface +}