├── .editorconfig ├── .env.example ├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .gitmodules ├── .npmignore ├── .prettierignore ├── .prettierrc ├── .solhint.json ├── .solhintignore ├── .vscode └── settings.json ├── README.md ├── addresses.json ├── contracts ├── FeeModuleBase.sol ├── collect │ ├── AaveFeeCollectModule.sol │ ├── AuctionCollectModule.sol │ ├── ERC4626FeeCollectModule.sol │ ├── MultirecipientFeeCollectModule.sol │ ├── SimpleFeeCollectModule.sol │ ├── StepwiseCollectModule.sol │ ├── UpdatableOwnableFeeCollectModule.sol │ └── base │ │ ├── BaseFeeCollectModule.sol │ │ └── IBaseFeeCollectModule.sol ├── interfaces │ └── IPoolDataProvider.sol ├── mocks │ ├── ACurrency.sol │ ├── MockPool.sol │ ├── MockPoolAddressesProvider.sol │ ├── MockVault.sol │ └── NFT.sol └── reference │ ├── DegreesOfSeparationReferenceModule.sol │ └── TokenGatedReferenceModule.sol ├── foundry.toml ├── hardhat.config.ts ├── helper-hardhat-config.ts ├── helpers ├── hardhat-constants.ts ├── test-wallets.ts ├── types.ts └── wallet-helpers.ts ├── package-lock.json ├── package.json ├── remappings.txt ├── script ├── deploy-module.s.sol ├── deploy-module.sh └── helpers │ ├── ForkManagement.sol │ ├── readAddress.js │ ├── readNetwork.js │ └── saveAddress.js ├── tasks ├── deployments │ ├── collect │ │ ├── deploy-ERC4626-fee-collect-module.ts │ │ ├── deploy-aave-fee-collect-module.ts │ │ ├── deploy-auction-collect-module.ts │ │ ├── deploy-stepwise-collect-module.ts │ │ └── deploy-updatable-ownable-fee-collect-module.ts │ └── reference │ │ └── deploy-degrees-of-separation-reference-module.ts └── helpers │ └── utils.ts ├── test ├── __setup.spec.ts ├── foundry │ ├── BaseSetup.t.sol │ ├── collect │ │ ├── BaseFeeCollectModule.base.sol │ │ ├── BaseFeeCollectModule.test.sol │ │ ├── MultirecipientCollectModule.base.sol │ │ ├── MultirecipientCollectModule.test.sol │ │ ├── StepwiseCollectModule.base.sol │ │ └── StepwiseCollectModule.test.sol │ ├── helpers │ │ └── TestHelpers.sol │ └── reference │ │ ├── TokenGatedReferenceModule.base.sol │ │ └── TokenGatedReferenceModule.test.sol ├── helpers │ ├── constants.ts │ ├── errors.ts │ ├── signatures │ │ ├── modules │ │ │ ├── collect │ │ │ │ ├── auction-collect-module.ts │ │ │ │ └── updatable-ownable-fee-collect-module.ts │ │ │ └── reference │ │ │ │ └── degrees-of-separation-reference-module.ts │ │ └── utils.ts │ └── utils.ts └── modules │ ├── collect │ ├── ERC4626-collect-module.spec.ts │ ├── aave-fee-collect-module.spec.ts │ ├── auction-collect-module.spec.ts │ └── updatable-ownable-fee-collect-module.spec.ts │ └── reference │ ├── degrees-of-separation-reference-module.spec.ts │ └── token-gated-reference-module.spec.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | insert_final_newline = true 3 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | ETHERSCAN_API_KEY= 2 | ROPSTEN_URL= 3 | PRIVATE_KEY= 4 | 5 | # FOUNDRY: 6 | export MNEMONIC="" 7 | # Can be either mnemonic or private key (but env var must be here empty even if not used) 8 | # Mnemonic is used first, and if it's an empty string - then private key is used 9 | export PRIVATE_KEY="" 10 | export POLYGON_RPC_URL= 11 | export MUMBAI_RPC_URL= 12 | export BLOCK_EXPLORER_KEY= 13 | export MAINNET_EXPLORER_API=https://api.polygonscan.com/api/ 14 | export TESTNET_EXPLORER_API=https://api-testnet.polygonscan.com/api/ 15 | 16 | # Testing fork: keep empty for local testing, mainnet / testnet / sandbox - for testing against forks 17 | export TESTING_FORK= 18 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | artifacts 3 | cache 4 | coverage 5 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: false, 4 | es2021: true, 5 | mocha: true, 6 | node: true, 7 | }, 8 | plugins: ['@typescript-eslint'], 9 | extends: ['standard', 'plugin:prettier/recommended', 'plugin:node/recommended'], 10 | parser: '@typescript-eslint/parser', 11 | parserOptions: { 12 | ecmaVersion: 12, 13 | }, 14 | rules: { 15 | 'node/no-unsupported-features/es-syntax': ['error', { ignores: ['modules'] }], 16 | 'no-unused-vars': 'warn', 17 | 'prefer-const': 'warn', 18 | eqeqeq: 'warn', 19 | camelcase: 'warn', 20 | 'spaced-comment': 'warn', 21 | 'node/no-unpublished-import': 'warn', 22 | 'node/no-unpublished-require': 'warn', 23 | 'eol-last': 'error', 24 | }, 25 | overrides: [ 26 | { 27 | files: ['*.test.ts', '*.spec.ts'], 28 | rules: { 29 | 'no-unused-expressions': 'off', 30 | }, 31 | }, 32 | { 33 | files: ['*.ts'], 34 | rules: { 35 | 'node/no-extraneous-import': 'off', 36 | }, 37 | }, 38 | ], 39 | settings: { 40 | node: { 41 | tryExtensions: ['.js', '.json', '.node', '.ts', '.d.ts'], 42 | }, 43 | }, 44 | }; 45 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | concurrency: 4 | group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}' 5 | cancel-in-progress: true 6 | 7 | on: 8 | push: 9 | branches: [master] 10 | pull_request: 11 | 12 | jobs: 13 | compile_and_run_tests: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v2 17 | - uses: actions/setup-node@v2 18 | with: 19 | node-version: '16' 20 | - name: Install dependencies 21 | run: npm ci 22 | - name: Compile code and run test coverage 23 | run: npm run coverage 24 | 25 | foundry: 26 | strategy: 27 | fail-fast: true 28 | 29 | name: Foundry Tests 30 | runs-on: ubuntu-latest 31 | steps: 32 | - uses: actions/checkout@v3 33 | with: 34 | submodules: recursive 35 | - uses: actions/setup-node@v3 36 | with: 37 | node-version: 16 38 | - name: Install dependencies 39 | run: npm ci 40 | - name: Install Foundry 41 | uses: foundry-rs/foundry-toolchain@v1 42 | with: 43 | version: nightly 44 | - name: Run Forge build 45 | run: | 46 | forge --version 47 | forge build 48 | - name: Run Forge tests 49 | run: | 50 | cp .env.example .env 51 | source .env 52 | forge test -vvv 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | coverage 4 | coverage.json 5 | typechain 6 | 7 | #Hardhat files 8 | cache 9 | artifacts 10 | 11 | # Compiler files 12 | forge-cache/ 13 | out/ 14 | 15 | broadcast/ 16 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "lib/forge-std"] 2 | path = lib/forge-std 3 | url = https://github.com/foundry-rs/forge-std 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | hardhat.config.ts 2 | scripts 3 | test 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | artifacts 3 | cache 4 | coverage* 5 | gasReporterOutput.json 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "trailingComma": "es5", 4 | "semi": true, 5 | "singleQuote": true, 6 | "tabWidth": 2, 7 | "overrides": [ 8 | { 9 | "files": "*.sol", 10 | "options": { 11 | "semi": true, 12 | "printWidth": 100, 13 | "tabWidth": 4 14 | } 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.solhint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "solhint:recommended", 3 | "rules": { 4 | "quotes": "single", 5 | "compiler-version": ["error", "^0.8.0"], 6 | "func-visibility": ["warn", { "ignoreConstructors": true }] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.solhintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.insertFinalNewline": true, 3 | "files.trimFinalNewlines": true, 4 | "solidity.packageDefaultDependenciesContractsDirectory": "src", 5 | "solidity.packageDefaultDependenciesDirectory": "lib" 6 | } 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # lens-modules 2 | 3 | Repository for adding Lens Protocol collect, follow and reference modules. 4 | 5 | **To have your module added to Lens Testnet or Mainnet please open a PR and follow the instructions here: #TODO** 6 | 7 | ## Installation 8 | 9 | 1. `npm install` 10 | 2. If you also want to use Foundry - follow the Foundry installation instructions [here](https://getfoundry.sh/). 11 | 12 | ## Testing 13 | 14 | This repository contains both - Hardhat and Foundry tests. Foundry will be used for all future modules, and existing modules will be migrated to Foundry testing suite. 15 | 16 | ### Hardhat 17 | 18 | 1. `npm run test` will compile and run the Hardhat tests suite 19 | 20 | ### Foundry 21 | 22 | 1. `forge test` will compile and run the Foundry tests suite. 23 | 24 | ### Foundry tests against forks 25 | 26 | 1. Edit `TESTING_FORK` .env variable to be one of `mainnet/testnet/sandbox` and fill the rest of .env (`FOUNDRY` section) 27 | 2. If a module is already deployed and its address exists in `addresses.json` - tests will be run against that deployment. If there is no module in json - a new local instance of the module will be deployed. Remove the module key from `addresses.json` if you want to force testing a local module deployment. 28 | 3. Run `forge test` to fork the chosen network and test against existing LensHub contracts. 29 | 30 | ## Deployment 31 | 32 | 1. Make sure to fill in the `.env` using `.env.example` (the `Foundry` section). You can specify either a `MNEMONIC` or a single `PRIVATE_KEY` (make sure to include both variables, even if one of them is an empty string) 33 | 2. Run deployment script with a command like `bash script/deploy-module.sh testnet StepwiseCollectModule` from the project root folder (e.g. to deploy `StepwiseCollectModule` on `testnet`). 34 | 3. Follow the on-screen instructions to verify if everything is correct and confirm deployment & contract verification. 35 | 4. If only the verification is needed of an existing deployed contract - use the `--verify-only` flag followed by ABI-Encoded constructor args. 36 | 37 | ## Deployement addresses in `addresses.json` 38 | 39 | The `addresses.json` file in root contains all existing deployed contracts on all of target environments (mainnet/testnet/sandbox) on corresponding chains. 40 | After a succesful module deployment the new address will be added to `addresses.json`, overwriting the existing one (the script will ask for confirmation if you want to redeploy an already existing deployment). 41 | 42 | ## Coverage 43 | 44 | 1. `npm run coverage` for Hardhat coverage report 45 | 2. `forge coverage` for Foundry coverage report 46 | 47 | # Modules 48 | 49 | ## Collect modules 50 | 51 | - [**Aave Fee Collect Module**](./contracts/collect/AaveFeeCollectModule.sol): Extend the LimitedFeeCollectModule to deposit all received fees into the Aave Polygon Market (if applicable for the asset) and send the resulting aTokens to the beneficiary. 52 | - [**Auction Collect Module**](./contracts/collect/AuctionCollectModule.sol): This module works by creating an English auction for the underlying publication. After the auction ends, only the auction winner is allowed to collect the publication. 53 | - [**Base Fee Collect Module**](./contracts/collect/base/BaseFeeCollectModule.sol): An abstract base fee collect module contract which can be used to construct flexible fee collect modules using inheritance. 54 | - [**Multirecipient Fee Collect Module**](./contracts/collect/MultirecipientFeeCollectModule.sol): Fee Collect module that allows multiple recipients (up to 5) with different proportions of fees payout. 55 | - [**Simple Fee Collect Module**](./contracts/collect/SimpleFeeCollectModule.sol): A simple fee collect module implementation, as an example of using base fee collect module abstract contract. 56 | - [**Updatable Ownable Fee Collect Module**](./contracts/collect/UpdatableOwnableFeeCollectModule.sol): A fee collect module that, for each publication that uses it, mints an ERC-721 ownership-NFT to its author. Whoever owns the ownership-NFT has the rights to update the parameters required to do a successful collect operation over its underlying publication. 57 | 58 | ## Follow modules 59 | 60 | ## Reference modules 61 | 62 | - [**Degrees Of Separation Reference Module**](./contracts/reference/DegreesOfSeparationReferenceModule.sol): This reference module allows to set a degree of separation `n`, and then allows to comment/mirror only to profiles that are at most at `n` degrees of separation from the author of the root publication. 63 | - [**Token Gated Reference Module**](./contracts/reference/TokenGatedReferenceModule.sol): A reference module that validates that the user who tries to reference has a required minimum balance of ERC20/ERC721 token. 64 | -------------------------------------------------------------------------------- /addresses.json: -------------------------------------------------------------------------------- 1 | { 2 | "mainnet": { 3 | "chainId": 137, 4 | "network": "polygon", 5 | "LensHubProxy": "0xDb46d1Dc155634FbC732f92E853b10B288AD5a1d", 6 | "LensHubImplementation": "0x96f1ba24294ffe0dfcd832d8376da4a4645a4cd6", 7 | "ModuleGlobals": "0x3Df697FF746a60CBe9ee8D47555c88CB66f03BB9", 8 | "FreeCollectModule": "0x23b9467334bEb345aAa6fd1545538F3d54436e96", 9 | "PoolAddressesProvider": "0xa97684ead0e402dC232d5A977953DF7ECBaB3CDb", 10 | "MultirecipientFeeCollectModule": "0xfa9da21d0a18c7b7de4566481c1e8952371f880a", 11 | "StepwiseCollectModule": "0x210c94877dcfceda8238acd382372278844a54d7", 12 | "ERC4626FeeCollectModule": "0x394fd100b13197787023ef4cf318cde456545db7", 13 | "AaveFeeCollectModule": "0xa94713d0688c8a483c3352635cec4e0ce88d6a29", 14 | "TokenGatedReferenceModule": "0x3d7f4f71a90fe5a9d13fab2716080f2917cf88f3" 15 | }, 16 | "testnet": { 17 | "chainId": 80001, 18 | "network": "mumbai", 19 | "LensHubProxy": "0x60Ae865ee4C725cd04353b5AAb364553f56ceF82", 20 | "LensHubImplementation": "0x1bF5788bEaa62Bc2C2De78523630263312040F77", 21 | "ModuleGlobals": "0x1353aAdfE5FeD85382826757A95DE908bd21C4f9", 22 | "FreeCollectModule": "0x0BE6bD7092ee83D44a6eC1D949626FeE48caB30c", 23 | "PoolAddressesProvider": "0x5343b5bA672Ae99d627A1C87866b8E53F47Db2E6", 24 | "MultirecipientFeeCollectModule": "0x99d6c3eabf05435e851c067d2c3222716f7fcfe5", 25 | "StepwiseCollectModule": "0x7a7b8e7699e0492da1d3c7eab7e2f3bf1065aa40", 26 | "ERC4626FeeCollectModule": "0x79697402bd2caa19a53d615fb1a30a98e35b84d5", 27 | "AaveFeeCollectModule": "0x912860ed4ed6160c48a52d52fcab5c059d34fe5a", 28 | "TokenGatedReferenceModule": "0xb4ba8dccd35bd3dcc5d58dbb9c7dff9c9268add9" 29 | }, 30 | "sandbox": { 31 | "chainId": 80001, 32 | "network": "mumbai", 33 | "LensHubProxy": "0x7582177F9E536aB0b6c721e11f383C326F2Ad1D5", 34 | "LensHubImplementation": "0x1b30F214c192EF4B7F8c9926c47C4161016955DA", 35 | "ModuleGlobals": "0xcbCC5b9611d22d11403373432642Df9Ef7Dd81AD", 36 | "FreeCollectModule": "0x11C45Cbc6fDa2dbe435C0079a2ccF9c4c7051595", 37 | "PoolAddressesProvider": "0x5343b5bA672Ae99d627A1C87866b8E53F47Db2E6", 38 | "MultirecipientFeeCollectModule": "0x1cff6c45b0de2fff70670ef4dc67a92a1ccfe0bb", 39 | "StepwiseCollectModule": "0x6928d6127dfa0da401737e6ff421fcf62d5617a3", 40 | "ERC4626FeeCollectModule": "0x31126c602cf88193825a99dcd1d17bf1124b1b4f", 41 | "AaveFeeCollectModule": "0x666e06215747879ee68b3e5a317dcd8411de1897", 42 | "TokenGatedReferenceModule": "0x86d35562ceb9f10d7c2c23c098dfeacb02f53853" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /contracts/FeeModuleBase.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.10; 4 | 5 | import {Errors} from '@aave/lens-protocol/contracts/libraries/Errors.sol'; 6 | import {Events} from '@aave/lens-protocol/contracts/libraries/Events.sol'; 7 | import {IModuleGlobals} from '@aave/lens-protocol/contracts/interfaces/IModuleGlobals.sol'; 8 | 9 | /** 10 | * @title FeeModuleBase 11 | * @author Lens Protocol 12 | * 13 | * @notice This is an abstract contract to be inherited from by modules that require basic fee functionality. It 14 | * contains getters for module globals parameters as well as a validation function to check expected data. 15 | */ 16 | abstract contract FeeModuleBase { 17 | uint16 internal constant BPS_MAX = 10000; 18 | 19 | address public immutable MODULE_GLOBALS; 20 | 21 | constructor(address moduleGlobals) { 22 | if (moduleGlobals == address(0)) revert Errors.InitParamsInvalid(); 23 | MODULE_GLOBALS = moduleGlobals; 24 | emit Events.FeeModuleBaseConstructed(moduleGlobals, block.timestamp); 25 | } 26 | 27 | function _currencyWhitelisted(address currency) internal view returns (bool) { 28 | return IModuleGlobals(MODULE_GLOBALS).isCurrencyWhitelisted(currency); 29 | } 30 | 31 | function _treasuryData() internal view returns (address, uint16) { 32 | return IModuleGlobals(MODULE_GLOBALS).getTreasuryData(); 33 | } 34 | 35 | function _validateDataIsExpected( 36 | bytes calldata data, 37 | address currency, 38 | uint256 amount 39 | ) internal pure virtual { 40 | (address decodedCurrency, uint256 decodedAmount) = abi.decode(data, (address, uint256)); 41 | if (decodedAmount != amount || decodedCurrency != currency) 42 | revert Errors.ModuleDataMismatch(); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /contracts/collect/AaveFeeCollectModule.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-only 2 | 3 | pragma solidity 0.8.10; 4 | 5 | import {Errors} from '@aave/lens-protocol/contracts/libraries/Errors.sol'; 6 | import {FeeModuleBase} from '@aave/lens-protocol/contracts/core/modules/FeeModuleBase.sol'; 7 | import {ICollectModule} from '@aave/lens-protocol/contracts/interfaces/ICollectModule.sol'; 8 | import {ModuleBase} from '@aave/lens-protocol/contracts/core/modules/ModuleBase.sol'; 9 | import {FollowValidationModuleBase} from '@aave/lens-protocol/contracts/core/modules/FollowValidationModuleBase.sol'; 10 | 11 | import {IPoolAddressesProvider} from '@aave/core-v3/contracts/interfaces/IPoolAddressesProvider.sol'; 12 | import {IPoolDataProvider} from '../interfaces/IPoolDataProvider.sol'; 13 | import {IPool} from '@aave/core-v3/contracts/interfaces/IPool.sol'; 14 | 15 | import {EIP712} from '@openzeppelin/contracts/utils/cryptography/draft-EIP712.sol'; 16 | import {IERC20} from '@openzeppelin/contracts/token/ERC20/IERC20.sol'; 17 | import {IERC721} from '@openzeppelin/contracts/token/ERC721/IERC721.sol'; 18 | import {SafeERC20} from '@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol'; 19 | 20 | /** 21 | * @notice A struct containing the necessary data to execute collect actions on a publication. 22 | * 23 | * @param amount The collecting cost associated with this publication. 24 | * @param currency The currency associated with this publication. 25 | * @param collectLimit The maximum number of collects for this publication. 0 for no limit. 26 | * @param currentCollects The current number of collects for this publication. 27 | * @param recipient The recipient address associated with this publication. 28 | * @param referralFee The referral fee associated with this publication. 29 | * @param followerOnly True if only followers of publisher may collect the post. 30 | * @param endTimestamp The end timestamp after which collecting is impossible. 0 for no expiry. 31 | */ 32 | struct ProfilePublicationData { 33 | uint256 amount; 34 | address currency; 35 | uint96 collectLimit; 36 | uint96 currentCollects; 37 | address recipient; 38 | uint16 referralFee; 39 | bool followerOnly; 40 | uint72 endTimestamp; 41 | } 42 | 43 | /** 44 | * @title AaveFeeCollectModule 45 | * @author Lens Protocol 46 | * 47 | * @notice Extend the LimitedFeeCollectModule to deposit all received fees into the Aave Polygon Market (if applicable for the asset) and send the resulting aTokens to the beneficiary. 48 | */ 49 | contract AaveFeeCollectModule is FeeModuleBase, FollowValidationModuleBase, ICollectModule { 50 | using SafeERC20 for IERC20; 51 | 52 | // Pool Address Provider on Polygon for Aave v3 - set in constructor 53 | IPoolAddressesProvider public immutable POOL_ADDRESSES_PROVIDER; 54 | 55 | address public aavePool; 56 | 57 | mapping(uint256 => mapping(uint256 => ProfilePublicationData)) 58 | internal _dataByPublicationByProfile; 59 | 60 | constructor( 61 | address hub, 62 | address moduleGlobals, 63 | IPoolAddressesProvider poolAddressesProvider 64 | ) ModuleBase(hub) FeeModuleBase(moduleGlobals) { 65 | POOL_ADDRESSES_PROVIDER = poolAddressesProvider; 66 | 67 | // Retrieve Aave pool address on module deployment 68 | aavePool = POOL_ADDRESSES_PROVIDER.getPool(); 69 | } 70 | 71 | /** 72 | * @dev Anyone can call this function to update Aave v3 addresses. 73 | */ 74 | function updateAavePoolAddress() public { 75 | aavePool = POOL_ADDRESSES_PROVIDER.getPool(); 76 | } 77 | 78 | /** 79 | * @notice This collect module levies a fee on collects and supports referrals. Thus, we need to decode data. 80 | * 81 | * @param data The arbitrary data parameter, decoded into: 82 | * uint96 collectLimit: The maximum amount of collects. 83 | * uint256 amount: The currency total amount to levy. 84 | * address currency: The currency address, must be internally whitelisted. 85 | * address recipient: The custom recipient address to direct earnings to. 86 | * uint16 referralFee: The referral fee to set. 87 | * bool followerOnly: Whether only followers should be able to collect. 88 | * uint72 endTimestamp: The end timestamp after which collecting is impossible. 89 | * 90 | * @return An abi encoded bytes parameter, which is the same as the passed data parameter. 91 | */ 92 | function initializePublicationCollectModule( 93 | uint256 profileId, 94 | uint256 pubId, 95 | bytes calldata data 96 | ) external override onlyHub returns (bytes memory) { 97 | ( 98 | uint96 collectLimit, 99 | uint256 amount, 100 | address currency, 101 | address recipient, 102 | uint16 referralFee, 103 | bool followerOnly, 104 | uint72 endTimestamp 105 | ) = abi.decode(data, (uint96, uint256, address, address, uint16, bool, uint72)); 106 | if ( 107 | !_currencyWhitelisted(currency) || 108 | recipient == address(0) || 109 | referralFee > BPS_MAX || 110 | (endTimestamp < block.timestamp && endTimestamp > 0) 111 | ) revert Errors.InitParamsInvalid(); 112 | 113 | _dataByPublicationByProfile[profileId][pubId].collectLimit = collectLimit; 114 | _dataByPublicationByProfile[profileId][pubId].amount = amount; 115 | _dataByPublicationByProfile[profileId][pubId].currency = currency; 116 | _dataByPublicationByProfile[profileId][pubId].recipient = recipient; 117 | _dataByPublicationByProfile[profileId][pubId].referralFee = referralFee; 118 | _dataByPublicationByProfile[profileId][pubId].followerOnly = followerOnly; 119 | _dataByPublicationByProfile[profileId][pubId].endTimestamp = endTimestamp; 120 | 121 | return data; 122 | } 123 | 124 | /** 125 | * @dev Processes a collect by: 126 | * 1. Ensuring the collector is a follower if followerOnly mode == true 127 | * 2. Ensuring the current timestamp is less than or equal to the collect end timestamp 128 | * 2. Ensuring the collect does not pass the collect limit 129 | * 3. Charging a fee 130 | */ 131 | function processCollect( 132 | uint256 referrerProfileId, 133 | address collector, 134 | uint256 profileId, 135 | uint256 pubId, 136 | bytes calldata data 137 | ) external override onlyHub { 138 | if (_dataByPublicationByProfile[profileId][pubId].followerOnly) 139 | _checkFollowValidity(profileId, collector); 140 | 141 | uint256 endTimestamp = _dataByPublicationByProfile[profileId][pubId].endTimestamp; 142 | uint256 collectLimit = _dataByPublicationByProfile[profileId][pubId].collectLimit; 143 | uint96 currentCollects = _dataByPublicationByProfile[profileId][pubId].currentCollects; 144 | 145 | if (collectLimit != 0 && currentCollects == collectLimit) { 146 | revert Errors.MintLimitExceeded(); 147 | } else if (block.timestamp > endTimestamp && endTimestamp != 0) { 148 | revert Errors.CollectExpired(); 149 | } else { 150 | _dataByPublicationByProfile[profileId][pubId].currentCollects = ++currentCollects; 151 | if (referrerProfileId == profileId) { 152 | _processCollect(collector, profileId, pubId, data); 153 | } else { 154 | _processCollectWithReferral(referrerProfileId, collector, profileId, pubId, data); 155 | } 156 | } 157 | } 158 | 159 | /** 160 | * @notice Returns the publication data for a given publication, or an empty struct if that publication was not 161 | * initialized with this module. 162 | * 163 | * @param profileId The token ID of the profile mapped to the publication to query. 164 | * @param pubId The publication ID of the publication to query. 165 | * 166 | * @return The ProfilePublicationData struct mapped to that publication. 167 | */ 168 | function getPublicationData(uint256 profileId, uint256 pubId) 169 | external 170 | view 171 | returns (ProfilePublicationData memory) 172 | { 173 | return _dataByPublicationByProfile[profileId][pubId]; 174 | } 175 | 176 | function _processCollect( 177 | address collector, 178 | uint256 profileId, 179 | uint256 pubId, 180 | bytes calldata data 181 | ) internal { 182 | uint256 amount = _dataByPublicationByProfile[profileId][pubId].amount; 183 | address currency = _dataByPublicationByProfile[profileId][pubId].currency; 184 | _validateDataIsExpected(data, currency, amount); 185 | 186 | (address treasury, uint16 treasuryFee) = _treasuryData(); 187 | address recipient = _dataByPublicationByProfile[profileId][pubId].recipient; 188 | uint256 treasuryAmount = (amount * treasuryFee) / BPS_MAX; 189 | 190 | _transferFromAndDepositToAaveIfApplicable( 191 | currency, 192 | collector, 193 | recipient, 194 | amount - treasuryAmount 195 | ); 196 | 197 | if (treasuryAmount > 0) { 198 | IERC20(currency).safeTransferFrom(collector, treasury, treasuryAmount); 199 | } 200 | } 201 | 202 | function _transferFromAndDepositToAaveIfApplicable( 203 | address currency, 204 | address from, 205 | address beneficiary, 206 | uint256 amount 207 | ) internal { 208 | // First, transfer funds to this contract 209 | IERC20(currency).safeTransferFrom(from, address(this), amount); 210 | IERC20(currency).approve(aavePool, amount); 211 | 212 | // Then, attempt to supply funds in Aave v3, sending aTokens to beneficiary 213 | try IPool(aavePool).supply(currency, amount, beneficiary, 0) {} catch { 214 | // If supply() above fails, send funds directly to beneficiary 215 | IERC20(currency).safeTransfer(beneficiary, amount); 216 | } 217 | } 218 | 219 | function _processCollectWithReferral( 220 | uint256 referrerProfileId, 221 | address collector, 222 | uint256 profileId, 223 | uint256 pubId, 224 | bytes calldata data 225 | ) internal { 226 | uint256 amount = _dataByPublicationByProfile[profileId][pubId].amount; 227 | address currency = _dataByPublicationByProfile[profileId][pubId].currency; 228 | _validateDataIsExpected(data, currency, amount); 229 | 230 | uint256 referralFee = _dataByPublicationByProfile[profileId][pubId].referralFee; 231 | address treasury; 232 | uint256 treasuryAmount; 233 | 234 | // Avoids stack too deep 235 | { 236 | uint16 treasuryFee; 237 | (treasury, treasuryFee) = _treasuryData(); 238 | treasuryAmount = (amount * treasuryFee) / BPS_MAX; 239 | } 240 | 241 | uint256 adjustedAmount = amount - treasuryAmount; 242 | 243 | if (referralFee != 0) { 244 | // The reason we levy the referral fee on the adjusted amount is so that referral fees 245 | // don't bypass the treasury fee, in essence referrals pay their fair share to the treasury. 246 | uint256 referralAmount = (adjustedAmount * referralFee) / BPS_MAX; 247 | adjustedAmount = adjustedAmount - referralAmount; 248 | 249 | address referralRecipient = IERC721(HUB).ownerOf(referrerProfileId); 250 | 251 | // Send referral fee in normal ERC20 tokens 252 | IERC20(currency).safeTransferFrom(collector, referralRecipient, referralAmount); 253 | } 254 | address recipient = _dataByPublicationByProfile[profileId][pubId].recipient; 255 | 256 | _transferFromAndDepositToAaveIfApplicable(currency, collector, recipient, adjustedAmount); 257 | 258 | if (treasuryAmount > 0) { 259 | IERC20(currency).safeTransferFrom(collector, treasury, treasuryAmount); 260 | } 261 | } 262 | } 263 | -------------------------------------------------------------------------------- /contracts/collect/ERC4626FeeCollectModule.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.10; 4 | 5 | import {Errors} from '@aave/lens-protocol/contracts/libraries/Errors.sol'; 6 | import {FeeModuleBase} from '@aave/lens-protocol/contracts/core/modules/FeeModuleBase.sol'; 7 | import {ICollectModule} from '@aave/lens-protocol/contracts/interfaces/ICollectModule.sol'; 8 | import {ModuleBase} from '@aave/lens-protocol/contracts/core/modules/ModuleBase.sol'; 9 | import {FollowValidationModuleBase} from '@aave/lens-protocol/contracts/core/modules/FollowValidationModuleBase.sol'; 10 | 11 | import {IERC4626} from '@openzeppelin/contracts/interfaces/IERC4626.sol'; 12 | import {IERC20} from '@openzeppelin/contracts/token/ERC20/IERC20.sol'; 13 | import {IERC721} from '@openzeppelin/contracts/token/ERC721/IERC721.sol'; 14 | import {SafeERC20} from '@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol'; 15 | 16 | /** 17 | * @notice A struct containing the necessary data to execute collect actions on a publication. 18 | * 19 | * @param collectLimit The maximum number of collects for this publication. 20 | * @param currentCollects The current number of collects for this publication. 21 | * @param amount The collecting cost associated with this publication. 22 | * @param vault The ERC4626 compatible vault in which fees are deposited. 23 | * @param currency The currency associated with this publication. 24 | * @param recipient The recipient address associated with this publication. 25 | * @param referralFee The referral fee associated with this publication. 26 | * @param endTimestamp The end timestamp after which collecting is impossible. 27 | */ 28 | struct ProfilePublicationData { 29 | uint256 amount; 30 | address vault; // ERC4626 Vault in which fees are deposited 31 | uint96 collectLimit; 32 | address currency; 33 | uint96 currentCollects; 34 | address recipient; 35 | uint16 referralFee; 36 | bool followerOnly; 37 | uint72 endTimestamp; 38 | } 39 | 40 | /** 41 | * @title ERC4626FeeCollectModule 42 | * @author Lens Protocol 43 | * 44 | * @notice Extend the LimitedFeeCollectModule to deposit all received fees into an ERC-4626 compatible vault and send the resulting shares to the beneficiary. 45 | */ 46 | contract ERC4626FeeCollectModule is FeeModuleBase, FollowValidationModuleBase, ICollectModule { 47 | using SafeERC20 for IERC20; 48 | 49 | mapping(uint256 => mapping(uint256 => ProfilePublicationData)) 50 | internal _dataByPublicationByProfile; 51 | 52 | constructor(address hub, address moduleGlobals) ModuleBase(hub) FeeModuleBase(moduleGlobals) {} 53 | 54 | /** 55 | * @notice This collect module levies a fee on collects and supports referrals. Thus, we need to decode data. 56 | * 57 | * @param data The arbitrary data parameter, decoded into: 58 | * uint96 collectLimit: The maximum amount of collects. 0 for no limit. 59 | * uint256 amount: The currency total amount to levy. 60 | * address vault: The ERC4626 compatible vault in which fees are deposited. 61 | * address recipient: The custom recipient address to direct earnings to. 62 | * uint16 referralFee: The referral fee to set. 63 | * bool followerOnly: Whether only followers should be able to collect. 64 | * uint72 endTimestamp: The end timestamp after which collecting is impossible. 0 for no expiry. 65 | * 66 | * @return An abi encoded bytes parameter, which is the same as the passed data parameter. 67 | */ 68 | function initializePublicationCollectModule( 69 | uint256 profileId, 70 | uint256 pubId, 71 | bytes calldata data 72 | ) external override onlyHub returns (bytes memory) { 73 | ( 74 | uint96 collectLimit, 75 | uint256 amount, 76 | address vault, 77 | address recipient, 78 | uint16 referralFee, 79 | bool followerOnly, 80 | uint72 endTimestamp 81 | ) = abi.decode(data, (uint96, uint256, address, address, uint16, bool, uint72)); 82 | 83 | // Get fee currency from vault's asset instead of publication params 84 | address currency = IERC4626(vault).asset(); 85 | 86 | if ( 87 | !_currencyWhitelisted(currency) || 88 | vault == address(0) || 89 | recipient == address(0) || 90 | referralFee > BPS_MAX || 91 | (endTimestamp < block.timestamp && endTimestamp > 0) 92 | ) revert Errors.InitParamsInvalid(); 93 | 94 | _dataByPublicationByProfile[profileId][pubId].collectLimit = collectLimit; 95 | _dataByPublicationByProfile[profileId][pubId].amount = amount; 96 | _dataByPublicationByProfile[profileId][pubId].vault = vault; 97 | _dataByPublicationByProfile[profileId][pubId].currency = currency; 98 | _dataByPublicationByProfile[profileId][pubId].recipient = recipient; 99 | _dataByPublicationByProfile[profileId][pubId].referralFee = referralFee; 100 | _dataByPublicationByProfile[profileId][pubId].followerOnly = followerOnly; 101 | _dataByPublicationByProfile[profileId][pubId].endTimestamp = endTimestamp; 102 | 103 | return data; 104 | } 105 | 106 | /** 107 | * @dev Processes a collect by: 108 | * 1. Ensuring the collector is a follower if followerOnly mode == true 109 | * 2. Ensuring the current timestamp is less than or equal to the collect end timestamp 110 | * 2. Ensuring the collect does not pass the collect limit 111 | * 3. Charging a fee 112 | * 113 | * @inheritdoc ICollectModule 114 | */ 115 | function processCollect( 116 | uint256 referrerProfileId, 117 | address collector, 118 | uint256 profileId, 119 | uint256 pubId, 120 | bytes calldata data 121 | ) external override onlyHub { 122 | if (_dataByPublicationByProfile[profileId][pubId].followerOnly) 123 | _checkFollowValidity(profileId, collector); 124 | 125 | uint256 endTimestamp = _dataByPublicationByProfile[profileId][pubId].endTimestamp; 126 | uint256 collectLimit = _dataByPublicationByProfile[profileId][pubId].collectLimit; 127 | uint96 currentCollects = _dataByPublicationByProfile[profileId][pubId].currentCollects; 128 | 129 | if (collectLimit != 0 && currentCollects >= collectLimit) { 130 | revert Errors.MintLimitExceeded(); 131 | } else if (block.timestamp > endTimestamp && endTimestamp != 0) { 132 | revert Errors.CollectExpired(); 133 | } else { 134 | _dataByPublicationByProfile[profileId][pubId].currentCollects = ++currentCollects; 135 | if (referrerProfileId == profileId) { 136 | _processCollect(collector, profileId, pubId, data); 137 | } else { 138 | _processCollectWithReferral(referrerProfileId, collector, profileId, pubId, data); 139 | } 140 | } 141 | } 142 | 143 | /** 144 | * @notice Returns the publication data for a given publication, or an empty struct if that publication was not 145 | * initialized with this module. 146 | * 147 | * @param profileId The token ID of the profile mapped to the publication to query. 148 | * @param pubId The publication ID of the publication to query. 149 | * 150 | * @return The ProfilePublicationData struct mapped to that publication. 151 | */ 152 | function getPublicationData(uint256 profileId, uint256 pubId) 153 | external 154 | view 155 | returns (ProfilePublicationData memory) 156 | { 157 | return _dataByPublicationByProfile[profileId][pubId]; 158 | } 159 | 160 | function _processCollect( 161 | address collector, 162 | uint256 profileId, 163 | uint256 pubId, 164 | bytes calldata data 165 | ) internal { 166 | uint256 amount = _dataByPublicationByProfile[profileId][pubId].amount; 167 | address currency = _dataByPublicationByProfile[profileId][pubId].currency; 168 | _validateDataIsExpected(data, currency, amount); 169 | 170 | address vault = _dataByPublicationByProfile[profileId][pubId].vault; 171 | 172 | (address treasury, uint16 treasuryFee) = _treasuryData(); 173 | address recipient = _dataByPublicationByProfile[profileId][pubId].recipient; 174 | uint256 treasuryAmount = (amount * treasuryFee) / BPS_MAX; 175 | 176 | _transferFromAndDepositInVaultIfApplicable( 177 | currency, 178 | vault, 179 | collector, 180 | recipient, 181 | amount - treasuryAmount 182 | ); 183 | 184 | if (treasuryAmount > 0) { 185 | IERC20(currency).safeTransferFrom(collector, treasury, treasuryAmount); 186 | } 187 | } 188 | 189 | function _processCollectWithReferral( 190 | uint256 referrerProfileId, 191 | address collector, 192 | uint256 profileId, 193 | uint256 pubId, 194 | bytes calldata data 195 | ) internal { 196 | uint256 amount = _dataByPublicationByProfile[profileId][pubId].amount; 197 | address currency = _dataByPublicationByProfile[profileId][pubId].currency; 198 | _validateDataIsExpected(data, currency, amount); 199 | 200 | uint256 referralFee = _dataByPublicationByProfile[profileId][pubId].referralFee; 201 | address treasury; 202 | uint256 treasuryAmount; 203 | 204 | // Avoids stack too deep 205 | { 206 | uint16 treasuryFee; 207 | (treasury, treasuryFee) = _treasuryData(); 208 | treasuryAmount = (amount * treasuryFee) / BPS_MAX; 209 | } 210 | 211 | uint256 adjustedAmount = amount - treasuryAmount; 212 | 213 | if (referralFee != 0) { 214 | // The reason we levy the referral fee on the adjusted amount is so that referral fees 215 | // don't bypass the treasury fee, in essence referrals pay their fair share to the treasury. 216 | uint256 referralAmount = (adjustedAmount * referralFee) / BPS_MAX; 217 | adjustedAmount = adjustedAmount - referralAmount; 218 | 219 | address referralRecipient = IERC721(HUB).ownerOf(referrerProfileId); 220 | 221 | // Send referral fee in normal ERC20 tokens 222 | IERC20(currency).safeTransferFrom(collector, referralRecipient, referralAmount); 223 | } 224 | address recipient = _dataByPublicationByProfile[profileId][pubId].recipient; 225 | 226 | _transferFromAndDepositInVaultIfApplicable( 227 | currency, 228 | _dataByPublicationByProfile[profileId][pubId].vault, 229 | collector, 230 | recipient, 231 | adjustedAmount 232 | ); 233 | 234 | if (treasuryAmount > 0) { 235 | IERC20(currency).safeTransferFrom(collector, treasury, treasuryAmount); 236 | } 237 | } 238 | 239 | function _transferFromAndDepositInVaultIfApplicable( 240 | address currency, 241 | address vault, 242 | address from, 243 | address beneficiary, 244 | uint256 amount 245 | ) internal { 246 | // First, transfer funds to this contract 247 | IERC20(currency).safeTransferFrom(from, address(this), amount); 248 | IERC20(currency).approve(vault, amount); 249 | 250 | // Then, attempt to deposit funds in vault, sending shares to beneficiary 251 | try IERC4626(vault).deposit(amount, beneficiary) {} catch { 252 | // If deposit() above fails, send funds directly to beneficiary 253 | IERC20(currency).safeTransfer(beneficiary, amount); 254 | } 255 | } 256 | } 257 | -------------------------------------------------------------------------------- /contracts/collect/MultirecipientFeeCollectModule.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.10; 4 | 5 | import {Errors} from '@aave/lens-protocol/contracts/libraries/Errors.sol'; 6 | import {BaseFeeCollectModule} from './base/BaseFeeCollectModule.sol'; 7 | import {BaseProfilePublicationData, BaseFeeCollectModuleInitData} from './base/IBaseFeeCollectModule.sol'; 8 | import {IERC20} from '@openzeppelin/contracts/token/ERC20/IERC20.sol'; 9 | import {SafeERC20} from '@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol'; 10 | import {ICollectModule} from '@aave/lens-protocol/contracts/interfaces/ICollectModule.sol'; 11 | 12 | struct RecipientData { 13 | address recipient; 14 | uint16 split; // fraction of BPS_MAX (10 000) 15 | } 16 | 17 | /** 18 | * @notice A struct containing the necessary data to initialize MultirecipientFeeCollectModule. 19 | * 20 | * @param amount The collecting cost associated with this publication. Cannot be 0. 21 | * @param collectLimit The maximum number of collects for this publication. 0 for no limit. 22 | * @param currency The currency associated with this publication. 23 | * @param referralFee The referral fee associated with this publication. 24 | * @param followerOnly True if only followers of publisher may collect the post. 25 | * @param endTimestamp The end timestamp after which collecting is impossible. 0 for no expiry. 26 | * @param recipients Array of RecipientData items to split collect fees across multiple recipients. 27 | */ 28 | struct MultirecipientFeeCollectModuleInitData { 29 | uint160 amount; 30 | uint96 collectLimit; 31 | address currency; 32 | uint16 referralFee; 33 | bool followerOnly; 34 | uint72 endTimestamp; 35 | RecipientData[] recipients; 36 | } 37 | 38 | /** 39 | * @notice A struct containing the necessary data to execute collect actions on a publication. 40 | * 41 | * @param amount The collecting cost associated with this publication. Cannot be 0. 42 | * @param collectLimit The maximum number of collects for this publication. 0 for no limit. 43 | * @param currency The currency associated with this publication. 44 | * @param currentCollects The current number of collects for this publication. 45 | * @param referralFee The referral fee associated with this publication. 46 | * @param followerOnly True if only followers of publisher may collect the post. 47 | * @param endTimestamp The end timestamp after which collecting is impossible. 0 for no expiry. 48 | * @param recipients Array of RecipientData items to split collect fees across multiple recipients. 49 | */ 50 | struct MultirecipientFeeCollectProfilePublicationData { 51 | uint160 amount; 52 | uint96 collectLimit; 53 | address currency; 54 | uint96 currentCollects; 55 | uint16 referralFee; 56 | bool followerOnly; 57 | uint72 endTimestamp; 58 | RecipientData[] recipients; 59 | } 60 | 61 | error TooManyRecipients(); 62 | error InvalidRecipientSplits(); 63 | error RecipientSplitCannotBeZero(); 64 | 65 | /** 66 | * @title MultirecipientCollectModule 67 | * @author Lens Protocol 68 | * 69 | * @notice This is a simple Lens CollectModule implementation, allowing customization of time to collect, number of collects, 70 | * splitting collect fee across multiple recipients, and whether only followers can collect. 71 | * It is charging a fee for collect and distributing it among (one or up to five) Receivers, Referral, Treasury. 72 | */ 73 | contract MultirecipientFeeCollectModule is BaseFeeCollectModule { 74 | using SafeERC20 for IERC20; 75 | 76 | uint256 internal constant MAX_RECIPIENTS = 5; 77 | 78 | mapping(uint256 => mapping(uint256 => RecipientData[])) 79 | internal _recipientsByPublicationByProfile; 80 | 81 | constructor(address hub, address moduleGlobals) BaseFeeCollectModule(hub, moduleGlobals) {} 82 | 83 | /** 84 | * @inheritdoc ICollectModule 85 | */ 86 | function initializePublicationCollectModule( 87 | uint256 profileId, 88 | uint256 pubId, 89 | bytes calldata data 90 | ) external override onlyHub returns (bytes memory) { 91 | MultirecipientFeeCollectModuleInitData memory initData = abi.decode( 92 | data, 93 | (MultirecipientFeeCollectModuleInitData) 94 | ); 95 | 96 | BaseFeeCollectModuleInitData memory baseInitData = BaseFeeCollectModuleInitData({ 97 | amount: initData.amount, 98 | collectLimit: initData.collectLimit, 99 | currency: initData.currency, 100 | referralFee: initData.referralFee, 101 | followerOnly: initData.followerOnly, 102 | endTimestamp: initData.endTimestamp, 103 | recipient: address(0) 104 | }); 105 | 106 | // Zero amount for collect doesn't make sense here (in a module with 5 recipients) 107 | // For this better use FreeCollect module instead 108 | if (baseInitData.amount == 0) revert Errors.InitParamsInvalid(); 109 | _validateBaseInitData(baseInitData); 110 | _validateAndStoreRecipients(initData.recipients, profileId, pubId); 111 | _storeBasePublicationCollectParameters(profileId, pubId, baseInitData); 112 | return data; 113 | } 114 | 115 | /** 116 | * @dev Validates the recipients array and stores them to (a separate from Base) storage. 117 | * 118 | * @param recipients An array of recipients 119 | * @param profileId The profile ID who is publishing the publication. 120 | * @param pubId The associated publication's LensHub publication ID. 121 | */ 122 | function _validateAndStoreRecipients( 123 | RecipientData[] memory recipients, 124 | uint256 profileId, 125 | uint256 pubId 126 | ) internal { 127 | uint256 len = recipients.length; 128 | 129 | // Check number of recipients is supported 130 | if (len > MAX_RECIPIENTS) revert TooManyRecipients(); 131 | if (len == 0) revert Errors.InitParamsInvalid(); 132 | 133 | // Skip loop check if only 1 recipient in the array 134 | if (len == 1) { 135 | if (recipients[0].recipient == address(0)) revert Errors.InitParamsInvalid(); 136 | if (recipients[0].split != BPS_MAX) revert InvalidRecipientSplits(); 137 | 138 | // If single recipient passes check above, store and return 139 | _recipientsByPublicationByProfile[profileId][pubId].push(recipients[0]); 140 | } else { 141 | // Check recipient splits sum to 10 000 BPS (100%) 142 | uint256 totalSplits; 143 | for (uint256 i = 0; i < len; ) { 144 | if (recipients[i].recipient == address(0)) revert Errors.InitParamsInvalid(); 145 | if (recipients[i].split == 0) revert RecipientSplitCannotBeZero(); 146 | totalSplits += recipients[i].split; 147 | 148 | // Store each recipient while looping - avoids extra gas costs in successful cases 149 | _recipientsByPublicationByProfile[profileId][pubId].push(recipients[i]); 150 | 151 | unchecked { 152 | ++i; 153 | } 154 | } 155 | 156 | if (totalSplits != BPS_MAX) revert InvalidRecipientSplits(); 157 | } 158 | } 159 | 160 | /** 161 | * @dev Transfers the fee to multiple recipients. 162 | * 163 | * @inheritdoc BaseFeeCollectModule 164 | */ 165 | function _transferToRecipients( 166 | address currency, 167 | address collector, 168 | uint256 profileId, 169 | uint256 pubId, 170 | uint256 amount 171 | ) internal override { 172 | RecipientData[] memory recipients = _recipientsByPublicationByProfile[profileId][pubId]; 173 | uint256 len = recipients.length; 174 | 175 | // If only 1 recipient, transfer full amount and skip split calculations 176 | if (len == 1) { 177 | IERC20(currency).safeTransferFrom(collector, recipients[0].recipient, amount); 178 | } else { 179 | uint256 splitAmount; 180 | for (uint256 i = 0; i < len; ) { 181 | splitAmount = (amount * recipients[i].split) / BPS_MAX; 182 | if (splitAmount != 0) 183 | IERC20(currency).safeTransferFrom( 184 | collector, 185 | recipients[i].recipient, 186 | splitAmount 187 | ); 188 | 189 | unchecked { 190 | ++i; 191 | } 192 | } 193 | } 194 | } 195 | 196 | /** 197 | * @notice Returns the publication data for a given publication, or an empty struct if that publication was not 198 | * initialized with this module. 199 | * 200 | * @param profileId The token ID of the profile mapped to the publication to query. 201 | * @param pubId The publication ID of the publication to query. 202 | * 203 | * @return The BaseProfilePublicationData struct mapped to that publication. 204 | */ 205 | function getPublicationData(uint256 profileId, uint256 pubId) 206 | external 207 | view 208 | returns (MultirecipientFeeCollectProfilePublicationData memory) 209 | { 210 | BaseProfilePublicationData memory baseData = getBasePublicationData(profileId, pubId); 211 | RecipientData[] memory recipients = _recipientsByPublicationByProfile[profileId][pubId]; 212 | 213 | return 214 | MultirecipientFeeCollectProfilePublicationData({ 215 | amount: baseData.amount, 216 | collectLimit: baseData.collectLimit, 217 | currency: baseData.currency, 218 | currentCollects: baseData.currentCollects, 219 | referralFee: baseData.referralFee, 220 | followerOnly: baseData.followerOnly, 221 | endTimestamp: baseData.endTimestamp, 222 | recipients: recipients 223 | }); 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /contracts/collect/SimpleFeeCollectModule.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.10; 3 | 4 | import {BaseFeeCollectModule} from './base/BaseFeeCollectModule.sol'; 5 | import {BaseFeeCollectModuleInitData, BaseProfilePublicationData} from './base/IBaseFeeCollectModule.sol'; 6 | 7 | /** 8 | * @title SimpleFeeCollectModule 9 | * @author Lens Protocol 10 | * 11 | * @notice This is a simple Lens CollectModule implementation, allowing customization of time to collect, 12 | * number of collects and whether only followers can collect. 13 | * 14 | * You can build your own collect modules by inheriting from BaseFeeCollectModule and adding your 15 | * functionality along with getPublicationData function. 16 | */ 17 | contract SimpleFeeCollectModule is BaseFeeCollectModule { 18 | constructor(address hub, address moduleGlobals) BaseFeeCollectModule(hub, moduleGlobals) {} 19 | 20 | /** 21 | * @notice This collect module levies a fee on collects and supports referrals. Thus, we need to decode data. 22 | * @param data The arbitrary data parameter, decoded into: 23 | * amount: The collecting cost associated with this publication. 0 for free collect. 24 | * collectLimit: The maximum number of collects for this publication. 0 for no limit. 25 | * currency: The currency associated with this publication. 26 | * referralFee: The referral fee associated with this publication. 27 | * followerOnly: True if only followers of publisher may collect the post. 28 | * endTimestamp: The end timestamp after which collecting is impossible. 0 for no expiry. 29 | * recipient: Recipient of collect fees. 30 | * 31 | * @return An abi encoded bytes parameter, which is the same as the passed data parameter. 32 | */ 33 | function initializePublicationCollectModule( 34 | uint256 profileId, 35 | uint256 pubId, 36 | bytes calldata data 37 | ) external virtual onlyHub returns (bytes memory) { 38 | BaseFeeCollectModuleInitData memory baseInitData = abi.decode( 39 | data, 40 | (BaseFeeCollectModuleInitData) 41 | ); 42 | _validateBaseInitData(baseInitData); 43 | _storeBasePublicationCollectParameters(profileId, pubId, baseInitData); 44 | return data; 45 | } 46 | 47 | /** 48 | * @notice Returns the publication data for a given publication, or an empty struct if that publication was not 49 | * initialized with this module. 50 | * 51 | * @param profileId The token ID of the profile mapped to the publication to query. 52 | * @param pubId The publication ID of the publication to query. 53 | * 54 | * @return The BaseProfilePublicationData struct mapped to that publication. 55 | */ 56 | function getPublicationData(uint256 profileId, uint256 pubId) 57 | external 58 | view 59 | virtual 60 | returns (BaseProfilePublicationData memory) 61 | { 62 | return getBasePublicationData(profileId, pubId); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /contracts/collect/StepwiseCollectModule.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.10; 4 | 5 | import {ICollectModule} from '@aave/lens-protocol/contracts/interfaces/ICollectModule.sol'; 6 | import {Errors} from '@aave/lens-protocol/contracts/libraries/Errors.sol'; 7 | import {FeeModuleBase} from '../FeeModuleBase.sol'; 8 | import {ModuleBase} from '@aave/lens-protocol/contracts/core/modules/ModuleBase.sol'; 9 | import {FollowValidationModuleBase} from '@aave/lens-protocol/contracts/core/modules/FollowValidationModuleBase.sol'; 10 | import {IERC20} from '@openzeppelin/contracts/token/ERC20/IERC20.sol'; 11 | import {SafeERC20} from '@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol'; 12 | import {IERC721} from '@openzeppelin/contracts/token/ERC721/IERC721.sol'; 13 | 14 | /** 15 | * @notice A struct containing the necessary data to execute collect actions on a publication. 16 | * @notice a, b, c are coefficients of a standard quadratic equation (ax^2+bx+c) curve. 17 | * @dev Variable sizes are optimized to fit in 3 slots. 18 | * @param currency The currency associated with this publication. 19 | * @param a The a multiplier of x^2 in quadratic equation (how quadratic is the curve) 20 | * @param referralFee The referral fee associated with this publication. 21 | * @param followerOnly Whether only followers should be able to collect. 22 | * @param recipient The recipient address associated with this publication. 23 | * @param b The b multiplier of x in quadratic equation (if a==0, how steep is the line) 24 | * @param endTimestamp The end timestamp after which collecting is impossible. 25 | * @param c The c constant in quadratic equation (aka start price) 26 | * @param currentCollects The current number of collects for this publication. 27 | * @param collectLimit The maximum number of collects for this publication (0 for unlimited) 28 | */ 29 | struct ProfilePublicationData { 30 | address currency; // 1st slot 31 | uint72 a; 32 | uint16 referralFee; 33 | bool followerOnly; 34 | address recipient; // 2nd slot 35 | uint56 b; 36 | uint40 endTimestamp; 37 | uint128 c; // 3rd slot 38 | uint64 currentCollects; 39 | uint64 collectLimit; 40 | } 41 | 42 | /** 43 | * @notice A struct containing the necessary data to initialize Stepwise Collect Module. 44 | * 45 | * @param collectLimit The maximum number of collects for this publication (0 for unlimited) 46 | * @param currency The currency associated with this publication. 47 | * @param recipient The recipient address associated with this publication. 48 | * @param referralFee The referral fee associated with this publication. 49 | * @param followerOnly Whether only followers should be able to collect. 50 | * @param endTimestamp The end timestamp after which collecting is impossible. 51 | * @param a The a multiplier of x^2 in quadratic equation (how quadratic is the curve) (9 decimals) 52 | * @param b The b multiplier of x in quadratic equation (if a==0, how steep is the line) (9 decimals) 53 | * @param c The c constant in quadratic equation (aka start price) (18 decimals) 54 | */ 55 | struct StepwiseCollectModuleInitData { 56 | uint64 collectLimit; 57 | address currency; 58 | address recipient; 59 | uint16 referralFee; 60 | bool followerOnly; 61 | uint40 endTimestamp; 62 | uint72 a; 63 | uint56 b; 64 | uint128 c; 65 | } 66 | 67 | /** 68 | * @title StepwiseCollectModule 69 | * @author Lens Protocol 70 | * 71 | * @notice This is a simple Lens CollectModule implementation, inheriting from the ICollectModule interface and 72 | * the FeeCollectModuleBase abstract contract. 73 | * 74 | * This module works by allowing limited collects for a publication within the allotted time with a changing fee. 75 | * 76 | * The fee is calculated based on a simple quadratic equation: 77 | * Fee = a*x^2 + b*x + c, 78 | * (where x is how many collects were already performed) 79 | * 80 | * a=b=0 makes it a constant-fee collect 81 | * a=0 makes it a linear-growing fee collect 82 | */ 83 | contract StepwiseCollectModule is FeeModuleBase, FollowValidationModuleBase, ICollectModule { 84 | using SafeERC20 for IERC20; 85 | 86 | // As there is hard storage optimisation of a,b,c parameters, the following decimals convention is assumed for fixed-point calculations: 87 | uint256 public constant A_DECIMALS = 1e9; // leaves 30 bits for fractional part, 42 bits for integer part 88 | uint256 public constant B_DECIMALS = 1e9; // leaves 30 bits for fractional part, 26 bits for integer part 89 | // For C the decimals will be equal to currency decimals 90 | 91 | mapping(uint256 => mapping(uint256 => ProfilePublicationData)) 92 | internal _dataByPublicationByProfile; 93 | 94 | constructor(address hub, address moduleGlobals) FeeModuleBase(moduleGlobals) ModuleBase(hub) {} 95 | 96 | /** 97 | * @notice This collect module levies a fee on collects and supports referrals. Thus, we need to decode data. 98 | * 99 | * @param profileId The profile ID of the publication to initialize this module for. 100 | * @param pubId The publication ID to initialize this module for. 101 | * @param data The arbitrary data parameter, decoded into: StepwiseCollectModuleInitData struct 102 | * @return bytes An abi encoded bytes parameter, containing a struct with module initialization data. 103 | */ 104 | function initializePublicationCollectModule( 105 | uint256 profileId, 106 | uint256 pubId, 107 | bytes calldata data 108 | ) external override onlyHub returns (bytes memory) { 109 | StepwiseCollectModuleInitData memory initData = abi.decode( 110 | data, 111 | (StepwiseCollectModuleInitData) 112 | ); 113 | { 114 | if ( 115 | !_currencyWhitelisted(initData.currency) || 116 | initData.recipient == address(0) || 117 | initData.referralFee > BPS_MAX || 118 | (initData.endTimestamp != 0 && initData.endTimestamp < block.timestamp) 119 | ) revert Errors.InitParamsInvalid(); 120 | } 121 | _dataByPublicationByProfile[profileId][pubId] = ProfilePublicationData({ 122 | currency: initData.currency, 123 | a: initData.a, 124 | referralFee: initData.referralFee, 125 | followerOnly: initData.followerOnly, 126 | recipient: initData.recipient, 127 | b: initData.b, 128 | endTimestamp: initData.endTimestamp, 129 | c: initData.c, 130 | currentCollects: 0, 131 | collectLimit: initData.collectLimit 132 | }); 133 | return data; 134 | } 135 | 136 | /** 137 | * @dev Processes a collect by: 138 | * 1. Ensuring the collector is a follower 139 | * 2. Ensuring the current timestamp is less than or equal to the collect end timestamp 140 | * 3. Ensuring the collect does not pass the collect limit 141 | * 4. Charging a fee 142 | * 143 | * @inheritdoc ICollectModule 144 | */ 145 | function processCollect( 146 | uint256 referrerProfileId, 147 | address collector, 148 | uint256 profileId, 149 | uint256 pubId, 150 | bytes calldata data 151 | ) external override onlyHub { 152 | if (_dataByPublicationByProfile[profileId][pubId].followerOnly) 153 | _checkFollowValidity(profileId, collector); 154 | uint256 endTimestamp = _dataByPublicationByProfile[profileId][pubId].endTimestamp; 155 | if (endTimestamp != 0 && block.timestamp > endTimestamp) revert Errors.CollectExpired(); 156 | 157 | if ( 158 | _dataByPublicationByProfile[profileId][pubId].collectLimit != 0 && 159 | _dataByPublicationByProfile[profileId][pubId].currentCollects >= 160 | _dataByPublicationByProfile[profileId][pubId].collectLimit 161 | ) { 162 | revert Errors.MintLimitExceeded(); 163 | } else { 164 | unchecked { 165 | ++_dataByPublicationByProfile[profileId][pubId].currentCollects; 166 | } 167 | if (referrerProfileId == profileId) { 168 | _processCollect(collector, profileId, pubId, data); 169 | } else { 170 | _processCollectWithReferral(referrerProfileId, collector, profileId, pubId, data); 171 | } 172 | } 173 | } 174 | 175 | /** 176 | * @notice Returns the publication data for a given publication, or an empty struct if that publication was not 177 | * initialized with this module. 178 | * 179 | * @param profileId The token ID of the profile mapped to the publication to query. 180 | * @param pubId The publication ID of the publication to query. 181 | * 182 | * @return ProfilePublicationData The ProfilePublicationData struct mapped to that publication. 183 | */ 184 | function getPublicationData(uint256 profileId, uint256 pubId) 185 | external 186 | view 187 | returns (ProfilePublicationData memory) 188 | { 189 | return _dataByPublicationByProfile[profileId][pubId]; 190 | } 191 | 192 | // TODO: Decide if we need a view function at all 193 | /** 194 | * @notice Estimates the amount next collect will cost for a given publication. 195 | * @notice Subject to front-running, thus some slippage should be added. 196 | * 197 | * @param profileId The token ID of the profile mapped to the publication to query. 198 | * @param pubId The publication ID of the publication to query. 199 | * 200 | * @return fee Collect fee 201 | */ 202 | function previewFee(uint256 profileId, uint256 pubId) public view returns (uint256) { 203 | ProfilePublicationData memory data = _dataByPublicationByProfile[profileId][pubId]; 204 | data.currentCollects++; 205 | return _calculateFee(data); 206 | } 207 | 208 | /** 209 | * @dev Calculates the collect fee using quadratic formula. 210 | * 211 | * @param data ProfilePublicationData from storage containing the publication parameters. 212 | * 213 | * @return fee Collect fee. 214 | */ 215 | function _calculateFee(ProfilePublicationData memory data) internal pure returns (uint256) { 216 | // Because we already incremented the current collects in storage - we need to adjust it here. 217 | // This is done to allow the first collect price to be equal to c parameter (better UX) 218 | uint256 collects = data.currentCollects - 1; 219 | if (data.a == 0) return (uint256(data.b) * collects) / B_DECIMALS + data.c; 220 | return 221 | ((uint256(data.a) * collects * collects) / A_DECIMALS) + 222 | ((uint256(data.b) * collects) / B_DECIMALS) + 223 | data.c; 224 | } 225 | 226 | /** 227 | * @dev Calculates and processes the collect action. 228 | * 229 | * @param collector The address that collects the publicaton. 230 | * @param profileId The token ID of the profile mapped to the publication to query. 231 | * @param pubId The publication ID of the publication to query. 232 | * @param data Abi encoded bytes parameter, containing currency address and fee amount 233 | */ 234 | function _processCollect( 235 | address collector, 236 | uint256 profileId, 237 | uint256 pubId, 238 | bytes calldata data 239 | ) internal { 240 | uint256 amount = _calculateFee(_dataByPublicationByProfile[profileId][pubId]); 241 | address currency = _dataByPublicationByProfile[profileId][pubId].currency; 242 | _validateDataIsExpected(data, currency, amount); 243 | 244 | if (amount > 0) { 245 | (address treasury, uint16 treasuryFee) = _treasuryData(); 246 | address recipient = _dataByPublicationByProfile[profileId][pubId].recipient; 247 | uint256 treasuryAmount = (amount * treasuryFee) / BPS_MAX; 248 | uint256 adjustedAmount = amount - treasuryAmount; 249 | 250 | IERC20(currency).safeTransferFrom(collector, recipient, adjustedAmount); 251 | if (treasuryAmount > 0) 252 | IERC20(currency).safeTransferFrom(collector, treasury, treasuryAmount); 253 | } 254 | } 255 | 256 | /** 257 | * @dev Calculates and processes the collect action with referral. 258 | * 259 | * @param referrerProfileId The profile receiving referral fees. 260 | * @param collector The address that collects the publicaton. 261 | * @param profileId The token ID of the profile mapped to the publication to query. 262 | * @param pubId The publication ID of the publication to query. 263 | * @param data Abi encoded bytes parameter, containing currency address and fee amount 264 | */ 265 | function _processCollectWithReferral( 266 | uint256 referrerProfileId, 267 | address collector, 268 | uint256 profileId, 269 | uint256 pubId, 270 | bytes calldata data 271 | ) internal { 272 | uint256 amount = _calculateFee(_dataByPublicationByProfile[profileId][pubId]); 273 | address currency = _dataByPublicationByProfile[profileId][pubId].currency; 274 | _validateDataIsExpected(data, currency, amount); 275 | 276 | if (amount > 0) { 277 | uint256 referralFee = _dataByPublicationByProfile[profileId][pubId].referralFee; 278 | address treasury; 279 | uint256 treasuryAmount; 280 | 281 | // Avoids stack too deep 282 | { 283 | uint16 treasuryFee; 284 | (treasury, treasuryFee) = _treasuryData(); 285 | treasuryAmount = (amount * treasuryFee) / BPS_MAX; 286 | } 287 | 288 | uint256 adjustedAmount = amount - treasuryAmount; 289 | 290 | if (referralFee != 0) { 291 | // The reason we levy the referral fee on the adjusted amount is so that referral fees 292 | // don't bypass the treasury fee, in essence referrals pay their fair share to the treasury. 293 | uint256 referralAmount = (adjustedAmount * referralFee) / BPS_MAX; 294 | 295 | if (referralAmount > 0) { 296 | adjustedAmount = adjustedAmount - referralAmount; 297 | 298 | address referralRecipient = IERC721(HUB).ownerOf(referrerProfileId); 299 | 300 | IERC20(currency).safeTransferFrom(collector, referralRecipient, referralAmount); 301 | } 302 | } 303 | address recipient = _dataByPublicationByProfile[profileId][pubId].recipient; 304 | 305 | IERC20(currency).safeTransferFrom(collector, recipient, adjustedAmount); 306 | if (treasuryAmount > 0) 307 | IERC20(currency).safeTransferFrom(collector, treasury, treasuryAmount); 308 | } 309 | } 310 | 311 | /** 312 | * @dev Validates if the desired data provided (currency and maximum amount) corresponds to actual values of the tx. 313 | * 314 | * @param data Abi encoded bytes parameter, containing currency address and fee amount 315 | * @param currency Currency of the fee. 316 | * @param amount Fee amount. 317 | */ 318 | function _validateDataIsExpected( 319 | bytes calldata data, 320 | address currency, 321 | uint256 amount 322 | ) internal pure override { 323 | (address decodedCurrency, uint256 decodedMaxAmount) = abi.decode(data, (address, uint256)); 324 | if (amount > decodedMaxAmount || decodedCurrency != currency) 325 | revert Errors.ModuleDataMismatch(); 326 | } 327 | } 328 | -------------------------------------------------------------------------------- /contracts/collect/base/BaseFeeCollectModule.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.10; 4 | 5 | import {Errors} from '@aave/lens-protocol/contracts/libraries/Errors.sol'; 6 | import {FeeModuleBase} from '@aave/lens-protocol/contracts/core/modules/FeeModuleBase.sol'; 7 | import {ICollectModule} from '@aave/lens-protocol/contracts/interfaces/ICollectModule.sol'; 8 | import {ModuleBase} from '@aave/lens-protocol/contracts/core/modules/ModuleBase.sol'; 9 | import {FollowValidationModuleBase} from '@aave/lens-protocol/contracts/core/modules/FollowValidationModuleBase.sol'; 10 | 11 | import {IERC20} from '@openzeppelin/contracts/token/ERC20/IERC20.sol'; 12 | import {IERC721} from '@openzeppelin/contracts/token/ERC721/IERC721.sol'; 13 | import {SafeERC20} from '@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol'; 14 | 15 | import {BaseFeeCollectModuleInitData, BaseProfilePublicationData, IBaseFeeCollectModule} from './IBaseFeeCollectModule.sol'; 16 | 17 | /** 18 | * @title BaseFeeCollectModule 19 | * @author Lens Protocol 20 | * 21 | * @notice This is an base Lens CollectModule implementation, allowing customization of time to collect, number of collects 22 | * and whether only followers can collect, charging a fee for collect and distributing it among Receiver/Referral/Treasury. 23 | * @dev Here we use "Base" terminology to anything that represents this base functionality (base structs, base functions, base storage). 24 | * @dev You can build your own collect modules on top of the "Base" by inheriting this contract and overriding functions. 25 | * @dev This contract is marked "abstract" as it requires you to implement initializePublicationCollectModule and getPublicationData functions when you inherit from it. 26 | * @dev See BaseFeeCollectModule as an example implementation. 27 | */ 28 | abstract contract BaseFeeCollectModule is 29 | FeeModuleBase, 30 | FollowValidationModuleBase, 31 | IBaseFeeCollectModule 32 | { 33 | using SafeERC20 for IERC20; 34 | 35 | mapping(uint256 => mapping(uint256 => BaseProfilePublicationData)) 36 | internal _dataByPublicationByProfile; 37 | 38 | constructor(address hub, address moduleGlobals) ModuleBase(hub) FeeModuleBase(moduleGlobals) {} 39 | 40 | /** 41 | * @dev Processes a collect by: 42 | * 1. Validating that collect action meets all needded criteria 43 | * 2. Processing the collect action either with or withour referral 44 | * 45 | * @inheritdoc ICollectModule 46 | */ 47 | function processCollect( 48 | uint256 referrerProfileId, 49 | address collector, 50 | uint256 profileId, 51 | uint256 pubId, 52 | bytes calldata data 53 | ) external virtual onlyHub { 54 | _validateAndStoreCollect(referrerProfileId, collector, profileId, pubId, data); 55 | 56 | if (referrerProfileId == profileId) { 57 | _processCollect(collector, profileId, pubId, data); 58 | } else { 59 | _processCollectWithReferral(referrerProfileId, collector, profileId, pubId, data); 60 | } 61 | } 62 | 63 | // This function is not implemented because each Collect module has its own return data type 64 | // function getPublicationData(uint256 profileId, uint256 pubId) external view returns (.....) {} 65 | 66 | /** 67 | * @notice Returns the Base publication data for a given publication, or an empty struct if that publication was not 68 | * initialized with this module. 69 | * 70 | * @param profileId The token ID of the profile mapped to the publication to query. 71 | * @param pubId The publication ID of the publication to query. 72 | * 73 | * @return The BaseProfilePublicationData struct mapped to that publication. 74 | */ 75 | function getBasePublicationData(uint256 profileId, uint256 pubId) 76 | public 77 | view 78 | virtual 79 | returns (BaseProfilePublicationData memory) 80 | { 81 | return _dataByPublicationByProfile[profileId][pubId]; 82 | } 83 | 84 | /** 85 | * @notice Calculates and returns the collect fee of a publication. 86 | * @dev Override this function to use a different formula for the fee. 87 | * 88 | * @param profileId The token ID of the profile mapped to the publication to query. 89 | * @param pubId The publication ID of the publication to query. 90 | * @param data Any additional params needed to calculate the fee. 91 | * 92 | * @return The collect fee of the specified publication. 93 | */ 94 | function calculateFee( 95 | uint256 profileId, 96 | uint256 pubId, 97 | bytes calldata data 98 | ) public view virtual returns (uint160) { 99 | return _dataByPublicationByProfile[profileId][pubId].amount; 100 | } 101 | 102 | /** 103 | * @dev Validates the Base parameters like: 104 | * 1) Is the currency whitelisted 105 | * 2) Is the referralFee in valid range 106 | * 3) Is the end of collects timestamp in valid range 107 | * 108 | * This should be called during initializePublicationCollectModule() 109 | * 110 | * @param baseInitData Module initialization data (see BaseFeeCollectModuleInitData struct) 111 | */ 112 | function _validateBaseInitData(BaseFeeCollectModuleInitData memory baseInitData) 113 | internal 114 | virtual 115 | { 116 | if ( 117 | !_currencyWhitelisted(baseInitData.currency) || 118 | baseInitData.referralFee > BPS_MAX || 119 | (baseInitData.endTimestamp != 0 && baseInitData.endTimestamp < block.timestamp) 120 | ) revert Errors.InitParamsInvalid(); 121 | } 122 | 123 | /** 124 | * @dev Stores the initial module parameters 125 | * 126 | * This should be called during initializePublicationCollectModule() 127 | * 128 | * @param profileId The token ID of the profile publishing the publication. 129 | * @param pubId The publication ID. 130 | * @param baseInitData Module initialization data (see BaseFeeCollectModuleInitData struct) 131 | */ 132 | function _storeBasePublicationCollectParameters( 133 | uint256 profileId, 134 | uint256 pubId, 135 | BaseFeeCollectModuleInitData memory baseInitData 136 | ) internal virtual { 137 | _dataByPublicationByProfile[profileId][pubId].amount = baseInitData.amount; 138 | _dataByPublicationByProfile[profileId][pubId].collectLimit = baseInitData.collectLimit; 139 | _dataByPublicationByProfile[profileId][pubId].currency = baseInitData.currency; 140 | _dataByPublicationByProfile[profileId][pubId].recipient = baseInitData.recipient; 141 | _dataByPublicationByProfile[profileId][pubId].referralFee = baseInitData.referralFee; 142 | _dataByPublicationByProfile[profileId][pubId].followerOnly = baseInitData.followerOnly; 143 | _dataByPublicationByProfile[profileId][pubId].endTimestamp = baseInitData.endTimestamp; 144 | } 145 | 146 | /** 147 | * @dev Validates the collect action by checking that: 148 | * 1) the collector is a follower (if enabled) 149 | * 2) the number of collects after the action doesn't surpass the collect limit (if enabled) 150 | * 3) the current block timestamp doesn't surpass the end timestamp (if enabled) 151 | * 152 | * This should be called during processCollect() 153 | * 154 | * @param referrerProfileId The LensHub profile token ID of the referrer's profile (only different in case of mirrors). 155 | * @param collector The collector address. 156 | * @param profileId The token ID of the profile associated with the publication being collected. 157 | * @param pubId The LensHub publication ID associated with the publication being collected. 158 | * @param data Arbitrary data __passed from the collector!__ to be decoded. 159 | */ 160 | function _validateAndStoreCollect( 161 | uint256 referrerProfileId, 162 | address collector, 163 | uint256 profileId, 164 | uint256 pubId, 165 | bytes calldata data 166 | ) internal virtual { 167 | uint96 collectsAfter = ++_dataByPublicationByProfile[profileId][pubId].currentCollects; 168 | 169 | if (_dataByPublicationByProfile[profileId][pubId].followerOnly) 170 | _checkFollowValidity(profileId, collector); 171 | 172 | uint256 endTimestamp = _dataByPublicationByProfile[profileId][pubId].endTimestamp; 173 | uint256 collectLimit = _dataByPublicationByProfile[profileId][pubId].collectLimit; 174 | 175 | if (collectLimit != 0 && collectsAfter > collectLimit) { 176 | revert Errors.MintLimitExceeded(); 177 | } 178 | if (endTimestamp != 0 && block.timestamp > endTimestamp) { 179 | revert Errors.CollectExpired(); 180 | } 181 | } 182 | 183 | /** 184 | * @dev Internal processing of a collect: 185 | * 1. Calculation of fees 186 | * 2. Validation that fees are what collector expected 187 | * 3. Transfer of fees to recipient(-s) and treasury 188 | * 189 | * @param collector The address that will collect the post. 190 | * @param profileId The token ID of the profile associated with the publication being collected. 191 | * @param pubId The LensHub publication ID associated with the publication being collected. 192 | * @param data Arbitrary data __passed from the collector!__ to be decoded. 193 | */ 194 | function _processCollect( 195 | address collector, 196 | uint256 profileId, 197 | uint256 pubId, 198 | bytes calldata data 199 | ) internal virtual { 200 | uint256 amount = calculateFee(profileId, pubId, data); 201 | address currency = _dataByPublicationByProfile[profileId][pubId].currency; 202 | _validateDataIsExpected(data, currency, amount); 203 | 204 | (address treasury, uint16 treasuryFee) = _treasuryData(); 205 | uint256 treasuryAmount = (amount * treasuryFee) / BPS_MAX; 206 | 207 | // Send amount after treasury cut, to all recipients 208 | _transferToRecipients(currency, collector, profileId, pubId, amount - treasuryAmount); 209 | 210 | if (treasuryAmount > 0) { 211 | IERC20(currency).safeTransferFrom(collector, treasury, treasuryAmount); 212 | } 213 | } 214 | 215 | /** 216 | * @dev Internal processing of a collect with a referral(-s). 217 | * 218 | * Same as _processCollect, but also includes transfer to referral(-s): 219 | * 1. Calculation of fees 220 | * 2. Validation that fees are what collector expected 221 | * 3. Transfer of fees to recipient(-s), referral(-s) and treasury 222 | * 223 | * @param referrerProfileId The address of the referral. 224 | * @param collector The address that will collect the post. 225 | * @param profileId The token ID of the profile associated with the publication being collected. 226 | * @param pubId The LensHub publication ID associated with the publication being collected. 227 | * @param data Arbitrary data __passed from the collector!__ to be decoded. 228 | */ 229 | function _processCollectWithReferral( 230 | uint256 referrerProfileId, 231 | address collector, 232 | uint256 profileId, 233 | uint256 pubId, 234 | bytes calldata data 235 | ) internal virtual { 236 | uint256 amount = calculateFee(profileId, pubId, data); 237 | address currency = _dataByPublicationByProfile[profileId][pubId].currency; 238 | _validateDataIsExpected(data, currency, amount); 239 | 240 | address treasury; 241 | uint256 treasuryAmount; 242 | 243 | // Avoids stack too deep 244 | { 245 | uint16 treasuryFee; 246 | (treasury, treasuryFee) = _treasuryData(); 247 | treasuryAmount = (amount * treasuryFee) / BPS_MAX; 248 | } 249 | 250 | uint256 adjustedAmount = amount - treasuryAmount; 251 | adjustedAmount = _transferToReferrals( 252 | currency, 253 | referrerProfileId, 254 | collector, 255 | profileId, 256 | pubId, 257 | adjustedAmount, 258 | data 259 | ); 260 | 261 | _transferToRecipients(currency, collector, profileId, pubId, adjustedAmount); 262 | 263 | if (treasuryAmount > 0) { 264 | IERC20(currency).safeTransferFrom(collector, treasury, treasuryAmount); 265 | } 266 | } 267 | 268 | /** 269 | * @dev Tranfers the fee to recipient(-s) 270 | * 271 | * Override this to add additional functionality (e.g. multiple recipients) 272 | * 273 | * @param currency Currency of the transaction 274 | * @param collector The address that collects the post (and pays the fee). 275 | * @param profileId The token ID of the profile associated with the publication being collected. 276 | * @param pubId The LensHub publication ID associated with the publication being collected. 277 | * @param amount Amount to transfer to recipient(-s) 278 | */ 279 | function _transferToRecipients( 280 | address currency, 281 | address collector, 282 | uint256 profileId, 283 | uint256 pubId, 284 | uint256 amount 285 | ) internal virtual { 286 | address recipient = _dataByPublicationByProfile[profileId][pubId].recipient; 287 | 288 | if (amount > 0) { 289 | IERC20(currency).safeTransferFrom(collector, recipient, amount); 290 | } 291 | } 292 | 293 | /** 294 | * @dev Tranfers the part of fee to referral(-s) 295 | * 296 | * Override this to add additional functionality (e.g. multiple referrals) 297 | * 298 | * @param currency Currency of the transaction 299 | * @param referrerProfileId The address of the referral. 300 | * @param collector The address that collects the post (and pays the fee). 301 | * @param profileId The token ID of the profile associated with the publication being collected. 302 | * @param pubId The LensHub publication ID associated with the publication being collected. 303 | * @param adjustedAmount Amount of the fee after subtracting the Treasury part. 304 | * @param data Arbitrary data __passed from the collector!__ to be decoded. 305 | */ 306 | function _transferToReferrals( 307 | address currency, 308 | uint256 referrerProfileId, 309 | address collector, 310 | uint256 profileId, 311 | uint256 pubId, 312 | uint256 adjustedAmount, 313 | bytes calldata data 314 | ) internal virtual returns (uint256) { 315 | uint256 referralFee = _dataByPublicationByProfile[profileId][pubId].referralFee; 316 | if (referralFee != 0) { 317 | // The reason we levy the referral fee on the adjusted amount is so that referral fees 318 | // don't bypass the treasury fee, in essence referrals pay their fair share to the treasury. 319 | uint256 referralAmount = (adjustedAmount * referralFee) / BPS_MAX; 320 | if (referralAmount > 0) { 321 | adjustedAmount = adjustedAmount - referralAmount; 322 | 323 | address referralRecipient = IERC721(HUB).ownerOf(referrerProfileId); 324 | 325 | // Send referral fee in normal ERC20 tokens 326 | IERC20(currency).safeTransferFrom(collector, referralRecipient, referralAmount); 327 | } 328 | } 329 | return adjustedAmount; 330 | } 331 | } 332 | -------------------------------------------------------------------------------- /contracts/collect/base/IBaseFeeCollectModule.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.10; 3 | 4 | import {ICollectModule} from '@aave/lens-protocol/contracts/interfaces/ICollectModule.sol'; 5 | 6 | /** 7 | * @notice A struct containing the necessary data to execute collect actions on a publication. 8 | * 9 | * @param amount The collecting cost associated with this publication. 0 for free collect. 10 | * @param collectLimit The maximum number of collects for this publication. 0 for no limit. 11 | * @param currency The currency associated with this publication. 12 | * @param currentCollects The current number of collects for this publication. 13 | * @param referralFee The referral fee associated with this publication. 14 | * @param followerOnly True if only followers of publisher may collect the post. 15 | * @param endTimestamp The end timestamp after which collecting is impossible. 0 for no expiry. 16 | * @param recipient Recipient of collect fees. 17 | */ 18 | struct BaseProfilePublicationData { 19 | uint160 amount; 20 | uint96 collectLimit; 21 | address currency; 22 | uint96 currentCollects; 23 | address recipient; 24 | uint16 referralFee; 25 | bool followerOnly; 26 | uint72 endTimestamp; 27 | } 28 | 29 | /** 30 | * @notice A struct containing the necessary data to initialize this Base Collect Module. 31 | * 32 | * @param amount The collecting cost associated with this publication. 0 for free collect. 33 | * @param collectLimit The maximum number of collects for this publication. 0 for no limit. 34 | * @param currency The currency associated with this publication. 35 | * @param referralFee The referral fee associated with this publication. 36 | * @param followerOnly True if only followers of publisher may collect the post. 37 | * @param endTimestamp The end timestamp after which collecting is impossible. 0 for no expiry. 38 | * @param recipient Recipient of collect fees. 39 | */ 40 | struct BaseFeeCollectModuleInitData { 41 | uint160 amount; 42 | uint96 collectLimit; 43 | address currency; 44 | uint16 referralFee; 45 | bool followerOnly; 46 | uint72 endTimestamp; 47 | address recipient; 48 | } 49 | 50 | interface IBaseFeeCollectModule is ICollectModule { 51 | function getBasePublicationData(uint256 profileId, uint256 pubId) 52 | external 53 | view 54 | returns (BaseProfilePublicationData memory); 55 | 56 | function calculateFee( 57 | uint256 profileId, 58 | uint256 pubId, 59 | bytes calldata data 60 | ) external view returns (uint160); 61 | } 62 | -------------------------------------------------------------------------------- /contracts/interfaces/IPoolDataProvider.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0 2 | pragma solidity ^0.8.0; 3 | 4 | interface IPoolDataProvider { 5 | /** 6 | * @notice Returns the configuration data of the reserve. 7 | * @dev Not returning borrow and supply caps for compatibility, nor pause flag. 8 | * @param asset The address of the underlying asset of the reserve. 9 | * @return decimals The number of decimals of the reserve. 10 | * @return ltv The ltv of the reserve. 11 | * @return liquidationThreshold The liquidationThreshold of the reserve. 12 | * @return liquidationBonus The liquidationBonus of the reserve. 13 | * @return reserveFactor The reserveFactor of the reserve. 14 | * @return usageAsCollateralEnabled True if the usage as collateral is enabled, false otherwise. 15 | * @return borrowingEnabled True if borrowing is enabled, false otherwise. 16 | * @return stableBorrowRateEnabled True if stable rate borrowing is enabled, false otherwise. 17 | * @return isActive True if it is active, false otherwise. 18 | * @return isFrozen True if it is frozen, false otherwise. 19 | **/ 20 | function getReserveConfigurationData(address asset) 21 | external 22 | view 23 | returns ( 24 | uint256 decimals, 25 | uint256 ltv, 26 | uint256 liquidationThreshold, 27 | uint256 liquidationBonus, 28 | uint256 reserveFactor, 29 | bool usageAsCollateralEnabled, 30 | bool borrowingEnabled, 31 | bool stableBorrowRateEnabled, 32 | bool isActive, 33 | bool isFrozen 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /contracts/mocks/ACurrency.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-only 2 | 3 | pragma solidity 0.8.10; 4 | 5 | import {ERC20} from '@openzeppelin/contracts/token/ERC20/ERC20.sol'; 6 | 7 | contract ACurrency is ERC20('ACurrency', 'aCRNC') { 8 | function mint(address to, uint256 amount) external { 9 | _mint(to, amount); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /contracts/mocks/MockPool.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-only 2 | 3 | pragma solidity 0.8.10; 4 | 5 | import {IERC20} from '@openzeppelin/contracts/token/ERC20/IERC20.sol'; 6 | 7 | interface IAToken { 8 | function mint(address to, uint256 amount) external; 9 | } 10 | 11 | contract MockPool { 12 | address public aTokenAddress; 13 | address public unsupportedToken; 14 | 15 | error UnsupportedTokenSupplied(); 16 | 17 | constructor(address _aTokenAddress, address _unsupportedToken) { 18 | aTokenAddress = _aTokenAddress; 19 | unsupportedToken = _unsupportedToken; 20 | } 21 | 22 | function supply( 23 | address asset, 24 | uint256 amount, 25 | address onBehalfOf, 26 | uint16 referralCode 27 | ) external { 28 | if (asset == unsupportedToken) revert UnsupportedTokenSupplied(); 29 | 30 | IERC20(asset).transferFrom(msg.sender, aTokenAddress, amount); 31 | 32 | IAToken(aTokenAddress).mint(onBehalfOf, amount); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /contracts/mocks/MockPoolAddressesProvider.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-only 2 | 3 | pragma solidity 0.8.10; 4 | 5 | contract MockPoolAddressesProvider { 6 | address public pool; 7 | 8 | constructor(address _pool) { 9 | pool = _pool; 10 | } 11 | 12 | function getPool() public view returns (address) { 13 | return pool; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /contracts/mocks/MockVault.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: AGPL-3.0-only 2 | pragma solidity 0.8.10; 3 | 4 | import {ERC4626} from 'solmate/src/mixins/ERC4626.sol'; 5 | import {ERC20} from 'solmate/src/tokens/ERC20.sol'; 6 | 7 | contract MockVault is ERC4626 { 8 | constructor(ERC20 _asset) ERC4626(_asset, 'MockVault Shares', 'MVS') {} 9 | 10 | function totalAssets() public view override returns (uint256) { 11 | return asset.balanceOf(address(this)); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /contracts/mocks/NFT.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.10; 4 | 5 | import {ERC721} from '@openzeppelin/contracts/token/ERC721/ERC721.sol'; 6 | 7 | contract NFT is ERC721('NFT', 'NFT') { 8 | function mint(address to, uint256 nftId) external { 9 | _mint(to, nftId); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /contracts/reference/DegreesOfSeparationReferenceModule.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.10; 4 | 5 | import {DataTypes} from '@aave/lens-protocol/contracts/libraries/DataTypes.sol'; 6 | import {EIP712} from '@openzeppelin/contracts/utils/cryptography/draft-EIP712.sol'; 7 | import {Errors} from '@aave/lens-protocol/contracts/libraries/Errors.sol'; 8 | import {Events} from '@aave/lens-protocol/contracts/libraries/Events.sol'; 9 | import {FollowValidationModuleBase} from '@aave/lens-protocol/contracts/core/modules/FollowValidationModuleBase.sol'; 10 | import {IERC721} from '@openzeppelin/contracts/token/ERC721/IERC721.sol'; 11 | import {IFollowModule} from '@aave/lens-protocol/contracts/interfaces/IFollowModule.sol'; 12 | import {ILensHub} from '@aave/lens-protocol/contracts/interfaces/ILensHub.sol'; 13 | import {IReferenceModule} from '@aave/lens-protocol/contracts/interfaces/IReferenceModule.sol'; 14 | import {ModuleBase} from '@aave/lens-protocol/contracts/core/modules/ModuleBase.sol'; 15 | 16 | /** 17 | * @notice Struct representing the module configuration for certain publication. 18 | * 19 | * @param setUp Indicates if the publication was set up to use this module, to then allow updating params. 20 | * @param commentsRestricted Indicates if the comment operation is restricted or open to everyone. 21 | * @param mirrorsRestricted Indicates if the mirror operation is restricted or open to everyone. 22 | * @param degreesOfSeparation The max degrees of separation allowed for restricted operations. 23 | */ 24 | struct ModuleConfig { 25 | bool setUp; 26 | bool commentsRestricted; 27 | bool mirrorsRestricted; 28 | uint8 degreesOfSeparation; 29 | } 30 | 31 | /** 32 | * @title DegreesOfSeparationReferenceModule 33 | * @author Lens Protocol 34 | * 35 | * @notice This reference module allows to set a degree of separation `n`, and then allows to comment/mirror only to 36 | * profiles that are at most at `n` degrees of separation from the author of the root publication. 37 | */ 38 | contract DegreesOfSeparationReferenceModule is 39 | EIP712, 40 | FollowValidationModuleBase, 41 | IReferenceModule 42 | { 43 | event ModuleParametersUpdated( 44 | uint256 indexed profileId, 45 | uint256 indexed pubId, 46 | bool commentsRestricted, 47 | bool mirrorsRestricted, 48 | uint8 degreesOfSeparation 49 | ); 50 | 51 | error InvalidDegreesOfSeparation(); 52 | error OperationDisabled(); 53 | error ProfilePathExceedsDegreesOfSeparation(); 54 | error PublicationNotSetUp(); 55 | 56 | /** 57 | * @dev Because of the "Six degrees of separation" theory, in the long term, setting up 5, 6 or more degrees of 58 | * separation will be almost equivalent to turning off the restriction. 59 | * If we also take into account the gas cost of performing the validations on-chain, makes sense to only support up 60 | * to 4 degrees of separation. 61 | */ 62 | uint8 constant MAX_DEGREES_OF_SEPARATION = 4; 63 | 64 | mapping(address => uint256) public nonces; 65 | 66 | mapping(uint256 => mapping(uint256 => ModuleConfig)) internal _moduleConfigByPubByProfile; 67 | 68 | constructor(address hub) EIP712('DegreesOfSeparationReferenceModule', '1') ModuleBase(hub) {} 69 | 70 | /** 71 | * @notice Initializes data for a given publication being published. This can only be called by the hub. 72 | * 73 | * @param profileId The token ID of the profile publishing the publication. 74 | * @param pubId The associated publication's LensHub publication ID. 75 | * @param data Arbitrary data passed from the user to be decoded. 76 | * 77 | * @return bytes An abi encoded byte array encapsulating the execution's state changes. This will be emitted by the 78 | * hub alongside the collect module's address and should be consumed by front ends. 79 | */ 80 | function initializeReferenceModule( 81 | uint256 profileId, 82 | uint256 pubId, 83 | bytes calldata data 84 | ) external override onlyHub returns (bytes memory) { 85 | (bool commentsRestricted, bool mirrorsRestricted, uint8 degreesOfSeparation) = abi.decode( 86 | data, 87 | (bool, bool, uint8) 88 | ); 89 | if (degreesOfSeparation > MAX_DEGREES_OF_SEPARATION) { 90 | revert InvalidDegreesOfSeparation(); 91 | } 92 | _moduleConfigByPubByProfile[profileId][pubId] = ModuleConfig( 93 | true, 94 | commentsRestricted, 95 | mirrorsRestricted, 96 | degreesOfSeparation 97 | ); 98 | return data; 99 | } 100 | 101 | /** 102 | * @notice Processes a comment action referencing a given publication. This can only be called by the hub. 103 | * 104 | * @dev It will apply the degrees of separation restriction if the publication has `commentsRestricted` enabled. 105 | * 106 | * @param profileId The token ID of the profile associated with the publication being published. 107 | * @param profileIdPointed The profile ID of the profile associated the publication being referenced. 108 | * @param pubIdPointed The publication ID of the publication being referenced. 109 | * @param data Encoded data containing the array of profile IDs representing the follower path between the owner of 110 | * the author of the root publication and the profile authoring the comment. 111 | */ 112 | function processComment( 113 | uint256 profileId, 114 | uint256 profileIdPointed, 115 | uint256 pubIdPointed, 116 | bytes calldata data 117 | ) external view override onlyHub { 118 | if (_moduleConfigByPubByProfile[profileIdPointed][pubIdPointed].commentsRestricted) { 119 | _validateDegreesOfSeparationRestriction( 120 | profileId, 121 | profileIdPointed, 122 | _moduleConfigByPubByProfile[profileIdPointed][pubIdPointed].degreesOfSeparation, 123 | abi.decode(data, (uint256[])) 124 | ); 125 | } 126 | } 127 | 128 | /** 129 | * @notice Processes a mirror action referencing a given publication. This can only be called by the hub. 130 | * 131 | * @dev It will apply the degrees of separation restriction if the publication has `mirrorsRestricted` enabled. 132 | * 133 | * @param profileId The token ID of the profile associated with the publication being published. 134 | * @param profileIdPointed The profile ID of the profile associated the publication being referenced. 135 | * @param pubIdPointed The publication ID of the publication being referenced. 136 | * @param data Encoded data containing the array of profile IDs representing the follower path between the owner of 137 | * the author of the root publication and the profile authoring the mirror. 138 | */ 139 | function processMirror( 140 | uint256 profileId, 141 | uint256 profileIdPointed, 142 | uint256 pubIdPointed, 143 | bytes calldata data 144 | ) external view override onlyHub { 145 | if (_moduleConfigByPubByProfile[profileIdPointed][pubIdPointed].mirrorsRestricted) { 146 | _validateDegreesOfSeparationRestriction( 147 | profileId, 148 | profileIdPointed, 149 | _moduleConfigByPubByProfile[profileIdPointed][pubIdPointed].degreesOfSeparation, 150 | abi.decode(data, (uint256[])) 151 | ); 152 | } 153 | } 154 | 155 | /** 156 | * @notice Updates the module parameters for the given publication. 157 | * 158 | * @param profileId The token ID of the profile publishing the publication. 159 | * @param pubId The associated publication's LensHub publication ID. 160 | * @param commentsRestricted Indicates if the comment operation is restricted or open to everyone. 161 | * @param mirrorsRestricted Indicates if the mirror operation is restricted or open to everyone. 162 | * @param degreesOfSeparation The max degrees of separation allowed for restricted operations. 163 | */ 164 | function updateModuleParameters( 165 | uint256 profileId, 166 | uint256 pubId, 167 | bool commentsRestricted, 168 | bool mirrorsRestricted, 169 | uint8 degreesOfSeparation 170 | ) external { 171 | _updateModuleParameters( 172 | profileId, 173 | pubId, 174 | commentsRestricted, 175 | mirrorsRestricted, 176 | degreesOfSeparation, 177 | msg.sender 178 | ); 179 | } 180 | 181 | /** 182 | * @notice Updates the module parameters for the given publication through EIP-712 signatures. 183 | * 184 | * @param profileId The token ID of the profile publishing the publication. 185 | * @param pubId The associated publication's LensHub publication ID. 186 | * @param commentsRestricted Indicates if the comment operation is restricted or open to everyone. 187 | * @param mirrorsRestricted Indicates if the mirror operation is restricted or open to everyone. 188 | * @param degreesOfSeparation The max degrees of separation allowed for restricted operations. 189 | * @param operator The address that is executing this parameter update. Should match the recovered signer. 190 | * @param sig The EIP-712 signature for this operation. 191 | */ 192 | function updateModuleParametersWithSig( 193 | uint256 profileId, 194 | uint256 pubId, 195 | bool commentsRestricted, 196 | bool mirrorsRestricted, 197 | uint8 degreesOfSeparation, 198 | address operator, 199 | DataTypes.EIP712Signature calldata sig 200 | ) external { 201 | _validateUpdateModuleParametersSignature( 202 | profileId, 203 | pubId, 204 | commentsRestricted, 205 | mirrorsRestricted, 206 | degreesOfSeparation, 207 | operator, 208 | sig 209 | ); 210 | _updateModuleParameters( 211 | profileId, 212 | pubId, 213 | commentsRestricted, 214 | mirrorsRestricted, 215 | degreesOfSeparation, 216 | operator 217 | ); 218 | } 219 | 220 | /** 221 | * @notice Gets the module configuration for the given publication. 222 | * 223 | * @param profileId The token ID of the profile publishing the publication. 224 | * @param pubId The associated publication's LensHub publication ID. 225 | * 226 | * @return ModuleConfig The module configuration set for the given publication. 227 | */ 228 | function getModuleConfig(uint256 profileId, uint256 pubId) 229 | external 230 | view 231 | returns (ModuleConfig memory) 232 | { 233 | return _moduleConfigByPubByProfile[profileId][pubId]; 234 | } 235 | 236 | /** 237 | * @dev The data has encoded an array of integers, each integer is a profile ID, the whole array represents a path 238 | * of `n` profiles. 239 | * 240 | * Let's define `X --> Y` as `The owner of X is following Y`. Then, being `path[i]` the i-th profile in the path, 241 | * the following condition must be met for a given path of `n` profiles: 242 | * 243 | * profileIdPointed --> path[0] --> path[1] --> path[2] --> ... --> path[n-2] --> path[n-1] --> profileId 244 | * 245 | * @param profileId The token ID of the profile associated with the publication being published. 246 | * @param profileIdPointed The profile ID of the profile associated the publication being referenced. 247 | * @param degreesOfSeparation The degrees of separations configured for the given publication. 248 | * @param profilePath The array of profile IDs representing the follower path between the owner of the author of the 249 | * root publication and the profile authoring the comment. 250 | */ 251 | function _validateDegreesOfSeparationRestriction( 252 | uint256 profileId, 253 | uint256 profileIdPointed, 254 | uint8 degreesOfSeparation, 255 | uint256[] memory profilePath 256 | ) internal view { 257 | if (degreesOfSeparation == 0) { 258 | revert OperationDisabled(); 259 | } 260 | if (profilePath.length > degreesOfSeparation - 1) { 261 | revert ProfilePathExceedsDegreesOfSeparation(); 262 | } 263 | address follower = IERC721(HUB).ownerOf(profileIdPointed); 264 | if (profilePath.length > 0) { 265 | // Checks the owner of the profile authoring the root publication follows the first profile in the path. 266 | // In the previous notation: profileIdPointed --> path[0] 267 | _checkFollowValidity(profilePath[0], follower); 268 | // Checks each profile owner in the path is following the profile coming next, according the order. 269 | // In the previous notaiton: path[0] --> path[1] --> path[2] --> ... --> path[n-2] --> path[n-1] 270 | uint256 i; 271 | while (i < profilePath.length - 1) { 272 | follower = IERC721(HUB).ownerOf(profilePath[i]); 273 | unchecked { 274 | ++i; 275 | } 276 | _checkFollowValidity(profilePath[i], follower); 277 | } 278 | // Checks the last profile in the path follows the profile commenting/mirroring. 279 | // In the previous notation: path[n-1] --> profileId 280 | follower = IERC721(HUB).ownerOf(profilePath[i]); 281 | _checkFollowValidity(profileId, follower); 282 | } else { 283 | // Checks the owner of the profile authoring the root publication follows the profile commenting/mirroring. 284 | // In the previous notation: profileIdPointed --> profileId 285 | _checkFollowValidity(profileId, follower); 286 | } 287 | } 288 | 289 | /** 290 | * @notice Internal function to abstract the logic regarding the parameter updating. 291 | * 292 | * @param profileId The token ID of the profile publishing the publication. 293 | * @param pubId The associated publication's LensHub publication ID. 294 | * @param commentsRestricted Indicates if the comment operation is restricted or open to everyone. 295 | * @param mirrorsRestricted Indicates if the mirror operation is restricted or open to everyone. 296 | * @param degreesOfSeparation The max degrees of separation allowed for restricted operations. 297 | * @param operator The address that is executing this parameter update. Should match the recovered signer. 298 | */ 299 | function _updateModuleParameters( 300 | uint256 profileId, 301 | uint256 pubId, 302 | bool commentsRestricted, 303 | bool mirrorsRestricted, 304 | uint8 degreesOfSeparation, 305 | address operator 306 | ) internal { 307 | if (IERC721(HUB).ownerOf(profileId) != operator) { 308 | revert Errors.NotProfileOwner(); 309 | } 310 | if (!_moduleConfigByPubByProfile[profileId][pubId].setUp) { 311 | revert PublicationNotSetUp(); 312 | } 313 | if (degreesOfSeparation > MAX_DEGREES_OF_SEPARATION) { 314 | revert InvalidDegreesOfSeparation(); 315 | } 316 | _moduleConfigByPubByProfile[profileId][pubId] = ModuleConfig( 317 | true, 318 | commentsRestricted, 319 | mirrorsRestricted, 320 | degreesOfSeparation 321 | ); 322 | emit ModuleParametersUpdated( 323 | profileId, 324 | pubId, 325 | commentsRestricted, 326 | mirrorsRestricted, 327 | degreesOfSeparation 328 | ); 329 | } 330 | 331 | /** 332 | * @notice Checks if the signature for the `UpdateModuleParametersWithSig` function is valid according EIP-712. 333 | * 334 | * @param profileId The token ID of the profile associated with the publication. 335 | * @param pubId The publication ID associated with the publication. 336 | * @param commentsRestricted Indicates if the comment operation is restricted or open to everyone. 337 | * @param mirrorsRestricted Indicates if the mirror operation is restricted or open to everyone. 338 | * @param degreesOfSeparation The max degrees of separation allowed for restricted operations. 339 | * @param operator The address that is executing this parameter update. Should match the recovered signer. 340 | * @param sig The EIP-712 signature for this operation. 341 | */ 342 | function _validateUpdateModuleParametersSignature( 343 | uint256 profileId, 344 | uint256 pubId, 345 | bool commentsRestricted, 346 | bool mirrorsRestricted, 347 | uint8 degreesOfSeparation, 348 | address operator, 349 | DataTypes.EIP712Signature calldata sig 350 | ) internal { 351 | unchecked { 352 | _validateRecoveredAddress( 353 | _calculateDigest( 354 | abi.encode( 355 | keccak256( 356 | 'UpdateModuleParametersWithSig(uint256 profileId,uint256 pubId,bool commentsRestricted,bool mirrorsRestricted,uint8 degreesOfSeparation,uint256 nonce,uint256 deadline)' 357 | ), 358 | profileId, 359 | pubId, 360 | commentsRestricted, 361 | mirrorsRestricted, 362 | degreesOfSeparation, 363 | nonces[operator]++, 364 | sig.deadline 365 | ) 366 | ), 367 | operator, 368 | sig 369 | ); 370 | } 371 | } 372 | 373 | /** 374 | * @notice Checks the recovered address is the expected signer for the given signature. 375 | * 376 | * @param digest The expected signed data. 377 | * @param expectedAddress The address of the expected signer. 378 | * @param sig The signature. 379 | */ 380 | function _validateRecoveredAddress( 381 | bytes32 digest, 382 | address expectedAddress, 383 | DataTypes.EIP712Signature calldata sig 384 | ) internal view { 385 | if (sig.deadline < block.timestamp) { 386 | revert Errors.SignatureExpired(); 387 | } 388 | address recoveredAddress = ecrecover(digest, sig.v, sig.r, sig.s); 389 | if (recoveredAddress == address(0) || recoveredAddress != expectedAddress) { 390 | revert Errors.SignatureInvalid(); 391 | } 392 | } 393 | 394 | /** 395 | * @notice Calculates the digest for the given bytes according EIP-712 standard. 396 | * 397 | * @param message The message, as bytes, to calculate the digest from. 398 | */ 399 | function _calculateDigest(bytes memory message) internal view returns (bytes32) { 400 | return keccak256(abi.encodePacked('\x19\x01', _domainSeparatorV4(), keccak256(message))); 401 | } 402 | } 403 | -------------------------------------------------------------------------------- /contracts/reference/TokenGatedReferenceModule.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity 0.8.10; 4 | 5 | import {IReferenceModule} from '@aave/lens-protocol/contracts/interfaces/IReferenceModule.sol'; 6 | import {ModuleBase} from '@aave/lens-protocol/contracts/core/modules/ModuleBase.sol'; 7 | import {IERC721} from '@openzeppelin/contracts/token/ERC721/IERC721.sol'; 8 | import {Errors} from '@aave/lens-protocol/contracts/libraries/Errors.sol'; 9 | 10 | interface IToken { 11 | /** 12 | * @dev Returns the amount of tokens owned by `account`. 13 | */ 14 | function balanceOf(address account) external view returns (uint256); 15 | } 16 | 17 | /** 18 | * @notice A struct containing the necessary data to execute TokenGated references. 19 | * 20 | * @param tokenAddress The address of ERC20/ERC721 token used for gating the reference 21 | * @param minThreshold The minimum balance threshold of the gated token required to execute a reference 22 | */ 23 | struct GateParams { 24 | address tokenAddress; 25 | uint256 minThreshold; 26 | } 27 | 28 | /** 29 | * @title TokenGatedReferenceModule 30 | * @author Lens Protocol 31 | * 32 | * @notice A reference module that validates that the user who tries to reference has a required minimum balance of ERC20/ERC721 token. 33 | */ 34 | contract TokenGatedReferenceModule is ModuleBase, IReferenceModule { 35 | uint256 internal constant UINT256_BYTES = 32; 36 | 37 | event TokenGatedReferencePublicationCreated( 38 | uint256 indexed profileId, 39 | uint256 indexed pubId, 40 | address tokenAddress, 41 | uint256 minThreshold 42 | ); 43 | 44 | error NotEnoughBalance(); 45 | 46 | mapping(uint256 => mapping(uint256 => GateParams)) internal _gateParamsByPublicationByProfile; 47 | 48 | constructor(address hub) ModuleBase(hub) {} 49 | 50 | /** 51 | * @dev The gating token address and minimum balance threshold is passed during initialization in data field (see `GateParams` struct) 52 | * 53 | * @inheritdoc IReferenceModule 54 | */ 55 | function initializeReferenceModule( 56 | uint256 profileId, 57 | uint256 pubId, 58 | bytes calldata data 59 | ) external override onlyHub returns (bytes memory) { 60 | GateParams memory gateParams = abi.decode(data, (GateParams)); 61 | 62 | // Checking if the tokenAddress resembles ERC20/ERC721 token (by calling balanceOf() function) 63 | (bool success, bytes memory result) = gateParams.tokenAddress.staticcall( 64 | abi.encodeWithSignature('balanceOf(address)', address(this)) 65 | ); 66 | // We don't check if the contract exists cause we expect the return data anyway 67 | if (gateParams.minThreshold == 0 || !success || result.length != UINT256_BYTES) 68 | revert Errors.InitParamsInvalid(); 69 | 70 | _gateParamsByPublicationByProfile[profileId][pubId] = gateParams; 71 | emit TokenGatedReferencePublicationCreated( 72 | profileId, 73 | pubId, 74 | gateParams.tokenAddress, 75 | gateParams.minThreshold 76 | ); 77 | return data; 78 | } 79 | 80 | /** 81 | * @notice Validates that the commenting profile's owner has enough balance of the gating token. 82 | */ 83 | function processComment( 84 | uint256 profileId, 85 | uint256 profileIdPointed, 86 | uint256 pubIdPointed, 87 | bytes calldata data 88 | ) external view override onlyHub { 89 | _validateTokenBalance(profileId, profileIdPointed, pubIdPointed); 90 | } 91 | 92 | /** 93 | * @notice Validates that the mirroring profile's owner has enough balance of the gating token. 94 | */ 95 | function processMirror( 96 | uint256 profileId, 97 | uint256 profileIdPointed, 98 | uint256 pubIdPointed, 99 | bytes calldata data 100 | ) external view override onlyHub { 101 | _validateTokenBalance(profileId, profileIdPointed, pubIdPointed); 102 | } 103 | 104 | /** 105 | * @dev Validates the profile's owner balance of gating token. 106 | * @dev Can work with both ERC20 and ERC721 as both interfaces support balanceOf() call 107 | */ 108 | function _validateTokenBalance( 109 | uint256 profileId, 110 | uint256 profileIdPointed, 111 | uint256 pubIdPointed 112 | ) internal view { 113 | GateParams memory gateParams = _gateParamsByPublicationByProfile[profileIdPointed][ 114 | pubIdPointed 115 | ]; 116 | if ( 117 | IToken(gateParams.tokenAddress).balanceOf(IERC721(HUB).ownerOf(profileId)) < 118 | gateParams.minThreshold 119 | ) { 120 | revert NotEnoughBalance(); 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /foundry.toml: -------------------------------------------------------------------------------- 1 | [profile.default] 2 | src = 'contracts' 3 | out = 'out' 4 | libs = ['node_modules', 'lib'] 5 | test = 'test/foundry' 6 | cache_path = 'forge-cache' 7 | solc_version = '0.8.10' 8 | optimizer = true 9 | optimizer_runs = 99999 10 | fs_permissions = [{ access = "read", path = "./addresses.json"}] 11 | 12 | [rpc_endpoints] 13 | polygon = "${POLYGON_RPC_URL}" 14 | mumbai = "${MUMBAI_RPC_URL}" 15 | 16 | [etherscan] 17 | polygon = { key = "${BLOCK_EXPLORER_KEY}" } 18 | mumbai = { key = "${BLOCK_EXPLORER_KEY}" } 19 | 20 | [fuzz] 21 | runs = 10000 22 | 23 | # See more config options https://github.com/foundry-rs/foundry/tree/master/config 24 | -------------------------------------------------------------------------------- /hardhat.config.ts: -------------------------------------------------------------------------------- 1 | import { HardhatUserConfig } from 'hardhat/types'; 2 | import { accounts } from './helpers/test-wallets'; 3 | import { HARDHATEVM_CHAINID } from './helpers/hardhat-constants'; 4 | import { NETWORKS_RPC_URL } from './helper-hardhat-config'; 5 | import dotenv from 'dotenv'; 6 | import glob from 'glob'; 7 | import path from 'path'; 8 | dotenv.config({ path: '../.env' }); 9 | 10 | import 'hardhat-dependency-compiler'; 11 | import '@nomiclabs/hardhat-ethers'; 12 | import '@nomiclabs/hardhat-etherscan'; 13 | import '@typechain/hardhat'; 14 | import 'solidity-coverage'; 15 | import 'hardhat-gas-reporter'; 16 | import 'hardhat-contract-sizer'; 17 | import 'hardhat-log-remover'; 18 | import 'hardhat-spdx-license-identifier'; 19 | import { eEthereumNetwork, eNetwork, ePolygonNetwork, eXDaiNetwork } from './helpers/types'; 20 | 21 | if (!process.env.SKIP_LOAD) { 22 | glob.sync('./tasks/**/*.ts').forEach(function (file) { 23 | require(path.resolve(file)); 24 | }); 25 | } 26 | 27 | const DEFAULT_BLOCK_GAS_LIMIT = 12450000; 28 | const MNEMONIC_PATH = "m/44'/60'/0'/0"; 29 | const MNEMONIC = process.env.MNEMONIC || ''; 30 | const MAINNET_FORK = process.env.MAINNET_FORK === 'true'; 31 | const TRACK_GAS = process.env.TRACK_GAS === 'true'; 32 | const BLOCK_EXPLORER_KEY = process.env.BLOCK_EXPLORER_KEY || ''; 33 | 34 | const getCommonNetworkConfig = (networkName: eNetwork, networkId: number) => ({ 35 | url: NETWORKS_RPC_URL[networkName] ?? '', 36 | accounts: { 37 | mnemonic: MNEMONIC, 38 | path: MNEMONIC_PATH, 39 | initialIndex: 0, 40 | count: 20, 41 | }, 42 | }); 43 | 44 | const mainnetFork = MAINNET_FORK 45 | ? { 46 | blockNumber: 12012081, 47 | url: NETWORKS_RPC_URL['main'], 48 | } 49 | : undefined; 50 | 51 | const config: HardhatUserConfig = { 52 | solidity: { 53 | compilers: [ 54 | { 55 | version: '0.8.10', 56 | settings: { 57 | optimizer: { 58 | enabled: true, 59 | runs: 200, 60 | details: { 61 | yul: true, 62 | }, 63 | }, 64 | }, 65 | }, 66 | ], 67 | }, 68 | networks: { 69 | kovan: getCommonNetworkConfig(eEthereumNetwork.kovan, 42), 70 | ropsten: getCommonNetworkConfig(eEthereumNetwork.ropsten, 3), 71 | main: getCommonNetworkConfig(eEthereumNetwork.main, 1), 72 | tenderlyMain: getCommonNetworkConfig(eEthereumNetwork.tenderlyMain, 3030), 73 | matic: getCommonNetworkConfig(ePolygonNetwork.matic, 137), 74 | mumbai: getCommonNetworkConfig(ePolygonNetwork.mumbai, 80001), 75 | xdai: getCommonNetworkConfig(eXDaiNetwork.xdai, 100), 76 | hardhat: { 77 | hardfork: 'london', 78 | blockGasLimit: DEFAULT_BLOCK_GAS_LIMIT, 79 | gas: DEFAULT_BLOCK_GAS_LIMIT, 80 | gasPrice: 8000000000, 81 | chainId: HARDHATEVM_CHAINID, 82 | throwOnTransactionFailures: true, 83 | throwOnCallFailures: true, 84 | accounts: accounts.map(({ secretKey, balance }: { secretKey: string; balance: string }) => ({ 85 | privateKey: secretKey, 86 | balance, 87 | })), 88 | forking: mainnetFork, 89 | }, 90 | }, 91 | gasReporter: { 92 | enabled: TRACK_GAS, 93 | }, 94 | spdxLicenseIdentifier: { 95 | overwrite: false, 96 | runOnCompile: false, 97 | }, 98 | etherscan: { 99 | apiKey: BLOCK_EXPLORER_KEY, 100 | }, 101 | dependencyCompiler: { 102 | paths: [ 103 | '@aave/lens-protocol/contracts/core/LensHub.sol', 104 | '@aave/lens-protocol/contracts/core/modules/ModuleGlobals.sol', 105 | '@aave/lens-protocol/contracts/core/modules/collect/FreeCollectModule.sol', 106 | '@aave/lens-protocol/contracts/core/modules/follow/ProfileFollowModule.sol', 107 | '@aave/lens-protocol/contracts/core/FollowNFT.sol', 108 | '@aave/lens-protocol/contracts/core/CollectNFT.sol', 109 | '@aave/lens-protocol/contracts/mocks/Currency.sol', 110 | '@aave/lens-protocol/contracts/upgradeability/TransparentUpgradeableProxy.sol', 111 | ], 112 | }, 113 | }; 114 | 115 | export default config; 116 | -------------------------------------------------------------------------------- /helper-hardhat-config.ts: -------------------------------------------------------------------------------- 1 | import { 2 | eEthereumNetwork, 3 | ePolygonNetwork, 4 | eXDaiNetwork, 5 | iParamsPerNetwork, 6 | } from './helpers/types'; 7 | 8 | import dotenv from 'dotenv'; 9 | dotenv.config({}); 10 | 11 | const TENDERLY_FORK_ID = process.env.TENDERLY_FORK_ID || ''; 12 | 13 | const GWEI = 1000 * 1000 * 1000; 14 | 15 | export const NETWORKS_RPC_URL: iParamsPerNetwork = { 16 | [eEthereumNetwork.kovan]: process.env.KOVAN_RPC_URL, 17 | [eEthereumNetwork.ropsten]: process.env.ROPSTEN_RPC_URL, 18 | [eEthereumNetwork.main]: process.env.MAINNET_RPC_URL, 19 | [eEthereumNetwork.hardhat]: 'http://localhost:8545', 20 | [eEthereumNetwork.harhatevm]: 'http://localhost:8545', 21 | [eEthereumNetwork.tenderlyMain]: `https://rpc.tenderly.co/fork/${TENDERLY_FORK_ID}`, 22 | [ePolygonNetwork.mumbai]: process.env.MUMBAI_RPC_URL, 23 | [ePolygonNetwork.matic]: process.env.POLYGON_RPC_URL, 24 | [eXDaiNetwork.xdai]: 'https://rpc.xdaichain.com/', 25 | }; 26 | -------------------------------------------------------------------------------- /helpers/hardhat-constants.ts: -------------------------------------------------------------------------------- 1 | export const TEST_SNAPSHOT_ID = '0x1'; 2 | export const HARDHATEVM_CHAINID = 31337; 3 | -------------------------------------------------------------------------------- /helpers/test-wallets.ts: -------------------------------------------------------------------------------- 1 | const balance = '1000000000000000000000000'; 2 | 3 | export const accounts = [ 4 | { 5 | secretKey: '0xc5e8f61d1ab959b397eecc0a37a6517b8e67a0e7cf1f4bce5591f3ed80199122', 6 | balance, 7 | }, 8 | { 9 | secretKey: '0xd49743deccbccc5dc7baa8e69e5be03298da8688a15dd202e20f15d5e0e9a9fb', 10 | balance, 11 | }, 12 | { 13 | secretKey: '0x23c601ae397441f3ef6f1075dcb0031ff17fb079837beadaf3c84d96c6f3e569', 14 | balance, 15 | }, 16 | { 17 | secretKey: '0xee9d129c1997549ee09c0757af5939b2483d80ad649a0eda68e8b0357ad11131', 18 | balance, 19 | }, 20 | { 21 | secretKey: '0x87630b2d1de0fbd5044eb6891b3d9d98c34c8d310c852f98550ba774480e47cc', 22 | balance, 23 | }, 24 | { 25 | secretKey: '0x275cc4a2bfd4f612625204a20a2280ab53a6da2d14860c47a9f5affe58ad86d4', 26 | balance, 27 | }, 28 | { 29 | secretKey: '0xaee25d55ce586148a853ca83fdfacaf7bc42d5762c6e7187e6f8e822d8e6a650', 30 | balance, 31 | }, 32 | { 33 | secretKey: '0xa2e0097c961c67ec197b6865d7ecea6caffc68ebeb00e6050368c8f67fc9c588', 34 | balance, 35 | }, 36 | { 37 | secretKey: '0xb2e0097c961c67ec197b6865d7ecea6caffc68ebeb00e6050368c8f67fc9c569', 38 | balance, 39 | }, 40 | { 41 | secretKey: '0xc2e0097c961c67ec197b6865d7ecea6caffc68ebeb00e6050368c8f67fc9c500', 42 | balance, 43 | }, 44 | ]; 45 | -------------------------------------------------------------------------------- /helpers/types.ts: -------------------------------------------------------------------------------- 1 | export interface SymbolMap { 2 | [symbol: string]: T; 3 | } 4 | 5 | export type eNetwork = eEthereumNetwork | ePolygonNetwork | eXDaiNetwork; 6 | 7 | export enum eEthereumNetwork { 8 | kovan = 'kovan', 9 | ropsten = 'ropsten', 10 | main = 'main', 11 | hardhat = 'hardhat', 12 | tenderlyMain = 'tenderlyMain', 13 | harhatevm = 'harhatevm', 14 | } 15 | 16 | export enum ePolygonNetwork { 17 | matic = 'matic', 18 | mumbai = 'mumbai', 19 | } 20 | 21 | export enum eXDaiNetwork { 22 | xdai = 'xdai', 23 | } 24 | 25 | export enum EthereumNetworkNames { 26 | kovan = 'kovan', 27 | ropsten = 'ropsten', 28 | main = 'main', 29 | matic = 'matic', 30 | mumbai = 'mumbai', 31 | xdai = 'xdai', 32 | } 33 | 34 | export type tEthereumAddress = string; 35 | export type tStringTokenBigUnits = string; // 1 ETH, or 10e6 USDC or 10e18 DAI 36 | export type tStringTokenSmallUnits = string; // 1 wei, or 1 basic unit of USDC, or 1 basic unit of DAI 37 | 38 | export type iParamsPerNetwork = 39 | | iEthereumParamsPerNetwork 40 | | iPolygonParamsPerNetwork 41 | | iXDaiParamsPerNetwork; 42 | 43 | export interface iParamsPerNetworkAll 44 | extends iEthereumParamsPerNetwork, 45 | iPolygonParamsPerNetwork, 46 | iXDaiParamsPerNetwork {} 47 | 48 | export interface iEthereumParamsPerNetwork { 49 | [eEthereumNetwork.harhatevm]: eNetwork; 50 | [eEthereumNetwork.kovan]: eNetwork; 51 | [eEthereumNetwork.ropsten]: eNetwork; 52 | [eEthereumNetwork.main]: eNetwork; 53 | [eEthereumNetwork.hardhat]: eNetwork; 54 | [eEthereumNetwork.tenderlyMain]: eNetwork; 55 | } 56 | 57 | export interface iPolygonParamsPerNetwork { 58 | [ePolygonNetwork.matic]: T; 59 | [ePolygonNetwork.mumbai]: T; 60 | } 61 | 62 | export interface iXDaiParamsPerNetwork { 63 | [eXDaiNetwork.xdai]: T; 64 | } 65 | 66 | export interface ObjectString { 67 | [key: string]: string; 68 | } 69 | -------------------------------------------------------------------------------- /helpers/wallet-helpers.ts: -------------------------------------------------------------------------------- 1 | import { Wallet, Signer } from 'ethers'; 2 | import { DefenderRelaySigner, DefenderRelayProvider } from 'defender-relay-client/lib/ethers'; 3 | import dotenv from 'dotenv'; 4 | 5 | dotenv.config({ path: '../.env' }); 6 | 7 | const PRIVATE_KEY = process.env.PRIVATE_KEY || ''; 8 | const MNEMONIC = process.env.MNEMONIC || ''; 9 | const DEFENDER_API_KEY = process.env.DEFENDER_API_KEY || ''; 10 | const DEFENDER_SECRET_KEY = process.env.DEFENDER_SECRET_KEY || ''; 11 | 12 | export const getPrivateKeyWallet = (): Signer => new Wallet(PRIVATE_KEY); 13 | 14 | export const getMnemonicWallet = (): Signer => Wallet.fromMnemonic(MNEMONIC); 15 | 16 | export const getDefenderSigner = (): Signer => { 17 | const credentials = { apiKey: DEFENDER_API_KEY, apiSecret: DEFENDER_SECRET_KEY }; 18 | const provider = new DefenderRelayProvider(credentials); 19 | return new DefenderRelaySigner(credentials, provider, { speed: 'fast' }); 20 | }; 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@lens-protocol/modules", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "test": "npm run compile && TRACK_GAS=true hardhat test", 6 | "quick-test": "hardhat test", 7 | "coverage": "npm run compile && SKIP_LOAD=true hardhat coverage", 8 | "compile": "SKIP_LOAD=true hardhat clean && SKIP_LOAD=true hardhat compile" 9 | }, 10 | "engines": { 11 | "node": ">=8.3.0" 12 | }, 13 | "devDependencies": { 14 | "@aave/core-v3": "1.16.2", 15 | "@ethersproject/abi": "^5.6.3", 16 | "@ethersproject/bignumber": "^5.6.2", 17 | "@nomiclabs/hardhat-ethers": "^2.0.6", 18 | "@nomiclabs/hardhat-etherscan": "^3.0.3", 19 | "@nomiclabs/hardhat-waffle": "^2.0.3", 20 | "@openzeppelin/contracts": "^4.7.3", 21 | "@typechain/ethers-v5": "^7.2.0", 22 | "@typechain/hardhat": "^2.3.1", 23 | "@types/chai": "^4.3.1", 24 | "@types/mocha": "^9.1.1", 25 | "@types/node": "^12.20.52", 26 | "@typescript-eslint/eslint-plugin": "^4.33.0", 27 | "@typescript-eslint/parser": "^4.33.0", 28 | "chai": "^4.3.6", 29 | "dotenv": "^10.0.0", 30 | "eslint": "^7.32.0", 31 | "eslint-config-prettier": "^8.5.0", 32 | "eslint-config-standard": "^16.0.3", 33 | "eslint-plugin-import": "^2.26.0", 34 | "eslint-plugin-node": "^11.1.0", 35 | "eslint-plugin-prettier": "^3.4.1", 36 | "eslint-plugin-promise": "^5.2.0", 37 | "ethereum-waffle": "^3.4.4", 38 | "ethers": "^5.6.6", 39 | "hardhat": "^2.9.5", 40 | "hardhat-contract-sizer": "^2.5.1", 41 | "hardhat-dependency-compiler": "^1.1.3", 42 | "hardhat-gas-reporter": "^1.0.8", 43 | "hardhat-log-remover": "^2.0.2", 44 | "hardhat-spdx-license-identifier": "^2.0.3", 45 | "prettier": "^2.6.2", 46 | "prettier-plugin-solidity": "^1.0.0-beta.13", 47 | "solhint": "^3.3.7", 48 | "solidity-coverage": "^0.7.21", 49 | "ts-node": "^10.7.0", 50 | "typechain": "^5.2.0", 51 | "typescript": "^4.6.4" 52 | }, 53 | "dependencies": { 54 | "@aave/lens-protocol": "^1.0.2", 55 | "@openzeppelin/contracts": "^4.7.3", 56 | "solmate": "^6.6.1" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /remappings.txt: -------------------------------------------------------------------------------- 1 | ds-test/=lib/forge-std/lib/ds-test/src/ 2 | forge-std/=lib/forge-std/src/ 3 | @aave/=node_modules/@aave/ 4 | @openzeppelin/=node_modules/@openzeppelin/ -------------------------------------------------------------------------------- /script/deploy-module.s.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.10; 3 | 4 | import 'forge-std/Script.sol'; 5 | import 'forge-std/StdJson.sol'; 6 | import {StepwiseCollectModule} from 'contracts/collect/StepwiseCollectModule.sol'; 7 | import {MultirecipientFeeCollectModule} from 'contracts/collect/MultirecipientFeeCollectModule.sol'; 8 | import {AaveFeeCollectModule} from 'contracts/collect/AaveFeeCollectModule.sol'; 9 | import {ERC4626FeeCollectModule} from 'contracts/collect/ERC4626FeeCollectModule.sol'; 10 | import {TokenGatedReferenceModule} from 'contracts/reference/TokenGatedReferenceModule.sol'; 11 | import {ForkManagement} from 'script/helpers/ForkManagement.sol'; 12 | 13 | contract DeployBase is Script, ForkManagement { 14 | using stdJson for string; 15 | 16 | uint256 deployerPrivateKey; 17 | address deployer; 18 | address lensHubProxy; 19 | address moduleGlobals; 20 | 21 | function loadBaseAddresses(string memory json, string memory targetEnv) internal virtual { 22 | lensHubProxy = json.readAddress(string(abi.encodePacked('.', targetEnv, '.LensHubProxy'))); 23 | moduleGlobals = json.readAddress( 24 | string(abi.encodePacked('.', targetEnv, '.ModuleGlobals')) 25 | ); 26 | } 27 | 28 | function loadPrivateKeys() internal { 29 | string memory mnemonic = vm.envString('MNEMONIC'); 30 | 31 | if (bytes(mnemonic).length > 0) { 32 | (deployer, deployerPrivateKey) = deriveRememberKey(mnemonic, 0); 33 | } else { 34 | deployerPrivateKey = vm.envUint('PRIVATE_KEY'); 35 | deployer = vm.addr(deployerPrivateKey); 36 | } 37 | 38 | console.log('\nDeployer address:', deployer); 39 | console.log('Deployer balance:', deployer.balance); 40 | } 41 | 42 | function run(string calldata targetEnv) external { 43 | string memory json = loadJson(); 44 | checkNetworkParams(json, targetEnv); 45 | loadBaseAddresses(json, targetEnv); 46 | loadPrivateKeys(); 47 | 48 | address module = deploy(); 49 | console.log('New Deployment Address:', address(module)); 50 | } 51 | 52 | function deploy() internal virtual returns (address) {} 53 | } 54 | 55 | contract DeployStepwiseCollectModule is DeployBase { 56 | function deploy() internal override returns (address) { 57 | console.log('\nContract: StepwiseCollectModule'); 58 | console.log('Init params:'); 59 | console.log('\tLensHubProxy:', lensHubProxy); 60 | console.log('\tModuleGlobals:', moduleGlobals); 61 | 62 | vm.startBroadcast(deployerPrivateKey); 63 | StepwiseCollectModule stepwiseCollectModule = new StepwiseCollectModule( 64 | lensHubProxy, 65 | moduleGlobals 66 | ); 67 | vm.stopBroadcast(); 68 | 69 | console.log('Constructor arguments:'); 70 | console.logBytes(abi.encode(lensHubProxy, moduleGlobals)); 71 | 72 | return address(stepwiseCollectModule); 73 | } 74 | } 75 | 76 | contract DeployMultirecipientFeeCollectModule is DeployBase { 77 | function deploy() internal override returns (address) { 78 | console.log('\nContract: MultirecipientFeeCollectModule'); 79 | console.log('Init params:'); 80 | console.log('\tLensHubProxy:', lensHubProxy); 81 | console.log('\tModuleGlobals:', moduleGlobals); 82 | 83 | vm.startBroadcast(deployerPrivateKey); 84 | MultirecipientFeeCollectModule module = new MultirecipientFeeCollectModule( 85 | lensHubProxy, 86 | moduleGlobals 87 | ); 88 | vm.stopBroadcast(); 89 | 90 | console.log('Constructor arguments:'); 91 | console.logBytes(abi.encode(lensHubProxy, moduleGlobals)); 92 | 93 | return address(module); 94 | } 95 | } 96 | 97 | import {IPoolAddressesProvider} from '@aave/core-v3/contracts/interfaces/IPoolAddressesProvider.sol'; 98 | 99 | contract DeployAaveFeeCollectModule is DeployBase { 100 | using stdJson for string; 101 | address poolAddressesProvider; 102 | 103 | function loadBaseAddresses(string memory json, string memory targetEnv) internal override { 104 | super.loadBaseAddresses(json, targetEnv); 105 | poolAddressesProvider = json.readAddress( 106 | string(abi.encodePacked('.', targetEnv, '.PoolAddressesProvider')) 107 | ); 108 | } 109 | 110 | function deploy() internal override returns (address) { 111 | console.log('\nContract: AaveFeeCollectModule'); 112 | console.log('Init params:'); 113 | console.log('\tLensHubProxy:', lensHubProxy); 114 | console.log('\tModuleGlobals:', moduleGlobals); 115 | console.log('\tPoolAddressesProvider:', poolAddressesProvider); 116 | 117 | vm.startBroadcast(deployerPrivateKey); 118 | AaveFeeCollectModule module = new AaveFeeCollectModule( 119 | lensHubProxy, 120 | moduleGlobals, 121 | IPoolAddressesProvider(poolAddressesProvider) 122 | ); 123 | vm.stopBroadcast(); 124 | 125 | console.log('Constructor arguments:'); 126 | console.logBytes( 127 | abi.encode(lensHubProxy, moduleGlobals, IPoolAddressesProvider(poolAddressesProvider)) 128 | ); 129 | 130 | return address(module); 131 | } 132 | } 133 | 134 | contract DeployERC4626FeeCollectModule is DeployBase { 135 | function deploy() internal override returns (address) { 136 | console.log('\nContract: ERC4626FeeCollectModule'); 137 | console.log('Init params:'); 138 | console.log('\tLensHubProxy:', lensHubProxy); 139 | console.log('\tModuleGlobals:', moduleGlobals); 140 | 141 | vm.startBroadcast(deployerPrivateKey); 142 | ERC4626FeeCollectModule module = new ERC4626FeeCollectModule(lensHubProxy, moduleGlobals); 143 | vm.stopBroadcast(); 144 | 145 | console.log('Constructor arguments:'); 146 | console.logBytes(abi.encode(lensHubProxy, moduleGlobals)); 147 | 148 | return address(module); 149 | } 150 | } 151 | 152 | contract DeployTokenGatedReferenceModule is DeployBase { 153 | function deploy() internal override returns (address) { 154 | console.log('\nContract: TokenGatedReferenceModule'); 155 | console.log('Init params:'); 156 | console.log('\tLensHubProxy:', lensHubProxy); 157 | 158 | vm.startBroadcast(deployerPrivateKey); 159 | TokenGatedReferenceModule module = new TokenGatedReferenceModule(lensHubProxy); 160 | vm.stopBroadcast(); 161 | 162 | console.log('Constructor arguments:'); 163 | console.logBytes(abi.encode(lensHubProxy)); 164 | 165 | return address(module); 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /script/deploy-module.sh: -------------------------------------------------------------------------------- 1 | source .env 2 | 3 | set -e 4 | 5 | if [[ $1 == "" || $2 == "" || ($3 != "--verify-only" && $3 != "")]] 6 | then 7 | echo "Usage:" 8 | echo " deploy-module.sh [target environment] [contractName] --verify-only [constructor-args]" 9 | echo " where target environment (required): mainnet / testnet / sandbox" 10 | echo " where contractName (required): contract name you want to deploy" 11 | echo " --verify-only: if you only want to verify the existing deployment source code" 12 | echo " [constructor-args] are ABI-Encoded for verification (see 'cast abi-encode' docs)" 13 | echo "" 14 | echo "Example:" 15 | echo " deploy-module.sh sandbox StepwiseCollectModule" 16 | exit 1 17 | fi 18 | 19 | if [[ $1 == "mainnet" ]] 20 | then 21 | VERIFIER_URL=$MAINNET_EXPLORER_API 22 | else 23 | if [[ $1 == "testnet" || $1 == "sandbox" ]] 24 | then 25 | VERIFIER_URL=$TESTNET_EXPLORER_API 26 | else 27 | echo "Unrecognized target environment '$1'. Should be one of mainnet/testnet/sandbox" 28 | exit 1 29 | fi 30 | fi 31 | 32 | NETWORK=$(node script/helpers/readNetwork.js $1) 33 | if [[ $NETWORK == "" ]] 34 | then 35 | echo "No network found for $1 environment target in addresses.json. Terminating" 36 | exit 1 37 | fi 38 | 39 | SAVED_ADDRESS=$(node script/helpers/readAddress.js $1 $2) 40 | if [[ $3 == "--verify-only" ]] 41 | then 42 | echo "Running in verify-only mode (will verify the source code of existing deployment)" 43 | if [[ $SAVED_ADDRESS != "" ]] 44 | then 45 | echo "Found $2 on '$1' at: $SAVED_ADDRESS" 46 | read -p "Should we proceed with verification? (y/n):" CONFIRMATION 47 | if [[ $CONFIRMATION != "y" && $CONFIRMATION != "Y" ]] 48 | then 49 | echo "Verification cancelled. Execution terminated." 50 | exit 1 51 | fi 52 | echo "forge verify-contract $SAVED_ADDRESS $2 $BLOCK_EXPLORER_KEY --verifier-url "$VERIFIER_URL" --constructor-args "$4" --watch" 53 | forge verify-contract $SAVED_ADDRESS $2 $BLOCK_EXPLORER_KEY --verifier-url "$VERIFIER_URL" --constructor-args "$4" --watch 54 | exit 0 55 | else 56 | echo "Can't find the $2 deployment address on '$1' for verification. Terminating" 57 | exit 1 58 | fi 59 | fi 60 | 61 | if [[ $SAVED_ADDRESS != "" ]] 62 | then 63 | echo "Found $2 already deployed on $1 at: $SAVED_ADDRESS" 64 | read -p "Should we redeploy it? (y/n):" CONFIRMATION 65 | if [[ $CONFIRMATION != "y" && $CONFIRMATION != "Y" ]] 66 | then 67 | echo "Deployment cancelled. Execution terminated." 68 | exit 1 69 | fi 70 | fi 71 | 72 | CALLDATA=$(cast calldata "run(string)" $1) 73 | 74 | forge script script/deploy-module.s.sol:Deploy$2 -s $CALLDATA --rpc-url $NETWORK 75 | 76 | read -p "Please verify the data and confirm the deployment (y/n):" CONFIRMATION 77 | 78 | if [[ $CONFIRMATION == "y" || $CONFIRMATION == "Y" ]] 79 | then 80 | echo "Deploying..." 81 | 82 | FORGE_OUTPUT=$(forge script script/deploy-module.s.sol:Deploy$2 -s $CALLDATA --rpc-url $NETWORK --broadcast) 83 | echo "$FORGE_OUTPUT" 84 | 85 | DEPLOYED_ADDRESS=$(echo "$FORGE_OUTPUT" | grep "Contract Address:" | sed -n 's/.*: \(0x[0-9a-hA-H]\{40\}\)/\1/p') 86 | 87 | if [[ $DEPLOYED_ADDRESS == "" ]] 88 | then 89 | echo "Cannot find Deployed address of $2 in foundry logs. Terminating" 90 | exit 1 91 | fi 92 | 93 | node script/helpers/saveAddress.js $1 $2 $DEPLOYED_ADDRESS 94 | 95 | CONSTRUCTOR_ARGS=$(echo "$FORGE_OUTPUT" | awk '/Constructor arguments:/{getline; gsub(/ /,""); print}') 96 | echo "($CONSTRUCTOR_ARGS)" 97 | 98 | echo "" 99 | read -p "Proceed with verification? (y/n):" CONFIRMATION 100 | if [[ $CONFIRMATION == "y" || $CONFIRMATION == "Y" ]] 101 | then 102 | forge verify-contract $DEPLOYED_ADDRESS $2 $BLOCK_EXPLORER_KEY --verifier-url "$VERIFIER_URL" --constructor-args "$CONSTRUCTOR_ARGS" --watch 103 | else 104 | "Verification cancelled. Terminating" 105 | exit 1 106 | fi 107 | else 108 | echo "Deployment cancelled. Execution terminated." 109 | fi 110 | -------------------------------------------------------------------------------- /script/helpers/ForkManagement.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.10; 3 | 4 | import 'forge-std/Script.sol'; 5 | 6 | contract ForkManagement is Script { 7 | using stdJson for string; 8 | 9 | function loadJson() internal returns (string memory) { 10 | string memory root = vm.projectRoot(); 11 | string memory path = string(abi.encodePacked(root, '/addresses.json')); 12 | string memory json = vm.readFile(path); 13 | return json; 14 | } 15 | 16 | function checkNetworkParams(string memory json, string memory targetEnv) 17 | internal 18 | returns (string memory network, uint256 chainId) 19 | { 20 | network = json.readString(string(abi.encodePacked('.', targetEnv, '.network'))); 21 | chainId = json.readUint(string(abi.encodePacked('.', targetEnv, '.chainId'))); 22 | 23 | console.log('\nTarget environment:', targetEnv); 24 | console.log('Network:', network); 25 | if (block.chainid != chainId) revert('Wrong chainId'); 26 | console.log('ChainId:', chainId); 27 | } 28 | 29 | function getNetwork(string memory json, string memory targetEnv) 30 | internal 31 | returns (string memory) 32 | { 33 | return json.readString(string(abi.encodePacked('.', targetEnv, '.network'))); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /script/helpers/readAddress.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const addressesPath = '../../addresses.json'; 4 | 5 | const addresses = require(path.join(__dirname, addressesPath)); 6 | const [targetEnv, contract] = process.argv.slice(2); 7 | 8 | if (!addresses[targetEnv]) { 9 | console.error(`ERROR: Target environment "${targetEnv}" not found in addresses.json`); 10 | process.exit(1); 11 | } 12 | 13 | const address = addresses[targetEnv][contract]; 14 | 15 | console.log(address ?? ''); 16 | -------------------------------------------------------------------------------- /script/helpers/readNetwork.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const addressesPath = '../../addresses.json'; 4 | 5 | const addresses = require(path.join(__dirname, addressesPath)); 6 | const [targetEnv] = process.argv.slice(2); 7 | 8 | if (!addresses[targetEnv]) { 9 | console.error(`ERROR: Target environment "${targetEnv}" not found in addresses.json`); 10 | process.exit(1); 11 | } 12 | 13 | const network = addresses[targetEnv].network; 14 | 15 | if (!network) { 16 | console.error( 17 | `ERROR: "network" parameter not found under "${targetEnv}" target environment in addresses.json` 18 | ); 19 | process.exit(1); 20 | } 21 | 22 | console.log(network); 23 | -------------------------------------------------------------------------------- /script/helpers/saveAddress.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const fs = require('fs'); 3 | 4 | const addressesPath = '../../addresses.json'; 5 | 6 | const addresses = require(path.join(__dirname, addressesPath)); 7 | const [targetEnv, contract, address] = process.argv.slice(2); 8 | addresses[targetEnv][contract] = address; 9 | 10 | fs.writeFileSync(path.join(__dirname, addressesPath), JSON.stringify(addresses, null, 2) + '\n'); 11 | console.log('Updated `addresses.json`'); 12 | -------------------------------------------------------------------------------- /tasks/deployments/collect/deploy-ERC4626-fee-collect-module.ts: -------------------------------------------------------------------------------- 1 | import '@nomiclabs/hardhat-ethers'; 2 | import { task } from 'hardhat/config'; 3 | import { HardhatRuntimeEnvironment } from 'hardhat/types'; 4 | import { deployWithVerify } from '../../helpers/utils'; 5 | import { ERC4626FeeCollectModule__factory, LensHub__factory } from '../../../typechain'; 6 | 7 | export let runtimeHRE: HardhatRuntimeEnvironment; 8 | 9 | task( 10 | 'deploy-ERC4626-fee-collect-module', 11 | 'Deploys, verifies and whitelists the ERC4626 fee collect module' 12 | ) 13 | .addParam('hub') 14 | .addParam('globals') 15 | .setAction(async ({ hub, globals }, hre) => { 16 | runtimeHRE = hre; 17 | const ethers = hre.ethers; 18 | const accounts = await ethers.getSigners(); 19 | const deployer = accounts[0]; 20 | const governance = accounts[1]; 21 | 22 | console.log('\n\n- - - - - - - - Deploying ERC4626 fee collect module\n\n'); 23 | const erc4626FeeCollectModule = await deployWithVerify( 24 | new ERC4626FeeCollectModule__factory(deployer).deploy(hub, globals), 25 | [hub, globals], 26 | 'contracts/collect/ERC4626FeeCollectModule.sol:ERC4626FeeCollectModule' 27 | ); 28 | 29 | if (process.env.HARDHAT_NETWORK !== 'matic') { 30 | console.log('\n\n- - - - - - - - Whitelisting ERC4626 fee collect module\n\n'); 31 | await LensHub__factory.connect(hub, governance).whitelistCollectModule( 32 | erc4626FeeCollectModule.address, 33 | true 34 | ); 35 | } 36 | }); 37 | -------------------------------------------------------------------------------- /tasks/deployments/collect/deploy-aave-fee-collect-module.ts: -------------------------------------------------------------------------------- 1 | import '@nomiclabs/hardhat-ethers'; 2 | import { task } from 'hardhat/config'; 3 | import { HardhatRuntimeEnvironment } from 'hardhat/types'; 4 | import { deployWithVerify } from '../../helpers/utils'; 5 | import { AaveFeeCollectModule__factory, LensHub__factory } from '../../../typechain'; 6 | 7 | export let runtimeHRE: HardhatRuntimeEnvironment; 8 | 9 | const POOL_ADDRESSES_PROVIDER_ADDRESS_MUMBAI = '0x5343b5bA672Ae99d627A1C87866b8E53F47Db2E6'; 10 | const POOL_ADDRESSES_PROVIDER_ADDRESS_POLYGON = '0xa97684ead0e402dC232d5A977953DF7ECBaB3CDb'; 11 | 12 | task( 13 | 'deploy-aave-fee-collect-module', 14 | 'Deploys, verifies and whitelists the Aave fee collect module' 15 | ) 16 | .addParam('hub') 17 | .addParam('globals') 18 | .addOptionalParam('poolAddressProvider') 19 | .setAction(async ({ hub, globals, poolAddressProvider }, hre) => { 20 | runtimeHRE = hre; 21 | const ethers = hre.ethers; 22 | const accounts = await ethers.getSigners(); 23 | const deployer = accounts[0]; 24 | const governance = accounts[1]; 25 | 26 | // Setting pool address provider if left undefined 27 | if (!poolAddressProvider) 28 | poolAddressProvider = 29 | process.env.HARDHAT_NETWORK == 'matic' 30 | ? POOL_ADDRESSES_PROVIDER_ADDRESS_POLYGON 31 | : POOL_ADDRESSES_PROVIDER_ADDRESS_MUMBAI; 32 | 33 | console.log('\n\n- - - - - - - - Deploying Aave fee collect module\n\n'); 34 | const aaveFeeCollectModule = await deployWithVerify( 35 | new AaveFeeCollectModule__factory(deployer).deploy(hub, globals, poolAddressProvider), 36 | [hub, globals, poolAddressProvider], 37 | 'contracts/collect/AaveFeeCollectModule.sol:AaveFeeCollectModule' 38 | ); 39 | 40 | if (process.env.HARDHAT_NETWORK !== 'matic') { 41 | console.log('\n\n- - - - - - - - Whitelisting Aave fee collect module\n\n'); 42 | await LensHub__factory.connect(hub, governance).whitelistCollectModule( 43 | aaveFeeCollectModule.address, 44 | true 45 | ); 46 | } 47 | }); 48 | -------------------------------------------------------------------------------- /tasks/deployments/collect/deploy-auction-collect-module.ts: -------------------------------------------------------------------------------- 1 | import '@nomiclabs/hardhat-ethers'; 2 | import { task } from 'hardhat/config'; 3 | import { HardhatRuntimeEnvironment } from 'hardhat/types'; 4 | import { deployWithVerify } from '../../helpers/utils'; 5 | import { AuctionCollectModule__factory, LensHub__factory } from '../../../typechain'; 6 | 7 | export let runtimeHRE: HardhatRuntimeEnvironment; 8 | 9 | task('deploy-auction-collect-module', 'Deploys, verifies and whitelists the auction collect module') 10 | .addParam('hub') 11 | .addParam('globals') 12 | .setAction(async ({ hub, globals }, hre) => { 13 | runtimeHRE = hre; 14 | const ethers = hre.ethers; 15 | const accounts = await ethers.getSigners(); 16 | const deployer = accounts[0]; 17 | const governance = accounts[1]; 18 | 19 | console.log('\n\n- - - - - - - - Deploying auction collect module\n\n'); 20 | const auctionCollectModule = await deployWithVerify( 21 | new AuctionCollectModule__factory(deployer).deploy(hub, globals), 22 | [hub, globals], 23 | 'contracts/collect/AuctionCollectModule.sol:AuctionCollectModule' 24 | ); 25 | 26 | console.log('\n\n- - - - - - - - Whitelisting auction collect module\n\n'); 27 | await LensHub__factory.connect(hub, governance).whitelistCollectModule( 28 | auctionCollectModule.address, 29 | true 30 | ); 31 | }); 32 | -------------------------------------------------------------------------------- /tasks/deployments/collect/deploy-stepwise-collect-module.ts: -------------------------------------------------------------------------------- 1 | import '@nomiclabs/hardhat-ethers'; 2 | import { task } from 'hardhat/config'; 3 | import { HardhatRuntimeEnvironment } from 'hardhat/types'; 4 | import { deployWithVerify } from '../../helpers/utils'; 5 | import { StepwiseCollectModule__factory, LensHub__factory } from '../../../typechain'; 6 | 7 | export let runtimeHRE: HardhatRuntimeEnvironment; 8 | 9 | task( 10 | 'deploy-stepwise-collect-module', 11 | 'Deploys, verifies and whitelists the stepwise collect module' 12 | ) 13 | .addParam('hub') 14 | .addParam('globals') 15 | .setAction(async ({ hub, globals }, hre) => { 16 | runtimeHRE = hre; 17 | const ethers = hre.ethers; 18 | const accounts = await ethers.getSigners(); 19 | const deployer = accounts[0]; 20 | const governance = accounts[1]; 21 | 22 | console.log('\n\n- - - - - - - - Deploying stepwise fee collect module\n\n'); 23 | const stepwiseCollectModule = await deployWithVerify( 24 | new StepwiseCollectModule__factory(deployer).deploy(hub, globals), 25 | [hub, globals], 26 | 'contracts/collect/StepwiseCollectModule.sol:StepwiseCollectModule' 27 | ); 28 | 29 | if (process.env.HARDHAT_NETWORK !== 'matic') { 30 | console.log('\n\n- - - - - - - - Whitelisting stepwise fee collect module\n\n'); 31 | await LensHub__factory.connect(hub, governance).whitelistCollectModule( 32 | stepwiseCollectModule.address, 33 | true 34 | ); 35 | } 36 | }); 37 | -------------------------------------------------------------------------------- /tasks/deployments/collect/deploy-updatable-ownable-fee-collect-module.ts: -------------------------------------------------------------------------------- 1 | import '@nomiclabs/hardhat-ethers'; 2 | import { task } from 'hardhat/config'; 3 | import { HardhatRuntimeEnvironment } from 'hardhat/types'; 4 | import { deployWithVerify } from '../../helpers/utils'; 5 | import { UpdatableOwnableFeeCollectModule__factory, LensHub__factory } from '../../../typechain'; 6 | 7 | export let runtimeHRE: HardhatRuntimeEnvironment; 8 | 9 | task( 10 | 'deploy-updatable-ownable-fee-collect-module', 11 | 'Deploys, verifies and whitelists the updatable ownable fee collect module' 12 | ) 13 | .addParam('hub') 14 | .addParam('globals') 15 | .setAction(async ({ hub, globals }, hre) => { 16 | runtimeHRE = hre; 17 | const ethers = hre.ethers; 18 | const accounts = await ethers.getSigners(); 19 | const deployer = accounts[0]; 20 | const governance = accounts[1]; 21 | 22 | console.log('\n\n- - - - - - - - Deploying updatable ownable fee collect module\n\n'); 23 | const updatableOwnableFeeCollectModule = await deployWithVerify( 24 | new UpdatableOwnableFeeCollectModule__factory(deployer).deploy(hub, globals), 25 | [hub, globals], 26 | 'contracts/collect/UpdatableOwnableFeeCollectModule.sol:UpdatableOwnableFeeCollectModule' 27 | ); 28 | 29 | console.log('\n\n- - - - - - - - Whitelisting updatable ownable fee collect module\n\n'); 30 | await LensHub__factory.connect(hub, governance).whitelistCollectModule( 31 | updatableOwnableFeeCollectModule.address, 32 | true 33 | ); 34 | }); 35 | -------------------------------------------------------------------------------- /tasks/deployments/reference/deploy-degrees-of-separation-reference-module.ts: -------------------------------------------------------------------------------- 1 | import '@nomiclabs/hardhat-ethers'; 2 | import { task } from 'hardhat/config'; 3 | import { HardhatRuntimeEnvironment } from 'hardhat/types'; 4 | import { deployWithVerify } from '../../helpers/utils'; 5 | import { DegreesOfSeparationReferenceModule__factory, LensHub__factory } from '../../../typechain'; 6 | 7 | export let runtimeHRE: HardhatRuntimeEnvironment; 8 | 9 | task( 10 | 'deploy-degrees-of-separation-reference-module', 11 | 'Deploys, verifies and whitelists the degrees of separation reference module' 12 | ) 13 | .addParam('hub') 14 | .setAction(async ({ hub }, hre) => { 15 | runtimeHRE = hre; 16 | const ethers = hre.ethers; 17 | const accounts = await ethers.getSigners(); 18 | const deployer = accounts[0]; 19 | const governance = accounts[1]; 20 | 21 | console.log('\n\n- - - - - - - - Deploying degrees of separation reference module\n\n'); 22 | const degreesOfSeparationReferenceModule = await deployWithVerify( 23 | new DegreesOfSeparationReferenceModule__factory(deployer).deploy(hub), 24 | [hub], 25 | 'contracts/reference/DegreesOfSeparationReferenceModule.sol:DegreesOfSeparationReferenceModule' 26 | ); 27 | 28 | console.log('\n\n- - - - - - - - Whitelisting degrees of separation reference module\n\n'); 29 | await LensHub__factory.connect(hub, governance).whitelistReferenceModule( 30 | degreesOfSeparationReferenceModule.address, 31 | true 32 | ); 33 | }); 34 | -------------------------------------------------------------------------------- /tasks/helpers/utils.ts: -------------------------------------------------------------------------------- 1 | import '@nomiclabs/hardhat-ethers'; 2 | import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'; 3 | import { Contract, ContractTransaction } from 'ethers'; 4 | import fs from 'fs'; 5 | import { HardhatRuntimeEnvironment } from 'hardhat/types'; 6 | 7 | export const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; 8 | 9 | export enum ProtocolState { 10 | Unpaused, 11 | PublishingPaused, 12 | Paused, 13 | } 14 | 15 | export function getAddrs(): any { 16 | const json = fs.readFileSync('addresses.json', 'utf8'); 17 | const addrs = JSON.parse(json); 18 | return addrs; 19 | } 20 | 21 | export async function waitForTx(tx: Promise) { 22 | await (await tx).wait(); 23 | } 24 | 25 | export async function deployContract(tx: any): Promise { 26 | const result = await tx; 27 | await result.deployTransaction.wait(); 28 | return result; 29 | } 30 | 31 | export async function deployWithVerify( 32 | tx: any, 33 | args: any, 34 | contractPath: string 35 | ): Promise { 36 | const deployedContract = await deployContract(tx); 37 | let count = 0; 38 | let maxTries = 8; 39 | const runtimeHRE = require('hardhat'); 40 | while (true) { 41 | await delay(10000); 42 | try { 43 | console.log('Verifying contract at', deployedContract.address); 44 | await runtimeHRE.run('verify:verify', { 45 | address: deployedContract.address, 46 | constructorArguments: args, 47 | contract: contractPath, 48 | }); 49 | break; 50 | } catch (error) { 51 | if (String(error).includes('Already Verified')) { 52 | console.log( 53 | `Already verified contract at ${contractPath} at address ${deployedContract.address}` 54 | ); 55 | break; 56 | } 57 | if (++count == maxTries) { 58 | console.log( 59 | `Failed to verify contract at ${contractPath} at address ${deployedContract.address}, error: ${error}` 60 | ); 61 | break; 62 | } 63 | console.log(`Retrying... Retry #${count}, last error: ${error}`); 64 | } 65 | } 66 | 67 | return deployedContract; 68 | } 69 | 70 | export async function initEnv(hre: HardhatRuntimeEnvironment): Promise { 71 | const ethers = hre.ethers; // This allows us to access the hre (Hardhat runtime environment)'s injected ethers instance easily 72 | 73 | const accounts = await ethers.getSigners(); // This returns an array of the default signers connected to the hre's ethers instance 74 | const governance = accounts[1]; 75 | const treasury = accounts[2]; 76 | const user = accounts[3]; 77 | 78 | return [governance, treasury, user]; 79 | } 80 | 81 | async function delay(ms: number) { 82 | return new Promise((resolve) => setTimeout(resolve, ms)); 83 | } 84 | -------------------------------------------------------------------------------- /test/__setup.spec.ts: -------------------------------------------------------------------------------- 1 | import { AbiCoder } from '@ethersproject/abi'; 2 | import { parseEther } from '@ethersproject/units'; 3 | import '@nomiclabs/hardhat-ethers'; 4 | import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'; 5 | import { expect, use } from 'chai'; 6 | import { solidity } from 'ethereum-waffle'; 7 | import { BytesLike, Wallet } from 'ethers'; 8 | import { ethers } from 'hardhat'; 9 | import { 10 | CollectNFT__factory, 11 | Events, 12 | Events__factory, 13 | FollowNFT__factory, 14 | InteractionLogic__factory, 15 | LensHub, 16 | LensHub__factory, 17 | ProfileTokenURILogic__factory, 18 | PublishingLogic__factory, 19 | FollowNFT, 20 | CollectNFT, 21 | ModuleGlobals__factory, 22 | TransparentUpgradeableProxy__factory, 23 | Currency__factory, 24 | Currency, 25 | ACurrency, 26 | ACurrency__factory, 27 | NFT__factory, 28 | NFT, 29 | ModuleGlobals, 30 | AuctionCollectModule, 31 | AuctionCollectModule__factory, 32 | FreeCollectModule__factory, 33 | FreeCollectModule, 34 | MockPool, 35 | MockPool__factory, 36 | MockPoolAddressesProvider, 37 | MockPoolAddressesProvider__factory, 38 | AaveFeeCollectModule, 39 | AaveFeeCollectModule__factory, 40 | UpdatableOwnableFeeCollectModule, 41 | UpdatableOwnableFeeCollectModule__factory, 42 | MockVault, 43 | MockVault__factory, 44 | ERC4626FeeCollectModule, 45 | ERC4626FeeCollectModule__factory, 46 | TokenGatedReferenceModule__factory, 47 | TokenGatedReferenceModule, 48 | DegreesOfSeparationReferenceModule, 49 | DegreesOfSeparationReferenceModule__factory, 50 | StepwiseCollectModule, 51 | StepwiseCollectModule__factory, 52 | } from '../typechain'; 53 | import { LensHubLibraryAddresses } from '../typechain/factories/LensHub__factory'; 54 | import { ProfileFollowModule__factory } from '../typechain/factories/ProfileFollowModule__factory'; 55 | import { ProfileFollowModule } from '../typechain/ProfileFollowModule'; 56 | import { 57 | computeContractAddress, 58 | ProtocolState, 59 | revertToSnapshot, 60 | takeSnapshot, 61 | } from './helpers/utils'; 62 | 63 | use(solidity); 64 | 65 | export const CURRENCY_MINT_AMOUNT = parseEther('100'); 66 | export const BPS_MAX = 10000; 67 | export const TREASURY_FEE_BPS = 50; 68 | export const REFERRAL_FEE_BPS = 250; 69 | export const MAX_PROFILE_IMAGE_URI_LENGTH = 6000; 70 | export const LENS_HUB_NFT_NAME = 'Lens Protocol Profiles'; 71 | export const LENS_HUB_NFT_SYMBOL = 'LPP'; 72 | export const MOCK_PROFILE_HANDLE = 'satoshi.lens'; 73 | export const FIRST_PROFILE_ID = 1; 74 | export const FIRST_PUB_ID = 1; 75 | export const FIRST_FOLLOW_NFT_ID = 1; 76 | export const MOCK_URI = 'https://ipfs.io/ipfs/QmY9dUwYu67puaWBMxRKW98LPbXCznPwHUbhX5NeWnCJbX'; 77 | export const OTHER_MOCK_URI = 'https://ipfs.io/ipfs/QmTFLSXdEQ6qsSzaXaCSNtiv6wA56qq87ytXJ182dXDQJS'; 78 | export const MOCK_PROFILE_URI = 79 | 'https://ipfs.io/ipfs/Qme7ss3ARVgxv6rXqVPiikMJ8u2NLgmgszg13pYrDKEoiu'; 80 | export const MOCK_FOLLOW_NFT_URI = 81 | 'https://ipfs.io/ipfs/QmU8Lv1fk31xYdghzFrLm6CiFcwVg7hdgV6BBWesu6EqLj'; 82 | export const DEFAULT_AMOUNT = parseEther('2'); 83 | 84 | export let chainId: number; 85 | export let accounts: SignerWithAddress[]; 86 | export let deployer: SignerWithAddress; 87 | export let governance: SignerWithAddress; 88 | export let proxyAdmin: SignerWithAddress; 89 | export let treasury: SignerWithAddress; 90 | export let user: SignerWithAddress; 91 | export let anotherUser: SignerWithAddress; 92 | export let thirdUser: SignerWithAddress; 93 | export let publisher: SignerWithAddress; 94 | export let feeRecipient: SignerWithAddress; 95 | export let collector: SignerWithAddress; 96 | 97 | export let lensHubImpl: LensHub; 98 | export let lensHub: LensHub; 99 | export let currency: Currency; 100 | export let aCurrency: ACurrency; 101 | export let currencyTwo: Currency; 102 | export let aavePool: MockPool; 103 | export let aavePoolAddressesProvider: MockPoolAddressesProvider; 104 | export let nft: NFT; 105 | export let abiCoder: AbiCoder; 106 | export let mockModuleData: BytesLike; 107 | export let hubLibs: LensHubLibraryAddresses; 108 | export let eventsLib: Events; 109 | export let moduleGlobals: ModuleGlobals; 110 | export let followNFTImpl: FollowNFT; 111 | export let collectNFTImpl: CollectNFT; 112 | export let freeCollectModule: FreeCollectModule; 113 | export let mockVault: MockVault; 114 | export let mockVaultTwo: MockVault; 115 | export let profileFollowModule: ProfileFollowModule; 116 | 117 | export let auctionCollectModule: AuctionCollectModule; 118 | export let aaveFeeCollectModule: AaveFeeCollectModule; 119 | export let updatableOwnableFeeCollectModule: UpdatableOwnableFeeCollectModule; 120 | export let stepwiseCollectModule: StepwiseCollectModule; 121 | export let erc4626FeeCollectModule: ERC4626FeeCollectModule; 122 | 123 | export let degreesOfSeparationReferenceModule: DegreesOfSeparationReferenceModule; 124 | 125 | export let tokenGatedReferenceModule: TokenGatedReferenceModule; 126 | 127 | export function makeSuiteCleanRoom(name: string, tests: () => void) { 128 | describe(name, () => { 129 | beforeEach(async function () { 130 | await takeSnapshot(); 131 | }); 132 | tests(); 133 | afterEach(async function () { 134 | await revertToSnapshot(); 135 | }); 136 | }); 137 | } 138 | 139 | beforeEach(async function () { 140 | chainId = (await ethers.provider.getNetwork()).chainId; 141 | abiCoder = ethers.utils.defaultAbiCoder; 142 | accounts = await ethers.getSigners(); 143 | deployer = accounts[0]; 144 | governance = accounts[1]; 145 | proxyAdmin = accounts[2]; 146 | treasury = accounts[3]; 147 | user = accounts[4]; 148 | anotherUser = accounts[5]; 149 | thirdUser = accounts[6]; 150 | publisher = accounts[7]; 151 | feeRecipient = accounts[8]; 152 | 153 | // Deployment 154 | moduleGlobals = await new ModuleGlobals__factory(deployer).deploy( 155 | governance.address, 156 | treasury.address, 157 | TREASURY_FEE_BPS 158 | ); 159 | const publishingLogic = await new PublishingLogic__factory(deployer).deploy(); 160 | const interactionLogic = await new InteractionLogic__factory(deployer).deploy(); 161 | const profileTokenURILogic = await new ProfileTokenURILogic__factory(deployer).deploy(); 162 | hubLibs = { 163 | '@aave/lens-protocol/contracts/libraries/PublishingLogic.sol:PublishingLogic': 164 | publishingLogic.address, 165 | '@aave/lens-protocol/contracts/libraries/InteractionLogic.sol:InteractionLogic': 166 | interactionLogic.address, 167 | '@aave/lens-protocol/contracts/libraries/ProfileTokenURILogic.sol:ProfileTokenURILogic': 168 | profileTokenURILogic.address, 169 | }; 170 | 171 | // Here, we pre-compute the nonces and addresses used to deploy the contracts. 172 | const nonce = await deployer.getTransactionCount(); 173 | // nonce + 0 is follow NFT impl 174 | // nonce + 1 is collect NFT impl 175 | // nonce + 2 is impl 176 | // nonce + 3 is hub proxy 177 | 178 | const hubProxyAddress = computeContractAddress(deployer.address, nonce + 3); // '0x' + keccak256(RLP.encode([deployerAddress, hubProxyNonce])).substr(26); 179 | 180 | followNFTImpl = await new FollowNFT__factory(deployer).deploy(hubProxyAddress); 181 | collectNFTImpl = await new CollectNFT__factory(deployer).deploy(hubProxyAddress); 182 | 183 | lensHubImpl = await new LensHub__factory(hubLibs, deployer).deploy( 184 | followNFTImpl.address, 185 | collectNFTImpl.address 186 | ); 187 | 188 | const data = lensHubImpl.interface.encodeFunctionData('initialize', [ 189 | LENS_HUB_NFT_NAME, 190 | LENS_HUB_NFT_SYMBOL, 191 | governance.address, 192 | ]); 193 | const proxy = await new TransparentUpgradeableProxy__factory(deployer).deploy( 194 | lensHubImpl.address, 195 | proxyAdmin.address, 196 | data 197 | ); 198 | 199 | // Connect the hub proxy to the LensHub factory and the user for ease of use. 200 | lensHub = LensHub__factory.connect(proxy.address, deployer); 201 | 202 | // Currency 203 | currency = await new Currency__factory(deployer).deploy(); 204 | currencyTwo = await new Currency__factory(deployer).deploy(); 205 | aCurrency = await new ACurrency__factory(deployer).deploy(); 206 | 207 | // ERC4626 Vault - accepts 'currency' as deposit asset 208 | mockVault = await new MockVault__factory(deployer).deploy(currency.address); 209 | mockVaultTwo = await new MockVault__factory(deployer).deploy(currencyTwo.address); 210 | 211 | // Aave Pool - currencyTwo is set as unsupported asset (in Aave, not Lens) for testing 212 | aavePool = await new MockPool__factory(deployer).deploy(aCurrency.address, currencyTwo.address); 213 | aavePoolAddressesProvider = await new MockPoolAddressesProvider__factory(deployer).deploy( 214 | aavePool.address 215 | ); 216 | 217 | // NFT 218 | nft = await new NFT__factory(deployer).deploy(); 219 | 220 | // Currency whitelisting 221 | await expect( 222 | moduleGlobals.connect(governance).whitelistCurrency(currency.address, true) 223 | ).to.not.be.reverted; 224 | await expect( 225 | moduleGlobals.connect(governance).whitelistCurrency(currencyTwo.address, true) 226 | ).to.not.be.reverted; 227 | 228 | // Modules used for testing purposes 229 | freeCollectModule = await new FreeCollectModule__factory(deployer).deploy(lensHub.address); 230 | await expect( 231 | lensHub.connect(governance).whitelistCollectModule(freeCollectModule.address, true) 232 | ).to.not.be.reverted; 233 | profileFollowModule = await new ProfileFollowModule__factory(deployer).deploy(lensHub.address); 234 | await expect( 235 | lensHub.connect(governance).whitelistFollowModule(profileFollowModule.address, true) 236 | ).to.not.be.reverted; 237 | 238 | // Collect modules 239 | auctionCollectModule = await new AuctionCollectModule__factory(deployer).deploy( 240 | lensHub.address, 241 | moduleGlobals.address 242 | ); 243 | aaveFeeCollectModule = await new AaveFeeCollectModule__factory(deployer).deploy( 244 | lensHub.address, 245 | moduleGlobals.address, 246 | aavePoolAddressesProvider.address 247 | ); 248 | 249 | await expect( 250 | lensHub.connect(governance).whitelistCollectModule(auctionCollectModule.address, true) 251 | ).to.not.be.reverted; 252 | 253 | await expect( 254 | lensHub.connect(governance).whitelistCollectModule(aaveFeeCollectModule.address, true) 255 | ).to.not.be.reverted; 256 | 257 | updatableOwnableFeeCollectModule = await new UpdatableOwnableFeeCollectModule__factory( 258 | deployer 259 | ).deploy(lensHub.address, moduleGlobals.address); 260 | await expect( 261 | lensHub 262 | .connect(governance) 263 | .whitelistCollectModule(updatableOwnableFeeCollectModule.address, true) 264 | ).to.not.be.reverted; 265 | 266 | erc4626FeeCollectModule = await new ERC4626FeeCollectModule__factory(deployer).deploy( 267 | lensHub.address, 268 | moduleGlobals.address 269 | ); 270 | await expect( 271 | lensHub.connect(governance).whitelistCollectModule(erc4626FeeCollectModule.address, true) 272 | ).to.not.be.reverted; 273 | 274 | // Reference modules 275 | degreesOfSeparationReferenceModule = await new DegreesOfSeparationReferenceModule__factory( 276 | deployer 277 | ).deploy(lensHub.address); 278 | await expect( 279 | lensHub 280 | .connect(governance) 281 | .whitelistReferenceModule(degreesOfSeparationReferenceModule.address, true) 282 | ).to.not.be.reverted; 283 | 284 | // Reference modules 285 | tokenGatedReferenceModule = await new TokenGatedReferenceModule__factory(deployer).deploy( 286 | lensHub.address 287 | ); 288 | await expect( 289 | lensHub.connect(governance).whitelistReferenceModule(tokenGatedReferenceModule.address, true) 290 | ).to.not.be.reverted; 291 | 292 | // Collect modules 293 | stepwiseCollectModule = await new StepwiseCollectModule__factory(deployer).deploy( 294 | lensHub.address, 295 | moduleGlobals.address 296 | ); 297 | await expect( 298 | lensHub.connect(governance).whitelistCollectModule(stepwiseCollectModule.address, true) 299 | ).to.not.be.reverted; 300 | 301 | // Unpausing protocol 302 | await expect(lensHub.connect(governance).setState(ProtocolState.Unpaused)).to.not.be.reverted; 303 | 304 | // Profile creator whitelisting 305 | await expect( 306 | lensHub.connect(governance).whitelistProfileCreator(deployer.address, true) 307 | ).to.not.be.reverted; 308 | 309 | // Event library deployment is only needed for testing and is not reproduced in the live environment 310 | eventsLib = await new Events__factory(deployer).deploy(); 311 | }); 312 | -------------------------------------------------------------------------------- /test/foundry/BaseSetup.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.10; 3 | 4 | import 'forge-std/Test.sol'; 5 | 6 | // Deployments 7 | import {LensHub} from '@aave/lens-protocol/contracts/core/LensHub.sol'; 8 | import {FollowNFT} from '@aave/lens-protocol/contracts/core/FollowNFT.sol'; 9 | import {CollectNFT} from '@aave/lens-protocol/contracts/core/CollectNFT.sol'; 10 | import {ModuleGlobals} from '@aave/lens-protocol/contracts/core/modules/ModuleGlobals.sol'; 11 | import {FreeCollectModule} from '@aave/lens-protocol/contracts/core/modules/collect/FreeCollectModule.sol'; 12 | import {TransparentUpgradeableProxy} from '@aave/lens-protocol/contracts/upgradeability/TransparentUpgradeableProxy.sol'; 13 | import {DataTypes} from '@aave/lens-protocol/contracts/libraries/DataTypes.sol'; 14 | import {Errors} from '@aave/lens-protocol/contracts/libraries/Errors.sol'; 15 | import {ForkManagement} from 'script/helpers/ForkManagement.sol'; 16 | 17 | import {Currency} from '@aave/lens-protocol/contracts/mocks/Currency.sol'; 18 | import {NFT} from 'contracts/mocks/NFT.sol'; 19 | 20 | contract BaseSetup is Test, ForkManagement { 21 | using stdJson for string; 22 | 23 | string forkEnv; 24 | bool fork; 25 | string network; 26 | string json; 27 | uint256 forkBlockNumber; 28 | 29 | uint256 firstProfileId; 30 | address deployer; 31 | address governance; 32 | address treasury; 33 | 34 | address constant publisher = address(4); 35 | address constant user = address(5); 36 | address constant userTwo = address(6); 37 | address constant userThree = address(7); 38 | address constant userFour = address(8); 39 | address constant userFive = address(9); 40 | address immutable me = address(this); 41 | 42 | string constant MOCK_HANDLE = 'mock'; 43 | string constant MOCK_URI = 'ipfs://QmUXfQWe43RKx31VzA2BnbwhSMW8WuaJvszFWChD59m76U'; 44 | string constant OTHER_MOCK_URI = 45 | 'https://ipfs.io/ipfs/QmTFLSXdEQ6qsSzaXaCSNtiv6wA56qq87ytXJ182dXDQJS'; 46 | string constant MOCK_FOLLOW_NFT_URI = 47 | 'https://ipfs.io/ipfs/QmU8Lv1fk31xYdghzFrLm6CiFcwVg7hdgV6BBWesu6EqLj'; 48 | 49 | uint16 TREASURY_FEE_BPS; 50 | uint16 constant TREASURY_FEE_MAX_BPS = 10000; 51 | 52 | address hubProxyAddr; 53 | CollectNFT collectNFT; 54 | FollowNFT followNFT; 55 | LensHub hubImpl; 56 | TransparentUpgradeableProxy hubAsProxy; 57 | LensHub hub; 58 | FreeCollectModule freeCollectModule; 59 | Currency currency; 60 | ModuleGlobals moduleGlobals; 61 | NFT nft; 62 | 63 | // TODO: Replace with forge-std/StdJson.sol::keyExists(...) when/if this PR is approved: 64 | // https://github.com/foundry-rs/forge-std/pull/226 65 | function keyExists(string memory key) internal returns (bool) { 66 | return json.parseRaw(key).length > 0; 67 | } 68 | 69 | function loadBaseAddresses(string memory json, string memory targetEnv) internal virtual { 70 | bytes32 PROXY_IMPLEMENTATION_STORAGE_SLOT = bytes32( 71 | uint256(keccak256('eip1967.proxy.implementation')) - 1 72 | ); 73 | 74 | console.log('targetEnv:', targetEnv); 75 | 76 | hubProxyAddr = json.readAddress(string(abi.encodePacked('.', targetEnv, '.LensHubProxy'))); 77 | console.log('hubProxyAddr:', hubProxyAddr); 78 | 79 | hub = LensHub(hubProxyAddr); 80 | 81 | console.log('Hub:', address(hub)); 82 | 83 | address followNFTAddr = hub.getFollowNFTImpl(); 84 | address collectNFTAddr = hub.getCollectNFTImpl(); 85 | 86 | address hubImplAddr = address( 87 | uint160(uint256(vm.load(hubProxyAddr, PROXY_IMPLEMENTATION_STORAGE_SLOT))) 88 | ); 89 | console.log('Found hubImplAddr:', hubImplAddr); 90 | hubImpl = LensHub(hubImplAddr); 91 | followNFT = FollowNFT(followNFTAddr); 92 | collectNFT = CollectNFT(collectNFTAddr); 93 | hubAsProxy = TransparentUpgradeableProxy(payable(address(hub))); 94 | freeCollectModule = FreeCollectModule( 95 | json.readAddress(string(abi.encodePacked('.', targetEnv, '.FreeCollectModule'))) 96 | ); 97 | 98 | moduleGlobals = ModuleGlobals( 99 | json.readAddress(string(abi.encodePacked('.', targetEnv, '.ModuleGlobals'))) 100 | ); 101 | 102 | currency = new Currency(); 103 | nft = new NFT(); 104 | 105 | firstProfileId = uint256(vm.load(hubProxyAddr, bytes32(uint256(22)))) + 1; 106 | console.log('firstProfileId:', firstProfileId); 107 | 108 | deployer = address(1); 109 | 110 | governance = hub.getGovernance(); 111 | treasury = moduleGlobals.getTreasury(); 112 | 113 | TREASURY_FEE_BPS = moduleGlobals.getTreasuryFee(); 114 | } 115 | 116 | function deployBaseContracts() internal { 117 | firstProfileId = 1; 118 | deployer = address(1); 119 | governance = address(2); 120 | treasury = address(3); 121 | 122 | TREASURY_FEE_BPS = 50; 123 | 124 | ///////////////////////////////////////// Start deployments. 125 | vm.startPrank(deployer); 126 | 127 | // Precompute needed addresss. 128 | address followNFTAddr = computeCreateAddress(deployer, 1); 129 | address collectNFTAddr = computeCreateAddress(deployer, 2); 130 | hubProxyAddr = computeCreateAddress(deployer, 3); 131 | 132 | // Deploy implementation contracts. 133 | hubImpl = new LensHub(followNFTAddr, collectNFTAddr); 134 | followNFT = new FollowNFT(hubProxyAddr); 135 | collectNFT = new CollectNFT(hubProxyAddr); 136 | 137 | // Deploy and initialize proxy. 138 | bytes memory initData = abi.encodeWithSelector( 139 | hubImpl.initialize.selector, 140 | 'Lens Protocol Profiles', 141 | 'LPP', 142 | governance 143 | ); 144 | hubAsProxy = new TransparentUpgradeableProxy(address(hubImpl), deployer, initData); 145 | 146 | // Cast proxy to LensHub interface. 147 | hub = LensHub(address(hubAsProxy)); 148 | 149 | // Deploy the FreeCollectModule. 150 | freeCollectModule = new FreeCollectModule(hubProxyAddr); 151 | 152 | moduleGlobals = new ModuleGlobals(governance, treasury, TREASURY_FEE_BPS); 153 | 154 | currency = new Currency(); 155 | nft = new NFT(); 156 | 157 | vm.stopPrank(); 158 | ///////////////////////////////////////// End deployments. 159 | } 160 | 161 | constructor() { 162 | forkEnv = vm.envString('TESTING_FORK'); 163 | 164 | if (bytes(forkEnv).length > 0) { 165 | fork = true; 166 | console.log('\n\n Testing using %s fork', forkEnv); 167 | json = loadJson(); 168 | 169 | network = getNetwork(json, forkEnv); 170 | vm.createSelectFork(network); 171 | 172 | forkBlockNumber = block.number; 173 | console.log('Fork Block number:', forkBlockNumber); 174 | 175 | checkNetworkParams(json, forkEnv); 176 | 177 | loadBaseAddresses(json, forkEnv); 178 | } else { 179 | deployBaseContracts(); 180 | } 181 | ///////////////////////////////////////// Start governance actions. 182 | vm.startPrank(governance); 183 | 184 | if (hub.getState() != DataTypes.ProtocolState.Unpaused) 185 | hub.setState(DataTypes.ProtocolState.Unpaused); 186 | 187 | // Whitelist the FreeCollectModule. 188 | hub.whitelistCollectModule(address(freeCollectModule), true); 189 | 190 | // Whitelist the test contract as a profile creator 191 | hub.whitelistProfileCreator(me, true); 192 | 193 | // Whitelist mock currency in ModuleGlobals 194 | moduleGlobals.whitelistCurrency(address(currency), true); 195 | 196 | vm.stopPrank(); 197 | ///////////////////////////////////////// End governance actions. 198 | } 199 | 200 | function _toUint256Array(uint256 n) internal pure returns (uint256[] memory) { 201 | uint256[] memory ret = new uint256[](1); 202 | ret[0] = n; 203 | return ret; 204 | } 205 | 206 | function _toBytesArray(bytes memory n) internal pure returns (bytes[] memory) { 207 | bytes[] memory ret = new bytes[](1); 208 | ret[0] = n; 209 | return ret; 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /test/foundry/collect/BaseFeeCollectModule.base.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.10; 3 | 4 | import 'forge-std/Test.sol'; 5 | import '../BaseSetup.t.sol'; 6 | import {SimpleFeeCollectModule} from 'contracts/collect/SimpleFeeCollectModule.sol'; 7 | import {BaseFeeCollectModuleInitData} from 'contracts/collect/base/IBaseFeeCollectModule.sol'; 8 | 9 | contract BaseFeeCollectModuleBase is BaseSetup { 10 | address baseFeeCollectModule; 11 | 12 | BaseFeeCollectModuleInitData exampleInitData; 13 | 14 | uint256 constant DEFAULT_COLLECT_LIMIT = 3; 15 | uint16 constant REFERRAL_FEE_BPS = 250; 16 | 17 | // Deploy & Whitelist BaseFeeCollectModule 18 | constructor() BaseSetup() { 19 | vm.prank(deployer); 20 | baseFeeCollectModule = address( 21 | new SimpleFeeCollectModule(hubProxyAddr, address(moduleGlobals)) 22 | ); 23 | vm.prank(governance); 24 | hub.whitelistCollectModule(address(baseFeeCollectModule), true); 25 | } 26 | 27 | function getEncodedInitData() internal virtual returns (bytes memory) { 28 | return abi.encode(exampleInitData); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /test/foundry/collect/MultirecipientCollectModule.base.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.10; 3 | 4 | import 'forge-std/Test.sol'; 5 | import './BaseFeeCollectModule.base.sol'; 6 | import 'contracts/collect/MultirecipientFeeCollectModule.sol'; 7 | 8 | contract MultirecipientCollectModuleBase is BaseFeeCollectModuleBase { 9 | using stdJson for string; 10 | uint16 constant BPS_MAX = 10000; 11 | uint256 MAX_RECIPIENTS = 5; 12 | 13 | MultirecipientFeeCollectModule multirecipientFeeCollectModule; 14 | MultirecipientFeeCollectModuleInitData multirecipientExampleInitData; 15 | 16 | // Deploy & Whitelist MultirecipientFeeCollectModule 17 | constructor() { 18 | if ( 19 | fork && 20 | keyExists(string(abi.encodePacked('.', forkEnv, '.MultirecipientFeeCollectModule'))) 21 | ) { 22 | multirecipientFeeCollectModule = MultirecipientFeeCollectModule( 23 | json.readAddress( 24 | string(abi.encodePacked('.', forkEnv, '.MultirecipientFeeCollectModule')) 25 | ) 26 | ); 27 | console.log( 28 | 'Testing against already deployed module at:', 29 | address(multirecipientFeeCollectModule) 30 | ); 31 | } else { 32 | vm.prank(deployer); 33 | multirecipientFeeCollectModule = new MultirecipientFeeCollectModule( 34 | hubProxyAddr, 35 | address(moduleGlobals) 36 | ); 37 | } 38 | baseFeeCollectModule = address(multirecipientFeeCollectModule); 39 | vm.startPrank(governance); 40 | hub.whitelistCollectModule(address(multirecipientFeeCollectModule), true); 41 | vm.stopPrank(); 42 | } 43 | 44 | function getEncodedInitData() internal virtual override returns (bytes memory) { 45 | multirecipientExampleInitData.amount = exampleInitData.amount; 46 | multirecipientExampleInitData.collectLimit = exampleInitData.collectLimit; 47 | multirecipientExampleInitData.currency = exampleInitData.currency; 48 | multirecipientExampleInitData.referralFee = exampleInitData.referralFee; 49 | multirecipientExampleInitData.followerOnly = exampleInitData.followerOnly; 50 | multirecipientExampleInitData.endTimestamp = exampleInitData.endTimestamp; 51 | if (multirecipientExampleInitData.recipients.length == 0) 52 | multirecipientExampleInitData.recipients.push( 53 | RecipientData({recipient: exampleInitData.recipient, split: BPS_MAX}) 54 | ); 55 | 56 | return abi.encode(multirecipientExampleInitData); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /test/foundry/collect/StepwiseCollectModule.base.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.10; 3 | 4 | import 'forge-std/Test.sol'; 5 | import '../BaseSetup.t.sol'; 6 | import 'contracts/collect/StepwiseCollectModule.sol'; 7 | 8 | contract StepwiseCollectModuleBase is BaseSetup { 9 | using stdJson for string; 10 | StepwiseCollectModule stepwiseCollectModule; 11 | 12 | uint256 constant DEFAULT_COLLECT_LIMIT = 3; 13 | uint16 constant REFERRAL_FEE_BPS = 250; 14 | 15 | // Deploy & Whitelist StepwiseCollectModule 16 | constructor() BaseSetup() { 17 | if (fork && keyExists(string(abi.encodePacked('.', forkEnv, '.StepwiseCollectModule')))) { 18 | stepwiseCollectModule = StepwiseCollectModule( 19 | json.readAddress(string(abi.encodePacked('.', forkEnv, '.StepwiseCollectModule'))) 20 | ); 21 | console.log( 22 | 'Testing against already deployed module at:', 23 | address(stepwiseCollectModule) 24 | ); 25 | } else { 26 | vm.prank(deployer); 27 | stepwiseCollectModule = new StepwiseCollectModule(hubProxyAddr, address(moduleGlobals)); 28 | } 29 | vm.prank(governance); 30 | hub.whitelistCollectModule(address(stepwiseCollectModule), true); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /test/foundry/helpers/TestHelpers.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.10; 3 | 4 | import {Vm} from 'forge-std/Test.sol'; 5 | 6 | library TestHelpers { 7 | // TODO: Replace these constants with Events.***.selector after upping to Solidity >=0.8.15 8 | bytes32 constant profileCreatedEventTopic = 9 | keccak256( 10 | 'ProfileCreated(uint256,address,address,string,string,address,bytes,string,uint256)' 11 | ); 12 | 13 | bytes32 constant postCreatedEventTopic = 14 | keccak256('PostCreated(uint256,uint256,string,address,bytes,address,bytes,uint256)'); 15 | 16 | bytes32 constant mirrorCreatedEventTopic = 17 | keccak256('MirrorCreated(uint256,uint256,uint256,uint256,bytes,address,bytes,uint256)'); 18 | 19 | bytes32 constant transferEventTopic = keccak256('Transfer(address,address,uint256)'); 20 | 21 | function getCreatedProfileIdFromEvents(Vm.Log[] memory entries) public pure returns (uint256) { 22 | for (uint256 i = 0; i < entries.length; i++) { 23 | if (entries[i].topics[0] == profileCreatedEventTopic) { 24 | return uint256(entries[i].topics[1]); // 0 is always event topic 25 | } 26 | } 27 | revert('No Profile creation event found'); 28 | } 29 | 30 | function getCreatedPubIdFromEvents(Vm.Log[] memory entries) public pure returns (uint256) { 31 | for (uint256 i = 0; i < entries.length; i++) { 32 | if (entries[i].topics[0] == postCreatedEventTopic) { 33 | return uint256(entries[i].topics[2]); // 0 is always event topic 34 | } 35 | } 36 | revert('No Publication creation event found'); 37 | } 38 | 39 | function getCreatedMirrorIdFromEvents(Vm.Log[] memory entries) public pure returns (uint256) { 40 | for (uint256 i = 0; i < entries.length; i++) { 41 | if (entries[i].topics[0] == mirrorCreatedEventTopic) { 42 | return uint256(entries[i].topics[2]); // 0 is always event topic 43 | } 44 | } 45 | revert('No Mirror creation event found'); 46 | } 47 | 48 | function getTransferFromEvents( 49 | Vm.Log[] memory entries, 50 | address from, 51 | address to 52 | ) public pure returns (uint256) { 53 | for (uint256 i = 0; i < entries.length; i++) { 54 | if (entries[i].topics[0] == transferEventTopic) { 55 | if ( 56 | entries[i].topics[1] == bytes32(abi.encode(from)) && 57 | entries[i].topics[2] == bytes32(abi.encode(to)) 58 | ) { 59 | return abi.decode(entries[i].data, (uint256)); // 0 is always event topic 60 | } 61 | } 62 | } 63 | revert('No Transfer event found'); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /test/foundry/reference/TokenGatedReferenceModule.base.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.10; 3 | 4 | import 'forge-std/Test.sol'; 5 | import '../BaseSetup.t.sol'; 6 | import 'contracts/reference/TokenGatedReferenceModule.sol'; 7 | 8 | // import '../helpers/TestHelpers.sol'; 9 | 10 | contract TokenGatedReferenceModuleBase is BaseSetup { 11 | using stdJson for string; 12 | TokenGatedReferenceModule tokenGatedReferenceModule; 13 | 14 | // Deploy & Whitelist TokenGatedReferenceModule 15 | constructor() BaseSetup() { 16 | if ( 17 | fork && keyExists(string(abi.encodePacked('.', forkEnv, '.TokenGatedReferenceModule'))) 18 | ) { 19 | tokenGatedReferenceModule = TokenGatedReferenceModule( 20 | json.readAddress( 21 | string(abi.encodePacked('.', forkEnv, '.TokenGatedReferenceModule')) 22 | ) 23 | ); 24 | console.log( 25 | 'Testing against already deployed module at:', 26 | address(tokenGatedReferenceModule) 27 | ); 28 | } else { 29 | vm.prank(deployer); 30 | tokenGatedReferenceModule = new TokenGatedReferenceModule(hubProxyAddr); 31 | } 32 | vm.prank(governance); 33 | hub.whitelistReferenceModule(address(tokenGatedReferenceModule), true); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /test/foundry/reference/TokenGatedReferenceModule.test.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.10; 3 | 4 | import '../BaseSetup.t.sol'; 5 | import {TokenGatedReferenceModuleBase} from './TokenGatedReferenceModule.base.sol'; 6 | import '../helpers/TestHelpers.sol'; 7 | import {TokenGatedReferenceModule} from 'contracts/reference/TokenGatedReferenceModule.sol'; 8 | 9 | ///////// 10 | // Publication Creation with TokenGatedReferenceModule 11 | // 12 | contract TokenGatedReferenceModule_Publication is TokenGatedReferenceModuleBase { 13 | uint256 immutable userProfileId; 14 | 15 | constructor() TokenGatedReferenceModuleBase() { 16 | vm.recordLogs(); 17 | hub.createProfile( 18 | DataTypes.CreateProfileData({ 19 | to: me, 20 | handle: 'user', 21 | imageURI: OTHER_MOCK_URI, 22 | followModule: address(0), 23 | followModuleInitData: '', 24 | followNFTURI: MOCK_FOLLOW_NFT_URI 25 | }) 26 | ); 27 | Vm.Log[] memory entries = vm.getRecordedLogs(); 28 | userProfileId = TestHelpers.getCreatedProfileIdFromEvents(entries); 29 | } 30 | 31 | // Negatives 32 | function testCannotPostWithZeroTokenAddress() public { 33 | vm.expectRevert(Errors.InitParamsInvalid.selector); 34 | hub.post( 35 | DataTypes.PostData({ 36 | profileId: userProfileId, 37 | contentURI: MOCK_URI, 38 | collectModule: address(freeCollectModule), 39 | collectModuleInitData: abi.encode(false), 40 | referenceModule: address(tokenGatedReferenceModule), 41 | referenceModuleInitData: abi.encode(address(0), 1) 42 | }) 43 | ); 44 | } 45 | 46 | function testCannotPostWithZeroMinThreshold() public { 47 | vm.expectRevert(Errors.InitParamsInvalid.selector); 48 | hub.post( 49 | DataTypes.PostData({ 50 | profileId: userProfileId, 51 | contentURI: MOCK_URI, 52 | collectModule: address(freeCollectModule), 53 | collectModuleInitData: abi.encode(false), 54 | referenceModule: address(tokenGatedReferenceModule), 55 | referenceModuleInitData: abi.encode(address(currency), 0) 56 | }) 57 | ); 58 | } 59 | 60 | function testCannotCallInitializeFromNonHub() public { 61 | vm.expectRevert(Errors.NotHub.selector); 62 | tokenGatedReferenceModule.initializeReferenceModule( 63 | userProfileId, 64 | 1, 65 | abi.encode(address(currency), 1) 66 | ); 67 | } 68 | 69 | function testCannotProcessCommentFromNonHub() public { 70 | vm.expectRevert(Errors.NotHub.selector); 71 | tokenGatedReferenceModule.processComment(userProfileId, userProfileId, 1, ''); 72 | } 73 | 74 | function testCannotProcessMirrorFromNonHub() public { 75 | vm.expectRevert(Errors.NotHub.selector); 76 | tokenGatedReferenceModule.processMirror(userProfileId, userProfileId, 1, ''); 77 | } 78 | 79 | // Scenarios 80 | function testCreatePublicationWithTokenGatedReferenceModule() public { 81 | hub.post( 82 | DataTypes.PostData({ 83 | profileId: userProfileId, 84 | contentURI: MOCK_URI, 85 | collectModule: address(freeCollectModule), 86 | collectModuleInitData: abi.encode(false), 87 | referenceModule: address(tokenGatedReferenceModule), 88 | referenceModuleInitData: abi.encode(address(currency), 1) 89 | }) 90 | ); 91 | } 92 | 93 | function testCreatePublicationWithTokenGatedReferenceModuleEmitsExpectedEvents() public { 94 | vm.recordLogs(); 95 | hub.post( 96 | DataTypes.PostData({ 97 | profileId: userProfileId, 98 | contentURI: MOCK_URI, 99 | collectModule: address(freeCollectModule), 100 | collectModuleInitData: abi.encode(false), 101 | referenceModule: address(tokenGatedReferenceModule), 102 | referenceModuleInitData: abi.encode(address(currency), 1) 103 | }) 104 | ); 105 | Vm.Log[] memory entries = vm.getRecordedLogs(); 106 | uint256 pubId = TestHelpers.getCreatedPubIdFromEvents(entries); 107 | assertEq(pubId, 1); 108 | } 109 | } 110 | 111 | ///////// 112 | // ERC20-Gated Reference 113 | // 114 | contract TokenGatedReferenceModule_ERC20_Gated is TokenGatedReferenceModuleBase { 115 | uint256 immutable publisherProfileId; 116 | uint256 immutable userProfileId; 117 | 118 | address immutable tokenAddress; 119 | uint256 constant minThreshold = 10 ether; 120 | 121 | bytes referenceModuleInitData; 122 | 123 | uint256 pubId; 124 | 125 | constructor() TokenGatedReferenceModuleBase() { 126 | vm.recordLogs(); 127 | hub.createProfile( 128 | DataTypes.CreateProfileData({ 129 | to: publisher, 130 | handle: MOCK_HANDLE, 131 | imageURI: MOCK_URI, 132 | followModule: address(0), 133 | followModuleInitData: '', 134 | followNFTURI: MOCK_URI 135 | }) 136 | ); 137 | Vm.Log[] memory entries = vm.getRecordedLogs(); 138 | publisherProfileId = TestHelpers.getCreatedProfileIdFromEvents(entries); 139 | vm.recordLogs(); 140 | hub.createProfile( 141 | DataTypes.CreateProfileData({ 142 | to: me, 143 | handle: 'user', 144 | imageURI: OTHER_MOCK_URI, 145 | followModule: address(0), 146 | followModuleInitData: '', 147 | followNFTURI: MOCK_FOLLOW_NFT_URI 148 | }) 149 | ); 150 | entries = vm.getRecordedLogs(); 151 | userProfileId = TestHelpers.getCreatedProfileIdFromEvents(entries); 152 | 153 | tokenAddress = address(currency); 154 | referenceModuleInitData = abi.encode(tokenAddress, minThreshold); 155 | } 156 | 157 | function setUp() public { 158 | vm.recordLogs(); 159 | vm.prank(publisher); 160 | hub.post( 161 | DataTypes.PostData({ 162 | profileId: publisherProfileId, 163 | contentURI: MOCK_URI, 164 | collectModule: address(freeCollectModule), 165 | collectModuleInitData: abi.encode(false), 166 | referenceModule: address(tokenGatedReferenceModule), 167 | referenceModuleInitData: referenceModuleInitData 168 | }) 169 | ); 170 | Vm.Log[] memory entries = vm.getRecordedLogs(); 171 | pubId = TestHelpers.getCreatedPubIdFromEvents(entries); 172 | } 173 | 174 | // Negatives 175 | function testCannotMirrorIfNotEnoughBalance() public { 176 | vm.expectRevert(TokenGatedReferenceModule.NotEnoughBalance.selector); 177 | hub.mirror( 178 | DataTypes.MirrorData({ 179 | profileId: userProfileId, 180 | profileIdPointed: publisherProfileId, 181 | pubIdPointed: pubId, 182 | referenceModuleData: '', 183 | referenceModule: address(tokenGatedReferenceModule), 184 | referenceModuleInitData: referenceModuleInitData 185 | }) 186 | ); 187 | } 188 | 189 | function testCannotCommentIfNotEnoughBalance() public { 190 | vm.expectRevert(TokenGatedReferenceModule.NotEnoughBalance.selector); 191 | hub.comment( 192 | DataTypes.CommentData({ 193 | profileId: userProfileId, 194 | contentURI: MOCK_URI, 195 | profileIdPointed: publisherProfileId, 196 | pubIdPointed: pubId, 197 | collectModule: address(freeCollectModule), 198 | collectModuleInitData: abi.encode(false), 199 | referenceModuleData: '', 200 | referenceModule: address(tokenGatedReferenceModule), 201 | referenceModuleInitData: referenceModuleInitData 202 | }) 203 | ); 204 | } 205 | 206 | // Scenarios 207 | function testMirrorWhileHoldingEnoughTokens() public { 208 | currency.mint(me, minThreshold); 209 | assert(currency.balanceOf(me) >= minThreshold); 210 | hub.mirror( 211 | DataTypes.MirrorData({ 212 | profileId: userProfileId, 213 | profileIdPointed: publisherProfileId, 214 | pubIdPointed: pubId, 215 | referenceModuleData: '', 216 | referenceModule: address(tokenGatedReferenceModule), 217 | referenceModuleInitData: referenceModuleInitData 218 | }) 219 | ); 220 | } 221 | 222 | function testCommentWhileHoldingEnoughTokens() public { 223 | currency.mint(me, minThreshold); 224 | assert(currency.balanceOf(me) >= minThreshold); 225 | hub.comment( 226 | DataTypes.CommentData({ 227 | profileId: userProfileId, 228 | contentURI: MOCK_URI, 229 | profileIdPointed: publisherProfileId, 230 | pubIdPointed: pubId, 231 | collectModule: address(freeCollectModule), 232 | collectModuleInitData: abi.encode(false), 233 | referenceModuleData: '', 234 | referenceModule: address(tokenGatedReferenceModule), 235 | referenceModuleInitData: referenceModuleInitData 236 | }) 237 | ); 238 | } 239 | } 240 | 241 | ///////// 242 | // ERC721-Gated Reference 243 | // 244 | contract TokenGatedReferenceModule_ERC721_Gated is TokenGatedReferenceModuleBase { 245 | uint256 immutable publisherProfileId; 246 | uint256 immutable userProfileId; 247 | 248 | address immutable tokenAddress; 249 | uint256 constant minThreshold = 1; 250 | 251 | bytes referenceModuleInitData; 252 | 253 | uint256 pubId; 254 | 255 | constructor() TokenGatedReferenceModuleBase() { 256 | vm.recordLogs(); 257 | hub.createProfile( 258 | DataTypes.CreateProfileData({ 259 | to: publisher, 260 | handle: MOCK_HANDLE, 261 | imageURI: MOCK_URI, 262 | followModule: address(0), 263 | followModuleInitData: '', 264 | followNFTURI: MOCK_URI 265 | }) 266 | ); 267 | Vm.Log[] memory entries = vm.getRecordedLogs(); 268 | publisherProfileId = TestHelpers.getCreatedProfileIdFromEvents(entries); 269 | vm.recordLogs(); 270 | hub.createProfile( 271 | DataTypes.CreateProfileData({ 272 | to: me, 273 | handle: 'user', 274 | imageURI: OTHER_MOCK_URI, 275 | followModule: address(0), 276 | followModuleInitData: '', 277 | followNFTURI: MOCK_FOLLOW_NFT_URI 278 | }) 279 | ); 280 | entries = vm.getRecordedLogs(); 281 | userProfileId = TestHelpers.getCreatedProfileIdFromEvents(entries); 282 | 283 | tokenAddress = address(nft); 284 | referenceModuleInitData = abi.encode(tokenAddress, minThreshold); 285 | } 286 | 287 | function setUp() public { 288 | vm.recordLogs(); 289 | vm.prank(publisher); 290 | hub.post( 291 | DataTypes.PostData({ 292 | profileId: publisherProfileId, 293 | contentURI: MOCK_URI, 294 | collectModule: address(freeCollectModule), 295 | collectModuleInitData: abi.encode(false), 296 | referenceModule: address(tokenGatedReferenceModule), 297 | referenceModuleInitData: referenceModuleInitData 298 | }) 299 | ); 300 | Vm.Log[] memory entries = vm.getRecordedLogs(); 301 | pubId = TestHelpers.getCreatedPubIdFromEvents(entries); 302 | console.log('post created:', pubId); 303 | } 304 | 305 | // Negatives 306 | function testCannotMirrorIfNotEnoughBalance() public { 307 | vm.expectRevert(TokenGatedReferenceModule.NotEnoughBalance.selector); 308 | hub.mirror( 309 | DataTypes.MirrorData({ 310 | profileId: userProfileId, 311 | profileIdPointed: publisherProfileId, 312 | pubIdPointed: pubId, 313 | referenceModuleData: '', 314 | referenceModule: address(tokenGatedReferenceModule), 315 | referenceModuleInitData: referenceModuleInitData 316 | }) 317 | ); 318 | } 319 | 320 | function testCannotCommentIfNotEnoughBalance() public { 321 | vm.expectRevert(TokenGatedReferenceModule.NotEnoughBalance.selector); 322 | hub.comment( 323 | DataTypes.CommentData({ 324 | profileId: userProfileId, 325 | contentURI: MOCK_URI, 326 | profileIdPointed: publisherProfileId, 327 | pubIdPointed: pubId, 328 | collectModule: address(freeCollectModule), 329 | collectModuleInitData: abi.encode(false), 330 | referenceModuleData: '', 331 | referenceModule: address(tokenGatedReferenceModule), 332 | referenceModuleInitData: referenceModuleInitData 333 | }) 334 | ); 335 | } 336 | 337 | // Scenarios 338 | function testMirrorWhileHoldingEnoughTokens() public { 339 | nft.mint(me, 1); 340 | assert(nft.balanceOf(me) >= minThreshold); 341 | hub.mirror( 342 | DataTypes.MirrorData({ 343 | profileId: userProfileId, 344 | profileIdPointed: publisherProfileId, 345 | pubIdPointed: pubId, 346 | referenceModuleData: '', 347 | referenceModule: address(tokenGatedReferenceModule), 348 | referenceModuleInitData: referenceModuleInitData 349 | }) 350 | ); 351 | } 352 | 353 | function testCommentWhileHoldingEnoughTokens() public { 354 | nft.mint(me, 1); 355 | assert(nft.balanceOf(me) >= minThreshold); 356 | hub.comment( 357 | DataTypes.CommentData({ 358 | profileId: userProfileId, 359 | contentURI: MOCK_URI, 360 | profileIdPointed: publisherProfileId, 361 | pubIdPointed: pubId, 362 | collectModule: address(freeCollectModule), 363 | collectModuleInitData: abi.encode(false), 364 | referenceModuleData: '', 365 | referenceModule: address(tokenGatedReferenceModule), 366 | referenceModuleInitData: referenceModuleInitData 367 | }) 368 | ); 369 | } 370 | } 371 | -------------------------------------------------------------------------------- /test/helpers/constants.ts: -------------------------------------------------------------------------------- 1 | export const MAX_UINT256 = '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'; 2 | export const WETH_ADDRESS = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'; 3 | export const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; 4 | export const ONE_DAY = 86400; 5 | export const POOL_ADDRESSES_PROVIDER_ADDRESS = '0xa97684ead0e402dC232d5A977953DF7ECBaB3CDb'; 6 | export const POOL_ADDRESS = '0x794a61358D6845594F94dc1DB02A252b5b4814aD'; 7 | 8 | // Fetched from $npx hardhat node, account # 7 9 | export const FAKE_PRIVATEKEY = '0xa2e0097c961c67ec197b6865d7ecea6caffc68ebeb00e6050368c8f67fc9c588'; 10 | 11 | export const HARDHAT_CHAINID = 31337; 12 | 13 | export enum PubType { 14 | Post, 15 | Comment, 16 | Mirror, 17 | Nonexistent, 18 | } 19 | -------------------------------------------------------------------------------- /test/helpers/errors.ts: -------------------------------------------------------------------------------- 1 | export const ERRORS = { 2 | // TokenGatedReferenceModule 3 | NOT_ENOUGH_BALANCE: 'NotEnoughBalance()', 4 | 5 | // AuctionCollectModule 6 | ONGOING_AUCTION: 'OngoingAuction()', 7 | UNAVAILABLE_AUCTION: 'UnavailableAuction()', 8 | COLLECT_ALREADY_PROCESSED: 'CollectAlreadyProcessed()', 9 | FEE_ALREADY_PROCESSED: 'FeeAlreadyProcessed()', 10 | INSUFFICIENT_BID_AMOUNT: 'InsufficientBidAmount()', 11 | 12 | // UpdatableOwnableFeeCollectModule 13 | ONLY_OWNER: 'OnlyOwner()', 14 | INVALID_PARAMETERS: 'InvalidParameters()', 15 | 16 | // DegreesOfSeparationReferenceModule 17 | INVALID_DEGREES_OF_SEPARATION: 'InvalidDegreesOfSeparation()', 18 | OPERATION_DISABLED: 'OperationDisabled()', 19 | PROFILE_PATH_EXCEEDS_DEGREES_OF_SEPARATION: 'ProfilePathExceedsDegreesOfSeparation()', 20 | PUBLICATION_NOT_SET_UP: 'PublicationNotSetUp()', 21 | 22 | // Core 23 | CANNOT_INIT_IMPL: 'CannotInitImplementation()', 24 | INITIALIZED: 'Initialized()', 25 | SIGNATURE_EXPIRED: 'SignatureExpired()', 26 | ZERO_SPENDER: 'ZeroSpender()', 27 | SIGNATURE_INVALID: 'SignatureInvalid()', 28 | NOT_OWNER_OR_APPROVED: 'NotOwnerOrApproved()', 29 | NOT_HUB: 'NotHub()', 30 | TOKEN_DOES_NOT_EXIST: 'TokenDoesNotExist()', 31 | CALLER_NOT_WHITELSITED_MODULE: 'CallerNotWhitelistedModule()', 32 | NOT_GOVERNANCE: 'NotGovernance()', 33 | COLLECT_MODULE_NOT_WHITELISTED: 'CollectModuleNotWhitelisted()', 34 | FOLLOW_MODULE_NOT_WHITELISTED: 'FollowModuleNotWhitelisted()', 35 | REFERENCE_MODULE_NOT_WHITELISTED: 'ReferenceModuleNotWhitelisted()', 36 | PROFILE_CREATOR_NOT_WHITELISTED: 'ProfileCreatorNotWhitelisted()', 37 | NOT_PROFILE_OWNER: 'NotProfileOwner()', 38 | NOT_PROFILE_OWNER_OR_DISPATCHER: 'NotProfileOwnerOrDispatcher()', 39 | PUBLICATION_DOES_NOT_EXIST: 'PublicationDoesNotExist()', 40 | PROFILE_HANDLE_TAKEN: 'HandleTaken()', 41 | INVALID_HANDLE_LENGTH: 'HandleLengthInvalid()', 42 | HANDLE_CONTAINS_INVALID_CHARACTERS: 'HandleContainsInvalidCharacters()', 43 | NOT_FOLLOW_NFT: 'CallerNotFollowNFT()', 44 | NOT_COLLECT_NFT: 'CallerNotCollectNFT()', 45 | BLOCK_NUMBER_INVALID: 'BlockNumberInvalid()', 46 | INIT_PARAMS_INVALID: 'InitParamsInvalid()', 47 | ZERO_CURRENCY: 'ZeroCurrency()', 48 | COLLECT_EXPIRED: 'CollectExpired()', 49 | COLLECT_NOT_ALLOWED: 'CollectNotAllowed()', 50 | MINT_LIMIT_EXCEEDED: 'MintLimitExceeded()', 51 | FOLLOW_INVALID: 'FollowInvalid()', 52 | MODULE_DATA_MISMATCH: 'ModuleDataMismatch()', 53 | FOLLOW_NOT_APPROVED: 'FollowNotApproved()', 54 | ARRAY_MISMATCH: 'ArrayMismatch()', 55 | ERC721_NOT_OWN: 'ERC721: transfer of token that is not own', 56 | ERC721_TRANSFER_NOT_OWNER_OR_APPROVED: 'ERC721: transfer caller is not owner nor approved', 57 | ERC721_QUERY_FOR_NONEXISTENT_TOKEN: 'ERC721: owner query for nonexistent token', 58 | ERC721_MINT_TO_ZERO_ADDRESS: 'ERC721: mint to the zero address', 59 | ERC20_TRANSFER_EXCEEDS_ALLOWANCE: 'ERC20: transfer amount exceeds allowance', 60 | ERC20_INSUFFICIENT_ALLOWANCE: 'ERC20: insufficient allowance', 61 | ERC20_TRANSFER_EXCEEDS_BALANCE: 'ERC20: transfer amount exceeds balance', 62 | NO_SELECTOR: 63 | "Transaction reverted: function selector was not recognized and there's no fallback function", 64 | PAUSED: 'Paused()', 65 | PUBLISHING_PAUSED: 'PublishingPaused()', 66 | NOT_GOVERNANCE_OR_EMERGENCY_ADMIN: 'NotGovernanceOrEmergencyAdmin()', 67 | NO_REASON_ABI_DECODE: 68 | "Transaction reverted and Hardhat couldn't infer the reason. Please report this to help us improve Hardhat.", 69 | }; 70 | -------------------------------------------------------------------------------- /test/helpers/signatures/modules/collect/auction-collect-module.ts: -------------------------------------------------------------------------------- 1 | // File based on https://github.com/dmihal/eth-permit/blob/master/src/eth-permit.ts, modified for other EIP-712 message 2 | 3 | import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'; 4 | import { BigNumberish } from 'ethers'; 5 | import { ethers } from 'hardhat'; 6 | import { AuctionCollectModule__factory } from '../../../../../typechain'; 7 | import { BID_WITH_SIG_DOMAIN } from '../../../../modules/collect/auction-collect-module.spec'; 8 | import { DEFAULT_AMOUNT, FIRST_PROFILE_ID, FIRST_PUB_ID } from '../../../../__setup.spec'; 9 | import { Domain, EIP712Domain, RSV, signData, toStringOrNumber } from '../../utils'; 10 | 11 | interface BidWithSigMessage { 12 | profileId: number | string; 13 | pubId: number | string; 14 | amount: number | string; 15 | followNftTokenId: number | string; 16 | nonce: number | string; 17 | deadline: number | string; 18 | } 19 | 20 | const createTypedData = (message: BidWithSigMessage, domain: Domain) => { 21 | const typedData = { 22 | types: { 23 | EIP712Domain, 24 | BidWithSig: [ 25 | { name: 'profileId', type: 'uint256' }, 26 | { name: 'pubId', type: 'uint256' }, 27 | { name: 'amount', type: 'uint256' }, 28 | { name: 'followNftTokenId', type: 'uint256' }, 29 | { name: 'nonce', type: 'uint256' }, 30 | { name: 'deadline', type: 'uint256' }, 31 | ], 32 | }, 33 | primaryType: 'BidWithSig', 34 | domain, 35 | message, 36 | }; 37 | 38 | return typedData; 39 | }; 40 | 41 | interface SignBidWithSigMessageData { 42 | signer: SignerWithAddress; 43 | domain?: Domain; 44 | profileId?: BigNumberish; 45 | pubId?: BigNumberish; 46 | amount?: BigNumberish; 47 | followNftTokenId?: BigNumberish; 48 | nonce?: BigNumberish; 49 | deadline?: BigNumberish; 50 | } 51 | 52 | export async function signBidWithSigMessage({ 53 | signer, 54 | domain = BID_WITH_SIG_DOMAIN, 55 | profileId = FIRST_PROFILE_ID, 56 | pubId = FIRST_PUB_ID, 57 | amount = DEFAULT_AMOUNT, 58 | followNftTokenId = 0, 59 | nonce, 60 | deadline = ethers.constants.MaxUint256, 61 | }: SignBidWithSigMessageData): Promise { 62 | const message: BidWithSigMessage = { 63 | profileId: toStringOrNumber(profileId), 64 | pubId: toStringOrNumber(pubId), 65 | amount: toStringOrNumber(amount), 66 | followNftTokenId: toStringOrNumber(followNftTokenId), 67 | nonce: toStringOrNumber( 68 | nonce || 69 | (await new AuctionCollectModule__factory(signer) 70 | .attach(domain.verifyingContract) 71 | .nonces(signer.address)) 72 | ), 73 | deadline: toStringOrNumber(deadline), 74 | }; 75 | 76 | const typedData = createTypedData(message, domain); 77 | const sig = await signData(signer.provider, signer.address, typedData); 78 | 79 | return { ...sig, ...message }; 80 | } 81 | -------------------------------------------------------------------------------- /test/helpers/signatures/modules/collect/updatable-ownable-fee-collect-module.ts: -------------------------------------------------------------------------------- 1 | // File based on https://github.com/dmihal/eth-permit/blob/master/src/eth-permit.ts, modified for other EIP-712 message 2 | 3 | import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'; 4 | import { BigNumberish } from 'ethers'; 5 | import { ethers } from 'hardhat'; 6 | import { UpdatableOwnableFeeCollectModule__factory } from '../../../../../typechain'; 7 | import { UPDATE_MODULE_PARAMETERS_WITH_SIG_DOMAIN } from '../../../../modules/collect/updatable-ownable-fee-collect-module.spec'; 8 | import { 9 | currency, 10 | DEFAULT_AMOUNT, 11 | feeRecipient, 12 | FIRST_PROFILE_ID, 13 | FIRST_PUB_ID, 14 | REFERRAL_FEE_BPS, 15 | } from '../../../../__setup.spec'; 16 | import { Domain, EIP712Domain, RSV, signData, toStringOrNumber } from '../../utils'; 17 | 18 | interface UpdateModuleParametersWithSigMessage { 19 | profileId: number | string; 20 | pubId: number | string; 21 | amount: number | string; 22 | currency: string; 23 | recipient: string; 24 | referralFee: number | string; 25 | followerOnly: boolean; 26 | nonce: number | string; 27 | deadline: number | string; 28 | } 29 | 30 | const createTypedData = (message: UpdateModuleParametersWithSigMessage, domain: Domain) => { 31 | const typedData = { 32 | types: { 33 | EIP712Domain, 34 | UpdateModuleParametersWithSig: [ 35 | { name: 'profileId', type: 'uint256' }, 36 | { name: 'pubId', type: 'uint256' }, 37 | { name: 'amount', type: 'uint256' }, 38 | { name: 'currency', type: 'address' }, 39 | { name: 'recipient', type: 'address' }, 40 | { name: 'referralFee', type: 'uint16' }, 41 | { name: 'followerOnly', type: 'bool' }, 42 | { name: 'nonce', type: 'uint256' }, 43 | { name: 'deadline', type: 'uint256' }, 44 | ], 45 | }, 46 | primaryType: 'UpdateModuleParametersWithSig', 47 | domain, 48 | message, 49 | }; 50 | 51 | return typedData; 52 | }; 53 | 54 | interface SignUpdateModuleParametersWithSigMessageData { 55 | signer: SignerWithAddress; 56 | domain?: Domain; 57 | profileId?: BigNumberish; 58 | pubId?: BigNumberish; 59 | amount?: BigNumberish; 60 | feeCurrency?: string; 61 | recipient?: string; 62 | referralFee?: BigNumberish; 63 | followerOnly?: boolean; 64 | nonce?: BigNumberish; 65 | deadline?: BigNumberish; 66 | } 67 | 68 | export async function signUpdateModuleParametersWithSigMessage({ 69 | signer, 70 | domain = UPDATE_MODULE_PARAMETERS_WITH_SIG_DOMAIN, 71 | profileId = FIRST_PROFILE_ID, 72 | pubId = FIRST_PUB_ID, 73 | amount = DEFAULT_AMOUNT, 74 | feeCurrency = currency.address, 75 | recipient = feeRecipient.address, 76 | referralFee = REFERRAL_FEE_BPS, 77 | followerOnly = false, 78 | nonce, 79 | deadline = ethers.constants.MaxUint256, 80 | }: SignUpdateModuleParametersWithSigMessageData): Promise< 81 | UpdateModuleParametersWithSigMessage & RSV 82 | > { 83 | const message: UpdateModuleParametersWithSigMessage = { 84 | profileId: toStringOrNumber(profileId), 85 | pubId: toStringOrNumber(pubId), 86 | amount: toStringOrNumber(amount), 87 | currency: feeCurrency, 88 | recipient: recipient, 89 | referralFee: toStringOrNumber(referralFee), 90 | followerOnly: followerOnly, 91 | nonce: toStringOrNumber( 92 | nonce || 93 | (await new UpdatableOwnableFeeCollectModule__factory(signer) 94 | .attach(domain.verifyingContract) 95 | .sigNonces(signer.address)) 96 | ), 97 | deadline: toStringOrNumber(deadline), 98 | }; 99 | 100 | const typedData = createTypedData(message, domain); 101 | const sig = await signData(signer.provider, signer.address, typedData); 102 | 103 | return { ...sig, ...message }; 104 | } 105 | -------------------------------------------------------------------------------- /test/helpers/signatures/modules/reference/degrees-of-separation-reference-module.ts: -------------------------------------------------------------------------------- 1 | // File based on https://github.com/dmihal/eth-permit/blob/master/src/eth-permit.ts, modified for other EIP-712 message 2 | 3 | import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'; 4 | import { BigNumberish } from 'ethers'; 5 | import { ethers } from 'hardhat'; 6 | import { DegreesOfSeparationReferenceModule__factory } from '../../../../../typechain'; 7 | import { 8 | DEFAULT_DEGREES_OF_SEPARATION, 9 | UPDATE_MODULE_PARAMETERS_WITH_SIG_DOMAIN, 10 | } from '../../../../modules/reference/degrees-of-separation-reference-module.spec'; 11 | import { FIRST_PROFILE_ID, FIRST_PUB_ID } from '../../../../__setup.spec'; 12 | import { Domain, EIP712Domain, RSV, signData, toStringOrNumber } from '../../utils'; 13 | 14 | interface UpdateModuleParametersWithSigMessage { 15 | profileId: number | string; 16 | pubId: number | string; 17 | commentsRestricted: boolean; 18 | mirrorsRestricted: boolean; 19 | degreesOfSeparation: number; 20 | nonce: number | string; 21 | deadline: number | string; 22 | } 23 | 24 | const createTypedData = (message: UpdateModuleParametersWithSigMessage, domain: Domain) => { 25 | const typedData = { 26 | types: { 27 | EIP712Domain, 28 | UpdateModuleParametersWithSig: [ 29 | { name: 'profileId', type: 'uint256' }, 30 | { name: 'pubId', type: 'uint256' }, 31 | { name: 'commentsRestricted', type: 'bool' }, 32 | { name: 'mirrorsRestricted', type: 'bool' }, 33 | { name: 'degreesOfSeparation', type: 'uint8' }, 34 | { name: 'nonce', type: 'uint256' }, 35 | { name: 'deadline', type: 'uint256' }, 36 | ], 37 | }, 38 | primaryType: 'UpdateModuleParametersWithSig', 39 | domain, 40 | message, 41 | }; 42 | 43 | return typedData; 44 | }; 45 | 46 | interface SignUpdateModuleParametersWithSigMessageData { 47 | signer: SignerWithAddress; 48 | domain?: Domain; 49 | profileId?: BigNumberish; 50 | pubId?: BigNumberish; 51 | commentsRestricted?: boolean; 52 | mirrorsRestricted?: boolean; 53 | degreesOfSeparation?: number; 54 | nonce?: BigNumberish; 55 | deadline?: BigNumberish; 56 | } 57 | 58 | export async function signUpdateModuleParametersWithSigMessage({ 59 | signer, 60 | domain = UPDATE_MODULE_PARAMETERS_WITH_SIG_DOMAIN, 61 | profileId = FIRST_PROFILE_ID, 62 | pubId = FIRST_PUB_ID, 63 | commentsRestricted = true, 64 | mirrorsRestricted = true, 65 | degreesOfSeparation = DEFAULT_DEGREES_OF_SEPARATION, 66 | nonce, 67 | deadline = ethers.constants.MaxUint256, 68 | }: SignUpdateModuleParametersWithSigMessageData): Promise< 69 | UpdateModuleParametersWithSigMessage & RSV 70 | > { 71 | const message: UpdateModuleParametersWithSigMessage = { 72 | profileId: toStringOrNumber(profileId), 73 | pubId: toStringOrNumber(pubId), 74 | commentsRestricted: commentsRestricted, 75 | mirrorsRestricted: mirrorsRestricted, 76 | degreesOfSeparation: degreesOfSeparation, 77 | nonce: toStringOrNumber( 78 | nonce || 79 | (await new DegreesOfSeparationReferenceModule__factory(signer) 80 | .attach(domain.verifyingContract) 81 | .nonces(signer.address)) 82 | ), 83 | deadline: toStringOrNumber(deadline), 84 | }; 85 | 86 | const typedData = createTypedData(message, domain); 87 | const sig = await signData(signer.provider, signer.address, typedData); 88 | 89 | return { ...sig, ...message }; 90 | } 91 | -------------------------------------------------------------------------------- /test/helpers/signatures/utils.ts: -------------------------------------------------------------------------------- 1 | // Most of code taken from https://github.com/dmihal/eth-permit repository 2 | 3 | import { BigNumber, BigNumberish } from '@ethersproject/bignumber'; 4 | 5 | export function toStringOrNumber(bigNumberish: BigNumberish): string | number { 6 | if (BigNumber.isBigNumber(bigNumberish)) { 7 | return bigNumberish.toString(); 8 | } else if (bigNumberish instanceof String) { 9 | return bigNumberish as string; 10 | } else if (bigNumberish instanceof Number) { 11 | return bigNumberish as number; 12 | } else { 13 | return BigNumber.from(bigNumberish).toString(); 14 | } 15 | } 16 | 17 | export interface Domain { 18 | name: string; 19 | version: string; 20 | chainId: number; 21 | verifyingContract: string; 22 | } 23 | 24 | export const EIP712Domain = [ 25 | { name: 'name', type: 'string' }, 26 | { name: 'version', type: 'string' }, 27 | { name: 'chainId', type: 'uint256' }, 28 | { name: 'verifyingContract', type: 'address' }, 29 | ]; 30 | 31 | const randomId = () => Math.floor(Math.random() * 10000000000); 32 | 33 | export const send = (provider: any, method: string, params?: any[]) => 34 | new Promise((resolve, reject) => { 35 | const payload = { 36 | id: randomId(), 37 | method, 38 | params, 39 | }; 40 | const callback = (err: any, result: any) => { 41 | if (err) { 42 | reject(err); 43 | } else if (result.error) { 44 | console.error(result.error); 45 | reject(result.error); 46 | } else { 47 | resolve(result.result); 48 | } 49 | }; 50 | 51 | const _provider = provider.provider?.provider || provider.provider || provider; 52 | 53 | if (_provider.getUncheckedSigner /* ethers provider */) { 54 | _provider 55 | .send(method, params) 56 | .then((r: any) => resolve(r)) 57 | .catch((e: any) => reject(e)); 58 | } else if (_provider.sendAsync) { 59 | _provider.sendAsync(payload, callback); 60 | } else { 61 | _provider.send(payload, callback).catch((error: any) => { 62 | if (error.message === "Hardhat Network doesn't support JSON-RPC params sent as an object") { 63 | _provider 64 | .send(method, params) 65 | .then((r: any) => resolve(r)) 66 | .catch((e: any) => reject(e)); 67 | } else { 68 | throw error; 69 | } 70 | }); 71 | } 72 | }); 73 | 74 | export interface RSV { 75 | r: string; 76 | s: string; 77 | v: number; 78 | } 79 | 80 | const splitSignatureToRSV = (signature: string): RSV => { 81 | const r = '0x' + signature.substring(2).substring(0, 64); 82 | const s = '0x' + signature.substring(2).substring(64, 128); 83 | const v = parseInt(signature.substring(2).substring(128, 130), 16); 84 | return { r, s, v }; 85 | }; 86 | 87 | const signWithEthers = async (signer: any, fromAddress: string, typeData: any): Promise => { 88 | const signerAddress = await signer.getAddress(); 89 | if (signerAddress.toLowerCase() !== fromAddress.toLowerCase()) { 90 | throw new Error('Signer address does not match requested signing address'); 91 | } 92 | 93 | const { EIP712Domain: _unused, ...types } = typeData.types; 94 | const rawSignature = await (signer.signTypedData 95 | ? signer.signTypedData(typeData.domain, types, typeData.message) 96 | : signer._signTypedData(typeData.domain, types, typeData.message)); 97 | 98 | return splitSignatureToRSV(rawSignature); 99 | }; 100 | 101 | export const signData = async (provider: any, fromAddress: string, typeData: any): Promise => { 102 | if (provider._signTypedData || provider.signTypedData) { 103 | return signWithEthers(provider, fromAddress, typeData); 104 | } 105 | 106 | const typeDataString = typeof typeData === 'string' ? typeData : JSON.stringify(typeData); 107 | const result = await send(provider, 'eth_signTypedData_v4', [fromAddress, typeDataString]).catch( 108 | (error: any) => { 109 | if (error.message === 'Method eth_signTypedData_v4 not supported.') { 110 | return send(provider, 'eth_signTypedData', [fromAddress, typeData]); 111 | } else { 112 | throw error; 113 | } 114 | } 115 | ); 116 | 117 | return { 118 | r: result.slice(0, 66), 119 | s: '0x' + result.slice(66, 130), 120 | v: parseInt(result.slice(130, 132), 16), 121 | }; 122 | }; 123 | 124 | let chainIdOverride: null | number = null; 125 | 126 | export const setChainIdOverride = (id: number) => { 127 | chainIdOverride = id; 128 | }; 129 | 130 | export const getChainId = async (provider: any): Promise => 131 | chainIdOverride || send(provider, 'eth_chainId'); 132 | -------------------------------------------------------------------------------- /test/modules/reference/token-gated-reference-module.spec.ts: -------------------------------------------------------------------------------- 1 | import { BigNumber } from '@ethersproject/bignumber'; 2 | import { expect } from 'chai'; 3 | import { ethers } from 'hardhat'; 4 | import { ERRORS } from '../../helpers/errors'; 5 | import { 6 | lensHub, 7 | abiCoder, 8 | makeSuiteCleanRoom, 9 | nft, 10 | user, 11 | publisher, 12 | MOCK_PROFILE_HANDLE, 13 | MOCK_URI, 14 | MOCK_FOLLOW_NFT_URI, 15 | FIRST_PROFILE_ID, 16 | FIRST_PUB_ID, 17 | tokenGatedReferenceModule, 18 | freeCollectModule, 19 | currency, 20 | OTHER_MOCK_URI, 21 | } from '../../__setup.spec'; 22 | import { matchEvent, waitForTx } from '../../helpers/utils'; 23 | import { parseEther } from '@ethersproject/units'; 24 | 25 | makeSuiteCleanRoom('TokenGatedReferenceModule', function () { 26 | const SECOND_PROFILE_ID = FIRST_PROFILE_ID + 1; 27 | 28 | interface TokenGatedReferenceModuleInitData { 29 | tokenAddress: string; 30 | minThreshold: BigNumber; 31 | } 32 | 33 | async function getTokenGatedReferenceModuleInitData({ 34 | tokenAddress = nft.address, 35 | minThreshold = BigNumber.from(1), 36 | }: TokenGatedReferenceModuleInitData): Promise { 37 | return abiCoder.encode(['address', 'uint256'], [tokenAddress, minThreshold]); 38 | } 39 | 40 | beforeEach(async function () { 41 | await expect( 42 | lensHub.createProfile({ 43 | to: publisher.address, 44 | handle: MOCK_PROFILE_HANDLE, 45 | imageURI: MOCK_URI, 46 | followModule: ethers.constants.AddressZero, 47 | followModuleInitData: [], 48 | followNFTURI: MOCK_FOLLOW_NFT_URI, 49 | }) 50 | ).to.not.be.reverted; 51 | await expect( 52 | lensHub.createProfile({ 53 | to: user.address, 54 | handle: 'user', 55 | imageURI: OTHER_MOCK_URI, 56 | followModule: ethers.constants.AddressZero, 57 | followModuleInitData: [], 58 | followNFTURI: MOCK_FOLLOW_NFT_URI, 59 | }) 60 | ).to.not.be.reverted; 61 | }); 62 | 63 | context('Publication creation', function () { 64 | context('Negatives', function () { 65 | it('User should fail to post setting zero token address', async function () { 66 | const referenceModuleInitData = await getTokenGatedReferenceModuleInitData({ 67 | tokenAddress: ethers.constants.AddressZero, 68 | minThreshold: BigNumber.from(1), 69 | }); 70 | await expect( 71 | lensHub.connect(publisher).post({ 72 | profileId: FIRST_PROFILE_ID, 73 | contentURI: MOCK_URI, 74 | collectModule: freeCollectModule.address, 75 | collectModuleInitData: abiCoder.encode(['bool'], [true]), 76 | referenceModule: tokenGatedReferenceModule.address, 77 | referenceModuleInitData: referenceModuleInitData, 78 | }) 79 | ).to.be.revertedWith(ERRORS.INIT_PARAMS_INVALID); 80 | }); 81 | it('User should fail to post setting zero minThreshold', async function () { 82 | const referenceModuleInitData = await getTokenGatedReferenceModuleInitData({ 83 | tokenAddress: currency.address, 84 | minThreshold: ethers.constants.Zero, 85 | }); 86 | await expect( 87 | lensHub.connect(publisher).post({ 88 | profileId: FIRST_PROFILE_ID, 89 | contentURI: MOCK_URI, 90 | collectModule: freeCollectModule.address, 91 | collectModuleInitData: abiCoder.encode(['bool'], [true]), 92 | referenceModule: tokenGatedReferenceModule.address, 93 | referenceModuleInitData: referenceModuleInitData, 94 | }) 95 | ).to.be.revertedWith(ERRORS.INIT_PARAMS_INVALID); 96 | }); 97 | }); 98 | 99 | context('Scenarios', function () { 100 | it('User should succeed to create a publication when all parameters are valid and tx should emit expected event', async function () { 101 | const referenceModuleInitData = await getTokenGatedReferenceModuleInitData({ 102 | tokenAddress: currency.address, 103 | minThreshold: BigNumber.from(1), 104 | }); 105 | const tx = lensHub.connect(publisher).post({ 106 | profileId: FIRST_PROFILE_ID, 107 | contentURI: MOCK_URI, 108 | collectModule: freeCollectModule.address, 109 | collectModuleInitData: abiCoder.encode(['bool'], [true]), 110 | referenceModule: tokenGatedReferenceModule.address, 111 | referenceModuleInitData: referenceModuleInitData, 112 | }); 113 | const txReceipt = await waitForTx(tx); 114 | matchEvent( 115 | txReceipt, 116 | 'TokenGatedReferencePublicationCreated', 117 | [FIRST_PROFILE_ID, FIRST_PUB_ID, currency.address, BigNumber.from(1)], 118 | tokenGatedReferenceModule 119 | ); 120 | }); 121 | }); 122 | }); 123 | 124 | context('ERC20 Gated Reference', function () { 125 | let referenceModuleInitData; 126 | const minThreshold = parseEther('10'); 127 | 128 | beforeEach(async function () { 129 | referenceModuleInitData = await getTokenGatedReferenceModuleInitData({ 130 | tokenAddress: currency.address, 131 | minThreshold, 132 | }); 133 | await expect( 134 | lensHub.connect(publisher).post({ 135 | profileId: FIRST_PROFILE_ID, 136 | contentURI: MOCK_URI, 137 | collectModule: freeCollectModule.address, 138 | collectModuleInitData: abiCoder.encode(['bool'], [true]), 139 | referenceModule: tokenGatedReferenceModule.address, 140 | referenceModuleInitData: referenceModuleInitData, 141 | }) 142 | ).to.not.be.reverted; 143 | }); 144 | 145 | context('Negatives', function () { 146 | it('User should fail to mirror if they dont hold enough gating tokens', async function () { 147 | expect(await currency.balanceOf(user.address)).to.be.lt(minThreshold); 148 | 149 | await expect( 150 | lensHub.connect(user).mirror({ 151 | profileId: SECOND_PROFILE_ID, 152 | profileIdPointed: FIRST_PROFILE_ID, 153 | pubIdPointed: FIRST_PUB_ID, 154 | referenceModuleData: '0x', 155 | referenceModule: tokenGatedReferenceModule.address, 156 | referenceModuleInitData: referenceModuleInitData, 157 | }) 158 | ).to.be.revertedWith(ERRORS.NOT_ENOUGH_BALANCE); 159 | }); 160 | 161 | it('User should fail to comment if they dont hold enough gating tokens', async function () { 162 | expect(await currency.balanceOf(user.address)).to.be.lt(minThreshold); 163 | await expect( 164 | lensHub.connect(user).comment({ 165 | profileId: SECOND_PROFILE_ID, 166 | contentURI: MOCK_URI, 167 | profileIdPointed: FIRST_PROFILE_ID, 168 | pubIdPointed: FIRST_PUB_ID, 169 | collectModule: freeCollectModule.address, 170 | collectModuleInitData: abiCoder.encode(['bool'], [true]), 171 | referenceModuleData: '0x', 172 | referenceModule: tokenGatedReferenceModule.address, 173 | referenceModuleInitData: referenceModuleInitData, 174 | }) 175 | ).to.be.revertedWith(ERRORS.NOT_ENOUGH_BALANCE); 176 | }); 177 | }); 178 | 179 | context('Scenarios', function () { 180 | it('Mirroring should work if mirrorer holds enough gating tokens', async function () { 181 | await currency.mint(user.address, minThreshold); 182 | expect(await currency.balanceOf(user.address)).to.be.gte(minThreshold); 183 | 184 | await expect( 185 | lensHub.connect(user).mirror({ 186 | profileId: SECOND_PROFILE_ID, 187 | profileIdPointed: FIRST_PROFILE_ID, 188 | pubIdPointed: FIRST_PUB_ID, 189 | referenceModuleData: '0x', 190 | referenceModule: tokenGatedReferenceModule.address, 191 | referenceModuleInitData: referenceModuleInitData, 192 | }) 193 | ).to.not.be.reverted; 194 | }); 195 | 196 | it('Commenting should work if commenter holds enough gating tokens', async function () { 197 | await currency.mint(user.address, minThreshold); 198 | expect(await currency.balanceOf(user.address)).to.be.gte(minThreshold); 199 | 200 | await expect( 201 | lensHub.connect(user).comment({ 202 | profileId: SECOND_PROFILE_ID, 203 | contentURI: MOCK_URI, 204 | profileIdPointed: FIRST_PROFILE_ID, 205 | pubIdPointed: FIRST_PUB_ID, 206 | collectModule: freeCollectModule.address, 207 | collectModuleInitData: abiCoder.encode(['bool'], [true]), 208 | referenceModuleData: '0x', 209 | referenceModule: tokenGatedReferenceModule.address, 210 | referenceModuleInitData: referenceModuleInitData, 211 | }) 212 | ).to.not.be.reverted; 213 | }); 214 | }); 215 | }); 216 | 217 | context('ERC721 Gated Reference', function () { 218 | let referenceModuleInitData; 219 | const minThreshold = BigNumber.from(1); 220 | 221 | beforeEach(async function () { 222 | referenceModuleInitData = await getTokenGatedReferenceModuleInitData({ 223 | tokenAddress: nft.address, 224 | minThreshold, 225 | }); 226 | await expect( 227 | lensHub.connect(publisher).post({ 228 | profileId: FIRST_PROFILE_ID, 229 | contentURI: MOCK_URI, 230 | collectModule: freeCollectModule.address, 231 | collectModuleInitData: abiCoder.encode(['bool'], [true]), 232 | referenceModule: tokenGatedReferenceModule.address, 233 | referenceModuleInitData: referenceModuleInitData, 234 | }) 235 | ).to.not.be.reverted; 236 | }); 237 | 238 | context('Negatives', function () { 239 | it('User should fail to mirror if they dont hold enough gating tokens', async function () { 240 | expect(await nft.balanceOf(user.address)).to.be.lt(minThreshold); 241 | 242 | await expect( 243 | lensHub.connect(user).mirror({ 244 | profileId: SECOND_PROFILE_ID, 245 | profileIdPointed: FIRST_PROFILE_ID, 246 | pubIdPointed: FIRST_PUB_ID, 247 | referenceModuleData: '0x', 248 | referenceModule: tokenGatedReferenceModule.address, 249 | referenceModuleInitData: referenceModuleInitData, 250 | }) 251 | ).to.be.revertedWith(ERRORS.NOT_ENOUGH_BALANCE); 252 | }); 253 | 254 | it('User should fail to comment if they dont hold enough gating tokens', async function () { 255 | expect(await nft.balanceOf(user.address)).to.be.lt(minThreshold); 256 | 257 | await expect( 258 | lensHub.connect(user).comment({ 259 | profileId: SECOND_PROFILE_ID, 260 | contentURI: MOCK_URI, 261 | profileIdPointed: FIRST_PROFILE_ID, 262 | pubIdPointed: FIRST_PUB_ID, 263 | collectModule: freeCollectModule.address, 264 | collectModuleInitData: abiCoder.encode(['bool'], [true]), 265 | referenceModuleData: '0x', 266 | referenceModule: tokenGatedReferenceModule.address, 267 | referenceModuleInitData: referenceModuleInitData, 268 | }) 269 | ).to.be.revertedWith(ERRORS.NOT_ENOUGH_BALANCE); 270 | }); 271 | }); 272 | 273 | context('Scenarios', function () { 274 | it('Mirroring should work if mirrorer holds enough gating tokens', async function () { 275 | await nft.mint(user.address, 1); 276 | 277 | expect(await nft.balanceOf(user.address)).to.be.gte(minThreshold); 278 | 279 | await expect( 280 | lensHub.connect(user).mirror({ 281 | profileId: SECOND_PROFILE_ID, 282 | profileIdPointed: FIRST_PROFILE_ID, 283 | pubIdPointed: FIRST_PUB_ID, 284 | referenceModuleData: '0x', 285 | referenceModule: tokenGatedReferenceModule.address, 286 | referenceModuleInitData: referenceModuleInitData, 287 | }) 288 | ).to.not.be.reverted; 289 | }); 290 | 291 | it('Commenting should work if commenter holds enough gating tokens', async function () { 292 | await nft.mint(user.address, 1); 293 | 294 | expect(await nft.balanceOf(user.address)).to.be.gte(minThreshold); 295 | 296 | await expect( 297 | lensHub.connect(user).comment({ 298 | profileId: SECOND_PROFILE_ID, 299 | contentURI: MOCK_URI, 300 | profileIdPointed: FIRST_PROFILE_ID, 301 | pubIdPointed: FIRST_PUB_ID, 302 | collectModule: freeCollectModule.address, 303 | collectModuleInitData: abiCoder.encode(['bool'], [true]), 304 | referenceModuleData: '0x', 305 | referenceModule: tokenGatedReferenceModule.address, 306 | referenceModuleInitData: referenceModuleInitData, 307 | }) 308 | ).to.not.be.reverted; 309 | }); 310 | }); 311 | }); 312 | }); 313 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "commonjs", 5 | "strict": true, 6 | "esModuleInterop": true, 7 | "outDir": "dist", 8 | "declaration": true, 9 | "noImplicitAny": false, 10 | "resolveJsonModule": true 11 | }, 12 | "include": ["./scripts", "./test", "./typechain"], 13 | "files": ["./hardhat.config.ts"] 14 | } 15 | --------------------------------------------------------------------------------