diff --git a/contracts/LensHub.sol b/contracts/LensHub.sol index f727365..85d17d2 100644 --- a/contracts/LensHub.sol +++ b/contracts/LensHub.sol @@ -22,7 +22,7 @@ import {LensHubEventHooks} from 'contracts/base/LensHubEventHooks.sol'; // Libraries import {ActionLib} from 'contracts/libraries/ActionLib.sol'; -import {CollectLib} from 'contracts/libraries/CollectLib.sol'; +import {LegacyCollectLib} from 'contracts/libraries/LegacyCollectLib.sol'; import {FollowLib} from 'contracts/libraries/FollowLib.sol'; import {GovernanceLib} from 'contracts/libraries/GovernanceLib.sol'; import {MetaTxLib} from 'contracts/libraries/MetaTxLib.sol'; @@ -440,7 +440,7 @@ contract LensHub is returns (uint256) { return - CollectLib.collect({ + LegacyCollectLib.collect({ collectParams: collectParams, transactionExecutor: msg.sender, collectorProfileOwner: ownerOf(collectParams.collectorProfileId), @@ -459,9 +459,9 @@ contract LensHub is onlyProfileOwnerOrDelegatedExecutor(signature.signer, collectParams.collectorProfileId) returns (uint256) { - MetaTxLib.validateCollectSignature(signature, collectParams); + MetaTxLib.validateLegacyCollectSignature(signature, collectParams); return - CollectLib.collect({ + LegacyCollectLib.collect({ collectParams: collectParams, transactionExecutor: signature.signer, collectorProfileOwner: ownerOf(collectParams.collectorProfileId), diff --git a/contracts/libraries/CollectLib.sol b/contracts/libraries/CollectLib.sol deleted file mode 100644 index c5c2385..0000000 --- a/contracts/libraries/CollectLib.sol +++ /dev/null @@ -1,193 +0,0 @@ -// SPDX-License-Identifier: MIT - -pragma solidity ^0.8.15; - -import {ValidationLib} from 'contracts/libraries/ValidationLib.sol'; -import {Types} from 'contracts/libraries/constants/Types.sol'; -import {Errors} from 'contracts/libraries/constants/Errors.sol'; -import {Events} from 'contracts/libraries/constants/Events.sol'; -import {ICollectNFT} from 'contracts/interfaces/ICollectNFT.sol'; -import {ICollectModule} from 'contracts/interfaces/ICollectModule.sol'; -import {ILegacyCollectModule} from 'contracts/interfaces/ILegacyCollectModule.sol'; -import {Clones} from '@openzeppelin/contracts/proxy/Clones.sol'; -import {Strings} from '@openzeppelin/contracts/utils/Strings.sol'; -import {StorageLib} from 'contracts/libraries/StorageLib.sol'; - -/** - * @title CollectLib - * @author Lens Protocol - */ -library CollectLib { - using Strings for uint256; - - string constant COLLECT_NFT_NAME_INFIX = '-Collect-'; - string constant COLLECT_NFT_SYMBOL_INFIX = '-Cl-'; - - function collect( - Types.CollectParams calldata collectParams, - address transactionExecutor, - address collectorProfileOwner, - address collectNFTImpl - ) external returns (uint256) { - ValidationLib.validateNotBlocked({ - profile: collectParams.collectorProfileId, - byProfile: collectParams.publicationCollectedProfileId - }); - - address collectModule; - Types.PublicationType[] memory referrerPubTypes; - uint256 tokenId; - address collectNFT; - { - Types.Publication storage _collectedPublication = StorageLib.getPublication( - collectParams.publicationCollectedProfileId, - collectParams.publicationCollectedId - ); - collectModule = _collectedPublication.__DEPRECATED__collectModule; - if (collectModule == address(0)) { - // Doesn't have collectModule, thus it cannot be collected (a mirror or non-existent). - revert Errors.CollectNotAllowed(); - } - - referrerPubTypes = ValidationLib.validateReferrersAndGetReferrersPubTypes( - collectParams.referrerProfileIds, - collectParams.referrerPubIds, - collectParams.publicationCollectedProfileId, - collectParams.publicationCollectedId - ); - collectNFT = _getOrDeployCollectNFT( - _collectedPublication, - collectParams.publicationCollectedProfileId, - collectParams.publicationCollectedId, - collectNFTImpl - ); - tokenId = ICollectNFT(collectNFT).mint(collectorProfileOwner); - } - - _processCollect( - collectParams, - ProcessCollectParams({ - transactionExecutor: transactionExecutor, - collectorProfileOwner: collectorProfileOwner, - referrerPubTypes: referrerPubTypes, - collectModule: collectModule - }) - ); - - emit Events.Collected({ - collectActionParams: Types.ProcessActionParams({ - publicationActedProfileId: collectParams.publicationCollectedProfileId, - publicationActedId: collectParams.publicationCollectedId, - actorProfileId: collectParams.collectorProfileId, - actorProfileOwner: collectorProfileOwner, - transactionExecutor: transactionExecutor, - referrerProfileIds: collectParams.referrerProfileIds, - referrerPubIds: collectParams.referrerPubIds, - referrerPubTypes: referrerPubTypes, - actionModuleData: collectParams.collectModuleData - }), - collectModule: collectModule, - collectNFT: collectNFT, - tokenId: tokenId, - collectActionResult: '', - timestamp: block.timestamp - }); - - return tokenId; - } - - function _getOrDeployCollectNFT( - Types.Publication storage _collectedPublication, - uint256 publicationCollectedProfileId, - uint256 publicationCollectedId, - address collectNFTImpl - ) private returns (address) { - address collectNFT = _collectedPublication.__DEPRECATED__collectNFT; - if (collectNFT == address(0)) { - collectNFT = _deployCollectNFT(publicationCollectedProfileId, publicationCollectedId, collectNFTImpl); - _collectedPublication.__DEPRECATED__collectNFT = collectNFT; - } - return collectNFT; - } - - // Stack too deep, so we need to use a struct. - struct ProcessCollectParams { - address transactionExecutor; - address collectorProfileOwner; - Types.PublicationType[] referrerPubTypes; - address collectModule; - } - - function _processCollect( - Types.CollectParams calldata collectParams, - ProcessCollectParams memory processCollectParams - ) private { - try - ICollectModule(processCollectParams.collectModule).processCollect( - Types.ProcessCollectParams({ - publicationCollectedProfileId: collectParams.publicationCollectedProfileId, - publicationCollectedId: collectParams.publicationCollectedId, - collectorProfileId: collectParams.collectorProfileId, - collectorProfileOwner: processCollectParams.collectorProfileOwner, - transactionExecutor: processCollectParams.transactionExecutor, - referrerProfileIds: collectParams.referrerProfileIds, - referrerPubIds: collectParams.referrerPubIds, - referrerPubTypes: processCollectParams.referrerPubTypes, - data: collectParams.collectModuleData - }) - ) - {} catch (bytes memory err) { - assembly { - // Equivalent to reverting with the returned error selector if - // the length is not zero. - let length := mload(err) - if iszero(iszero(length)) { - revert(add(err, 32), length) - } - } - uint256 referrerProfileId; - uint256 referrerPubId; - if (collectParams.referrerProfileIds.length > 0) { - if (collectParams.referrerProfileIds.length > 1) { - // Deprecated modules only support one referrer. - revert Errors.DeprecaredModulesOnlySupportOneReferrer(); - } - // Only one referral was passed. - referrerProfileId = collectParams.referrerProfileIds[0]; - referrerPubId = collectParams.referrerPubIds[0]; - } - ILegacyCollectModule(processCollectParams.collectModule).processCollect( - collectParams.publicationCollectedProfileId, - processCollectParams.transactionExecutor, - referrerProfileId, - referrerPubId, - collectParams.collectModuleData - ); - } - } - - /** - * @notice Deploys the given profile's Collect NFT contract. - * - * @param profileId The token ID of the profile which Collect NFT should be deployed. - * @param pubId The publication ID of the publication being collected, which Collect NFT should be deployed. - * @param collectNFTImpl The address of the Collect NFT implementation that should be used for the deployment. - * - * @return address The address of the deployed Collect NFT contract. - */ - function _deployCollectNFT(uint256 profileId, uint256 pubId, address collectNFTImpl) private returns (address) { - address collectNFT = Clones.clone(collectNFTImpl); - - string memory collectNFTName = string( - abi.encodePacked(profileId.toString(), COLLECT_NFT_NAME_INFIX, pubId.toString()) - ); - string memory collectNFTSymbol = string( - abi.encodePacked(profileId.toString(), COLLECT_NFT_SYMBOL_INFIX, pubId.toString()) - ); - - ICollectNFT(collectNFT).initialize(profileId, pubId, collectNFTName, collectNFTSymbol); - emit Events.CollectNFTDeployed(profileId, pubId, collectNFT, block.timestamp); - - return collectNFT; - } -} diff --git a/contracts/libraries/LegacyCollectLib.sol b/contracts/libraries/LegacyCollectLib.sol new file mode 100644 index 0000000..4a5f497 --- /dev/null +++ b/contracts/libraries/LegacyCollectLib.sol @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.15; + +import {ValidationLib} from 'contracts/libraries/ValidationLib.sol'; +import {Types} from 'contracts/libraries/constants/Types.sol'; +import {Errors} from 'contracts/libraries/constants/Errors.sol'; +import {Events} from 'contracts/libraries/constants/Events.sol'; +import {ICollectNFT} from 'contracts/interfaces/ICollectNFT.sol'; +import {ICollectModule} from 'contracts/interfaces/ICollectModule.sol'; +import {ILegacyCollectModule} from 'contracts/interfaces/ILegacyCollectModule.sol'; +import {Clones} from '@openzeppelin/contracts/proxy/Clones.sol'; +import {Strings} from '@openzeppelin/contracts/utils/Strings.sol'; +import {StorageLib} from 'contracts/libraries/StorageLib.sol'; +import {PublicationLib} from 'contracts/libraries/PublicationLib.sol'; + +/** + * @title LegacyCollectLib + * @author Lens Protocol + * @notice Library containing the logic for legacy collect operation. + */ +library LegacyCollectLib { + using Strings for uint256; + + string constant COLLECT_NFT_NAME_INFIX = '-Collect-'; + string constant COLLECT_NFT_SYMBOL_INFIX = '-Cl-'; + + /** + * @dev Emitted upon a successful legacy collect action. + * + * @param publicationCollectedProfileId The profile ID of the publication being collected. + * @param publicationCollectedId The publication ID of the publication being collected. + * @param transactionExecutor The address of the account that executed the collect transaction. + * @param referrerProfileId The profile ID of the referrer, if any. Zero if no referrer. + * @param referrerPubId The publication ID of the referrer, if any. Zero if no referrer. + * @param collectModuleData The data passed to the collect module's collect action. This is ABI-encoded and depends + * on the collect module chosen. + * @param timestamp The current block timestamp. + */ + event CollectedLegacy( + uint256 indexed publicationCollectedProfileId, + uint256 indexed publicationCollectedId, + address transactionExecutor, + uint256 referrerProfileId, + uint256 referrerPubId, + bytes collectModuleData, + uint256 timestamp + ); + + function collect( + Types.CollectParams calldata collectParams, + address transactionExecutor, + address collectorProfileOwner, + address collectNFTImpl + ) external returns (uint256) { + ValidationLib.validateNotBlocked({ + profile: collectParams.collectorProfileId, + byProfile: collectParams.publicationCollectedProfileId + }); + + address collectModule; + uint256 tokenId; + address collectNFT; + { + Types.Publication storage _collectedPublication = StorageLib.getPublication( + collectParams.publicationCollectedProfileId, + collectParams.publicationCollectedId + ); + // This is a legacy collect operation, so we get the collect module from the deprecated storage field. + collectModule = _collectedPublication.__DEPRECATED__collectModule; + if (collectModule == address(0)) { + // It doesn't have collect module, thus it cannot be collected (a mirror or non-existent). + revert Errors.CollectNotAllowed(); + } + + if (collectParams.referrerProfileId != 0 || collectParams.referrerPubId != 0) { + ValidationLib.validateLegacyCollectReferrer( + collectParams.referrerProfileId, + collectParams.referrerPubId, + collectParams.publicationCollectedProfileId, + collectParams.publicationCollectedId + ); + } + + collectNFT = _getOrDeployCollectNFT( + _collectedPublication, + collectParams.publicationCollectedProfileId, + collectParams.publicationCollectedId, + collectNFTImpl + ); + tokenId = ICollectNFT(collectNFT).mint(collectorProfileOwner); + } + + ILegacyCollectModule(collectModule).processCollect({ + // Legacy collect modules expect referrer profile ID to match the collected pub's author if no referrer set. + referrerProfileId: collectParams.referrerProfileId == 0 + ? collectParams.publicationCollectedProfileId + : collectParams.referrerProfileId, + // Collect NFT is minted to the `collectorProfileOwner`. Some follow-based constraints are expected to be + // broken in legacy collect modules if the `transactionExecutor` does not match the `collectorProfileOwner`. + collector: transactionExecutor, + profileId: collectParams.publicationCollectedProfileId, + pubId: collectParams.publicationCollectedId, + data: collectParams.collectModuleData + }); + + emit CollectedLegacy({ + publicationCollectedProfileId: collectParams.publicationCollectedProfileId, + publicationCollectedId: collectParams.publicationCollectedId, + transactionExecutor: transactionExecutor, + referrerProfileId: collectParams.referrerProfileId, + referrerPubId: collectParams.referrerPubId, + collectModuleData: collectParams.collectModuleData, + timestamp: block.timestamp + }); + + return tokenId; + } + + function _getOrDeployCollectNFT( + Types.Publication storage _collectedPublication, + uint256 publicationCollectedProfileId, + uint256 publicationCollectedId, + address collectNFTImpl + ) private returns (address) { + address collectNFT = _collectedPublication.__DEPRECATED__collectNFT; + if (collectNFT == address(0)) { + collectNFT = _deployCollectNFT(publicationCollectedProfileId, publicationCollectedId, collectNFTImpl); + _collectedPublication.__DEPRECATED__collectNFT = collectNFT; + } + return collectNFT; + } + + /** + * @notice Deploys the given profile's Collect NFT contract. + * + * @param profileId The token ID of the profile which Collect NFT should be deployed. + * @param pubId The publication ID of the publication being collected, which Collect NFT should be deployed. + * @param collectNFTImpl The address of the Collect NFT implementation that should be used for the deployment. + * + * @return address The address of the deployed Collect NFT contract. + */ + function _deployCollectNFT(uint256 profileId, uint256 pubId, address collectNFTImpl) private returns (address) { + address collectNFT = Clones.clone(collectNFTImpl); + + string memory collectNFTName = string( + abi.encodePacked(profileId.toString(), COLLECT_NFT_NAME_INFIX, pubId.toString()) + ); + string memory collectNFTSymbol = string( + abi.encodePacked(profileId.toString(), COLLECT_NFT_SYMBOL_INFIX, pubId.toString()) + ); + + ICollectNFT(collectNFT).initialize(profileId, pubId, collectNFTName, collectNFTSymbol); + emit Events.CollectNFTDeployed(profileId, pubId, collectNFT, block.timestamp); + + return collectNFT; + } +} diff --git a/contracts/libraries/MetaTxLib.sol b/contracts/libraries/MetaTxLib.sol index 7917b5a..1434424 100644 --- a/contracts/libraries/MetaTxLib.sol +++ b/contracts/libraries/MetaTxLib.sol @@ -398,7 +398,7 @@ library MetaTxLib { ); } - function validateCollectSignature( + function validateLegacyCollectSignature( Types.EIP712Signature calldata signature, Types.CollectParams calldata collectParams ) external { @@ -406,12 +406,12 @@ library MetaTxLib { _calculateDigest( keccak256( abi.encode( - Typehash.COLLECT, + Typehash.LEGACY_COLLECT, collectParams.publicationCollectedProfileId, collectParams.publicationCollectedId, collectParams.collectorProfileId, - collectParams.referrerProfileIds, - collectParams.referrerPubIds, + collectParams.referrerProfileId, + collectParams.referrerPubId, keccak256(collectParams.collectModuleData), _getAndIncrementNonce(signature.signer), signature.deadline diff --git a/contracts/libraries/PublicationLib.sol b/contracts/libraries/PublicationLib.sol index 37f6ce1..77933a2 100644 --- a/contracts/libraries/PublicationLib.sol +++ b/contracts/libraries/PublicationLib.sol @@ -259,7 +259,7 @@ library PublicationLib { } function _fillReferencePublicationStorage( - Types.ReferencePubParams memory referencePubParams, + Types.ReferencePubParams calldata referencePubParams, Types.PublicationType referencePubType ) private returns (uint256) { uint256 pubIdAssigned = ++StorageLib.getProfile(referencePubParams.profileId).pubCount; @@ -273,14 +273,20 @@ library PublicationLib { referencePubParams.pointedProfileId, referencePubParams.pointedPubId ); - if (_pubPointed.pubType == Types.PublicationType.Post) { + Types.PublicationType pubPointedType = _pubPointed.pubType; + if (pubPointedType == Types.PublicationType.Post) { + // The publication pointed is a Lens V2 post. _referencePub.rootProfileId = referencePubParams.pointedProfileId; _referencePub.rootPubId = referencePubParams.pointedPubId; - } else { - // The publication pointed is either a comment or a quote. + } else if (pubPointedType == Types.PublicationType.Comment || pubPointedType == Types.PublicationType.Quote) { + // The publication pointed is either a Lens V2 comment or a Lens V2 quote. + // Note that even when the publication pointed is a V2 one, it will lack `rootProfileId` and `rootPubId` if + // there is a Lens V1 Legacy publication in the thread of interactions (including the root post itself). _referencePub.rootProfileId = _pubPointed.rootProfileId; _referencePub.rootPubId = _pubPointed.rootPubId; } + // Otherwise the root is not filled, as the pointed publication is a Lens V1 Legacy publication, which does not + // support Lens V2 referral system. return pubIdAssigned; } diff --git a/contracts/libraries/ValidationLib.sol b/contracts/libraries/ValidationLib.sol index a9c63fa..7308760 100644 --- a/contracts/libraries/ValidationLib.sol +++ b/contracts/libraries/ValidationLib.sol @@ -87,8 +87,8 @@ library ValidationLib { function validateReferrersAndGetReferrersPubTypes( uint256[] memory referrerProfileIds, uint256[] memory referrerPubIds, - uint256 profileId, - uint256 pubId + uint256 targetedProfileId, + uint256 targetedPubId ) internal view returns (Types.PublicationType[] memory) { if (referrerProfileIds.length != referrerPubIds.length) { revert Errors.ArrayMismatch(); @@ -105,8 +105,8 @@ library ValidationLib { referrerPubTypes[i] = _validateReferrerAndGetReferrerPubType( referrerProfileId, referrerPubId, - profileId, - pubId + targetedProfileId, + targetedPubId ); unchecked { i++; @@ -115,15 +115,34 @@ library ValidationLib { return referrerPubTypes; } + function validateLegacyCollectReferrer( + uint256 referrerProfileId, + uint256 referrerPubId, + uint256 publicationCollectedProfileId, + uint256 publicationCollectedId + ) external view { + if (PublicationLib.getPublicationType(referrerProfileId, referrerPubId) != Types.PublicationType.Mirror) { + revert Errors.InvalidReferrer(); + } + _validateReferrerAsMirror( + referrerProfileId, + referrerPubId, + publicationCollectedProfileId, + publicationCollectedId + ); + } + function _validateReferrerAndGetReferrerPubType( uint256 referrerProfileId, uint256 referrerPubId, - uint256 profileId, - uint256 pubId + uint256 targetedProfileId, + uint256 targetedPubId ) private view returns (Types.PublicationType) { if (referrerPubId == 0) { // Unchecked/Unverified referral. Profile referrer, not attached to a publication. - if (StorageLib.getTokenData(referrerProfileId).owner == address(0) || referrerProfileId == profileId) { + if ( + StorageLib.getTokenData(referrerProfileId).owner == address(0) || referrerProfileId == targetedProfileId + ) { revert Errors.InvalidReferrer(); } return Types.PublicationType.Nonexistent; @@ -131,19 +150,19 @@ library ValidationLib { // Checked/Verified referral. Publication referrer. if ( // Cannot pass itself as a referrer. - referrerProfileId == profileId && referrerPubId == pubId + referrerProfileId == targetedProfileId && referrerPubId == targetedPubId ) { revert Errors.InvalidReferrer(); } Types.PublicationType referrerPubType = PublicationLib.getPublicationType(referrerProfileId, referrerPubId); if (referrerPubType == Types.PublicationType.Mirror) { - _validateReferrerAsMirror(referrerProfileId, referrerPubId, profileId, pubId); + _validateReferrerAsMirror(referrerProfileId, referrerPubId, targetedProfileId, targetedPubId); } else if ( referrerPubType == Types.PublicationType.Comment || referrerPubType == Types.PublicationType.Quote ) { - _validateReferrerAsCommentOrQuote(referrerProfileId, referrerPubId, profileId, pubId); + _validateReferrerAsCommentOrQuote(referrerProfileId, referrerPubId, targetedProfileId, targetedPubId); } else if (referrerPubType == Types.PublicationType.Post) { - _validateReferrerAsPost(referrerProfileId, referrerPubId, profileId, pubId); + _validateReferrerAsPost(referrerProfileId, referrerPubId, targetedProfileId, targetedPubId); } else { revert Errors.InvalidReferrer(); } @@ -154,16 +173,13 @@ library ValidationLib { function _validateReferrerAsPost( uint256 referrerProfileId, uint256 referrerPubId, - uint256 profileId, - uint256 pubId + uint256 targetedProfileId, + uint256 targetedPubId ) private view { - Types.Publication storage _publication = StorageLib.getPublication(profileId, pubId); - if ( - // Publication being collected/referenced is not pointing to the referrer post and... - (_publication.pointedProfileId != referrerProfileId || _publication.pointedPubId != referrerPubId) && - // ...publication being collected/referenced does not have the referrer post as the root. - (_publication.rootProfileId != referrerProfileId || _publication.rootPubId != referrerPubId) - ) { + Types.Publication storage _targetedPub = StorageLib.getPublication(targetedProfileId, targetedPubId); + // Publication targeted must have the referrer post as the root. This enables the use case of rewarding the + // root publication for an action over any of its descendants. + if (_targetedPub.rootProfileId != referrerProfileId || _targetedPub.rootPubId != referrerPubId) { revert Errors.InvalidReferrer(); } } @@ -171,13 +187,13 @@ library ValidationLib { function _validateReferrerAsMirror( uint256 referrerProfileId, uint256 referrerPubId, - uint256 profileId, - uint256 pubId + uint256 targetedProfileId, + uint256 targetedPubId ) private view { Types.Publication storage _referrerMirror = StorageLib.getPublication(referrerProfileId, referrerPubId); if ( // A mirror can only be a referrer of a publication if it is pointing to it. - _referrerMirror.pointedProfileId != profileId || _referrerMirror.pointedPubId != pubId + _referrerMirror.pointedProfileId != targetedProfileId || _referrerMirror.pointedPubId != targetedPubId ) { revert Errors.InvalidReferrer(); } @@ -188,30 +204,35 @@ library ValidationLib { * * @param referrerProfileId The profile id of the referrer. * @param referrerPubId The publication id of the referrer. - * @param profileId This is the ID of the profile who authored the publication being collected or referenced. - * @param pubId This is the pub user collects or references. + * @param targetedProfileId The ID of the profile who authored the publication being acted or referenced. + * @param targetedPubId The pub ID being acted or referenced. */ function _validateReferrerAsCommentOrQuote( uint256 referrerProfileId, uint256 referrerPubId, - uint256 profileId, - uint256 pubId + uint256 targetedProfileId, + uint256 targetedPubId ) private view { Types.Publication storage _referrerPub = StorageLib.getPublication(referrerProfileId, referrerPubId); - Types.PublicationType typeOfPubPointedByReferrer = PublicationLib.getPublicationType(profileId, pubId); - // We already know that the publication being collected/referenced is not a mirror nor a non-existent one. - if (typeOfPubPointedByReferrer == Types.PublicationType.Post) { - // If the publication collected/referenced is a post, the referrer comment/quote must have it as the root. - if (_referrerPub.rootProfileId != profileId || _referrerPub.rootPubId != pubId) { + Types.PublicationType typeOfTargetedPub = PublicationLib.getPublicationType(targetedProfileId, targetedPubId); + // We already know that the publication being acted/referenced is not a mirror nor a non-existent one. + if (typeOfTargetedPub == Types.PublicationType.Post) { + // If the publication acted/referenced is a post, the referrer comment/quote must have it as the root. + if (_referrerPub.rootProfileId != targetedProfileId || _referrerPub.rootPubId != targetedPubId) { revert Errors.InvalidReferrer(); } } else { - // The publication collected/referenced is a comment or a quote. - Types.Publication storage _pubPointedByReferrer = StorageLib.getPublication(profileId, pubId); - // The referrer publication and the collected/referenced publication must share the same root. + // The publication acted/referenced is a comment or a quote. + Types.Publication storage _targetedPub = StorageLib.getPublication(targetedProfileId, targetedPubId); if ( - _referrerPub.rootProfileId != _pubPointedByReferrer.rootProfileId || - _referrerPub.rootPubId != _pubPointedByReferrer.rootPubId + // Targeted pub must be a "pure" Lens V2 comment/quote, which means there is no Lens V1 Legacy comment + // or post on its tree of interactions, and its root pub is filled. + // Otherwise, two Lens V2 "non-pure" publications could be passed as a referrer to each other, + // even without having any interaction in common. + _targetedPub.rootPubId == 0 || + // The referrer publication and the acted/referenced publication must share the same root. + _referrerPub.rootProfileId != _targetedPub.rootProfileId || + _referrerPub.rootPubId != _targetedPub.rootPubId ) { revert Errors.InvalidReferrer(); } diff --git a/contracts/libraries/constants/Errors.sol b/contracts/libraries/constants/Errors.sol index 67cd19b..c906f2b 100644 --- a/contracts/libraries/constants/Errors.sol +++ b/contracts/libraries/constants/Errors.sol @@ -27,7 +27,6 @@ library Errors { error NotFollowing(); error SelfFollow(); error InvalidReferrer(); - error DeprecaredModulesOnlySupportOneReferrer(); error InvalidPointedPub(); error NonERC721ReceiverImplementer(); @@ -38,7 +37,7 @@ library Errors { error InitParamsInvalid(); error ActionNotAllowed(); - error CollectNotAllowed(); // Used in CollectLib (pending deprecation) + error CollectNotAllowed(); // Used in LegacyCollectLib (pending deprecation) // MultiState Errors error Paused(); diff --git a/contracts/libraries/constants/Typehash.sol b/contracts/libraries/constants/Typehash.sol index d95fbc2..e5cb0fe 100644 --- a/contracts/libraries/constants/Typehash.sol +++ b/contracts/libraries/constants/Typehash.sol @@ -10,7 +10,7 @@ library Typehash { bytes32 constant CHANGE_DELEGATED_EXECUTORS_CONFIG = keccak256('ChangeDelegatedExecutorsConfig(uint256 delegatorProfileId,address[] delegatedExecutors,bool[] approvals,uint64 configNumber,bool switchToGivenConfig,uint256 nonce,uint256 deadline)'); - bytes32 constant COLLECT = keccak256('Collect(uint256 publicationCollectedProfileId,uint256 publicationCollectedId,uint256 collectorProfileId,uint256[] referrerProfileIds,uint256[] referrerPubIds,bytes collectModuleData,uint256 nonce,uint256 deadline)'); + bytes32 constant LEGACY_COLLECT = keccak256('Collect(uint256 publicationCollectedProfileId,uint256 publicationCollectedId,uint256 collectorProfileId,uint256 referrerProfileId,uint256 referrerPubId,bytes collectModuleData,uint256 nonce,uint256 deadline)'); bytes32 constant COMMENT = keccak256('Comment(uint256 profileId,string contentURI,uint256 pointedProfileId,uint256 pointedPubId,uint256[] referrerProfileIds,uint256[] referrerPubIds,bytes referenceModuleData,address collectModule,bytes collectModuleInitData,address referenceModule,bytes referenceModuleInitData,uint256 nonce,uint256 deadline)'); diff --git a/contracts/libraries/constants/Types.sol b/contracts/libraries/constants/Types.sol index be650ee..c1733cd 100644 --- a/contracts/libraries/constants/Types.sol +++ b/contracts/libraries/constants/Types.sol @@ -261,21 +261,22 @@ library Types { /** * Deprecated in V2: Will be removed after some time after upgrading to V2. - * @notice A struct containing the parameters required for the `collect()` function. + * @notice A struct containing the parameters required for the legacy `collect()` function. + * @dev The referrer can only be a mirror of the publication being collected. * * @param publicationCollectedProfileId The token ID of the profile that published the publication to collect. * @param publicationCollectedId The publication to collect's publication ID. * @param collectorProfileId The collector profile. - * @param referrerProfileId - * @param referrerPubId + * @param referrerProfileId The ID of a profile that authored a mirror that helped discovering the collected pub. + * @param referrerPubId The ID of the mirror that helped discovering the collected pub. * @param collectModuleData The arbitrary data to pass to the collectModule if needed. */ struct CollectParams { uint256 publicationCollectedProfileId; uint256 publicationCollectedId; uint256 collectorProfileId; - uint256[] referrerProfileIds; - uint256[] referrerPubIds; + uint256 referrerProfileId; + uint256 referrerPubId; bytes collectModuleData; } diff --git a/foundry.toml b/foundry.toml index ce0e5e0..daccc12 100644 --- a/foundry.toml +++ b/foundry.toml @@ -6,7 +6,7 @@ test = 'test/foundry' cache_path = 'forge-cache' fs_permissions = [{ access = "read-write", path = "./"}] optimizer = true -optimizer_runs = 65 +optimizer_runs = 10 ignored_error_codes = [] [rpc_endpoints] diff --git a/test/foundry/fork/UpgradeForkTest.t.sol b/test/foundry/fork/UpgradeForkTest.t.sol index 85c4d86..21d7fa8 100644 --- a/test/foundry/fork/UpgradeForkTest.t.sol +++ b/test/foundry/fork/UpgradeForkTest.t.sol @@ -311,8 +311,8 @@ contract UpgradeForkTest is BaseTest { publicationCollectedProfileId: profileId, publicationCollectedId: 1, collectorProfileId: profileId, - referrerProfileIds: _emptyUint256Array(), - referrerPubIds: _emptyUint256Array(), + referrerProfileId: 0, + referrerPubId: 0, collectModuleData: '' }) ); @@ -321,8 +321,8 @@ contract UpgradeForkTest is BaseTest { publicationCollectedProfileId: profileId, publicationCollectedId: 2, collectorProfileId: profileId, - referrerProfileIds: _emptyUint256Array(), - referrerPubIds: _emptyUint256Array(), + referrerProfileId: 0, + referrerPubId: 0, collectModuleData: '' }) ); @@ -331,8 +331,8 @@ contract UpgradeForkTest is BaseTest { publicationCollectedProfileId: profileId, publicationCollectedId: 3, collectorProfileId: profileId, - referrerProfileIds: _emptyUint256Array(), - referrerPubIds: _emptyUint256Array(), + referrerProfileId: 0, + referrerPubId: 0, collectModuleData: '' }) ); diff --git a/test/foundry/helpers/ArrayHelpers.sol b/test/foundry/helpers/ArrayHelpers.sol index 7badab6..25e31b4 100644 --- a/test/foundry/helpers/ArrayHelpers.sol +++ b/test/foundry/helpers/ArrayHelpers.sol @@ -1,6 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.13; +import {Types} from 'contracts/libraries/constants/Types.sol'; + contract ArrayHelpers { function testArrayHelpers() public { // Prevents being counted in Foundry Coverage @@ -11,6 +13,21 @@ contract ArrayHelpers { return ret; } + function _emptyAddressArray() internal pure returns (address[] memory) { + address[] memory ret = new address[](0); + return ret; + } + + function _emptyBytesArray() internal pure returns (bytes[] memory) { + bytes[] memory ret = new bytes[](0); + return ret; + } + + function _emptyPubTypesArray() internal pure returns (Types.PublicationType[] memory) { + Types.PublicationType[] memory ret = new Types.PublicationType[](0); + return ret; + } + function _toUint256Array(uint256 n) internal pure returns (uint256[] memory) { uint256[] memory ret = new uint256[](1); ret[0] = n; diff --git a/test/foundry/modules/reference/TokenGatedReferenceModule.test.sol b/test/foundry/modules/reference/TokenGatedReferenceModule.test.sol new file mode 100644 index 0000000..f6be7f7 --- /dev/null +++ b/test/foundry/modules/reference/TokenGatedReferenceModule.test.sol @@ -0,0 +1,490 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.10; + +import 'test/foundry/base/BaseTest.t.sol'; +import {TokenGatedReferenceModule, GateParams} from 'contracts/modules/reference/TokenGatedReferenceModule.sol'; +import {Types} from 'contracts/libraries/constants/Types.sol'; +import {ArrayHelpers} from 'test/foundry/helpers/ArrayHelpers.sol'; +import {Currency} from 'test/mocks/Currency.sol'; +import {NFT} from 'test/mocks/NFT.sol'; + +contract TokenGatedReferenceModuleBase is BaseTest { + using stdJson for string; + TokenGatedReferenceModule tokenGatedReferenceModule; + + NFT nft; + Currency currency; + uint256 profileId; + + event TokenGatedReferencePublicationCreated( + uint256 indexed profileId, + uint256 indexed pubId, + address tokenAddress, + uint256 minThreshold + ); + + function setUp() public override { + super.setUp(); + currency = new Currency(); + nft = new NFT(); + profileId = _createProfile(profileOwner); + } + + // Deploy & Whitelist TokenGatedReferenceModule + constructor() TestSetup() { + if (fork && keyExists(string(abi.encodePacked('.', forkEnv, '.TokenGatedReferenceModule')))) { + tokenGatedReferenceModule = TokenGatedReferenceModule( + json.readAddress(string(abi.encodePacked('.', forkEnv, '.TokenGatedReferenceModule'))) + ); + console.log('Testing against already deployed module at:', address(tokenGatedReferenceModule)); + } else { + vm.prank(deployer); + tokenGatedReferenceModule = new TokenGatedReferenceModule(hubProxyAddr); + } + } +} + +///////// +// Publication Creation with TokenGatedReferenceModule +// +contract TokenGatedReferenceModule_Publication is TokenGatedReferenceModuleBase { + constructor() TokenGatedReferenceModuleBase() {} + + // Negatives + function testCannotPostWithZeroTokenAddress() public { + vm.expectRevert(Errors.InitParamsInvalid.selector); + vm.prank(address(hub)); + tokenGatedReferenceModule.initializeReferenceModule( + 1, + 2, + address(3), + abi.encode(GateParams({tokenAddress: address(0), minThreshold: 1})) + ); + } + + function testCannotPostWithZeroMinThreshold() public { + vm.expectRevert(Errors.InitParamsInvalid.selector); + vm.prank(address(hub)); + tokenGatedReferenceModule.initializeReferenceModule( + 1, + 2, + address(3), + abi.encode(GateParams({tokenAddress: address(currency), minThreshold: 0})) + ); + } + + function testCannotCallInitializeFromNonHub() public { + vm.expectRevert(Errors.NotHub.selector); + tokenGatedReferenceModule.initializeReferenceModule( + profileId, + 1, + profileOwner, + abi.encode(GateParams({tokenAddress: address(currency), minThreshold: 1})) + ); + } + + function testCannotProcessCommentFromNonHub() public { + vm.expectRevert(Errors.NotHub.selector); + tokenGatedReferenceModule.processComment( + Types.ProcessCommentParams({ + profileId: profileId, + transactionExecutor: profileOwner, + pointedProfileId: profileId, + pointedPubId: 1, + referrerProfileIds: _emptyUint256Array(), + referrerPubIds: _emptyUint256Array(), + referrerPubTypes: _emptyPubTypesArray(), + data: '' + }) + ); + } + + function testCannotProcessQuoteFromNonHub() public { + vm.expectRevert(Errors.NotHub.selector); + tokenGatedReferenceModule.processQuote( + Types.ProcessQuoteParams({ + profileId: profileId, + transactionExecutor: profileOwner, + pointedProfileId: profileId, + pointedPubId: 1, + referrerProfileIds: _emptyUint256Array(), + referrerPubIds: _emptyUint256Array(), + referrerPubTypes: _emptyPubTypesArray(), + data: '' + }) + ); + } + + function testCannotProcessMirrorFromNonHub() public { + vm.expectRevert(Errors.NotHub.selector); + tokenGatedReferenceModule.processMirror( + Types.ProcessMirrorParams({ + profileId: profileId, + transactionExecutor: profileOwner, + pointedProfileId: profileId, + pointedPubId: 1, + referrerProfileIds: _emptyUint256Array(), + referrerPubIds: _emptyUint256Array(), + referrerPubTypes: _emptyPubTypesArray(), + data: '' + }) + ); + } + + // Scenarios + function testCanInitializeTokenGatedReferenceModule(uint256 profileId, uint256 pubId, uint256 minThreshold) public { + vm.assume(profileId != 0); + vm.assume(pubId != 0); + vm.assume(minThreshold != 0); + + vm.prank(address(hub)); + tokenGatedReferenceModule.initializeReferenceModule( + profileId, + pubId, + address(0), + abi.encode(GateParams({tokenAddress: address(currency), minThreshold: minThreshold})) + ); + } + + function testCreatePublicationWithTokenGatedReferenceModule_EmitsExpectedEvents( + uint256 profileId, + uint256 pubId, + uint256 minThreshold + ) public { + vm.assume(profileId != 0); + vm.assume(pubId != 0); + vm.assume(minThreshold != 0); + + vm.expectEmit(true, true, true, true, address(tokenGatedReferenceModule)); + emit TokenGatedReferencePublicationCreated(profileId, pubId, address(currency), minThreshold); + vm.prank(address(hub)); + tokenGatedReferenceModule.initializeReferenceModule( + profileId, + pubId, + address(0), + abi.encode(GateParams({tokenAddress: address(currency), minThreshold: minThreshold})) + ); + } +} + +///////// +// ERC20-Gated Reference +// +contract TokenGatedReferenceModule_ERC20_Gated is TokenGatedReferenceModuleBase { + function _initialize(uint256 publisherProfileId, uint256 publisherPubId, uint256 minThreshold) internal { + vm.assume(publisherProfileId != 0); + vm.assume(publisherPubId != 0); + vm.assume(minThreshold != 0); + vm.prank(address(hub)); + tokenGatedReferenceModule.initializeReferenceModule( + publisherProfileId, + publisherPubId, + address(0), + abi.encode(GateParams({tokenAddress: address(currency), minThreshold: minThreshold})) + ); + } + + constructor() TokenGatedReferenceModuleBase() {} + + // Negatives + function testCannotProcessComment_IfNotEnoughBalance( + uint256 publisherProfileId, + uint256 publisherPubId, + uint256 minThreshold + ) public { + assertEq(currency.balanceOf(address(profileOwner)), 0); + + _initialize(publisherProfileId, publisherPubId, minThreshold); + + vm.expectRevert(TokenGatedReferenceModule.NotEnoughBalance.selector); + + vm.prank(address(hub)); + tokenGatedReferenceModule.processComment( + Types.ProcessCommentParams({ + profileId: profileId, + transactionExecutor: profileOwner, + pointedProfileId: publisherProfileId, + pointedPubId: publisherPubId, + referrerProfileIds: _emptyUint256Array(), + referrerPubIds: _emptyUint256Array(), + referrerPubTypes: _emptyPubTypesArray(), + data: '' + }) + ); + } + + function testCannotProcessMirror_IfNotEnoughBalance( + uint256 publisherProfileId, + uint256 publisherPubId, + uint256 minThreshold + ) public { + assertEq(currency.balanceOf(address(profileOwner)), 0); + + _initialize(publisherProfileId, publisherPubId, minThreshold); + + vm.expectRevert(TokenGatedReferenceModule.NotEnoughBalance.selector); + vm.prank(address(hub)); + tokenGatedReferenceModule.processMirror( + Types.ProcessMirrorParams({ + profileId: profileId, + transactionExecutor: profileOwner, + pointedProfileId: publisherProfileId, + pointedPubId: publisherPubId, + referrerProfileIds: _emptyUint256Array(), + referrerPubIds: _emptyUint256Array(), + referrerPubTypes: _emptyPubTypesArray(), + data: '' + }) + ); + } + + function testCannotProcessQuote_IfNotEnoughBalance( + uint256 publisherProfileId, + uint256 publisherPubId, + uint256 minThreshold + ) public { + assertEq(currency.balanceOf(address(profileOwner)), 0); + + _initialize(publisherProfileId, publisherPubId, minThreshold); + + vm.expectRevert(TokenGatedReferenceModule.NotEnoughBalance.selector); + vm.prank(address(hub)); + tokenGatedReferenceModule.processQuote( + Types.ProcessQuoteParams({ + profileId: profileId, + transactionExecutor: profileOwner, + pointedProfileId: publisherProfileId, + pointedPubId: publisherPubId, + referrerProfileIds: _emptyUint256Array(), + referrerPubIds: _emptyUint256Array(), + referrerPubTypes: _emptyPubTypesArray(), + data: '' + }) + ); + } + + // Scenarios + function testProcessComment_HoldingEnoughTokens( + uint256 publisherProfileId, + uint256 publisherPubId, + uint256 minThreshold + ) public { + currency.mint(profileOwner, minThreshold); + assertTrue(currency.balanceOf(profileOwner) >= minThreshold); + + _initialize(publisherProfileId, publisherPubId, minThreshold); + + vm.prank(address(hub)); + tokenGatedReferenceModule.processComment( + Types.ProcessCommentParams({ + profileId: profileId, + transactionExecutor: profileOwner, + pointedProfileId: publisherProfileId, + pointedPubId: publisherPubId, + referrerProfileIds: _emptyUint256Array(), + referrerPubIds: _emptyUint256Array(), + referrerPubTypes: _emptyPubTypesArray(), + data: '' + }) + ); + } + + function testProcessMirror_HoldingEnoughTokens( + uint256 publisherProfileId, + uint256 publisherPubId, + uint256 minThreshold + ) public { + currency.mint(profileOwner, minThreshold); + assertTrue(currency.balanceOf(profileOwner) >= minThreshold); + + _initialize(publisherProfileId, publisherPubId, minThreshold); + + vm.prank(address(hub)); + tokenGatedReferenceModule.processMirror( + Types.ProcessMirrorParams({ + profileId: profileId, + transactionExecutor: profileOwner, + pointedProfileId: publisherProfileId, + pointedPubId: publisherPubId, + referrerProfileIds: _emptyUint256Array(), + referrerPubIds: _emptyUint256Array(), + referrerPubTypes: _emptyPubTypesArray(), + data: '' + }) + ); + } + + function testProcessQuote_HoldingEnoughTokens( + uint256 publisherProfileId, + uint256 publisherPubId, + uint256 minThreshold + ) public { + currency.mint(profileOwner, minThreshold); + assertTrue(currency.balanceOf(profileOwner) >= minThreshold); + + _initialize(publisherProfileId, publisherPubId, minThreshold); + + vm.prank(address(hub)); + tokenGatedReferenceModule.processQuote( + Types.ProcessQuoteParams({ + profileId: profileId, + transactionExecutor: profileOwner, + pointedProfileId: publisherProfileId, + pointedPubId: publisherPubId, + referrerProfileIds: _emptyUint256Array(), + referrerPubIds: _emptyUint256Array(), + referrerPubTypes: _emptyPubTypesArray(), + data: '' + }) + ); + } +} + +///////// +// ERC721-Gated Reference +// +contract TokenGatedReferenceModule_ERC721_Gated is TokenGatedReferenceModuleBase { + uint256 constant minThreshold = 1; + + function _initialize(uint256 publisherProfileId, uint256 publisherPubId) internal { + vm.assume(publisherProfileId != 0); + vm.assume(publisherPubId != 0); + vm.prank(address(hub)); + tokenGatedReferenceModule.initializeReferenceModule( + publisherProfileId, + publisherPubId, + address(0), + abi.encode(GateParams({tokenAddress: address(nft), minThreshold: minThreshold})) + ); + } + + constructor() TokenGatedReferenceModuleBase() {} + + // Negatives + function testCannotProcessComment_IfNotEnoughBalance(uint256 publisherProfileId, uint256 publisherPubId) public { + assertEq(nft.balanceOf(address(profileOwner)), 0); + + _initialize(publisherProfileId, publisherPubId); + + vm.expectRevert(TokenGatedReferenceModule.NotEnoughBalance.selector); + vm.prank(address(hub)); + tokenGatedReferenceModule.processComment( + Types.ProcessCommentParams({ + profileId: profileId, + transactionExecutor: profileOwner, + pointedProfileId: publisherProfileId, + pointedPubId: publisherPubId, + referrerProfileIds: _emptyUint256Array(), + referrerPubIds: _emptyUint256Array(), + referrerPubTypes: _emptyPubTypesArray(), + data: '' + }) + ); + } + + function testCannotProcessMirror_IfNotEnoughBalance(uint256 publisherProfileId, uint256 publisherPubId) public { + assertEq(nft.balanceOf(address(profileOwner)), 0); + + _initialize(publisherProfileId, publisherPubId); + + vm.expectRevert(TokenGatedReferenceModule.NotEnoughBalance.selector); + vm.prank(address(hub)); + tokenGatedReferenceModule.processMirror( + Types.ProcessMirrorParams({ + profileId: profileId, + transactionExecutor: profileOwner, + pointedProfileId: publisherProfileId, + pointedPubId: publisherPubId, + referrerProfileIds: _emptyUint256Array(), + referrerPubIds: _emptyUint256Array(), + referrerPubTypes: _emptyPubTypesArray(), + data: '' + }) + ); + } + + function testCannotProcessQuote_IfNotEnoughBalance(uint256 publisherProfileId, uint256 publisherPubId) public { + assertEq(nft.balanceOf(address(profileOwner)), 0); + + _initialize(publisherProfileId, publisherPubId); + + vm.expectRevert(TokenGatedReferenceModule.NotEnoughBalance.selector); + vm.prank(address(hub)); + tokenGatedReferenceModule.processQuote( + Types.ProcessQuoteParams({ + profileId: profileId, + transactionExecutor: profileOwner, + pointedProfileId: publisherProfileId, + pointedPubId: publisherPubId, + referrerProfileIds: _emptyUint256Array(), + referrerPubIds: _emptyUint256Array(), + referrerPubTypes: _emptyPubTypesArray(), + data: '' + }) + ); + } + + // Scenarios + function testProcessComment_HoldingEnoughTokens(uint256 publisherProfileId, uint256 publisherPubId) public { + nft.mint({to: profileOwner, nftId: 1}); + assertTrue(nft.balanceOf(profileOwner) >= minThreshold); + + _initialize(publisherProfileId, publisherPubId); + + vm.prank(address(hub)); + tokenGatedReferenceModule.processComment( + Types.ProcessCommentParams({ + profileId: profileId, + transactionExecutor: profileOwner, + pointedProfileId: publisherProfileId, + pointedPubId: publisherPubId, + referrerProfileIds: _emptyUint256Array(), + referrerPubIds: _emptyUint256Array(), + referrerPubTypes: _emptyPubTypesArray(), + data: '' + }) + ); + } + + function testProcessMirror_HoldingEnoughTokens(uint256 publisherProfileId, uint256 publisherPubId) public { + nft.mint({to: profileOwner, nftId: 1}); + assertTrue(nft.balanceOf(profileOwner) >= minThreshold); + + _initialize(publisherProfileId, publisherPubId); + + vm.prank(address(hub)); + tokenGatedReferenceModule.processMirror( + Types.ProcessMirrorParams({ + profileId: profileId, + transactionExecutor: profileOwner, + pointedProfileId: publisherProfileId, + pointedPubId: publisherPubId, + referrerProfileIds: _emptyUint256Array(), + referrerPubIds: _emptyUint256Array(), + referrerPubTypes: _emptyPubTypesArray(), + data: '' + }) + ); + } + + function testProcessQuote_HoldingEnoughTokens(uint256 publisherProfileId, uint256 publisherPubId) public { + nft.mint({to: profileOwner, nftId: 1}); + assertTrue(nft.balanceOf(profileOwner) >= minThreshold); + + _initialize(publisherProfileId, publisherPubId); + + vm.prank(address(hub)); + tokenGatedReferenceModule.processQuote( + Types.ProcessQuoteParams({ + profileId: profileId, + transactionExecutor: profileOwner, + pointedProfileId: publisherProfileId, + pointedPubId: publisherPubId, + referrerProfileIds: _emptyUint256Array(), + referrerPubIds: _emptyUint256Array(), + referrerPubTypes: _emptyPubTypesArray(), + data: '' + }) + ); + } +} diff --git a/test/mocks/NFT.sol b/test/mocks/NFT.sol new file mode 100644 index 0000000..82929b8 --- /dev/null +++ b/test/mocks/NFT.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.13; + +import {ERC721} from '@openzeppelin/contracts/token/ERC721/ERC721.sol'; + +contract NFT is ERC721('NFT', 'NFT') { + function mint(address to, uint256 nftId) external { + _mint(to, nftId); + } +}