diff --git a/README.md b/README.md index ea992fb..ad78b1e 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,16 @@ Unlock the potential of decentralized application development on Vechain with our X-App template for VeBetterDAO. Designed for the Vechain Thor blockchain, this template integrates cutting-edge technologies such as React, TypeScript, Hardhat, and Express, ensuring a seamless and efficient DApp development experience. 🌟 +Read more about the implementation and key features of this template in our [Developer Docs](https://docs.vebetterdao.org/developer-guides/integration-examples/pattern-2-use-smart-contracts-and-backend). + +This template uses the VeBetterDAO ecosystem to distribute rewards to users. To learn more about VeBetterDAO, visit our [documentation](https://docs.vebetterdao.org/developer-guides/integration-examples). + +When using the solo node you can import the following mnemonic into your wallet and have access to 10 pre-funded accounts: + +``` +denial kitchen pet squirrel other broom bar gas better priority spoil cross +``` + ## Requirements Ensure your development environment is set up with the following: @@ -33,14 +43,17 @@ Ensure your development environment is set up with the following: - **Hardhat (for smart contracts):** [Getting Started with Hardhat](https://hardhat.org/hardhat-runner/docs/getting-started) ⛑️ ## Project Structure + ### Frontend (apps/frontend) 🌐 A blazing-fast React application powered by Vite: + - **Vechain dapp-kit:** Streamline wallet connections and interactions. [Learn more](https://docs.vechain.org/developer-resources/sdks-and-providers/dapp-kit) ### Backend (apps/backend) 🔙 An Express server crafted with TypeScript for robust API development: + - **Vechain SDK:** Seamlessly fetch and perform transactions with the VechainThor blockchain. [Learn more](https://docs.vechain.org/developer-resources/sdks-and-providers/sdk) - **OpenAI GPT-Vision-Preview:** Integrate image analysis capabilities. [Explore here](https://platform.openai.com/docs/guides/vision) @@ -59,29 +72,41 @@ Configure your environment variables for seamless integration: ### Frontend Place your `.env` files in `apps/frontend`: + - **VITE_RECAPTCHA_V3_SITE_KEY:** [Request your RecaptchaV3 site keys](https://developers.google.com/recaptcha/docs/v3) ### Backend Store your environment-specific `.env` files in `apps/backend`. `.env.development.local` & `.env.production.local` allow for custom environment variables based on the environment: + - **OPENAI_API_KEY:** [Get your GPT-4 OpenAI key](https://platform.openai.com/api-keys) (Enable GPT-4 [here](https://help.openai.com/en/articles/7102672-how-can-i-access-gpt-4-gpt-4-turbo-and-gpt-4o)) - **RECAPTCHA_SECRET_KEY:** [Request your RecaptchaV3 site keys](https://developers.google.com/recaptcha/docs/v3) ### Contracts Manage deployment parameters and network configurations in `hardhat.config.js` under `apps/contracts`: + - **MNEMONIC:** Mnemonic of the deploying wallet ## Getting Started 🏁 Clone the repository and install dependencies with ease: + ```bash yarn install # Run this at the root level of the project ``` +To distribute rewards this contract necesitates of a valid APP_ID provided by VeBetterDAO when joining the ecosystem. +In testnet you can generate the APP_ID by using the [VeBetterDAO sandbox](https://dev.testnet.governance.vebetterdao.org/). +This contract can be initially deployed without this information and DEFAULT_ADMIN_ROLE can update it later through {EcoEarn-setAppId}. + +This contract must me set as a `rewardDistributor` inside the X2EarnApps contract to be able to send rewards to users and withdraw. + ## Deploying Contracts 🚀 -Deploy your contracts effortlessly on either the Testnet or Solo networks: +Deploy your contracts effortlessly on either the Testnet or Solo networks. +If you are deploying on the Testnet, ensure you have the correct addresses in the `config-contracts` package (generated on the [VeBetterDAO sandbox](https://dev.testnet.governance.vebetterdao.org/)). +When deploying on the SOLO network the script will deploy for you the mocked VeBetterDAO contracts and generate an APP_ID. ### Deploying on Solo Network @@ -98,25 +123,79 @@ yarn contracts:deploy:testnet ## Running on Solo Network 🔧 Run the Frontend and Backend, connected to the Solo network and pointing to your deployed contracts. Ensure all environment variables are properly configured: + ```bash -yarn dev +yarn dev ``` ### Setting up rewards -Run vechain devpal + +## Testnet + +Read the [VeBetterDAO documentation](https://docs.vebetterdao.org/developer-guides/test-environmnet) to learn how to set up rewards for your users and use the Testnet environment. + +Test environment: [https://dev.testnet.governance.vebetterdao.org/](https://dev.testnet.governance.vebetterdao.org/) + +
+ +Thanks to the test environment you will be able to mint and deposit B3TR tokens int the rewards pool that you will use to distribute rewards to users. + +Now you just need to trigger cycles and set amount of rewards per cycle on your EcoEarn contract. + +1. Go to our online [inspector app](https://solid-funicular-1wmop55.pages.github.io/#/contracts) that you can use to interact with your contracts. Be sure to select the correct network (Testnet). + +2. Add the `EcoEarn` contract to the inspector app. Get the address from `config-contracts` package and the ABI from the `apps/contracts/artifacts/contracts/EcoEarn.sol/EcoEarn.json` file. + ![image](https://i.ibb.co/TK8519c/SCR-20240723-kjid.png) + +3. Set how many rewards you want to distribute per cycle: + ![image](https://i.ibb.co/qpJnL5x/SCR-20240723-kkti.png) + +4. Trigger a cycle: + ![image](https://i.ibb.co/47V2Zjb/SCR-20240723-kkxx.png) + +## Solo Network + +Since the Solo network is a local network with mocked VeBetterDAO contracts you can use the following steps to set up available rewards to distribute to users: + +0. Ensure you are using a wallet with imported pre-funded accounts mnemonic into your wallet. Mnemoninc: + +``` +denial kitchen pet squirrel other broom bar gas better priority spoil cross +``` + +1. Copy the `APP_ID` generated by the `contracts:deploy:solo` script and logged in the console. +2. Run `devpal`, a frontend tool to interact with your contracts: + ```bash npx @vechain/devpal http://localhost:8669 ``` -Open the `Inspector` tab and perform the following transactions: -- **Add Contracts:** Add the EcoEarn contract and the Token contract deployed previously. Addresses can be found in the `config-contracts` package. ABIs can be found in the artifacts folder of the `contracts` app. -![image](https://github.com/vechain/x-app-template/assets/64158778/e288ada4-5973-4428-9e72-a362388b1826) -- **Approve token:** Approve the EcoEarn contract to spend your tokens -![image](https://github.com/vechain/x-app-template/assets/64158778/70787d8d-ae60-40ea-b277-87359aaca4ee) -- **Claim rewards:** Claim rewards for the EcoEarn contract -![image](https://github.com/vechain/x-app-template/assets/64158778/834437e5-8de1-4802-9ed7-dca6fe4df332) -- **Trigger cycle:** Trigger the cycle for the EcoEarn contract -![image](https://github.com/vechain/x-app-template/assets/64158778/00236dcd-5b64-4493-9acd-55c6a7f0981f) +3. Open the `Inspector` tab and perform the following actions: +4. Add the B3TR_Mock contract (get the address from the console logs and ABI from the `apps/contracts/artifacts/contracts/mock/B3TR_Mock.sol/B3TR_Mock.json` file) + ![image](https://i.ibb.co/6Zrj7Nx/SCR-20240723-jorq.png) +5. Add the X2EarnRewardsPool contract (get the address from the console logs and ABI from the `apps/contracts/artifacts/contracts/mock/X2EarnRewardsPoolMock.sol/X2EarnRewardsPoolMock.json` file) + ![image](https://i.ibb.co/yYjLw9v/SCR-20240723-jozk.png) +6. You should now have the following setup: + ![image](https://i.ibb.co/w4XWyh9/SCR-20240723-jpbc.png) +7. To recharge the rewards pool you will need to mint some mocked B3TR tokens, then deposit them into the rewards pool. Perform the following actions: + - Mint some tokens by calling the `mint` function on the B3TR_Mock contract + ![image](https://i.ibb.co/XCQ7LNR/SCR-20240723-kgll.png) + - Approve the X2EarnRewards contract to spend the tokens by calling the `approve` function on the B3TR_Mock contract + ![image](https://i.ibb.co/X7Txx7Y/SCR-20240723-keuu.png) + - Deposit the tokens into the rewards pool by calling the `deposit` function on the X2EarnRewardsPool contract + ![image](https://i.ibb.co/X7Txx7Y/SCR-20240723-keuu.png) +8. Now you just need to set how many rewards you want to distribute per cycle and trigger the start of the cycle + +- Add the `EcoEarn` contract to the inspector app. Get the address from `config-contracts` package and the ABI from the `apps/contracts/artifacts/contracts/EcoEarn.sol/EcoEarn.json` file. + ![image](https://i.ibb.co/TK8519c/SCR-20240723-kjid.png) + +- Set how many rewards you want to distribute per cycle: + ![image](https://i.ibb.co/qpJnL5x/SCR-20240723-kkti.png) + +- Trigger a cycle: + ![image](https://i.ibb.co/47V2Zjb/SCR-20240723-kkxx.png) + +NB: Values are in wei, use this tool to convert to VET: [https://eth-converter.com/](https://eth-converter.com/) ## Disclaimer ⚠️ diff --git a/apps/contracts/contracts/EcoEarn.sol b/apps/contracts/contracts/EcoEarn.sol index eb1dc6c..af73265 100644 --- a/apps/contracts/contracts/EcoEarn.sol +++ b/apps/contracts/contracts/EcoEarn.sol @@ -26,14 +26,26 @@ pragma solidity ^0.8.19; import '@openzeppelin/contracts/access/AccessControl.sol'; -import './interfaces/IToken.sol'; +import './interfaces/IX2EarnRewardsPool.sol'; +import '@openzeppelin/contracts/utils/Strings.sol'; /** * @title EcoEarn Contract * @dev This contract manages a reward system based on cycles. Participants can make valid submissions to earn rewards. + * Rewards are being distributed by interacting with the VeBetterDAO's X2EarnRewardsPool contract. + * + * @notice To distribute rewards this contract necesitates of a valid APP_ID provided by VeBetterDAO when joining the ecosystem. + * In testnet you can generate the APP_ID by using the VeBetterDAO sandbox at https://dev.testnet.governance.vebetterdao.org/. + * This contract can be initially deployed without this information and DEFAULT_ADMIN_ROLE can update it later through {EcoEarn-setAppId}. + * + * @notice This contract must me set as a `rewardDistributor` inside the X2EarnApps contract to be able to send rewards to users and withdraw. */ contract EcoEarn is AccessControl { - IToken public token; + // The X2EarnRewardsPool contract used to distribute rewards + IX2EarnRewardsPool public x2EarnRewardsPoolContract; + + // AppID given by the X2EarnApps contract of VeBetterDAO + bytes32 public appId; // Mapping from cycle to total rewards mapping(uint256 => uint256) public rewards; @@ -67,15 +79,20 @@ contract EcoEarn is AccessControl { /** * @dev Constructor for the EcoEarn contract * @param _admin Address of the admin - * @param _token Address of the token contract + * @param _x2EarnRewardsPoolContract Address of the X2EarnRewardsPool contract * @param _cycleDuration Duration of each cycle in blocks * @param _maxSubmissionsPerCycle Maximum submissions allowed per cycle + * @param _appId The appId generated by the X2EarnApps contract when app was added to VeBetterDAO */ - constructor(address _admin, address _token, uint256 _cycleDuration, uint256 _maxSubmissionsPerCycle) { - token = IToken(_token); + constructor(address _admin, address _x2EarnRewardsPoolContract, uint256 _cycleDuration, uint256 _maxSubmissionsPerCycle, bytes32 _appId) { + require(_admin != address(0), 'EcoEarn: _admin address cannot be the zero address'); + require(_x2EarnRewardsPoolContract != address(0), 'EcoEearn: x2EarnRewardsPool contract address cannot be the zero address'); + + x2EarnRewardsPoolContract = IX2EarnRewardsPool(_x2EarnRewardsPoolContract); maxSubmissionsPerCycle = _maxSubmissionsPerCycle; cycleDuration = _cycleDuration; nextCycle = 1; + appId = _appId; _grantRole(DEFAULT_ADMIN_ROLE, _admin); } @@ -106,21 +123,23 @@ contract EcoEarn is AccessControl { // Decrease the rewards left rewardsLeft[getCurrentCycle()] -= amount; - // Transfer the reward to the participant - require(token.transfer(participant, amount)); + // Transfer the reward to the participant, will revert if the transfer fails + // The last parameter is the proof of the sustainable action the user is rewarded for. + // It is optional and can be left empty, but it is recommended to provide a proof for transparency and to avoid disputes. + // Read more about proofs in the VeBetterDAO documentation: https://docs.vebetterdao.org/developer-guides/sustainability-proofs + x2EarnRewardsPoolContract.distributeReward(appId, amount, participant, ''); emit Submission(participant, amount); } /** - * @dev Claims allocation for the next cycle + * @dev Set the allocation for the next cycle * @param amount Amount of tokens to be allocated */ - function claimAllocation(uint256 amount) public onlyRole(DEFAULT_ADMIN_ROLE) { - require(amount <= token.balanceOf(msg.sender), 'EcoEarn: Insufficient balance'); + function setRewardsAmount(uint256 amount) public onlyRole(DEFAULT_ADMIN_ROLE) { + require(amount <= x2EarnRewardsPoolContract.availableFunds(appId), 'EcoEarn: Insufficient balance on the X2EarnRewardsPool contract'); rewards[nextCycle] = amount; rewardsLeft[nextCycle] = amount; - require(token.transferFrom(msg.sender, address(this), amount)); emit ClaimedAllocation(nextCycle, amount); } @@ -133,7 +152,9 @@ contract EcoEarn is AccessControl { require(cycle < getCurrentCycle(), 'EcoEarn: Cycle is not over'); uint256 amount = rewardsLeft[cycle]; rewardsLeft[cycle] = 0; - require(token.transfer(msg.sender, amount)); + + // will revert if the withdraw fails + x2EarnRewardsPoolContract.withdraw(amount, appId, string.concat('Withdraws remaining rewards of cycle nr.', Strings.toString(cycle))); } // ---------------- SETTERS ---------------- // @@ -147,14 +168,6 @@ contract EcoEarn is AccessControl { maxSubmissionsPerCycle = _maxSubmissionsPerCycle; } - /** - * @dev Sets the token address - * @param _token New token contract address - */ - function setToken(address _token) external onlyRole(DEFAULT_ADMIN_ROLE) { - token = IToken(_token); - } - /** * @dev Sets the next cycle number * @param _nextCycle New next cycle number @@ -163,6 +176,14 @@ contract EcoEarn is AccessControl { nextCycle = _nextCycle; } + /** + * @dev Sets the appId provied by VeBetterDAO + * @param _appId The new app id + */ + function setAppId(bytes32 _appId) external onlyRole(DEFAULT_ADMIN_ROLE) { + appId = _appId; + } + // ---------------- GETTERS ---------------- // /** diff --git a/apps/contracts/contracts/Token.sol b/apps/contracts/contracts/Token.sol deleted file mode 100644 index 3aefa26..0000000 --- a/apps/contracts/contracts/Token.sol +++ /dev/null @@ -1,14 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.19; - -import '@openzeppelin/contracts/token/ERC20/ERC20.sol'; -import '@openzeppelin/contracts/access/Ownable.sol'; -import '@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol'; - -contract Token is ERC20, Ownable, ERC20Permit { - constructor(address initialOwner) ERC20('Token', 'TKN') Ownable(initialOwner) ERC20Permit('Token') {} - - function mint(address to, uint256 amount) public onlyOwner { - _mint(to, amount); - } -} diff --git a/apps/contracts/contracts/interfaces/IX2EarnRewardsPool.sol b/apps/contracts/contracts/interfaces/IX2EarnRewardsPool.sol new file mode 100644 index 0000000..2246645 --- /dev/null +++ b/apps/contracts/contracts/interfaces/IX2EarnRewardsPool.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.20; + +/** + * @title IX2EarnRewardsPool + * @dev Interface designed to be used by a contract that allows x2Earn apps to reward users that performed sustainable actions. + * Funds can be deposited into this contract by specifying the app id that can access the funds. + * Admins of x2EarnApps can withdraw funds from the rewards pool, whihc are sent to the team wallet. + */ +interface IX2EarnRewardsPool { + /** + * @dev Event emitted when a new deposit is made into the rewards pool. + * + * @param amount The amount of $B3TR deposited. + * @param appId The ID of the app for which the deposit was made. + * @param depositor The address of the user that deposited the funds. + */ + event NewDeposit(uint256 amount, bytes32 indexed appId, address indexed depositor); + + /** + * @dev Event emitted when a team withdraws funds from the rewards pool. + * + * @param amount The amount of $B3TR withdrawn. + * @param appId The ID of the app for which the withdrawal was made. + * @param teamWallet The address of the team wallet that received the funds. + * @param withdrawer The address of the user that withdrew the funds. + * @param reason The reason for the withdrawal. + */ + event TeamWithdrawal(uint256 amount, bytes32 indexed appId, address indexed teamWallet, address withdrawer, string reason); + + /** + * @dev Event emitted when a reward is emitted by an app. + * + * @param amount The amount of $B3TR rewarded. + * @param appId The ID of the app that emitted the reward. + * @param receiver The address of the user that received the reward. + * @param proof The proof of the sustainable action that was performed. + * @param distributor The address of the user that distributed the reward. + */ + event RewardDistributed(uint256 amount, bytes32 indexed appId, address indexed receiver, string proof, address indexed distributor); + + /** + * @dev Retrieves the current version of the contract. + * + * @return The version of the contract. + */ + function version() external pure returns (string memory); + + /** + * @dev Function used by x2earn apps to deposit funds into the rewards pool. + * + * @param amount The amount of $B3TR to deposit. + * @param appId The ID of the app. + */ + function deposit(uint256 amount, bytes32 appId) external returns (bool); + + /** + * @dev Function used by x2earn apps to withdraw funds from the rewards pool. + * + * @param amount The amount of $B3TR to withdraw. + * @param appId The ID of the app. + * @param reason The reason for the withdrawal. + */ + function withdraw(uint256 amount, bytes32 appId, string memory reason) external; + + /** + * @dev Gets the amount of funds available for an app to reward users. + * + * @param appId The ID of the app. + */ + function availableFunds(bytes32 appId) external view returns (uint256); + + /** + * @dev Function used by x2earn apps to reward users that performed sustainable actions. + * + * @param appId the app id that is emitting the reward + * @param amount the amount of B3TR token the user is rewarded with + * @param receiver the address of the user that performed the sustainable action and is rewarded + * @param proof a JSON file uploaded on IPFS by the app that adds information on the type of action that was performed + */ + function distributeReward(bytes32 appId, uint256 amount, address receiver, string memory proof) external; +} diff --git a/apps/contracts/contracts/mock/B3TR_Mock.sol b/apps/contracts/contracts/mock/B3TR_Mock.sol new file mode 100644 index 0000000..b2de792 --- /dev/null +++ b/apps/contracts/contracts/mock/B3TR_Mock.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +/** + * @title B3TR_Mock + * @dev Mock contract for the B3TR token. + */ +contract B3TR_Mock is ERC20 { + // Mint 10,000,000 B3TR tokens to the deployer + constructor() ERC20("B3TR", "B3TR") { + _mint(msg.sender, 10000000 * 10 ** decimals()); + } + + // Public function to mint tokens + function mint(address to, uint256 amount) public { + _mint(to, amount); + } +} diff --git a/apps/contracts/contracts/mock/X2EarnAppsMock.sol b/apps/contracts/contracts/mock/X2EarnAppsMock.sol new file mode 100644 index 0000000..bca35fb --- /dev/null +++ b/apps/contracts/contracts/mock/X2EarnAppsMock.sol @@ -0,0 +1,364 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.20; + +import {IX2EarnAppsMock} from "./interfaces/IX2EarnAppsMock.sol"; +import {X2EarnAppsDataTypes} from "./interfaces/X2EarnAppsDataTypes.sol"; + +/** + * @title X2EarnApps + * + * @notice DEV MOCKED VERSION of the VeBetterDAO's X2EarnApps contract. + * @dev This contract can be used to add an app, set the admin of the app, add reward distributors to the app, and remove reward distributors from the app. + * It has a function to check the existence of an app, check if an account is the admin of the app, and check if an account is a reward distributor of the app. + * This is the minimum required functionality for the VeBetterDAO's X2EarnRewardsPool contract to function. + * + */ +contract X2EarnAppsMock is IX2EarnAppsMock { + uint256 public constant MAX_MODERATORS = 100; + uint256 public constant MAX_REWARD_DISTRIBUTORS = 100; + + /// @custom:storage-location erc7201:b3tr.storage.X2EarnApps.AppsStorage + struct AppsStorageStorage { + // Mapping from app ID to app + mapping(bytes32 appId => X2EarnAppsDataTypes.App) _apps; + // List of app IDs to enable retrieval of all _apps + bytes32[] _appIds; + } + + // keccak256(abi.encode(uint256(keccak256("b3tr.storage.X2EarnApps.AppsStorage")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant AppsStorageStorageLocation = + 0xb6909058bd527140b8d55a44344c5e42f1f148f1b3b16df7641882df8dd72900; + + function _getAppsStorageStorage() + internal + pure + returns (AppsStorageStorage storage $) + { + assembly { + $.slot := AppsStorageStorageLocation + } + } + + /// @custom:storage-location erc7201:b3tr.storage.X2EarnApps.Administration + struct AdministrationStorage { + mapping(bytes32 appId => address) _admin; + mapping(bytes32 appId => address[]) _moderators; + mapping(bytes32 appId => address[]) _rewardDistributors; // addresses that can distribute rewards from X2EarnRewardsPool + mapping(bytes32 appId => address) _teamWalletAddress; + mapping(bytes32 appId => uint256) _teamAllocationPercentage; // by default this is 0 and all funds are sent to the X2EarnRewardsPool + mapping(bytes32 appId => string) _metadataURI; + } + + // keccak256(abi.encode(uint256(keccak256("b3tr.storage.X2EarnApps.Administration")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant AdministrationStorageLocation = + 0x5830f0e95c01712d916c34d9e2fa42e9f749b325b67bce7382d70bb99c623500; + + function _getAdministrationStorage() + internal + pure + returns (AdministrationStorage storage $) + { + assembly { + $.slot := AdministrationStorageLocation + } + } + + constructor() {} + + // ---------- Modifiers ------------ // + + /** + * @dev Throws if called by any account that is not an app admin. + * @param appId the app ID + */ + modifier onlyAppAdmin(bytes32 appId) { + if (!isAppAdmin(appId, msg.sender)) { + revert X2EarnUnauthorizedUser(msg.sender); + } + _; + } + + // ---------- Getters ------------ // + /** + * @dev See {IX2EarnApps-hashAppName}. + */ + function hashAppName(string memory appName) public pure returns (bytes32) { + return keccak256(abi.encodePacked(appName)); + } + + /** + * @dev See {IX2EarnApps-appExists}. + */ + function appExists(bytes32 appId) public view returns (bool) { + AppsStorageStorage storage $ = _getAppsStorageStorage(); + + return $._apps[appId].createdAtTimestamp != 0; + } + + /** + * @dev Check if an account is the admin of the app + * + * @param appId the hashed name of the app + * @param account the address of the account + */ + function isAppAdmin( + bytes32 appId, + address account + ) public view returns (bool) { + AdministrationStorage storage $ = _getAdministrationStorage(); + + return $._admin[appId] == account; + } + + /** + * @dev Returns true if an account is a reward distributor of the app + * + * @param appId the hashed name of the app + * @param account the address of the account + */ + function isRewardDistributor( + bytes32 appId, + address account + ) public view returns (bool) { + AdministrationStorage storage $ = _getAdministrationStorage(); + + address[] memory distributors = $._rewardDistributors[appId]; + for (uint256 i; i < distributors.length; i++) { + if (distributors[i] == account) { + return true; + } + } + + return false; + } + + /** + * @dev Get the address where the x2earn app receives allocation funds + * + * @param appId the hashed name of the app + */ + function teamWalletAddress( + bytes32 appId + ) public view override returns (address) { + AdministrationStorage storage $ = _getAdministrationStorage(); + + return $._teamWalletAddress[appId]; + } + + // ---------- Overrides ------------ // + + /** + * @dev See {IX2EarnApps-addApp}. + * + * DEV-TESTNET: Everyone can add an app for testing purposes. + */ + function addApp( + address _teamWalletAddress, + address _admin, + string memory _appName + ) public { + _addApp(_teamWalletAddress, _admin, _appName); + } + + /** + * @dev See {IX2EarnApps-setAppAdmin}. + */ + function setAppAdmin( + bytes32 _appId, + address _newAdmin + ) public onlyAppAdmin(_appId) { + _setAppAdmin(_appId, _newAdmin); + } + + /** + * @dev See {IX2EarnApps-addRewardDistributor}. + */ + function addRewardDistributor( + bytes32 _appId, + address _distributor + ) public onlyAppAdmin(_appId) { + _addRewardDistributor(_appId, _distributor); + } + + /** + * @dev See {IX2EarnApps-removeRewardDistributor}. + */ + function removeRewardDistributor( + bytes32 _appId, + address _distributor + ) public onlyAppAdmin(_appId) { + _removeRewardDistributor(_appId, _distributor); + } + + /** + * @dev Create app. + * The id of the app is the hash of the app name. + * Will be eligible for voting by default from the next round and + * the team allocation percentage will be 0%. + * + * @param _teamWalletAddress the address where the app should receive allocation funds + * @param _admin the address of the admin + * @param _appName the name of the app + * + * Emits a {AppAdded} event. + */ + function _addApp( + address _teamWalletAddress, + address _admin, + string memory _appName + ) internal { + if (_teamWalletAddress == address(0)) { + revert X2EarnInvalidAddress(_teamWalletAddress); + } + if (_admin == address(0)) { + revert X2EarnInvalidAddress(_admin); + } + + AppsStorageStorage storage $ = _getAppsStorageStorage(); + bytes32 id = hashAppName(_appName); + + if (appExists(id)) { + revert X2EarnAppAlreadyExists(id); + } + + // Store the new app + $._apps[id] = X2EarnAppsDataTypes.App(id, _appName, block.timestamp); + $._appIds.push(id); + _setAppAdmin(id, _admin); + _updateTeamWalletAddress(id, _teamWalletAddress); + + emit AppAdded(id, _teamWalletAddress, _appName, true); + } + + /** + * @dev Get the app data saved in storage + * + * @param appId the if of the app + */ + function _getAppStorage( + bytes32 appId + ) internal view returns (X2EarnAppsDataTypes.App memory) { + if (!appExists(appId)) { + revert X2EarnNonexistentApp(appId); + } + + AppsStorageStorage storage $ = _getAppsStorageStorage(); + return $._apps[appId]; + } + + /** + * @dev Internal function to set the admin address of the app + * + * @param appId the hashed name of the app + * @param newAdmin the address of the new admin + */ + function _setAppAdmin(bytes32 appId, address newAdmin) internal { + if (!appExists(appId)) { + revert X2EarnNonexistentApp(appId); + } + + if (newAdmin == address(0)) { + revert X2EarnInvalidAddress(newAdmin); + } + + AdministrationStorage storage $ = _getAdministrationStorage(); + + emit AppAdminUpdated(appId, $._admin[appId], newAdmin); + + $._admin[appId] = newAdmin; + } + + /** + * @dev Internal function to add a reward distributor to the app + * + * @param appId the hashed name of the app + * @param distributor the address of the reward distributor + */ + function _addRewardDistributor( + bytes32 appId, + address distributor + ) internal { + if (distributor == address(0)) { + revert X2EarnInvalidAddress(distributor); + } + + if (!appExists(appId)) { + revert X2EarnNonexistentApp(appId); + } + + AdministrationStorage storage $ = _getAdministrationStorage(); + + if ($._rewardDistributors[appId].length >= MAX_REWARD_DISTRIBUTORS) { + revert X2EarnMaxRewardDistributorsReached(appId); + } + + $._rewardDistributors[appId].push(distributor); + + emit RewardDistributorAddedToApp(appId, distributor); + } + + /** + * @dev Internal function to remove a reward distributor from the app + * + * @param appId the hashed name of the app + * @param distributor the address of the reward distributor + */ + function _removeRewardDistributor( + bytes32 appId, + address distributor + ) internal { + if (distributor == address(0)) { + revert X2EarnInvalidAddress(distributor); + } + + if (!appExists(appId)) { + revert X2EarnNonexistentApp(appId); + } + + if (!isRewardDistributor(appId, distributor)) { + revert X2EarnNonexistentRewardDistributor(appId, distributor); + } + + AdministrationStorage storage $ = _getAdministrationStorage(); + + address[] storage distributors = $._rewardDistributors[appId]; + for (uint256 i; i < distributors.length; i++) { + if (distributors[i] == distributor) { + distributors[i] = distributors[distributors.length - 1]; + distributors.pop(); + emit RewardDistributorRemovedFromApp(appId, distributor); + break; + } + } + } + + /** + * @dev Update the address where the x2earn app receives allocation funds + * + * @param appId the hashed name of the app + * @param newTeamWalletAddress the address of the new wallet where the team will receive the funds + */ + function _updateTeamWalletAddress( + bytes32 appId, + address newTeamWalletAddress + ) internal { + if (newTeamWalletAddress == address(0)) { + revert X2EarnInvalidAddress(newTeamWalletAddress); + } + + if (!appExists(appId)) { + revert X2EarnNonexistentApp(appId); + } + + AdministrationStorage storage $ = _getAdministrationStorage(); + address oldTeamWalletAddress = $._teamWalletAddress[appId]; + $._teamWalletAddress[appId] = newTeamWalletAddress; + + emit TeamWalletAddressUpdated( + appId, + oldTeamWalletAddress, + newTeamWalletAddress + ); + } +} diff --git a/apps/contracts/contracts/mock/X2EarnRewardsPoolMock.sol b/apps/contracts/contracts/mock/X2EarnRewardsPoolMock.sol new file mode 100644 index 0000000..6e498d6 --- /dev/null +++ b/apps/contracts/contracts/mock/X2EarnRewardsPoolMock.sol @@ -0,0 +1,213 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.20; + +import {AccessControl} from '@openzeppelin/contracts/access/AccessControl.sol'; +import {ReentrancyGuard} from '@openzeppelin/contracts/utils/ReentrancyGuard.sol'; +import {IToken} from '../interfaces/IToken.sol'; +import {IX2EarnAppsMock} from './interfaces/IX2EarnAppsMock.sol'; +import {IX2EarnRewardsPool} from '../interfaces/IX2EarnRewardsPool.sol'; +import {IERC1155Receiver} from '@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol'; +import {IERC721Receiver} from '@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol'; + +/** + * Mock contract forked from vebetterdao-contracts. + * + * @title X2EarnRewardsPool + * @dev This contract is used by x2Earn apps to reward users that performed sustainable actions. + * The XAllocationPool contract or other contracts/users can deposit funds into this contract by specifying the app + * that can access the funds. + * Admins of x2EarnApps can withdraw funds from the rewards pool, whihch are sent to the team wallet. + * Reward distributors of a x2Earn app can distribute rewards to users that performed sustainable actions or withdraw funds + * to the team wallet. + */ +contract X2EarnRewardsPoolMock is IX2EarnRewardsPool, AccessControl, ReentrancyGuard { + bytes32 public constant CONTRACTS_ADDRESS_MANAGER_ROLE = keccak256('CONTRACTS_ADDRESS_MANAGER_ROLE'); + + /// @custom:storage-location erc7201:b3tr.storage.X2EarnRewardsPool + struct X2EarnRewardsPoolStorage { + IToken b3tr; + IX2EarnAppsMock x2EarnApps; + mapping(bytes32 appId => uint256) availableFunds; // Funds that the app can use to reward users + } + + // keccak256(abi.encode(uint256(keccak256("b3tr.storage.X2EarnRewardsPool")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant X2EarnRewardsPoolStorageLocation = 0x7c0dcc5654efea34bf150fefe2d7f927494d4026026590e81037cb4c7a9cdc00; + + function _getX2EarnRewardsPoolStorage() private pure returns (X2EarnRewardsPoolStorage storage $) { + assembly { + $.slot := X2EarnRewardsPoolStorageLocation + } + } + + constructor(address _admin, IToken _b3tr, IX2EarnAppsMock _x2EarnApps) { + require(_admin != address(0), 'X2EarnRewardsPool: admin is the zero address'); + require(address(_b3tr) != address(0), 'X2EarnRewardsPool: b3tr is the zero address'); + require(address(_x2EarnApps) != address(0), 'X2EarnRewardsPool: x2EarnApps is the zero address'); + + _grantRole(DEFAULT_ADMIN_ROLE, _admin); + + X2EarnRewardsPoolStorage storage $ = _getX2EarnRewardsPoolStorage(); + $.b3tr = _b3tr; + $.x2EarnApps = _x2EarnApps; + } + + // ---------- Setters ---------- // + + /** + * @dev See {IX2EarnRewardsPool-deposit} + */ + function deposit(uint256 amount, bytes32 appId) external returns (bool) { + X2EarnRewardsPoolStorage storage $ = _getX2EarnRewardsPoolStorage(); + + // check that app exists + require($.x2EarnApps.appExists(appId), 'X2EarnRewardsPool: app does not exist'); + + // increase available amount for the app + $.availableFunds[appId] += amount; + + // transfer tokens to this contract + require($.b3tr.transferFrom(msg.sender, address(this), amount), 'X2EarnRewardsPool: deposit transfer failed'); + + emit NewDeposit(amount, appId, msg.sender); + + return true; + } + + /** + * @dev See {IX2EarnRewardsPool-withdraw} + */ + function withdraw(uint256 amount, bytes32 appId, string memory reason) external nonReentrant { + X2EarnRewardsPoolStorage storage $ = _getX2EarnRewardsPoolStorage(); + + require($.x2EarnApps.appExists(appId), 'X2EarnRewardsPool: app does not exist'); + + require( + $.x2EarnApps.isAppAdmin(appId, msg.sender) || $.x2EarnApps.isRewardDistributor(appId, msg.sender), + 'X2EarnRewardsPool: not an app admin nor a reward distributor' + ); + + // check if the app has enough available funds to withdraw + require($.availableFunds[appId] >= amount, 'X2EarnRewardsPool: app has insufficient funds'); + + // check if the contract has enough funds + require($.b3tr.balanceOf(address(this)) >= amount, 'X2EarnRewardsPool: insufficient funds on contract'); + + // Get the team wallet address + address teamWalletAddress = $.x2EarnApps.teamWalletAddress(appId); + + // transfer the rewards to the team wallet + $.availableFunds[appId] -= amount; + require($.b3tr.transfer(teamWalletAddress, amount), 'X2EarnRewardsPool: Allocation transfer to app failed'); + + emit TeamWithdrawal(amount, appId, teamWalletAddress, msg.sender, reason); + } + + /** + * @dev See {IX2EarnRewardsPool-distributeReward} + */ + function distributeReward(bytes32 appId, uint256 amount, address receiver, string memory proof) external nonReentrant { + X2EarnRewardsPoolStorage storage $ = _getX2EarnRewardsPoolStorage(); + + require($.x2EarnApps.appExists(appId), 'X2EarnRewardsPool: app does not exist'); + + require($.x2EarnApps.isRewardDistributor(appId, msg.sender), 'X2EarnRewardsPool: not a reward distributor'); + + // check if the app has enough available funds to reward users + require($.availableFunds[appId] >= amount, 'X2EarnRewardsPool: app has insufficient funds'); + + // check if the contract has enough funds + require($.b3tr.balanceOf(address(this)) >= amount, 'X2EarnRewardsPool: insufficient funds on contract'); + + // transfer the rewards to the receiver + $.availableFunds[appId] -= amount; + require($.b3tr.transfer(receiver, amount), 'X2EarnRewardsPool: Allocation transfer to app failed'); + + // emit event + emit RewardDistributed(amount, appId, receiver, proof, msg.sender); + } + + /** + * @dev Sets the X2EarnApps contract address. + * + * @param _x2EarnApps the new X2EarnApps contract + */ + function setX2EarnApps(IX2EarnAppsMock _x2EarnApps) external onlyRole(CONTRACTS_ADDRESS_MANAGER_ROLE) { + require(address(_x2EarnApps) != address(0), 'X2EarnRewardsPool: x2EarnApps is the zero address'); + + X2EarnRewardsPoolStorage storage $ = _getX2EarnRewardsPoolStorage(); + $.x2EarnApps = _x2EarnApps; + } + + // ---------- Getters ---------- // + + /** + * @dev See {IX2EarnRewardsPool-availableFunds} + */ + function availableFunds(bytes32 appId) external view returns (uint256) { + X2EarnRewardsPoolStorage storage $ = _getX2EarnRewardsPoolStorage(); + return $.availableFunds[appId]; + } + + /** + * @dev See {IX2EarnRewardsPool-version} + */ + function version() external pure virtual returns (string memory) { + return '1'; + } + + /** + * @dev Retrieves the B3TR token contract. + */ + function b3tr() external view returns (IToken) { + X2EarnRewardsPoolStorage storage $ = _getX2EarnRewardsPoolStorage(); + return $.b3tr; + } + + /** + * @dev Retrieves the X2EarnApps contract. + */ + function x2EarnApps() external view returns (IX2EarnAppsMock) { + X2EarnRewardsPoolStorage storage $ = _getX2EarnRewardsPoolStorage(); + return $.x2EarnApps; + } + + // ---------- Fallbacks ---------- // + + /** + * @dev Transfers of VET to this contract are not allowed. + */ + receive() external payable virtual { + revert('X2EarnRewardsPool: contract does not accept VET'); + } + + /** + * @dev Contract does not accept calls/data. + */ + fallback() external payable { + revert('X2EarnRewardsPool: contract does not accept calls/data'); + } + + /** + * @dev Transfers of ERC721 tokens to this contract are not allowed. + * + * @notice supported only when safeTransferFrom is used + */ + function onERC721Received(address, address, uint256, bytes memory) public virtual returns (bytes4) { + revert('X2EarnRewardsPool: contract does not accept ERC721 tokens'); + } + + /** + * @dev Transfers of ERC1155 tokens to this contract are not allowed. + */ + function onERC1155Received(address, address, uint256, uint256, bytes memory) public virtual returns (bytes4) { + revert('X2EarnRewardsPool: contract does not accept ERC1155 tokens'); + } + + /** + * @dev Transfers of ERC1155 tokens to this contract are not allowed. + */ + function onERC1155BatchReceived(address, address, uint256[] memory, uint256[] memory, bytes memory) public virtual returns (bytes4) { + revert('X2EarnRewardsPool: contract does not accept batch transfers of ERC1155 tokens'); + } +} diff --git a/apps/contracts/contracts/mock/interfaces/IX2EarnAppsMock.sol b/apps/contracts/contracts/mock/interfaces/IX2EarnAppsMock.sol new file mode 100644 index 0000000..578da20 --- /dev/null +++ b/apps/contracts/contracts/mock/interfaces/IX2EarnAppsMock.sol @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +import {X2EarnAppsDataTypes} from "./X2EarnAppsDataTypes.sol"; + +/** + * Mocked interface updated with only a subset of the functions that should mimic some + * functionality of the VeBetterDAO X2EarnApps contract. + * + * @title IX2EarnApps + * @notice Interface for the X2EarnApps contract. + */ +interface IX2EarnAppsMock { + /** + * @dev The `appId` doesn't exist. + */ + error X2EarnNonexistentApp(bytes32 appId); + + /** + * @dev The `addr` is not valid (eg: is the ZERO ADDRESS). + */ + error X2EarnInvalidAddress(address addr); + + /** + * @dev An app with the specified `appId` already exists. + */ + error X2EarnAppAlreadyExists(bytes32 appId); + + /** + * @dev The user is not authorized to perform the action. + */ + error X2EarnUnauthorizedUser(address user); + + /** + * @dev The maximum number of reward distributors has been reached. + */ + error X2EarnMaxRewardDistributorsReached(bytes32 appId); + + /** + * @dev The `distributorAddress` is not valid. + */ + error X2EarnNonexistentRewardDistributor( + bytes32 appId, + address distributorAddress + ); + + /** + * @dev Event fired when a new app is added. + */ + event AppAdded( + bytes32 indexed id, + address addr, + string name, + bool appAvailableForAllocationVoting + ); + + /** + * @dev Event fired when the admin adds a new reward distributor to the app. + */ + event RewardDistributorAddedToApp( + bytes32 indexed appId, + address distributorAddress + ); + + /** + * @dev Event fired when the admin removes a reward distributor from the app. + */ + event RewardDistributorRemovedFromApp( + bytes32 indexed appId, + address distributorAddress + ); + + /** + * @dev Event fired when the admin of an app changes. + */ + event AppAdminUpdated( + bytes32 indexed appId, + address oldAdmin, + address newAdmin + ); + + /** + * @dev Event fired when the address where the x2earn app receives allocation funds is changed. + */ + event TeamWalletAddressUpdated( + bytes32 indexed appId, + address oldTeamWalletAddress, + address newTeamWalletAddress + ); + + /** + * @dev Get the address where the x2earn app receives allocation funds. + * + * @param appId the id of the app + */ + function teamWalletAddress(bytes32 appId) external view returns (address); + + /** + * @dev Add a new app to the x2earn apps. + * + * @param teamWalletAddress the address where the app should receive allocation funds + * @param admin the address of the admin that will be able to manage the app and perform all administration actions + * @param appName the name of the app + * + * Emits a {AppAdded} event. + */ + function addApp( + address teamWalletAddress, + address admin, + string memory appName + ) external; + + /** + * @dev Check if an account is the admin of the app + * + * @param appId the hashed name of the app + * @param account the address of the account + */ + function isAppAdmin( + bytes32 appId, + address account + ) external view returns (bool); + + /** + * @dev Add a new reward distributor to the app. + * + * @param appId the id of the app + * @param distributorAddress the address of the reward distributor + * + * Emits a {RewardDistributorAddedToApp} event. + */ + function addRewardDistributor( + bytes32 appId, + address distributorAddress + ) external; + + /** + * @dev Remove a reward distributor from the app. + * + * @param appId the id of the app + * @param distributorAddress the address of the reward distributor + * + * Emits a {RewardDistributorRemovedFromApp} event. + */ + function removeRewardDistributor( + bytes32 appId, + address distributorAddress + ) external; + + /** + * @dev Returns true if an account is a reward distributor of the app + * + * @param appId the id of the app + * @param distributorAddress the address of the account + */ + function isRewardDistributor( + bytes32 appId, + address distributorAddress + ) external view returns (bool); + + /** + * @dev Check if there is an app with the specified `appId`. + * + * @param appId the id of the app + */ + function appExists(bytes32 appId) external view returns (bool); +} diff --git a/apps/contracts/contracts/mock/interfaces/X2EarnAppsDataTypes.sol b/apps/contracts/contracts/mock/interfaces/X2EarnAppsDataTypes.sol new file mode 100644 index 0000000..48ad101 --- /dev/null +++ b/apps/contracts/contracts/mock/interfaces/X2EarnAppsDataTypes.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +library X2EarnAppsDataTypes { + struct App { + bytes32 id; + string name; + uint256 createdAtTimestamp; + } + + struct AppWithDetailsReturnType { + bytes32 id; + address teamWalletAddress; + string name; + string metadataURI; + uint256 createdAtTimestamp; + bool appAvailableForAllocationVoting; + } +} diff --git a/apps/contracts/package.json b/apps/contracts/package.json index 0ed604a..b169c0f 100644 --- a/apps/contracts/package.json +++ b/apps/contracts/package.json @@ -1,50 +1,47 @@ { - "name": "@repo/contracts", - "version": "1.0.0", - "scripts": { - "test": "npx hardhat test --network hardhat ", - "test:solo": "npx hardhat test --network vechain_solo", - "compile": "npx hardhat compile", - "deploy:token:solo": "npx hardhat run scripts/deployToken.ts --network vechain_solo", - "deploy:ecoearn:solo": "npx hardhat run scripts/deployEcoEarn.ts --network vechain_solo", - "deploy:token:testnet": "npx hardhat run scripts/deployToken.ts --network vechain_testnet", - "deploy:ecoearn:testnet": "npx hardhat run scripts/deployEcoEarn.ts --network vechain_testnet", - "deploy:solo": "yarn solo-up && yarn deploy:token:solo && yarn deploy:ecoearn:solo", - "deploy:testnet": "yarn deploy:token:testnet && yarn deploy:ecoearn:testnet", - "solo-up": "make solo-up" - }, - "dependencies": { - "@nomicfoundation/hardhat-chai-matchers": "^2.0.2", - "@nomicfoundation/hardhat-ethers": "^3.0.4", - "@nomicfoundation/hardhat-network-helpers": "^1.0.0", - "@nomiclabs/hardhat-etherscan": "^3.0.0", - "@nomiclabs/hardhat-truffle5": "^2.0.7", - "@nomiclabs/hardhat-web3": "^2.0.0", - "@openzeppelin/contracts": "^5.0.1", - "@repo/config-contract": "*", - "@typechain/hardhat": "^8.0.0", - "@types/chai": "^4.2.0", - "@types/mocha": ">=9.1.0", - "@vechain/hardhat-ethers": "^0.1.4", - "@vechain/hardhat-vechain": "^0.1.4", - "@vechain/web3-providers-connex": "^1.1.2", - "chai": "^4.2.0", - "dayjs": "^1.11.10", - "hardhat": "^2.12.6", - "hardhat-gas-reporter": "^1.0.8", - "react-slot-counter": "^2.2.5", - "solidity-coverage": "^0.8.1", - "ts-node": ">=8.0.0", - "typechain": "^8.1.0", - "typescript": ">=4.5.0" - }, - "devDependencies": { - "@nomicfoundation/hardhat-toolbox": "^3.0.0", - "@nomicfoundation/hardhat-verify": "^1.1.1", - "@typechain/ethers-v6": "^0.4.3", - "@vechain/sdk-hardhat-plugin": "^1.0.0-beta.12", - "ethers": "^6.7.1", - "ganache-cli": "^6.12.2", - "hardhat": "^2.19.1" - } + "name": "@repo/contracts", + "version": "1.0.0", + "scripts": { + "test": "npx hardhat test --network hardhat ", + "test:solo": "npx hardhat test --network vechain_solo", + "contracts:test": "yarn test", + "compile": "npx hardhat compile", + "deploy:solo": "npx hardhat run scripts --network vechain_solo", + "deploy:testnet": "npx hardhat run scripts --network vechain_testnet", + "solo-up": "make solo-up" + }, + "dependencies": { + "@nomicfoundation/hardhat-chai-matchers": "^2.0.2", + "@nomicfoundation/hardhat-ethers": "^3.0.4", + "@nomicfoundation/hardhat-network-helpers": "^1.0.0", + "@nomiclabs/hardhat-etherscan": "^3.0.0", + "@nomiclabs/hardhat-truffle5": "^2.0.7", + "@nomiclabs/hardhat-web3": "^2.0.0", + "@openzeppelin/contracts": "^5.0.1", + "@repo/config-contract": "*", + "@typechain/hardhat": "^8.0.0", + "@types/chai": "^4.2.0", + "@types/mocha": ">=9.1.0", + "@vechain/hardhat-ethers": "^0.1.4", + "@vechain/hardhat-vechain": "^0.1.4", + "@vechain/web3-providers-connex": "^1.1.2", + "chai": "^4.2.0", + "dayjs": "^1.11.10", + "hardhat": "^2.12.6", + "hardhat-gas-reporter": "^1.0.8", + "react-slot-counter": "^2.2.5", + "solidity-coverage": "^0.8.1", + "ts-node": ">=8.0.0", + "typechain": "^8.1.0", + "typescript": ">=4.5.0" + }, + "devDependencies": { + "@nomicfoundation/hardhat-toolbox": "^3.0.0", + "@nomicfoundation/hardhat-verify": "^1.1.1", + "@typechain/ethers-v6": "^0.4.3", + "@vechain/sdk-hardhat-plugin": "^1.0.0-beta.12", + "ethers": "^6.7.1", + "ganache-cli": "^6.12.2", + "hardhat": "^2.19.1" + } } diff --git a/apps/contracts/scripts/deploy.ts b/apps/contracts/scripts/deploy.ts new file mode 100644 index 0000000..4823ce5 --- /dev/null +++ b/apps/contracts/scripts/deploy.ts @@ -0,0 +1,76 @@ +import { ethers, network } from 'hardhat'; +import { updateConfig, config } from '@repo/config-contract'; + +export async function deploy() { + const deployer = (await ethers.getSigners())[0]; + console.log(`Deploying on ${network.name} with wallet ${deployer.address}...`); + + let REWARD_TOKEN_ADDRESS = config.TOKEN_ADDRESS; + let X2EARN_REWARDS_POOL = config.X2EARN_REWARDS_POOL; + let X2EARN_APPS = config.X2EARN_APPS; + let APP_ID = config.APP_ID; + + // If we are running on the solo network, we need to deploy the mock contracts + // and generate the appID + if (network.name === 'vechain_solo') { + console.log(`Deploying mock RewardToken...`); + const RewardTokenContract = await ethers.getContractFactory('B3TR_Mock'); + const rewardToken = await RewardTokenContract.deploy(); + await rewardToken.waitForDeployment(); + REWARD_TOKEN_ADDRESS = await rewardToken.getAddress(); + console.log(`RewardToken deployed to ${REWARD_TOKEN_ADDRESS}`); + + console.log('Deploying X2EarnApps mock contract...'); + const X2EarnAppsContract = await ethers.getContractFactory('X2EarnAppsMock'); + const x2EarnApps = await X2EarnAppsContract.deploy(); + await x2EarnApps.waitForDeployment(); + X2EARN_APPS = await x2EarnApps.getAddress(); + console.log(`X2EarnApps deployed to ${await x2EarnApps.getAddress()}`); + + console.log('Deploying X2EarnRewardsPool mock contract...'); + const X2EarnRewardsPoolContract = await ethers.getContractFactory('X2EarnRewardsPoolMock'); + const x2EarnRewardsPool = await X2EarnRewardsPoolContract.deploy(deployer.address, REWARD_TOKEN_ADDRESS, await x2EarnApps.getAddress()); + await x2EarnRewardsPool.waitForDeployment(); + X2EARN_REWARDS_POOL = await x2EarnRewardsPool.getAddress(); + console.log(`X2EarnRewardsPool deployed to ${await x2EarnRewardsPool.getAddress()}`); + + console.log('Adding app in X2EarnApps...'); + await x2EarnApps.addApp(deployer.address, deployer.address, 'EcoEarn'); + const appID = await x2EarnApps.hashAppName('EcoEarn'); + APP_ID = appID; + console.log(`AppID: ${appID}`); + + console.log(`Funding contract...`); + await rewardToken.approve(await x2EarnRewardsPool.getAddress(), ethers.parseEther('10000')); + await x2EarnRewardsPool.deposit(ethers.parseEther('2000'), appID); + console.log('Funded'); + } + + console.log('Deploying EcoEarn contract...'); + const ecoEarn = await ethers.getContractFactory('EcoEarn'); + const ecoEarnInstance = await ecoEarn.deploy( + deployer.address, + X2EARN_REWARDS_POOL, // mock in solo, from config in testnet/mainnet + config.CYCLE_DURATION, + config.MAX_SUBMISSIONS_PER_CYCLE, + APP_ID, // mock in solo, from config in testnet/mainnet + ); + await ecoEarnInstance.waitForDeployment(); + const ecoEarnAddress = await ecoEarnInstance.getAddress(); + console.log(`EcoEarn deployed to: ${ecoEarnAddress}`); + + // In solo network, we need to add the EcoEarn contract as a distributor + if (network.name === 'vechain_solo') { + console.log('Add EcoEarn contracts as distributor...'); + const x2EarnApps = await ethers.getContractAt('X2EarnAppsMock', X2EARN_APPS); + await x2EarnApps.addRewardDistributor(APP_ID, ecoEarnAddress); + console.log('Added'); + } + + updateConfig({ + ...config, + CONTRACT_ADDRESS: ecoEarnAddress, + }); + + console.log(`Done`); +} diff --git a/apps/contracts/scripts/deployEcoEarn.ts b/apps/contracts/scripts/deployEcoEarn.ts deleted file mode 100644 index ae7a4c9..0000000 --- a/apps/contracts/scripts/deployEcoEarn.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { updateConfig, config } from '@repo/config-contract'; -import { ethers } from 'hardhat'; -async function deployMugshot() { - const [owner] = await ethers.getSigners(); - - const ecoEarn = await ethers.getContractFactory('EcoEarn'); - - const ecoEarnInstance = await ecoEarn.deploy( - owner, - config.TOKEN_ADDRESS, - config.CYCLE_DURATION, - config.MAX_SUBMISSIONS_PER_CYCLE, - ); - - const ecoEarnAddress = await ecoEarnInstance.getAddress(); - - console.log(`EcoEarn deployed to: ${ecoEarnAddress}`); - - updateConfig({ - ...config, - CONTRACT_ADDRESS: ecoEarnAddress, - }); -} - -deployMugshot() - .then(() => process.exit(0)) - .catch(error => { - console.error(error); - process.exit(1); - }); diff --git a/apps/contracts/scripts/deployToken.ts b/apps/contracts/scripts/deployToken.ts deleted file mode 100644 index 189827d..0000000 --- a/apps/contracts/scripts/deployToken.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { updateConfig, config } from '@repo/config-contract'; -import { ethers } from 'hardhat'; - -async function deployToken() { - const [owner] = await ethers.getSigners(); - - const token = await ethers.getContractFactory('Token'); - const tokenInstance = await token.deploy(owner); - - const tokenAddress = await tokenInstance.getAddress(); - - console.log(`Token deployed to: ${tokenAddress}`); - - updateConfig({ - ...config, - TOKEN_ADDRESS: tokenAddress, - }); -} - -deployToken() - .then(() => process.exit(0)) - .catch(error => { - console.error(error); - process.exit(1); - }); diff --git a/apps/contracts/scripts/index.ts b/apps/contracts/scripts/index.ts new file mode 100644 index 0000000..ffbd7fc --- /dev/null +++ b/apps/contracts/scripts/index.ts @@ -0,0 +1,15 @@ +// We recommend this pattern to be able to use async/await everywhere + +import { deploy } from './deploy'; + +// and properly handle errors. +const execute = async () => { + await deploy(); +}; + +execute() + .then(() => process.exit(0)) + .catch(error => { + console.error(error); + process.exit(1); + }); diff --git a/apps/contracts/test/EcoEarn.test.ts b/apps/contracts/test/EcoEarn.test.ts index d042927..658c79d 100644 --- a/apps/contracts/test/EcoEarn.test.ts +++ b/apps/contracts/test/EcoEarn.test.ts @@ -14,18 +14,17 @@ describe('ecoearn', () => { describe('Allocations', () => { it('Should track allocations for a cycle correctly', async () => { - const { ecoearn, token, owner, admin } = await getAndDeployContracts(); + const { ecoearn, token, x2EarnRewardsPool, appId, owner, admin } = await getAndDeployContracts(); // Simulate receiving tokens from X Allocations Round await token.connect(owner).mint(admin, ethers.parseEther('6700')); // Allowance - await token.connect(admin).approve(await ecoearn.getAddress(), ethers.parseEther(Number.MAX_SAFE_INTEGER.toString())); + await token.connect(admin).approve(await x2EarnRewardsPool.getAddress(), ethers.parseEther('6700')); + await x2EarnRewardsPool.connect(admin).deposit(ethers.parseEther('6700'), appId); - // Claim allocation for the current cycle - await ecoearn.connect(admin).claimAllocation(await token.balanceOf(admin.address)); - - expect(await token.balanceOf(await ecoearn.getAddress())).to.equal(ethers.parseEther('6700')); + // Set rewards for the current cycle + await ecoearn.connect(admin).setRewardsAmount(await token.balanceOf(admin.address)); await waitForNextCycle(ecoearn); // Assure cycle can be triggered @@ -35,9 +34,9 @@ describe('ecoearn', () => { }); it('Should track allocations for multiple cycles correctly', async () => { - const { ecoearn, token, owner, admin } = await getAndDeployContracts(); + const { ecoearn, token, x2EarnRewardsPool, appId, owner, admin } = await getAndDeployContracts(); - await receiveAllocations(ecoearn, token, owner, admin, '6700'); + await receiveAllocations(ecoearn, token, owner, admin, '6700', x2EarnRewardsPool, appId); await waitForNextCycle(ecoearn); @@ -45,7 +44,7 @@ describe('ecoearn', () => { expect(await ecoearn.getCurrentCycle()).to.equal(1); - await receiveAllocations(ecoearn, token, owner, admin, '6700'); + await receiveAllocations(ecoearn, token, owner, admin, '6700', x2EarnRewardsPool, appId); await waitForNextCycle(ecoearn); @@ -57,9 +56,9 @@ describe('ecoearn', () => { describe('Rewards', () => { it('Should track valid submissions correctly', async () => { - const { ecoearn, owner, admin, account3, token } = await getAndDeployContracts(); + const { ecoearn, owner, admin, account3, token, x2EarnRewardsPool, appId } = await getAndDeployContracts(); - await receiveAllocations(ecoearn, token, owner, admin, '6700'); + await receiveAllocations(ecoearn, token, owner, admin, '6700', x2EarnRewardsPool, appId); await waitForNextCycle(ecoearn); @@ -79,21 +78,24 @@ describe('ecoearn', () => { }); it('Should be able to receive expected rewards', async () => { - const { ecoearn, token, owner, account3, admin } = await getAndDeployContracts(); + const { ecoearn, token, owner, account3, account4, admin, x2EarnRewardsPool, appId } = await getAndDeployContracts(); - await receiveAllocations(ecoearn, token, owner, admin, '6700'); + await receiveAllocations(ecoearn, token, owner, admin, '6700', x2EarnRewardsPool, appId); await waitForNextCycle(ecoearn); await ecoearn.connect(admin).triggerCycle(); - await ecoearn.connect(admin).registerValidSubmission(owner.address, ethers.parseEther('1')); + expect(await token.balanceOf(account4.address)).to.equal(ethers.parseEther('0')); + expect(await token.balanceOf(account3.address)).to.equal(ethers.parseEther('0')); + + await ecoearn.connect(admin).registerValidSubmission(account4.address, ethers.parseEther('1')); await ecoearn.connect(admin).registerValidSubmission(account3.address, ethers.parseEther('1')); - await ecoearn.connect(admin).registerValidSubmission(owner.address, ethers.parseEther('1')); + await ecoearn.connect(admin).registerValidSubmission(account4.address, ethers.parseEther('1')); - expect(await token.balanceOf(owner.address)).to.equal( + expect(await token.balanceOf(account4.address)).to.equal( ethers.parseEther('2'), // Received 2 tokens ); @@ -103,9 +105,9 @@ describe('ecoearn', () => { }); it('Should calculate correctly rewards left', async () => { - const { ecoearn, token, owner, account3, admin, account4, otherAccounts } = await getAndDeployContracts(); + const { ecoearn, token, owner, account3, admin, account4, otherAccounts, x2EarnRewardsPool, appId } = await getAndDeployContracts(); - await receiveAllocations(ecoearn, token, owner, admin, '50'); // Receive 50 tokens + await receiveAllocations(ecoearn, token, owner, admin, '50', x2EarnRewardsPool, appId); // Receive 50 tokens await waitForNextCycle(ecoearn); @@ -132,9 +134,9 @@ describe('ecoearn', () => { describe('Withdrawals', () => { it("Should be able to withdraw if user's did not claim all their rewards", async () => { - const { ecoearn, token, owner, admin, account3 } = await getAndDeployContracts(); + const { ecoearn, token, owner, admin, account3, x2EarnRewardsPool, appId } = await getAndDeployContracts(); - await receiveAllocations(ecoearn, token, owner, admin, '6700'); + await receiveAllocations(ecoearn, token, owner, admin, '6700', x2EarnRewardsPool, appId); await waitForNextCycle(ecoearn); @@ -148,7 +150,7 @@ describe('ecoearn', () => { await waitForNextCycle(ecoearn); - await receiveAllocations(ecoearn, token, owner, admin, '6700'); + await receiveAllocations(ecoearn, token, owner, admin, '6700', x2EarnRewardsPool, appId); await waitForNextCycle(ecoearn); diff --git a/apps/contracts/test/helpers/allocation.ts b/apps/contracts/test/helpers/allocation.ts index 8413079..17719be 100644 --- a/apps/contracts/test/helpers/allocation.ts +++ b/apps/contracts/test/helpers/allocation.ts @@ -1,11 +1,20 @@ import { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/signers'; -import { EcoEarn, Token } from '../../typechain-types'; +import { EcoEarn, B3TR_Mock, X2EarnRewardsPoolMock } from '../../typechain-types'; import { ethers } from 'ethers'; -export const receiveAllocations = async (mugshot: EcoEarn, token: Token, owner: HardhatEthersSigner, admin: HardhatEthersSigner, amount: string) => { +export const receiveAllocations = async ( + mugshot: EcoEarn, + token: B3TR_Mock, + owner: HardhatEthersSigner, + admin: HardhatEthersSigner, + amount: string, + x2EarnRewardsPool: X2EarnRewardsPoolMock, + appId: string, +) => { await token.connect(owner).mint(admin, ethers.parseEther(amount)); - await token.connect(admin).approve(await mugshot.getAddress(), ethers.parseEther(Number.MAX_SAFE_INTEGER.toString())); + await token.connect(admin).approve(await x2EarnRewardsPool.getAddress(), ethers.parseEther(Number.MAX_SAFE_INTEGER.toString())); + await x2EarnRewardsPool.connect(admin).deposit(ethers.parseEther(amount), appId); - await mugshot.connect(admin).claimAllocation(ethers.parseEther(amount)); + await mugshot.connect(admin).setRewardsAmount(ethers.parseEther(amount)); }; diff --git a/apps/contracts/test/helpers/deploy.ts b/apps/contracts/test/helpers/deploy.ts index e9c0db0..39871fb 100644 --- a/apps/contracts/test/helpers/deploy.ts +++ b/apps/contracts/test/helpers/deploy.ts @@ -10,11 +10,44 @@ export const getAndDeployContracts = async () => { // Contracts are deployed using the first signer/account by default const [owner, admin, account3, account4, ...otherAccounts] = await ethers.getSigners(); - const tokenContract = await ethers.getContractFactory('Token'); - const token = await tokenContract.deploy(owner); + const RewardTokenContract = await ethers.getContractFactory('B3TR_Mock'); + const rewardToken = await RewardTokenContract.deploy(); + await rewardToken.waitForDeployment(); - const ecoEarnContract = await ethers.getContractFactory('EcoEarn'); - const ecoearn = await ecoEarnContract.deploy(admin, await token.getAddress(), CYCLE_DURATION, MAX_SUBMISSIONS_PER_CYCLE); + const X2EarnAppsContract = await ethers.getContractFactory('X2EarnAppsMock'); + const x2EarnApps = await X2EarnAppsContract.deploy(); + await x2EarnApps.waitForDeployment(); - return { token, ecoearn, owner, admin, account3, account4, otherAccounts }; + const X2EarnRewardsPoolContract = await ethers.getContractFactory('X2EarnRewardsPoolMock'); + const x2EarnRewardsPool = await X2EarnRewardsPoolContract.deploy(admin.address, await rewardToken.getAddress(), await x2EarnApps.getAddress()); + await x2EarnRewardsPool.waitForDeployment(); + + await x2EarnApps.addApp(admin.address, admin.address, 'EcoEarn'); + const APP_ID = await x2EarnApps.hashAppName('EcoEarn'); + + const ecoEarn = await ethers.getContractFactory('EcoEarn'); + const ecoEarnInstance = await ecoEarn.deploy( + admin.address, + await x2EarnRewardsPool.getAddress(), + CYCLE_DURATION, + MAX_SUBMISSIONS_PER_CYCLE, + APP_ID, + ); + await ecoEarnInstance.waitForDeployment(); + const ecoEarnAddress = await ecoEarnInstance.getAddress(); + + await x2EarnApps.connect(admin).addRewardDistributor(APP_ID, ecoEarnAddress); + + return { + token: rewardToken, + ecoearn: ecoEarnInstance, + x2EarnApps, + x2EarnRewardsPool, + appId: APP_ID, + owner, + admin, + account3, + account4, + otherAccounts, + }; }; diff --git a/package.json b/package.json index d533aa0..0ae8e70 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "lint:fix": "turbo lint:fix", "test": "turbo test", "format": "prettier --write \"**/*.{ts,tsx,md}\"", + "contracts:compile": "turbo run compile", + "contracts:test": "turbo run contracts:test", "contracts:deploy:solo": "turbo run deploy:solo", "contracts:deploy:testnet": "turbo run deploy:testnet" }, @@ -20,4 +22,4 @@ "apps/*", "packages/*" ] -} \ No newline at end of file +} diff --git a/packages/config-contract/config.ts b/packages/config-contract/config.ts index 6875d3b..482230e 100644 --- a/packages/config-contract/config.ts +++ b/packages/config-contract/config.ts @@ -1,6 +1,9 @@ export const config = { - "TOKEN_ADDRESS": "0x316Fb7a5D1461363037fca9f44B1631252669D6F", - "CONTRACT_ADDRESS": "0xA5A7C5623fb234C961A9c8a50bDefaD2Cae68Fe8", + "TOKEN_ADDRESS": "0xbf64cf86894Ee0877C4e7d03936e35Ee8D8b864F", + "CONTRACT_ADDRESS": "0x0d515384440b09ebc7247eff9e864c75a96ad450", + "X2EARN_REWARDS_POOL": "0x5F8f86B8D0Fa93cdaE20936d150175dF0205fB38", + "X2EARN_APPS": "0xcB23Eb1bBD5c07553795b9538b1061D0f4ABA153", + "APP_ID": "0x899de0d0f0b39e484c8835b2369194c4c102b230c813862db383d44a4efe14d3", "CYCLE_DURATION": 60480, "MAX_SUBMISSIONS_PER_CYCLE": 10 }; diff --git a/turbo.json b/turbo.json index 4b095d5..637f421 100644 --- a/turbo.json +++ b/turbo.json @@ -2,12 +2,8 @@ "$schema": "https://turbo.build/schema.json", "pipeline": { "build": { - "dependsOn": [ - "^build" - ], - "outputs": [ - "dist/**" - ] + "dependsOn": ["^build"], + "outputs": ["dist/**"] }, "lint": {}, "lint:fix": {}, @@ -16,20 +12,20 @@ "persistent": true }, "test": { - "dependsOn": [ - "^lint" - ], + "dependsOn": ["^lint"], "cache": false }, "deploy:solo": { - "dependsOn": [ - "^build" - ] + "dependsOn": ["^build"] }, "deploy:testnet": { - "dependsOn": [ - "^build" - ] + "dependsOn": ["^build"] + }, + "compile": { + "dependsOn": ["^build"] + }, + "contracts:test": { + "dependsOn": ["^build"] } } -} \ No newline at end of file +}