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(
+ ''
+ )
+ );
+ }
+
+ /**
+ * @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"