diff --git a/contracts/core/FollowNFT.sol b/contracts/core/FollowNFT.sol index 3ef68c4..52a137b 100644 --- a/contracts/core/FollowNFT.sol +++ b/contracts/core/FollowNFT.sol @@ -80,25 +80,21 @@ contract FollowNFT is LensNFTBase, IFollowNFT { address delegatee, DataTypes.EIP712Signature calldata sig ) external override { - bytes32 digest; - unchecked { - digest = keccak256( - abi.encodePacked( - '\x19\x01', - _calculateDomainSeparator(), - keccak256( - abi.encode( - DELEGATE_BY_SIG_TYPEHASH, - delegator, - delegatee, - sigNonces[delegator]++, - sig.deadline - ) + _validateRecoveredAddress( + _calculateDigest( + keccak256( + abi.encode( + DELEGATE_BY_SIG_TYPEHASH, + delegator, + delegatee, + sigNonces[delegator]++, + sig.deadline ) ) - ); - } - _validateRecoveredAddress(digest, delegator, sig); + ), + delegator, + sig + ); _delegate(delegator, delegatee); } @@ -203,7 +199,7 @@ contract FollowNFT is LensNFTBase, IFollowNFT { uint256 tokenId ) internal override { address fromDelegatee = _delegates[from]; - address toDelegatee = _delegates[to]; + address toDelegatee = _delegates[to]; address followModule = ILensHub(HUB).getFollowModule(_profileId); _moveDelegate(fromDelegatee, toDelegatee, 1); diff --git a/contracts/core/LensHub.sol b/contracts/core/LensHub.sol index 7080d40..191cc91 100644 --- a/contracts/core/LensHub.sol +++ b/contracts/core/LensHub.sol @@ -5,10 +5,13 @@ pragma solidity 0.8.10; import {ILensHub} from '../interfaces/ILensHub.sol'; import {Events} from '../libraries/Events.sol'; import {Helpers} from '../libraries/Helpers.sol'; +import {Constants} from '../libraries/Constants.sol'; import {DataTypes} from '../libraries/DataTypes.sol'; import {Errors} from '../libraries/Errors.sol'; import {PublishingLogic} from '../libraries/PublishingLogic.sol'; +import {ProfileTokenURILogic} from '../libraries/ProfileTokenURILogic.sol'; import {InteractionLogic} from '../libraries/InteractionLogic.sol'; +import {IERC721Enumerable} from '@openzeppelin/contracts/token/ERC721/extensions/IERC721Enumerable.sol'; import {LensNFTBase} from './base/LensNFTBase.sol'; import {LensMultiState} from './base/LensMultiState.sol'; import {LensHubStorage} from './storage/LensHubStorage.sol'; @@ -167,27 +170,21 @@ contract LensHub is ILensHub, LensNFTBase, VersionedInitializable, LensMultiStat override whenNotPaused { - bytes32 digest; - unchecked { - digest = keccak256( - abi.encodePacked( - '\x19\x01', - _calculateDomainSeparator(), - keccak256( - abi.encode( - SET_DEFAULT_PROFILE_WITH_SIG_TYPEHASH, - vars.wallet, - vars.profileId, - sigNonces[vars.wallet]++, - vars.sig.deadline - ) + _validateRecoveredAddress( + _calculateDigest( + keccak256( + abi.encode( + SET_DEFAULT_PROFILE_WITH_SIG_TYPEHASH, + vars.wallet, + vars.profileId, + sigNonces[vars.wallet]++, + vars.sig.deadline ) ) - ); - } - - _validateRecoveredAddress(digest, vars.wallet, vars.sig); - + ), + vars.wallet, + vars.sig + ); _setDefaultProfile(vars.wallet, vars.profileId); } @@ -214,27 +211,22 @@ contract LensHub is ILensHub, LensNFTBase, VersionedInitializable, LensMultiStat whenNotPaused { address owner = ownerOf(vars.profileId); - bytes32 digest; - unchecked { - digest = keccak256( - abi.encodePacked( - '\x19\x01', - _calculateDomainSeparator(), - keccak256( - abi.encode( - SET_FOLLOW_MODULE_WITH_SIG_TYPEHASH, - vars.profileId, - vars.followModule, - keccak256(vars.followModuleData), - sigNonces[owner]++, - vars.sig.deadline - ) + _validateRecoveredAddress( + _calculateDigest( + keccak256( + abi.encode( + SET_FOLLOW_MODULE_WITH_SIG_TYPEHASH, + vars.profileId, + vars.followModule, + keccak256(vars.followModuleData), + sigNonces[owner]++, + vars.sig.deadline ) ) - ); - } - - _validateRecoveredAddress(digest, owner, vars.sig); + ), + owner, + vars.sig + ); PublishingLogic.setFollowModule( vars.profileId, vars.followModule, @@ -257,26 +249,21 @@ contract LensHub is ILensHub, LensNFTBase, VersionedInitializable, LensMultiStat whenNotPaused { address owner = ownerOf(vars.profileId); - bytes32 digest; - unchecked { - digest = keccak256( - abi.encodePacked( - '\x19\x01', - _calculateDomainSeparator(), - keccak256( - abi.encode( - SET_DISPATCHER_WITH_SIG_TYPEHASH, - vars.profileId, - vars.dispatcher, - sigNonces[owner]++, - vars.sig.deadline - ) + _validateRecoveredAddress( + _calculateDigest( + keccak256( + abi.encode( + SET_DISPATCHER_WITH_SIG_TYPEHASH, + vars.profileId, + vars.dispatcher, + sigNonces[owner]++, + vars.sig.deadline ) ) - ); - } - - _validateRecoveredAddress(digest, owner, vars.sig); + ), + owner, + vars.sig + ); _setDispatcher(vars.profileId, vars.dispatcher); } @@ -297,26 +284,21 @@ contract LensHub is ILensHub, LensNFTBase, VersionedInitializable, LensMultiStat whenNotPaused { address owner = ownerOf(vars.profileId); - bytes32 digest; - unchecked { - digest = keccak256( - abi.encodePacked( - '\x19\x01', - _calculateDomainSeparator(), - keccak256( - abi.encode( - SET_PROFILE_IMAGE_URI_WITH_SIG_TYPEHASH, - vars.profileId, - keccak256(bytes(vars.imageURI)), - sigNonces[owner]++, - vars.sig.deadline - ) + _validateRecoveredAddress( + _calculateDigest( + keccak256( + abi.encode( + SET_PROFILE_IMAGE_URI_WITH_SIG_TYPEHASH, + vars.profileId, + keccak256(bytes(vars.imageURI)), + sigNonces[owner]++, + vars.sig.deadline ) ) - ); - } - - _validateRecoveredAddress(digest, owner, vars.sig); + ), + owner, + vars.sig + ); _setProfileImageURI(vars.profileId, vars.imageURI); } @@ -337,26 +319,21 @@ contract LensHub is ILensHub, LensNFTBase, VersionedInitializable, LensMultiStat whenNotPaused { address owner = ownerOf(vars.profileId); - bytes32 digest; - unchecked { - digest = keccak256( - abi.encodePacked( - '\x19\x01', - _calculateDomainSeparator(), - keccak256( - abi.encode( - SET_FOLLOW_NFT_URI_WITH_SIG_TYPEHASH, - vars.profileId, - keccak256(bytes(vars.followNFTURI)), - sigNonces[owner]++, - vars.sig.deadline - ) + _validateRecoveredAddress( + _calculateDigest( + keccak256( + abi.encode( + SET_FOLLOW_NFT_URI_WITH_SIG_TYPEHASH, + vars.profileId, + keccak256(bytes(vars.followNFTURI)), + sigNonces[owner]++, + vars.sig.deadline ) ) - ); - } - - _validateRecoveredAddress(digest, owner, vars.sig); + ), + owner, + vars.sig + ); _setFollowNFTURI(vars.profileId, vars.followNFTURI); } @@ -380,30 +357,25 @@ contract LensHub is ILensHub, LensNFTBase, VersionedInitializable, LensMultiStat whenPublishingEnabled { address owner = ownerOf(vars.profileId); - bytes32 digest; - unchecked { - digest = keccak256( - abi.encodePacked( - '\x19\x01', - _calculateDomainSeparator(), - keccak256( - abi.encode( - POST_WITH_SIG_TYPEHASH, - vars.profileId, - keccak256(bytes(vars.contentURI)), - vars.collectModule, - keccak256(vars.collectModuleData), - vars.referenceModule, - keccak256(vars.referenceModuleData), - sigNonces[owner]++, - vars.sig.deadline - ) + _validateRecoveredAddress( + _calculateDigest( + keccak256( + abi.encode( + POST_WITH_SIG_TYPEHASH, + vars.profileId, + keccak256(bytes(vars.contentURI)), + vars.collectModule, + keccak256(vars.collectModuleData), + vars.referenceModule, + keccak256(vars.referenceModuleData), + sigNonces[owner]++, + vars.sig.deadline ) ) - ); - } - - _validateRecoveredAddress(digest, owner, vars.sig); + ), + owner, + vars.sig + ); _createPost( vars.profileId, vars.contentURI, @@ -427,32 +399,27 @@ contract LensHub is ILensHub, LensNFTBase, VersionedInitializable, LensMultiStat whenPublishingEnabled { address owner = ownerOf(vars.profileId); - bytes32 digest; - unchecked { - digest = keccak256( - abi.encodePacked( - '\x19\x01', - _calculateDomainSeparator(), - keccak256( - abi.encode( - COMMENT_WITH_SIG_TYPEHASH, - vars.profileId, - keccak256(bytes(vars.contentURI)), - vars.profileIdPointed, - vars.pubIdPointed, - vars.collectModule, - keccak256(vars.collectModuleData), - vars.referenceModule, - keccak256(vars.referenceModuleData), - sigNonces[owner]++, - vars.sig.deadline - ) + _validateRecoveredAddress( + _calculateDigest( + keccak256( + abi.encode( + COMMENT_WITH_SIG_TYPEHASH, + vars.profileId, + keccak256(bytes(vars.contentURI)), + vars.profileIdPointed, + vars.pubIdPointed, + vars.collectModule, + keccak256(vars.collectModuleData), + vars.referenceModule, + keccak256(vars.referenceModuleData), + sigNonces[owner]++, + vars.sig.deadline ) ) - ); - } - - _validateRecoveredAddress(digest, owner, vars.sig); + ), + owner, + vars.sig + ); _createComment( DataTypes.CommentData( vars.profileId, @@ -486,29 +453,24 @@ contract LensHub is ILensHub, LensNFTBase, VersionedInitializable, LensMultiStat whenPublishingEnabled { address owner = ownerOf(vars.profileId); - bytes32 digest; - unchecked { - digest = keccak256( - abi.encodePacked( - '\x19\x01', - _calculateDomainSeparator(), - keccak256( - abi.encode( - MIRROR_WITH_SIG_TYPEHASH, - vars.profileId, - vars.profileIdPointed, - vars.pubIdPointed, - vars.referenceModule, - keccak256(vars.referenceModuleData), - sigNonces[owner]++, - vars.sig.deadline - ) + _validateRecoveredAddress( + _calculateDigest( + keccak256( + abi.encode( + MIRROR_WITH_SIG_TYPEHASH, + vars.profileId, + vars.profileIdPointed, + vars.pubIdPointed, + vars.referenceModule, + keccak256(vars.referenceModuleData), + sigNonces[owner]++, + vars.sig.deadline ) ) - ); - } - - _validateRecoveredAddress(digest, owner, vars.sig); + ), + owner, + vars.sig + ); _createMirror( vars.profileId, vars.profileIdPointed, @@ -576,27 +538,21 @@ contract LensHub is ILensHub, LensNFTBase, VersionedInitializable, LensMultiStat for (uint256 i = 0; i < vars.datas.length; ++i) { dataHashes[i] = keccak256(vars.datas[i]); } - - bytes32 digest; - unchecked { - digest = keccak256( - abi.encodePacked( - '\x19\x01', - _calculateDomainSeparator(), - keccak256( - abi.encode( - FOLLOW_WITH_SIG_TYPEHASH, - keccak256(abi.encodePacked(vars.profileIds)), - keccak256(abi.encodePacked(dataHashes)), - sigNonces[vars.follower]++, - vars.sig.deadline - ) + _validateRecoveredAddress( + _calculateDigest( + keccak256( + abi.encode( + FOLLOW_WITH_SIG_TYPEHASH, + keccak256(abi.encodePacked(vars.profileIds)), + keccak256(abi.encodePacked(dataHashes)), + sigNonces[vars.follower]++, + vars.sig.deadline ) ) - ); - } - - _validateRecoveredAddress(digest, vars.follower, vars.sig); + ), + vars.follower, + vars.sig + ); InteractionLogic.follow( vars.follower, vars.profileIds, @@ -630,27 +586,22 @@ contract LensHub is ILensHub, LensNFTBase, VersionedInitializable, LensMultiStat override whenNotPaused { - bytes32 digest; - unchecked { - digest = keccak256( - abi.encodePacked( - '\x19\x01', - _calculateDomainSeparator(), - keccak256( - abi.encode( - COLLECT_WITH_SIG_TYPEHASH, - vars.profileId, - vars.pubId, - keccak256(vars.data), - sigNonces[vars.collector]++, - vars.sig.deadline - ) + _validateRecoveredAddress( + _calculateDigest( + keccak256( + abi.encode( + COLLECT_WITH_SIG_TYPEHASH, + vars.profileId, + vars.pubId, + keccak256(vars.data), + sigNonces[vars.collector]++, + vars.sig.deadline ) ) - ); - } - - _validateRecoveredAddress(digest, vars.collector, vars.sig); + ), + vars.collector, + vars.sig + ); InteractionLogic.collect( vars.collector, vars.profileId, @@ -880,7 +831,15 @@ contract LensHub is ILensHub, LensNFTBase, VersionedInitializable, LensMultiStat * @dev Overrides the ERC721 tokenURI function to return the associated URI with a given profile. */ function tokenURI(uint256 tokenId) public view override returns (string memory) { - return _profileById[tokenId].imageURI; // temp + address followNFT = _profileById[tokenId].followNFT; + return + ProfileTokenURILogic.getProfileTokenURI( + tokenId, + followNFT == address(0) ? 0 : IERC721Enumerable(followNFT).totalSupply(), + ownerOf(tokenId), + _profileById[tokenId].handle, + _profileById[tokenId].imageURI + ); } /// **************************** @@ -961,6 +920,8 @@ contract LensHub is ILensHub, LensNFTBase, VersionedInitializable, LensMultiStat } function _setProfileImageURI(uint256 profileId, string memory imageURI) internal { + if (bytes(imageURI).length > Constants.MAX_PROFILE_IMAGE_URI_LENGTH) + revert Errors.ProfileImageURILengthInvalid(); _profileById[profileId].imageURI = imageURI; emit Events.ProfileImageURISet(profileId, imageURI, block.timestamp); } diff --git a/contracts/core/base/LensNFTBase.sol b/contracts/core/base/LensNFTBase.sol index 9dfcbdd..978813b 100644 --- a/contracts/core/base/LensNFTBase.sol +++ b/contracts/core/base/LensNFTBase.sol @@ -55,27 +55,15 @@ abstract contract LensNFTBase is ILensNFTBase, ERC721Enumerable { ) external override { if (spender == address(0)) revert Errors.ZeroSpender(); address owner = ownerOf(tokenId); - - bytes32 digest; - unchecked { - digest = keccak256( - abi.encodePacked( - '\x19\x01', - _calculateDomainSeparator(), - keccak256( - abi.encode( - PERMIT_TYPEHASH, - spender, - tokenId, - sigNonces[owner]++, - sig.deadline - ) - ) + _validateRecoveredAddress( + _calculateDigest( + keccak256( + abi.encode(PERMIT_TYPEHASH, spender, tokenId, sigNonces[owner]++, sig.deadline) ) - ); - } - - _validateRecoveredAddress(digest, owner, sig); + ), + owner, + sig + ); _approve(spender, tokenId); } @@ -87,28 +75,22 @@ abstract contract LensNFTBase is ILensNFTBase, ERC721Enumerable { DataTypes.EIP712Signature calldata sig ) external override { if (operator == address(0)) revert Errors.ZeroSpender(); - - bytes32 digest; - unchecked { - digest = keccak256( - abi.encodePacked( - '\x19\x01', - _calculateDomainSeparator(), - keccak256( - abi.encode( - PERMIT_FOR_ALL_TYPEHASH, - owner, - operator, - approved, - sigNonces[owner]++, - sig.deadline - ) + _validateRecoveredAddress( + _calculateDigest( + keccak256( + abi.encode( + PERMIT_FOR_ALL_TYPEHASH, + owner, + operator, + approved, + sigNonces[owner]++, + sig.deadline ) ) - ); - } - - _validateRecoveredAddress(digest, owner, sig); + ), + owner, + sig + ); _setOperatorApproval(owner, operator, approved); } @@ -131,25 +113,15 @@ abstract contract LensNFTBase is ILensNFTBase, ERC721Enumerable { { address owner = ownerOf(tokenId); - bytes32 digest; - unchecked { - digest = keccak256( - abi.encodePacked( - '\x19\x01', - _calculateDomainSeparator(), - keccak256( - abi.encode( - BURN_WITH_SIG_TYPEHASH, - tokenId, - sigNonces[owner]++, - sig.deadline - ) - ) + _validateRecoveredAddress( + _calculateDigest( + keccak256( + abi.encode(BURN_WITH_SIG_TYPEHASH, tokenId, sigNonces[owner]++, sig.deadline) ) - ); - } - - _validateRecoveredAddress(digest, owner, sig); + ), + owner, + sig + ); _burn(tokenId); } @@ -182,4 +154,21 @@ abstract contract LensNFTBase is ILensNFTBase, ERC721Enumerable { ) ); } + + /** + * @dev Calculates EIP712 digest based on the current DOMAIN_SEPARATOR. + * + * @param hashedMessage The message hash from which the digest should be calculated. + * + * @return A 32-byte output representing the EIP712 digest. + */ + function _calculateDigest(bytes32 hashedMessage) internal view returns (bytes32) { + bytes32 digest; + unchecked { + digest = keccak256( + abi.encodePacked('\x19\x01', _calculateDomainSeparator(), hashedMessage) + ); + } + return digest; + } } diff --git a/contracts/libraries/Constants.sol b/contracts/libraries/Constants.sol index 9d09243..6994918 100644 --- a/contracts/libraries/Constants.sol +++ b/contracts/libraries/Constants.sol @@ -8,4 +8,5 @@ library Constants { string internal constant COLLECT_NFT_NAME_INFIX = '-Collect-'; string internal constant COLLECT_NFT_SYMBOL_INFIX = '-Cl-'; uint8 internal constant MAX_HANDLE_LENGTH = 31; + uint16 internal constant MAX_PROFILE_IMAGE_URI_LENGTH = 6000; } diff --git a/contracts/libraries/Errors.sol b/contracts/libraries/Errors.sol index ee67eff..ec75ee6 100644 --- a/contracts/libraries/Errors.sol +++ b/contracts/libraries/Errors.sol @@ -24,6 +24,7 @@ library Errors { error HandleTaken(); error HandleLengthInvalid(); error HandleContainsInvalidCharacters(); + error ProfileImageURILengthInvalid(); error CallerNotFollowNFT(); error CallerNotCollectNFT(); error BlockNumberInvalid(); diff --git a/contracts/libraries/ProfileTokenURILogic.sol b/contracts/libraries/ProfileTokenURILogic.sol new file mode 100644 index 0000000..ab2e076 --- /dev/null +++ b/contracts/libraries/ProfileTokenURILogic.sol @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +pragma solidity 0.8.10; + +import '@openzeppelin/contracts/utils/Base64.sol'; +import '@openzeppelin/contracts/utils/Strings.sol'; + +library ProfileTokenURILogic { + uint8 internal constant DEFAULT_FONT_SIZE = 24; + uint8 internal constant MAX_HANDLE_LENGTH_WITH_DEFAULT_FONT_SIZE = 17; + + /** + * @notice Generates the token URI for the profile NFT. + * + * @dev The decoded token URI JSON metadata contains the following fields: name, description, image and attributes. + * The image field contains a base64-encoded SVG. Both the JSON metadata and the image are generated fully on-chain. + * + * @param id The token ID of the profile. + * @param followers The number of profile's followers. + * @param owner The address which owns the profile. + * @param handle The profile's handle. + * @param imageURI The profile's picture URI. An empty string if has not been set. + * + * @return The profile's token URI as a base64-encoded JSON string. + */ + function getProfileTokenURI( + uint256 id, + uint256 followers, + address owner, + string memory handle, + string memory imageURI + ) external pure returns (string memory) { + string memory handleWithAtSymbol = string(abi.encodePacked('@', handle)); + return + string( + abi.encodePacked( + 'data:application/json;base64,', + Base64.encode( + abi.encodePacked( + '{"name":"', + handleWithAtSymbol, + '","description":"', + handleWithAtSymbol, + ' - Lens profile","image":"data:image/svg+xml;base64,', + _getSVGImageBase64Encoded(handleWithAtSymbol, imageURI), + '","attributes":[{"trait_type":"id","value":"#', + Strings.toString(id), + '"},{"trait_type":"followers","value":"', + Strings.toString(followers), + '"},{"trait_type":"owner","value":"', + Strings.toHexString(uint160(owner)), + '"},{"trait_type":"handle","value":"', + handleWithAtSymbol, + '"}]}' + ) + ) + ) + ); + } + + /** + * @notice Generates the token image. + * + * @dev If the image URI was set and meets URI format conditions, it will be embedded in the token image. + * Otherwise, a default picture will be used. Handle font size is a function of handle length. + * + * @param handleWithAtSymbol The profile's handle beginning with "@" symbol. + * @param imageURI The profile's picture URI. An empty string if has not been set. + * + * @return The profile token image as a base64-encoded SVG. + */ + function _getSVGImageBase64Encoded(string memory handleWithAtSymbol, string memory imageURI) + internal + pure + returns (string memory) + { + return + Base64.encode( + abi.encodePacked( + '', + _getSVGProfilePicture(imageURI), + '', + handleWithAtSymbol, + '' + ) + ); + } + + /** + * @notice Gets the fragment of the SVG correponding to the profile picture. + * + * @dev If the image URI was set and meets URI format conditions, this will return an image tag referencing it. + * Otherwise, a group tag that renders the default picture will be returned. + * + * @param imageURI The profile's picture URI. An empty string if has not been set. + * + * @return The fragment of the SVG token's image correspondending to the profile picture. + */ + function _getSVGProfilePicture(string memory imageURI) internal pure returns (string memory) { + if (_shouldUseCustomPicture(imageURI)) { + return + string( + abi.encodePacked( + '' + ) + ); + } else { + return + ''; + } + } + + /** + * @notice Maps the handle length to a font size. + * + * @dev Gives the font size as a function of handle length using the following formula: + * + * fontSize(handleLength) = 24 when handleLength <= 17 + * fontSize(handleLength) = 24 - (handleLength - 12) / 2 when handleLength > 17 + * + * @param handleLength The profile's handle length. + * @return The font size. + */ + function _handleLengthToFontSize(uint256 handleLength) internal pure returns (uint256) { + return + handleLength <= MAX_HANDLE_LENGTH_WITH_DEFAULT_FONT_SIZE + ? DEFAULT_FONT_SIZE + : DEFAULT_FONT_SIZE - (handleLength - 12) / 2; + } + + /** + * @notice Decides if Profile NFT should use user provided custom profile picture or the default one. + * + * @dev It checks if there is a custom imageURI set and makes sure it does not contain double-quotes to prevent + * injection attacks through the generated SVG. + * + * @param imageURI The imageURI set by the profile owner. + * + * @return A boolean indicating whether custom profile picture should be used or not. + */ + function _shouldUseCustomPicture(string memory imageURI) internal pure returns (bool) { + bytes memory imageURIBytes = bytes(imageURI); + if (imageURIBytes.length == 0) { + return false; + } + for (uint256 i = 0; i < imageURIBytes.length; i++) { + if (imageURIBytes[i] == '"') { + // Avoids embedding a user provided imageURI containing double-quotes to prevent injection attacks + return false; + } + } + return true; + } +} diff --git a/package-lock.json b/package-lock.json index 23d4d9b..29c52fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "AGPL-3.0-only", "dependencies": { - "@openzeppelin/contracts": "4.4.0" + "@openzeppelin/contracts": "4.5.0" }, "devDependencies": { "@nomiclabs/hardhat-ethers": "2.0.2", @@ -3839,8 +3839,9 @@ } }, "node_modules/@openzeppelin/contracts": { - "version": "4.4.0", - "license": "MIT" + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@openzeppelin/contracts/-/contracts-4.5.0.tgz", + "integrity": "sha512-fdkzKPYMjrRiPK6K4y64e6GzULR7R7RwxSigHS8DDp7aWDeoReqsQI+cxHV1UuhAqX69L1lAaWDxenfP+xiqzA==" }, "node_modules/@resolver-engine/core": { "version": "0.3.3", @@ -26166,7 +26167,9 @@ } }, "@openzeppelin/contracts": { - "version": "4.4.0" + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@openzeppelin/contracts/-/contracts-4.5.0.tgz", + "integrity": "sha512-fdkzKPYMjrRiPK6K4y64e6GzULR7R7RwxSigHS8DDp7aWDeoReqsQI+cxHV1UuhAqX69L1lAaWDxenfP+xiqzA==" }, "@resolver-engine/core": { "version": "0.3.3", diff --git a/package.json b/package.json index 3803c1e..e61b62c 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,6 @@ "eslint": "8.3.0", "eslint-config-prettier": "8.3.0", "eslint-plugin-prettier": "4.0.0", - "prettier-plugin-solidity": "1.0.0-beta.19", "ethereum-waffle": "3.4.0", "ethers": "5.5.1", "hardhat": "2.7.0", @@ -59,6 +58,7 @@ "hardhat-spdx-license-identifier": "2.0.3", "husky": "7.0.4", "prettier": "2.5.0", + "prettier-plugin-solidity": "1.0.0-beta.19", "solidity-coverage": "0.7.17", "ts-generator": "0.1.1", "ts-node": "10.4.0", @@ -71,7 +71,7 @@ } }, "dependencies": { - "@openzeppelin/contracts": "4.4.0" + "@openzeppelin/contracts": "4.5.0" }, "author": "Lens", "contributors": [ diff --git a/tasks/full-deploy-verify.ts b/tasks/full-deploy-verify.ts index 28cf1ba..eeb12e5 100644 --- a/tasks/full-deploy-verify.ts +++ b/tasks/full-deploy-verify.ts @@ -20,6 +20,7 @@ import { RevertCollectModule__factory, TimedFeeCollectModule__factory, TransparentUpgradeableProxy__factory, + ProfileTokenURILogic__factory, } from '../typechain-types'; import { deployWithVerify, waitForTx } from './helpers/utils'; @@ -79,9 +80,16 @@ task('full-deploy-verify', 'deploys the entire Lens Protocol with explorer verif [], 'contracts/libraries/InteractionLogic.sol:InteractionLogic' ); + const profileTokenURILogic = await deployWithVerify( + new ProfileTokenURILogic__factory(deployer).deploy({ nonce: deployerNonce++ }), + [], + 'contracts/libraries/ProfileTokenURILogic.sol:ProfileTokenURILogic' + ); const hubLibs = { 'contracts/libraries/PublishingLogic.sol:PublishingLogic': publishingLogic.address, 'contracts/libraries/InteractionLogic.sol:InteractionLogic': interactionLogic.address, + 'contracts/libraries/ProfileTokenURILogic.sol:ProfileTokenURILogic': + profileTokenURILogic.address, }; // Here, we pre-compute the nonces and addresses used to deploy the contracts. @@ -279,6 +287,7 @@ task('full-deploy-verify', 'deploys the entire Lens Protocol with explorer verif 'lensHub impl:': lensHubImpl.address, 'publishing logic lib': publishingLogic.address, 'interaction logic lib': interactionLogic.address, + 'profile token URI logic lib': profileTokenURILogic.address, 'follow NFT impl': followNFTImplAddress, 'collect NFT impl': collectNFTImplAddress, 'module globals': moduleGlobals.address, diff --git a/tasks/full-deploy.ts b/tasks/full-deploy.ts index 9b56a06..e6b9694 100644 --- a/tasks/full-deploy.ts +++ b/tasks/full-deploy.ts @@ -20,6 +20,7 @@ import { RevertCollectModule__factory, TimedFeeCollectModule__factory, TransparentUpgradeableProxy__factory, + ProfileTokenURILogic__factory, } from '../typechain-types'; import { deployContract, waitForTx } from './helpers/utils'; @@ -57,9 +58,14 @@ task('full-deploy', 'deploys the entire Lens Protocol').setAction(async ({}, hre const interactionLogic = await deployContract( new InteractionLogic__factory(deployer).deploy({ nonce: deployerNonce++ }) ); + const profileTokenURILogic = await deployContract( + new ProfileTokenURILogic__factory(deployer).deploy({ nonce: deployerNonce++ }) + ); const hubLibs = { 'contracts/libraries/PublishingLogic.sol:PublishingLogic': publishingLogic.address, 'contracts/libraries/InteractionLogic.sol:InteractionLogic': interactionLogic.address, + 'contracts/libraries/ProfileTokenURILogic.sol:ProfileTokenURILogic': + profileTokenURILogic.address, }; // Here, we pre-compute the nonces and addresses used to deploy the contracts. @@ -72,7 +78,8 @@ task('full-deploy', 'deploys the entire Lens Protocol').setAction(async ({}, hre '0x' + keccak256(RLP.encode([deployer.address, followNFTNonce])).substr(26); const collectNFTImplAddress = '0x' + keccak256(RLP.encode([deployer.address, collectNFTNonce])).substr(26); - const hubProxyAddress = '0x' + keccak256(RLP.encode([deployer.address, hubProxyNonce])).substr(26); + const hubProxyAddress = + '0x' + keccak256(RLP.encode([deployer.address, hubProxyNonce])).substr(26); // Next, we deploy first the hub implementation, then the followNFT implementation, the collectNFT, and finally the // hub proxy with initialization. @@ -187,7 +194,9 @@ task('full-deploy', 'deploys the entire Lens Protocol').setAction(async ({}, hre }) ); await waitForTx( - lensHub.whitelistCollectModule(timedFeeCollectModule.address, true, { nonce: governanceNonce++ }) + lensHub.whitelistCollectModule(timedFeeCollectModule.address, true, { + nonce: governanceNonce++, + }) ); await waitForTx( lensHub.whitelistCollectModule(limitedTimedFeeCollectModule.address, true, { diff --git a/test/__setup.spec.ts b/test/__setup.spec.ts index 51ff553..911435d 100644 --- a/test/__setup.spec.ts +++ b/test/__setup.spec.ts @@ -37,6 +37,7 @@ import { MockReferenceModule__factory, ModuleGlobals, ModuleGlobals__factory, + ProfileTokenURILogic__factory, PublishingLogic__factory, RevertCollectModule, RevertCollectModule__factory, @@ -59,18 +60,17 @@ export const CURRENCY_MINT_AMOUNT = parseEther('100'); export const BPS_MAX = 10000; export const TREASURY_FEE_BPS = 50; export const REFERRAL_FEE_BPS = 250; +export const MAX_PROFILE_IMAGE_URI_LENGTH = 6000; export const LENS_HUB_NFT_NAME = 'Lens Profiles'; export const LENS_HUB_NFT_SYMBOL = 'LENS'; export const MOCK_PROFILE_HANDLE = 'plant1ghost.eth'; export const FIRST_PROFILE_ID = 1; -export const MOCK_URI = - 'https://ipfs.fleek.co/ipfs/plantghostplantghostplantghostplantghostplantghostplantghos'; -export const OTHER_MOCK_URI = - 'https://ipfs.fleek.co/ipfs/ghostplantghostplantghostplantghostplantghostplantghostplan'; +export const MOCK_URI = 'https://ipfs.io/ipfs/QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR'; +export const OTHER_MOCK_URI = 'https://ipfs.io/ipfs/QmSfyMcnh1wnJHrAWCBjZHapTS859oNSsuDFiAPPdAHgHP'; export const MOCK_PROFILE_URI = - 'https://ipfs.fleek.co/ipfs/runningoutofthingstowriterunningoutofthingstowriterunningou'; + 'https://ipfs.io/ipfs/Qme7ss3ARVgxv6rXqVPiikMJ8u2NLgmgszg13pYrDKEoiu'; export const MOCK_FOLLOW_NFT_URI = - 'https://ipfs.fleek.co/ipfs/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + 'https://ipfs.fleek.co/ipfs/ghostplantghostplantghostplantghostplantghostplantghostplan'; export let accounts: Signer[]; export let deployer: Signer; @@ -149,9 +149,12 @@ before(async function () { ); const publishingLogic = await new PublishingLogic__factory(deployer).deploy(); const interactionLogic = await new InteractionLogic__factory(deployer).deploy(); + const profileTokenURILogic = await new ProfileTokenURILogic__factory(deployer).deploy(); hubLibs = { 'contracts/libraries/PublishingLogic.sol:PublishingLogic': publishingLogic.address, 'contracts/libraries/InteractionLogic.sol:InteractionLogic': interactionLogic.address, + 'contracts/libraries/ProfileTokenURILogic.sol:ProfileTokenURILogic': + profileTokenURILogic.address, }; // Here, we pre-compute the nonces and addresses used to deploy the contracts. diff --git a/test/helpers/errors.ts b/test/helpers/errors.ts index 0669dbc..ccbf296 100644 --- a/test/helpers/errors.ts +++ b/test/helpers/errors.ts @@ -18,6 +18,7 @@ export const ERRORS = { PUBLICATION_DOES_NOT_EXIST: 'PublicationDoesNotExist()', PROFILE_HANDLE_TAKEN: 'HandleTaken()', INVALID_HANDLE_LENGTH: 'HandleLengthInvalid()', + INVALID_IMAGE_URI_LENGTH: 'ProfileImageURILengthInvalid()', HANDLE_CONTAINS_INVALID_CHARACTERS: 'HandleContainsInvalidCharacters()', NOT_FOLLOW_NFT: 'CallerNotFollowNFT()', NOT_COLLECT_NFT: 'CallerNotCollectNFT()', @@ -35,6 +36,7 @@ export const ERRORS = { ERC721_TRANSFER_NOT_OWNER_OR_APPROVED: 'ERC721: transfer caller is not owner nor approved', ERC721_QUERY_FOR_NONEXISTENT_TOKEN: 'ERC721: owner query for nonexistent token', ERC20_TRANSFER_EXCEEDS_ALLOWANCE: 'ERC20: transfer amount exceeds allowance', + ERC20_INSUFFICIENT_ALLOWANCE: 'ERC20: insufficient allowance', NO_SELECTOR: "Transaction reverted: function selector was not recognized and there's no fallback function", PAUSED: 'Paused()', diff --git a/test/helpers/utils.ts b/test/helpers/utils.ts index 522b60c..b24df5a 100644 --- a/test/helpers/utils.ts +++ b/test/helpers/utils.ts @@ -1,12 +1,12 @@ -import { TransactionReceipt, TransactionResponse } from '@ethersproject/providers'; import '@nomiclabs/hardhat-ethers'; -import { expect } from 'chai'; -import { BigNumber, BigNumberish, Bytes, Contract, logger, utils } from 'ethers'; -import { hexlify, keccak256, RLP, toUtf8Bytes } from 'ethers/lib/utils'; -import hre from 'hardhat'; -import { LensHub__factory } from '../../typechain-types'; +import { BigNumberish, Bytes, logger, utils, BigNumber, Contract } from 'ethers'; import { eventsLib, helper, lensHub, LENS_HUB_NFT_NAME, testWallet } from '../__setup.spec'; +import { expect } from 'chai'; import { HARDHAT_CHAINID, MAX_UINT256 } from './constants'; +import { hexlify, keccak256, RLP, toUtf8Bytes } from 'ethers/lib/utils'; +import { LensHub__factory } from '../../typechain-types'; +import { TransactionReceipt, TransactionResponse } from '@ethersproject/providers'; +import hre, { ethers } from 'hardhat'; export enum ProtocolState { Unpaused, @@ -429,6 +429,18 @@ export async function getCollectWithSigParts( return await getSig(msgParams); } +export async function getJsonMetadataFromBase64TokenUri(tokenUri: string) { + const splittedTokenUri = tokenUri.split('data:application/json;base64,'); + if (splittedTokenUri.length != 2) { + logger.throwError('Wrong or unrecognized token URI format'); + } else { + const jsonMetadataBase64String = splittedTokenUri[1]; + const jsonMetadataBytes = ethers.utils.base64.decode(jsonMetadataBase64String); + const jsonMetadataString = ethers.utils.toUtf8String(jsonMetadataBytes); + return JSON.parse(jsonMetadataString); + } +} + // Modified from AaveTokenV2 repo const buildPermitParams = ( nft: string, diff --git a/test/hub/profiles/profile-uri.spec.ts b/test/hub/profiles/profile-uri.spec.ts index 4fb4c5c..318afb8 100644 --- a/test/hub/profiles/profile-uri.spec.ts +++ b/test/hub/profiles/profile-uri.spec.ts @@ -1,10 +1,12 @@ import '@nomiclabs/hardhat-ethers'; import { expect } from 'chai'; +import { keccak256, toUtf8Bytes } from 'ethers/lib/utils'; import { FollowNFT__factory } from '../../../typechain-types'; import { MAX_UINT256, ZERO_ADDRESS } from '../../helpers/constants'; import { ERRORS } from '../../helpers/errors'; import { cancelWithPermitForAll, + getJsonMetadataFromBase64TokenUri, getSetFollowNFTURIWithSigParts, getSetProfileImageURIWithSigParts, } from '../../helpers/utils'; @@ -12,6 +14,7 @@ import { FIRST_PROFILE_ID, lensHub, makeSuiteCleanRoom, + MAX_PROFILE_IMAGE_URI_LENGTH, MOCK_FOLLOW_NFT_URI, MOCK_PROFILE_HANDLE, MOCK_PROFILE_URI, @@ -46,6 +49,14 @@ makeSuiteCleanRoom('Profile URI Functionality', function () { ).to.be.revertedWith(ERRORS.NOT_PROFILE_OWNER_OR_DISPATCHER); }); + it('UserTwo should fail to set the profile URI on profile owned by user 1', async function () { + const profileURITooLong = MOCK_URI.repeat(500); + expect(profileURITooLong.length).to.be.greaterThan(MAX_PROFILE_IMAGE_URI_LENGTH); + await expect( + lensHub.setProfileImageURI(FIRST_PROFILE_ID, profileURITooLong) + ).to.be.revertedWith(ERRORS.INVALID_IMAGE_URI_LENGTH); + }); + it('UserTwo should fail to change the follow NFT URI for profile one', async function () { await expect( lensHub.connect(userTwo).setFollowNFTURI(FIRST_PROFILE_ID, OTHER_MOCK_URI) @@ -54,9 +65,134 @@ makeSuiteCleanRoom('Profile URI Functionality', function () { }); context('Scenarios', function () { - it('User should set the profile URI', async function () { + it('User should have a custom picture tokenURI after setting the profile imageURI', async function () { await expect(lensHub.setProfileImageURI(FIRST_PROFILE_ID, MOCK_URI)).to.not.be.reverted; - expect(await lensHub.tokenURI(FIRST_PROFILE_ID)).to.eq(MOCK_URI); + const tokenURI = await lensHub.tokenURI(FIRST_PROFILE_ID); + const jsonMetadata = await getJsonMetadataFromBase64TokenUri(tokenURI); + expect(jsonMetadata.name).to.eq(`@${MOCK_PROFILE_HANDLE}`); + expect(jsonMetadata.description).to.eq(`@${MOCK_PROFILE_HANDLE} - Lens profile`); + const expectedAttributes = [ + { trait_type: 'id', value: `#${FIRST_PROFILE_ID.toString()}` }, + { trait_type: 'followers', value: '0' }, + { trait_type: 'owner', value: userAddress.toLowerCase() }, + { trait_type: 'handle', value: `@${MOCK_PROFILE_HANDLE}` }, + ]; + expect(jsonMetadata.attributes).to.eql(expectedAttributes); + expect(keccak256(toUtf8Bytes(tokenURI))).to.eq( + '0xe1731460db6ce5fa9f3a9f2bd778a8af49e623dceb531df6b1a5c162b7c2d79a' + ); + }); + + it('Default image should be used when no imageURI set', async function () { + await expect(lensHub.setProfileImageURI(FIRST_PROFILE_ID, '')).to.not.be.reverted; + const tokenURI = await lensHub.tokenURI(FIRST_PROFILE_ID); + const jsonMetadata = await getJsonMetadataFromBase64TokenUri(tokenURI); + expect(jsonMetadata.name).to.eq(`@${MOCK_PROFILE_HANDLE}`); + expect(jsonMetadata.description).to.eq(`@${MOCK_PROFILE_HANDLE} - Lens profile`); + const expectedAttributes = [ + { trait_type: 'id', value: `#${FIRST_PROFILE_ID.toString()}` }, + { trait_type: 'followers', value: '0' }, + { trait_type: 'owner', value: userAddress.toLowerCase() }, + { trait_type: 'handle', value: `@${MOCK_PROFILE_HANDLE}` }, + ]; + expect(jsonMetadata.attributes).to.eql(expectedAttributes); + expect(keccak256(toUtf8Bytes(tokenURI))).to.eq( + '0xa21f2a3aa9300a248d3a7acd3f4ff309291653121df87ffe3be545fa1dbd65e5' + ); + }); + + it('Default image should be used when imageURI contains double-quotes', async function () { + const imageURI = + 'https://ipfs.io/ipfs/QmbWqxBEKC3P8tqsKc98xmWNzrztRLMiMPL8wBuTGsMnR"