diff --git a/config/contracts/envs/local.ts b/config/contracts/envs/local.ts index a67a8b2..3bb3ca7 100644 --- a/config/contracts/envs/local.ts +++ b/config/contracts/envs/local.ts @@ -112,5 +112,17 @@ export function createLocalConfig() { // Migration MIGRATION_ADDRESS: "0x865306084235Bf804c8Bba8a8d56890940ca8F0b", // 10th account from mnemonic of solo network MIGRATION_AMOUNT: BigInt("3750000000000000000000000"), // 3.75 million B3TR tokens from pilot show + + // X 2 Earn Rewards Pool + X_2_EARN_INITIAL_IMPACT_KEYS: [ + "carbon", + "water", + "energy", + "waste_mass", + "education_time", + "timber", + "plastic", + "trees_planted", + ], }) } diff --git a/config/contracts/type.ts b/config/contracts/type.ts index 05a514f..b4173cc 100644 --- a/config/contracts/type.ts +++ b/config/contracts/type.ts @@ -44,4 +44,7 @@ export type ContractsConfig = { // Migration MIGRATION_ADDRESS: string MIGRATION_AMOUNT: bigint + + // X 2 Earn Rewards Pool + X_2_EARN_INITIAL_IMPACT_KEYS: string[] } diff --git a/contracts/X2EarnRewardsPool.sol b/contracts/X2EarnRewardsPool.sol index 2a593c3..7ab00b7 100644 --- a/contracts/X2EarnRewardsPool.sol +++ b/contracts/X2EarnRewardsPool.sol @@ -31,6 +31,7 @@ import { IX2EarnApps } from "./interfaces/IX2EarnApps.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"; +import "@openzeppelin/contracts/utils/Strings.sol"; /** * @title X2EarnRewardsPool @@ -50,6 +51,7 @@ contract X2EarnRewardsPool is { bytes32 public constant UPGRADER_ROLE = keccak256("UPGRADER_ROLE"); bytes32 public constant CONTRACTS_ADDRESS_MANAGER_ROLE = keccak256("CONTRACTS_ADDRESS_MANAGER_ROLE"); + bytes32 public constant IMPACT_KEY_MANAGER_ROLE = keccak256("IMPACT_KEY_MANAGER_ROLE"); /// @custom:oz-upgrades-unsafe-allow constructor constructor() { @@ -61,6 +63,8 @@ contract X2EarnRewardsPool is IB3TR b3tr; IX2EarnApps x2EarnApps; mapping(bytes32 appId => uint256) availableFunds; // Funds that the app can use to reward users + mapping(string => uint256) impactKeyIndex; // Mapping from impact key to its index (1-based to distinguish from non-existent) + string[] allowedImpactKeys; // Array storing impact keys } // keccak256(abi.encode(uint256(keccak256("b3tr.storage.X2EarnRewardsPool")) - 1)) & ~bytes32(uint256(0xff)) @@ -99,6 +103,31 @@ contract X2EarnRewardsPool is $.x2EarnApps = _x2EarnApps; } + function initializeV2(address _impactKeyManager, string[] memory _initialImpactKeys) external reinitializer(2) { + require(_impactKeyManager != address(0), "X2EarnRewardsPool: impactKeyManager is the zero address"); + require(_initialImpactKeys.length > 0, "X2EarnRewardsPool: initialImpactKeys is empty"); + + _grantRole(IMPACT_KEY_MANAGER_ROLE, _impactKeyManager); + + X2EarnRewardsPoolStorage storage $ = _getX2EarnRewardsPoolStorage(); + + for (uint256 i; i < _initialImpactKeys.length; i++) { + _addImpactKey(_initialImpactKeys[i], $); + } + } + + // ---------- Modifiers ---------- // + /** + * @notice Modifier to check if the user has the required role or is the DEFAULT_ADMIN_ROLE + * @param role - the role to check + */ + modifier onlyRoleOrAdmin(bytes32 role) { + if (!hasRole(role, msg.sender) && !hasRole(DEFAULT_ADMIN_ROLE, msg.sender)) { + revert("X2EarnRewardsPool: sender is not an admin nor has the required role"); + } + _; + } + // ---------- Authorizers ---------- // function _authorizeUpgrade(address newImplementation) internal virtual override onlyRole(UPGRADER_ROLE) {} @@ -155,32 +184,210 @@ contract X2EarnRewardsPool is } /** - * @dev See {IX2EarnRewardsPool-distributeReward} + * @dev Distribute rewards to a user with a self provided proof. + * @notice This function is deprecated and kept for backwards compatibility, will be removed in future versions. */ - function distributeReward( + function distributeRewardDeprecated(bytes32 appId, uint256 amount, address receiver, string memory proof) external { + // emit event with provided json proof + emit RewardDistributed(amount, appId, receiver, proof, msg.sender); + + _distributeReward(appId, amount, receiver); + } + + /** + * @dev {IX2EarnRewardsPool-distributeReward} + * @notice the proof argument is unused but kept for backwards compatibility + */ + function distributeReward(bytes32 appId, uint256 amount, address receiver, string memory /*proof*/) external { + // emit event with empty proof + emit RewardDistributed(amount, appId, receiver, "", msg.sender); + + _distributeReward(appId, amount, receiver); + } + + /** + * @dev See {IX2EarnRewardsPool-distributeRewardWithProof} + */ + function distributeRewardWithProof( bytes32 appId, uint256 amount, address receiver, - string memory proof - ) external nonReentrant { + string[] memory proofTypes, + string[] memory proofValues, + string[] memory impactCodes, + uint256[] memory impactValues, + string memory description + ) external { + _emitProof(appId, amount, receiver, proofTypes, proofValues, impactCodes, impactValues, description); + _distributeReward(appId, amount, receiver); + } + + /** + * @dev See {IX2EarnRewardsPool-distributeReward} + * @notice The impact is an array of integers and codes that represent the impact of the action. + * Each index of the array represents a different impact. + * The codes are predefined and the values are the impact values. + * Example: ["carbon", "water", "energy"], [100, 200, 300] + */ + function _distributeReward(bytes32 appId, uint256 amount, address receiver) internal nonReentrant { X2EarnRewardsPoolStorage storage $ = _getX2EarnRewardsPoolStorage(); + // check authorization 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 + // check if the app has enough available funds to distribute 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 + // Transfer the rewards to the receiver $.availableFunds[appId] -= amount; require($.b3tr.transfer(receiver, amount), "X2EarnRewardsPool: Allocation transfer to app failed"); + } + + /** + * @dev Emits the RewardDistributed event with the provided proofs and impacts. + */ + function _emitProof( + bytes32 appId, + uint256 amount, + address receiver, + string[] memory proofTypes, + string[] memory proofValues, + string[] memory impactCodes, + uint256[] memory impactValues, + string memory description + ) internal { + // Build the JSON proof string from the proof and impact data + string memory jsonProof = buildProof(proofTypes, proofValues, impactCodes, impactValues, description); // emit event - emit RewardDistributed(amount, appId, receiver, proof, msg.sender); + emit RewardDistributed(amount, appId, receiver, jsonProof, msg.sender); + } + + /** + * @dev see {IX2EarnRewardsPool-buildProof} + */ + function buildProof( + string[] memory proofTypes, + string[] memory proofValues, + string[] memory impactCodes, + uint256[] memory impactValues, + string memory description + ) public view virtual returns (string memory) { + bool hasProof = proofTypes.length > 0 && proofValues.length > 0; + bool hasImpact = impactCodes.length > 0 && impactValues.length > 0; + bool hasDescription = bytes(description).length > 0; + + // If neither proof nor impact is provided, return an empty string + if (!hasProof && !hasImpact) { + return ""; + } + + // Initialize an empty JSON bytes array with version + bytes memory json = abi.encodePacked('{"version": 2'); + + // Add description if available + if (hasDescription) { + json = abi.encodePacked(json, ',"description": "', description, '"'); + } + + // Add proof if available + if (hasProof) { + bytes memory jsonProof = _buildProofJson(proofTypes, proofValues); + + json = abi.encodePacked(json, ',"proof": ', jsonProof); + } + + // Add impact if available + if (hasImpact) { + bytes memory jsonImpact = _buildImpactJson(impactCodes, impactValues); + + json = abi.encodePacked(json, ',"impact": ', jsonImpact); + } + + // Close the JSON object + json = abi.encodePacked(json, "}"); + + return string(json); + } + + /** + * @dev Builds the proof JSON string from the proof data. + * @param proofTypes the proof types + * @param proofValues the proof values + */ + function _buildProofJson( + string[] memory proofTypes, + string[] memory proofValues + ) internal pure returns (bytes memory) { + require(proofTypes.length == proofValues.length, "X2EarnRewardsPool: Mismatched input lengths for Proof"); + + bytes memory json = abi.encodePacked("{"); + + for (uint256 i; i < proofTypes.length; i++) { + if (_isValidProofType(proofTypes[i])) { + json = abi.encodePacked(json, '"', proofTypes[i], '":', '"', proofValues[i], '"'); + if (i < proofTypes.length - 1) { + json = abi.encodePacked(json, ","); + } + } else { + revert("X2EarnRewardsPool: Invalid proof type"); + } + } + + json = abi.encodePacked(json, "}"); + + return json; + } + + /** + * @dev Builds the impact JSON string from the impact data. + * + * @param impactCodes the impact codes + * @param impactValues the impact values + */ + function _buildImpactJson( + string[] memory impactCodes, + uint256[] memory impactValues + ) internal view returns (bytes memory) { + require(impactCodes.length == impactValues.length, "X2EarnRewardsPool: Mismatched input lengths for Impact"); + + bytes memory json = abi.encodePacked("{"); + + for (uint256 i; i < impactValues.length; i++) { + if (_isAllowedImpactKey(impactCodes[i])) { + json = abi.encodePacked(json, '"', impactCodes[i], '":', Strings.toString(impactValues[i])); + if (i < impactValues.length - 1) { + json = abi.encodePacked(json, ","); + } + } else { + revert("X2EarnRewardsPool: Invalid impact key"); + } + } + + json = abi.encodePacked(json, "}"); + + return json; + } + + /** + * @dev Checks if the key is allowed. + */ + function _isAllowedImpactKey(string memory key) internal view returns (bool) { + X2EarnRewardsPoolStorage storage $ = _getX2EarnRewardsPoolStorage(); + return $.impactKeyIndex[key] > 0; + } + + /** + * @dev Checks if the proof type is valid. + */ + function _isValidProofType(string memory proofType) internal pure returns (bool) { + return + keccak256(abi.encodePacked(proofType)) == keccak256(abi.encodePacked("image")) || + keccak256(abi.encodePacked(proofType)) == keccak256(abi.encodePacked("link")) || + keccak256(abi.encodePacked(proofType)) == keccak256(abi.encodePacked("text")) || + keccak256(abi.encodePacked(proofType)) == keccak256(abi.encodePacked("video")); } /** @@ -195,6 +402,45 @@ contract X2EarnRewardsPool is $.x2EarnApps = _x2EarnApps; } + /** + * @dev Adds a new allowed impact key. + * @param newKey the new key to add + */ + function addImpactKey(string memory newKey) external onlyRoleOrAdmin(IMPACT_KEY_MANAGER_ROLE) { + X2EarnRewardsPoolStorage storage $ = _getX2EarnRewardsPoolStorage(); + _addImpactKey(newKey, $); + } + + /** + * @dev Internal function to add a new allowed impact key. + * @param key the new key to add + * @param $ the storage pointer + */ + function _addImpactKey(string memory key, X2EarnRewardsPoolStorage storage $) internal { + require($.impactKeyIndex[key] == 0, "X2EarnRewardsPool: Key already exists"); + $.allowedImpactKeys.push(key); + $.impactKeyIndex[key] = $.allowedImpactKeys.length; // Store 1-based index + } + + /** + * @dev Removes an allowed impact key. + * @param keyToRemove the key to remove + */ + function removeImpactKey(string memory keyToRemove) external onlyRoleOrAdmin(IMPACT_KEY_MANAGER_ROLE) { + X2EarnRewardsPoolStorage storage $ = _getX2EarnRewardsPoolStorage(); + uint256 index = $.impactKeyIndex[keyToRemove]; + require(index > 0, "X2EarnRewardsPool: Key not found"); + + // Move the last element into the place to delete + string memory lastKey = $.allowedImpactKeys[$.allowedImpactKeys.length - 1]; + $.allowedImpactKeys[index - 1] = lastKey; + $.impactKeyIndex[lastKey] = index; // Update the index of the last key + + // Remove the last element + $.allowedImpactKeys.pop(); + delete $.impactKeyIndex[keyToRemove]; + } + // ---------- Getters ---------- // /** @@ -209,7 +455,7 @@ contract X2EarnRewardsPool is * @dev See {IX2EarnRewardsPool-version} */ function version() external pure virtual returns (string memory) { - return "1"; + return "2"; } /** @@ -228,6 +474,14 @@ contract X2EarnRewardsPool is return $.x2EarnApps; } + /** + * @dev Retrieves the allowed impact keys. + */ + function getAllowedImpactKeys() external view returns (string[] memory) { + X2EarnRewardsPoolStorage storage $ = _getX2EarnRewardsPoolStorage(); + return $.allowedImpactKeys; + } + // ---------- Fallbacks ---------- // /** diff --git a/contracts/depreceated/V1/VoterRewardsV1.sol b/contracts/deprecated/V1/VoterRewardsV1.sol similarity index 100% rename from contracts/depreceated/V1/VoterRewardsV1.sol rename to contracts/deprecated/V1/VoterRewardsV1.sol diff --git a/contracts/deprecated/V1/X2EarnRewardsPoolV1.sol b/contracts/deprecated/V1/X2EarnRewardsPoolV1.sol new file mode 100644 index 0000000..75f84af --- /dev/null +++ b/contracts/deprecated/V1/X2EarnRewardsPoolV1.sol @@ -0,0 +1,275 @@ +// SPDX-License-Identifier: MIT + +// ####### +// ################ +// #################### +// ########### ######### +// ######### ######### +// ####### ######### ######### +// ######### ######### ########## +// ########## ######## #################### +// ########## ######### ######################### +// ################### ############################ +// ################# ########## ######## +// ############## ### ######## +// ############ ######### +// ########## ########## +// ######## ########### +// ### ############ +// ############## +// ################# +// ############## +// ######### + +pragma solidity 0.8.20; + +import { UUPSUpgradeable } from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import { AccessControlUpgradeable } from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import { ReentrancyGuardUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol"; +import { IB3TR } from "../../interfaces/IB3TR.sol"; +import { IX2EarnApps } from "../../interfaces/IX2EarnApps.sol"; +import { IX2EarnRewardsPoolV1 } from "./interfaces/IX2EarnRewardsPoolV1.sol"; +import { IERC1155Receiver } from "@openzeppelin/contracts/token/ERC1155/IERC1155Receiver.sol"; +import { IERC721Receiver } from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; + +/** + * @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. + * The contract is upgradable through the UUPS proxy pattern and UPGRADER_ROLE can authorize the upgrade. + */ +contract X2EarnRewardsPoolV1 is + IX2EarnRewardsPoolV1, + UUPSUpgradeable, + AccessControlUpgradeable, + ReentrancyGuardUpgradeable +{ + bytes32 public constant UPGRADER_ROLE = keccak256("UPGRADER_ROLE"); + bytes32 public constant CONTRACTS_ADDRESS_MANAGER_ROLE = keccak256("CONTRACTS_ADDRESS_MANAGER_ROLE"); + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /// @custom:storage-location erc7201:b3tr.storage.X2EarnRewardsPool + struct X2EarnRewardsPoolStorage { + IB3TR b3tr; + IX2EarnApps 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 + } + } + + function initialize( + address _admin, + address _contractsManagerAdmin, + address _upgrader, + IB3TR _b3tr, + IX2EarnApps _x2EarnApps + ) external initializer { + require(_admin != address(0), "X2EarnRewardsPool: admin is the zero address"); + require(_contractsManagerAdmin != address(0), "X2EarnRewardsPool: contracts manager admin is the zero address"); + require(_upgrader != address(0), "X2EarnRewardsPool: upgrader 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"); + + __UUPSUpgradeable_init(); + __AccessControl_init(); + __ReentrancyGuard_init(); + + _grantRole(DEFAULT_ADMIN_ROLE, _admin); + _grantRole(UPGRADER_ROLE, _upgrader); + _grantRole(CONTRACTS_ADDRESS_MANAGER_ROLE, _contractsManagerAdmin); + + X2EarnRewardsPoolStorage storage $ = _getX2EarnRewardsPoolStorage(); + $.b3tr = _b3tr; + $.x2EarnApps = _x2EarnApps; + } + + // ---------- Authorizers ---------- // + + function _authorizeUpgrade(address newImplementation) internal virtual override onlyRole(UPGRADER_ROLE) {} + + // ---------- 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(IX2EarnApps _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 (IB3TR) { + X2EarnRewardsPoolStorage storage $ = _getX2EarnRewardsPoolStorage(); + return $.b3tr; + } + + /** + * @dev Retrieves the X2EarnApps contract. + */ + function x2EarnApps() external view returns (IX2EarnApps) { + 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/contracts/depreceated/V1/interfaces/IVoterRewardsV1.sol b/contracts/deprecated/V1/interfaces/IVoterRewardsV1.sol similarity index 100% rename from contracts/depreceated/V1/interfaces/IVoterRewardsV1.sol rename to contracts/deprecated/V1/interfaces/IVoterRewardsV1.sol diff --git a/contracts/deprecated/V1/interfaces/IX2EarnRewardsPoolV1.sol b/contracts/deprecated/V1/interfaces/IX2EarnRewardsPoolV1.sol new file mode 100644 index 0000000..fbe9607 --- /dev/null +++ b/contracts/deprecated/V1/interfaces/IX2EarnRewardsPoolV1.sol @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.20; + +/** + * @title IX2EarnRewardsPoolV1 + * @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 IX2EarnRewardsPoolV1 { + /** + * @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/contracts/interfaces/IX2EarnRewardsPool.sol b/contracts/interfaces/IX2EarnRewardsPool.sol index 29dec0e..b2e5fbb 100644 --- a/contracts/interfaces/IX2EarnRewardsPool.sol +++ b/contracts/interfaces/IX2EarnRewardsPool.sol @@ -89,7 +89,59 @@ interface IX2EarnRewardsPool { * @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 + * @param proof deprecated argument, pass an empty string instead */ function distributeReward(bytes32 appId, uint256 amount, address receiver, string memory proof) external; + + /** + * @dev Function used by x2earn apps to reward users that performed sustainable actions. + * @notice This function is depracted in favor of distributeRewardWithProof. + * + * @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 the JSON string that contains the proof and impact of the sustainable action + */ + function distributeRewardDeprecated(bytes32 appId, uint256 amount, address receiver, string memory proof) external; + + /** + * @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 proofTypes the types of the proof of the sustainable action + * @param proofValues the values of the proof of the sustainable action + * @param impactCodes the codes of the impacts of the sustainable action + * @param impactValues the values of the impacts of the sustainable action + * @param description the description of the sustainable action + */ + function distributeRewardWithProof( + bytes32 appId, + uint256 amount, + address receiver, + string[] memory proofTypes, // link, photo, video, text, etc. + string[] memory proofValues, // "https://...", "Qm...", etc., + string[] memory impactCodes, // carbon, water, etc. + uint256[] memory impactValues, // 100, 200, etc., + string memory description + ) external; + + /** + * @dev Builds the JSON proof string that will be stored + * on chain regarding the proofs, impacts and description of the sustainable action. + * + * @param proofTypes the types of the proof of the sustainable action + * @param proofValues the values of the proof of the sustainable action + * @param impactCodes the codes of the impacts of the sustainable action + * @param impactValues the values of the impacts of the sustainable action + * @param description the description of the sustainable action + */ + function buildProof( + string[] memory proofTypes, // link, photo, video, text, etc. + string[] memory proofValues, // "https://...", "Qm...", etc., + string[] memory impactCodes, // carbon, water, etc. + uint256[] memory impactValues, // 100, 200, etc., + string memory description + ) external returns (string memory); } diff --git a/scripts/deploy/deploy.ts b/scripts/deploy/deploy.ts index 06fd891..c4e58b8 100644 --- a/scripts/deploy/deploy.ts +++ b/scripts/deploy/deploy.ts @@ -152,15 +152,24 @@ export async function deployAll(config: ContractsConfig) { ])) as X2EarnApps console.log(`X2EarnApps deployed at ${await x2EarnApps.getAddress()}`) - const x2EarnRewardsPool = (await deployProxy( - "X2EarnRewardsPool", + const x2EarnRewardsPool = (await deployAndUpgrade( + ["X2EarnRewardsPoolV1", "X2EarnRewardsPool"], [ - config.CONTRACTS_ADMIN_ADDRESS, // admin - config.CONTRACTS_ADMIN_ADDRESS, // contracts address manager - config.CONTRACTS_ADMIN_ADDRESS, // upgrader - await b3tr.getAddress(), - await x2EarnApps.getAddress(), - ])) as X2EarnRewardsPool + [ + config.CONTRACTS_ADMIN_ADDRESS, // admin + config.CONTRACTS_ADMIN_ADDRESS, // contracts address manager + config.CONTRACTS_ADMIN_ADDRESS, // upgrader + await b3tr.getAddress(), + await x2EarnApps.getAddress(), + ], + [ + config.CONTRACTS_ADMIN_ADDRESS, // impact admin address + ], + ], + { + versions: [undefined, 2], + }, + )) as X2EarnRewardsPool console.log(`X2EarnRewardsPool deployed at ${await x2EarnRewardsPool.getAddress()}`) @@ -195,7 +204,7 @@ export async function deployAll(config: ContractsConfig) { ])) as GalaxyMember console.log(`GalaxyMember deployed at ${await galaxyMember.getAddress()}`) - const emissions = (await deployProxy("Emissions", [ + const emissions = (await deployProxy("Emissions", [ { minter: TEMP_ADMIN, admin: TEMP_ADMIN, diff --git a/scripts/helpers/type.ts b/scripts/helpers/type.ts index a6df0f5..f18bcc9 100644 --- a/scripts/helpers/type.ts +++ b/scripts/helpers/type.ts @@ -1,4 +1,5 @@ export type DeployUpgradeOptions = { - libraries?: { [libraryName: string]: string }[] - versions?: (number | undefined)[] - } \ No newline at end of file + libraries?: { [libraryName: string]: string }[] + versions?: (number | undefined)[] + logOutput?: boolean +} diff --git a/scripts/helpers/upgrades.ts b/scripts/helpers/upgrades.ts index a725c30..e191874 100644 --- a/scripts/helpers/upgrades.ts +++ b/scripts/helpers/upgrades.ts @@ -1,12 +1,14 @@ import { BaseContract, Interface } from "ethers" import { ethers } from "hardhat" import { getImplementationAddress } from "@openzeppelin/upgrades-core" +import { compareAddresses } from "./utils" import { DeployUpgradeOptions } from "./type" export const deployProxy = async ( contractName: string, args: any[], libraries: { [libraryName: string]: string } = {}, + logOutput: boolean = false, version?: number, ): Promise => { // Deploy the implementation contract @@ -15,6 +17,7 @@ export const deployProxy = async ( }) const implementation = await Contract.deploy() await implementation.waitForDeployment() + logOutput && console.log(`${contractName} impl.: ${await implementation.getAddress()}`) // Deploy the proxy contract, link it to the implementation and call the initializer const proxyFactory = await ethers.getContractFactory("B3TRProxy") @@ -23,9 +26,10 @@ export const deployProxy = async ( getInitializerData(Contract.interface, args, version), ) await proxy.waitForDeployment() + logOutput && console.log(`${contractName} proxy: ${await proxy.getAddress()}`) const newImplementationAddress = await getImplementationAddress(ethers.provider, await proxy.getAddress()) - if (newImplementationAddress !== (await implementation.getAddress())) { + if (!compareAddresses(newImplementationAddress, await implementation.getAddress())) { throw new Error( `The implementation address is not the one expected: ${newImplementationAddress} !== ${await implementation.getAddress()}`, ) @@ -35,6 +39,60 @@ export const deployProxy = async ( return Contract.attach(await proxy.getAddress()) } +export const deployProxyOnly = async ( + contractName: string, + libraries: { [libraryName: string]: string } = {}, + logOutput: boolean = false, +): Promise => { + // Deploy the implementation contract + const Contract = await ethers.getContractFactory(contractName, { + libraries: libraries, + }) + const implementation = await Contract.deploy() + await implementation.waitForDeployment() + logOutput && console.log(`${contractName} impl.: ${await implementation.getAddress()}`) + + // Deploy the proxy contract without initialization + const proxyFactory = await ethers.getContractFactory("B3TRProxy") + const proxy = await proxyFactory.deploy(await implementation.getAddress(), "0x") + await proxy.waitForDeployment() + logOutput && console.log(`${contractName} proxy: ${await proxy.getAddress()}`) + + const newImplementationAddress = await getImplementationAddress(ethers.provider, await proxy.getAddress()) + if (!compareAddresses(newImplementationAddress, await implementation.getAddress())) { + throw new Error( + `The implementation address is not the one expected: ${newImplementationAddress} !== ${await implementation.getAddress()}`, + ) + } + + // Return the proxy address + return await proxy.getAddress() +} + +export const initializeProxy = async ( + proxyAddress: string, + contractName: string, + args: any[], + version?: number, +): Promise => { + // Get the ContractFactory + const Contract = await ethers.getContractFactory(contractName) + + // Prepare the initializer data using getInitializerData + const initializerData = getInitializerData(Contract.interface, args, version) + + // Interact with the proxy contract to call the initializer using the prepared initializer data + const signer = (await ethers.getSigners())[0] + const tx = await signer.sendTransaction({ + to: proxyAddress, + data: initializerData, + }) + await tx.wait() + + // Return an instance of the contract using the proxy address + return Contract.attach(proxyAddress) +} + export const upgradeProxy = async ( previousVersionContractName: string, newVersionContractName: string, @@ -89,6 +147,7 @@ export const deployAndUpgrade = async ( contractName, contractArgs, options.libraries?.[0], + options.logOutput, options.versions?.[0], ) @@ -118,4 +177,4 @@ export function getInitializerData(contractInterface: Interface, args: any[], ve throw new Error(`Contract initializer not found`) } return contractInterface.encodeFunctionData(fragment, args) -} \ No newline at end of file +} diff --git a/scripts/helpers/utils.ts b/scripts/helpers/utils.ts new file mode 100644 index 0000000..0dd3e00 --- /dev/null +++ b/scripts/helpers/utils.ts @@ -0,0 +1,136 @@ +import { address } from "thor-devkit" + +/** + * Checks if two addresses are equal. Returns true if both values are strings AND: + * - The two values are equal OR + * - The checksumed addresses are equal + * + * @param address1 + * @param address2 + */ +export const compareAddresses = (address1?: string, address2?: string): boolean => { + if (!address1 || !address2) return false + + if (address2 === address1) { + return true + } + + try { + return normalize(address1) === normalize(address2) + } catch (e) { + return false + } +} + +export const compareListOfAddresses = (add1: string[], add2: string[]) => { + if (add1.length !== add2.length) return false + const sortedAdd1 = [...add1].map(e => e.toLowerCase()).sort((a, b) => a.localeCompare(b)) + const sortedAdd2 = [...add2].map(e => e.toLowerCase()).sort((a, b) => a.localeCompare(b)) + + for (let i = 0; i < sortedAdd1.length; i++) { + if (!compareAddresses(sortedAdd1[i], sortedAdd2[i])) return false + } + + return true +} + +export const regexPattern = () => { + return /^0x[a-fA-F0-9]{40}$/ +} + +export const isValid = (addr: string | undefined | null): boolean => { + try { + if (typeof addr !== "string") return false + address.toChecksumed(addPrefix(addr)) + return true + } catch (e) { + return false + } +} + +export const leftPadWithZeros = (str: string, length: number): string => { + // Remove '0x' prefix if it exists + const cleanStr = str.startsWith("0x") ? str.slice(2) : str + if (cleanStr.length > length) { + throw new Error("Input string is longer than the specified length") + } + // Pad the string to the specified length + const paddedStr = cleanStr.padStart(length, "0") + return `0x${paddedStr}` +} + +import crypto from "crypto" +const PREFIX = "0x" +const PREFIX_REGEX = /^0[xX]/ +const HEX_REGEX = /^(0[xX])?[a-fA-F0-9]+$/ + +/** + * Returns the provied hex string with the hex prefix removed. + * If the prefix doesn't exist the hex is returned unmodified + * @param hex - the input hex string + * @returns the input hex string with the hex prefix removed + * @throws an error if the input is not a valid hex string + */ +export const removePrefix = (hex: string): string => { + validate(hex) + return hex.replace(PREFIX_REGEX, "") +} + +/** + * Returns the provided hex string with the hex prefix added. + * If the prefix already exists the string is returned unmodified. + * If the string contains an UPPER case `X` in the prefix it will be replaced with a lower case `x` + * @param hex - the input hex string + * @returns the input hex string with the hex prefix added + * @throws an error if the input is not a valid hex string + */ +export const addPrefix = (hex: string): string => { + validate(hex) + return PREFIX_REGEX.test(hex) ? hex.replace(PREFIX_REGEX, PREFIX) : `${PREFIX}${hex}` +} + +/** + * Validate the hex string. Throws an Error if not valid + * @param hex - the input hex string + * @throws an error if the input is not a valid hex string + */ +export const validate = (hex: string) => { + if (!isHexValid(hex)) throw Error("Provided hex value is not valid") +} + +/** + * Check if input string is valid + * @param hex - the input hex string + * @returns boolean representing whether the input hex is valid + */ +export const isHexValid = (hex?: string | null): boolean => { + return !!hex && HEX_REGEX.test(hex) +} + +export const isInvalid = (hex?: string | null): boolean => { + return !isHexValid(hex) +} + +export const normalize = (hex: string): string => { + return addPrefix(hex.toLowerCase().trim()) +} + +export const compare = (hex1: string, hex2: string): boolean => { + try { + return removePrefix(hex1).toLowerCase() === removePrefix(hex2).toLowerCase() + } catch (e) { + return false + } +} + +/** + * Generate a random hex string of the defined length + * @param size - the length of the random hex output + * @returns a random hex string of length `size` + */ +export const generateRandom = (size: number): string => { + if (size < 1) throw Error("Size must be > 0") + const randBuffer = crypto.randomBytes(Math.ceil(size / 2)) + if (!randBuffer) throw Error("Failed to generate random hex") + return `${PREFIX}${randBuffer.toString("hex").substring(0, size)}` +} diff --git a/test/X2EarnRewardsPool.test.ts b/test/X2EarnRewardsPool.test.ts index e812d58..3c9b7ba 100644 --- a/test/X2EarnRewardsPool.test.ts +++ b/test/X2EarnRewardsPool.test.ts @@ -3,7 +3,9 @@ import { expect } from "chai" import { ZERO_ADDRESS, catchRevert, filterEventsByName, getOrDeployContractInstances } from "./helpers" import { describe, it } from "mocha" import { getImplementationAddress } from "@openzeppelin/upgrades-core" -import { deployProxy } from "../scripts/helpers" +import { deployProxy, upgradeProxy } from "../scripts/helpers" +import { X2EarnRewardsPool, X2EarnRewardsPoolV1 } from "../typechain-types" +import { createLocalConfig } from "../config/contracts/envs/local" describe("X2EarnRewardsPool", function () { // deployment @@ -46,7 +48,7 @@ describe("X2EarnRewardsPool", function () { it("Version should be set correctly", async function () { const { x2EarnRewardsPool } = await getOrDeployContractInstances({ forceDeploy: false }) - expect(await x2EarnRewardsPool.version()).to.equal("1") + expect(await x2EarnRewardsPool.version()).to.equal("2") }) it("X2EarnApps should be set correctly", async function () { @@ -125,6 +127,105 @@ describe("X2EarnRewardsPool", function () { expect(await x2EarnApps.version()).to.equal("1") }) + + it("Storage should be preserved after upgrade", async () => { + const config = createLocalConfig() + const { owner, b3tr, x2EarnApps, minterAccount } = await getOrDeployContractInstances({ + forceDeploy: true, + config, + }) + + const x2EarnRewardsPoolV1 = (await deployProxy("X2EarnRewardsPoolV1", [ + owner.address, + owner.address, + owner.address, + await b3tr.getAddress(), + await x2EarnApps.getAddress(), + ])) as X2EarnRewardsPoolV1 + + expect(await x2EarnRewardsPoolV1.version()).to.equal("1") + + // update x2EarnApps address + await x2EarnRewardsPoolV1.connect(owner).setX2EarnApps(await x2EarnApps.getAddress()) + const x2EarnAppsAddress = await x2EarnRewardsPoolV1.x2EarnApps() + + // deposit some funds + const amount = ethers.parseEther("100") + + await b3tr.connect(minterAccount).mint(owner.address, amount) + + // create app + await x2EarnApps.addApp(owner.address, owner.address, "My app", "metadataURI") + await x2EarnApps.addApp(owner.address, owner.address, "My app #2", "metadataURI") + + await b3tr.connect(owner).approve(await x2EarnRewardsPoolV1.getAddress(), amount) + await x2EarnRewardsPoolV1.connect(owner).deposit(amount, await x2EarnApps.hashAppName("My app")) + + expect(await b3tr.balanceOf(await x2EarnRewardsPoolV1.getAddress())).to.equal(amount) + + // upgrade to new version + const x2EarnRewardsPoolV2 = (await upgradeProxy( + "X2EarnRewardsPoolV1", + "X2EarnRewardsPool", + await x2EarnRewardsPoolV1.getAddress(), + [owner.address, config.X_2_EARN_INITIAL_IMPACT_KEYS], + { + version: 2, + }, + )) as X2EarnRewardsPool + + expect(await x2EarnRewardsPoolV2.version()).to.equal("2") + expect(await x2EarnRewardsPoolV2.x2EarnApps()).to.equal(x2EarnAppsAddress) + expect(await x2EarnRewardsPoolV2.availableFunds(await x2EarnApps.hashAppName("My app"))).to.equal(amount) + }) + + it("Should not be able to upgrade if initial impact keys is empty", async () => { + const config = createLocalConfig() + const { owner, b3tr, x2EarnApps, minterAccount } = await getOrDeployContractInstances({ + forceDeploy: true, + config, + }) + + const x2EarnRewardsPoolV1 = (await deployProxy("X2EarnRewardsPoolV1", [ + owner.address, + owner.address, + owner.address, + await b3tr.getAddress(), + await x2EarnApps.getAddress(), + ])) as X2EarnRewardsPoolV1 + + expect(await x2EarnRewardsPoolV1.version()).to.equal("1") + + // update x2EarnApps address + await x2EarnRewardsPoolV1.connect(owner).setX2EarnApps(await x2EarnApps.getAddress()) + + // deposit some funds + const amount = ethers.parseEther("100") + + await b3tr.connect(minterAccount).mint(owner.address, amount) + + // create app + await x2EarnApps.addApp(owner.address, owner.address, "My app", "metadataURI") + await x2EarnApps.addApp(owner.address, owner.address, "My app #2", "metadataURI") + + await b3tr.connect(owner).approve(await x2EarnRewardsPoolV1.getAddress(), amount) + await x2EarnRewardsPoolV1.connect(owner).deposit(amount, await x2EarnApps.hashAppName("My app")) + + expect(await b3tr.balanceOf(await x2EarnRewardsPoolV1.getAddress())).to.equal(amount) + + // upgrade to new version + await expect( + upgradeProxy( + "X2EarnRewardsPoolV1", + "X2EarnRewardsPool", + await x2EarnRewardsPoolV1.getAddress(), + [owner.address, []], + { + version: 2, + }, + ), + ).to.be.reverted + }) }) // settings @@ -299,7 +400,7 @@ describe("X2EarnRewardsPool", function () { const receipt = await tx.wait() if (!receipt) throw new Error("No receipt") - const event = filterEventsByName(receipt.logs, "NewDeposit") + let event = filterEventsByName(receipt.logs, "NewDeposit") expect(event).not.to.eql([]) expect(event[0].args[0]).to.equal(amount) @@ -365,7 +466,7 @@ describe("X2EarnRewardsPool", function () { const receipt = await tx.wait() if (!receipt) throw new Error("No receipt") - const event = filterEventsByName(receipt.logs, "TeamWithdrawal") + let event = filterEventsByName(receipt.logs, "TeamWithdrawal") expect(event).not.to.eql([]) expect(event[0].args[0]).to.equal(ethers.parseEther("1")) expect(event[0].args[1]).to.equal(await x2EarnApps.hashAppName("My app")) @@ -415,7 +516,7 @@ describe("X2EarnRewardsPool", function () { const receipt = await tx.wait() if (!receipt) throw new Error("No receipt") - const event = filterEventsByName(receipt.logs, "TeamWithdrawal") + let event = filterEventsByName(receipt.logs, "TeamWithdrawal") expect(event).not.to.eql([]) expect(event[0].args[0]).to.equal(ethers.parseEther("1")) expect(event[0].args[1]).to.equal(await x2EarnApps.hashAppName("My app")) @@ -570,13 +671,13 @@ describe("X2EarnRewardsPool", function () { // event emitted if (!receipt) throw new Error("No receipt") - const event = filterEventsByName(receipt.logs, "RewardDistributed") + let event = filterEventsByName(receipt.logs, "RewardDistributed") expect(event).not.to.eql([]) expect(event[0].args[0]).to.equal(ethers.parseEther("1")) expect(event[0].args[1]).to.equal(appId) expect(event[0].args[2]).to.equal(user.address) - expect(event[0].args[3]).to.equal("ipfs://metadata") + expect(event[0].args[3]).to.equal("") // Because it's using a deprecated function expect(event[0].args[4]).to.equal(owner.address) }) @@ -728,4 +829,803 @@ describe("X2EarnRewardsPool", function () { ).to.be.reverted }) }) + + describe("Proofs and Impact", async function () { + it("Json proof is created by the contract", async function () { + const { x2EarnRewardsPool, x2EarnApps, b3tr, owner, otherAccounts, minterAccount } = + await getOrDeployContractInstances({ + forceDeploy: true, + bootstrapAndStartEmissions: true, + }) + + const teamWallet = otherAccounts[10] + const user = otherAccounts[11] + const amount = ethers.parseEther("100") + + await b3tr.connect(minterAccount).mint(owner.address, amount) + + await x2EarnApps.addApp(teamWallet.address, owner.address, "My app", "metadataURI") + const appId = await x2EarnApps.hashAppName("My app") + + await x2EarnApps.connect(owner).addRewardDistributor(appId, owner.address) + expect(await x2EarnApps.isRewardDistributor(appId, owner.address)).to.equal(true) + + // fill the pool + await b3tr.connect(owner).approve(await x2EarnRewardsPool.getAddress(), amount) + await x2EarnRewardsPool.connect(owner).deposit(amount, appId) + + const tx = await x2EarnRewardsPool + .connect(owner) + .distributeRewardWithProof( + appId, + ethers.parseEther("1"), + user.address, + ["image"], + ["https://image.png"], + ["carbon", "water"], + [100, 200], + "The description of the action", + ) + + const receipt = await tx.wait() + + expect(await b3tr.balanceOf(user.address)).to.equal(ethers.parseEther("1")) + expect(await b3tr.balanceOf(await x2EarnRewardsPool.getAddress())).to.equal(ethers.parseEther("99")) + + // event emitted + if (!receipt) throw new Error("No receipt") + + let event = filterEventsByName(receipt.logs, "RewardDistributed") + + expect(event).not.to.eql([]) + expect(event[0].args[0]).to.equal(ethers.parseEther("1")) + expect(event[0].args[1]).to.equal(appId) + expect(event[0].args[2]).to.equal(user.address) + + const emittedProof = JSON.parse(event[0].args[3]) + + expect(emittedProof).to.have.property("version") + expect(emittedProof.version).to.equal(2) + expect(emittedProof).to.have.deep.property("proof", { image: "https://image.png" }) + expect(emittedProof).to.have.property("description") + expect(emittedProof.description).to.equal("The description of the action") + expect(emittedProof).to.have.deep.property("impact", { carbon: 100, water: 200 }) + + expect(event[0].args[4]).to.equal(owner.address) + }) + + it("App can provide multiple proofs", async function () { + const { x2EarnRewardsPool, x2EarnApps, b3tr, owner, otherAccounts, minterAccount } = + await getOrDeployContractInstances({ + forceDeploy: true, + bootstrapAndStartEmissions: true, + }) + + const teamWallet = otherAccounts[10] + const user = otherAccounts[11] + const amount = ethers.parseEther("100") + + await b3tr.connect(minterAccount).mint(owner.address, amount) + + await x2EarnApps.addApp(teamWallet.address, owner.address, "My app", "metadataURI") + const appId = await x2EarnApps.hashAppName("My app") + + await x2EarnApps.connect(owner).addRewardDistributor(appId, owner.address) + expect(await x2EarnApps.isRewardDistributor(appId, owner.address)).to.equal(true) + + // fill the pool + await b3tr.connect(owner).approve(await x2EarnRewardsPool.getAddress(), amount) + await x2EarnRewardsPool.connect(owner).deposit(amount, appId) + + const tx = await x2EarnRewardsPool + .connect(owner) + .distributeRewardWithProof( + appId, + ethers.parseEther("1"), + user.address, + ["image", "link"], + ["https://image.png", "https://twitter.com/tweet/1"], + ["carbon", "water"], + [100, 200], + "The description of the action", + ) + + const receipt = await tx.wait() + + expect(await b3tr.balanceOf(user.address)).to.equal(ethers.parseEther("1")) + expect(await b3tr.balanceOf(await x2EarnRewardsPool.getAddress())).to.equal(ethers.parseEther("99")) + + // event emitted + if (!receipt) throw new Error("No receipt") + + let event = filterEventsByName(receipt.logs, "RewardDistributed") + + expect(event).not.to.eql([]) + expect(event[0].args[0]).to.equal(ethers.parseEther("1")) + expect(event[0].args[1]).to.equal(appId) + expect(event[0].args[2]).to.equal(user.address) + + const emittedProof = JSON.parse(event[0].args[3]) + expect(emittedProof).to.have.property("version") + expect(emittedProof.version).to.equal(2) + expect(emittedProof).to.have.deep.property("proof", { + image: "https://image.png", + link: "https://twitter.com/tweet/1", + }) + expect(emittedProof).to.have.property("description") + expect(emittedProof.description).to.equal("The description of the action") + expect(emittedProof).to.have.deep.property("impact", { carbon: 100, water: 200 }) + + expect(event[0].args[4]).to.equal(owner.address) + }) + + it("App can provide only proofs without impact", async function () { + const { x2EarnRewardsPool, x2EarnApps, b3tr, owner, otherAccounts, minterAccount } = + await getOrDeployContractInstances({ + forceDeploy: true, + bootstrapAndStartEmissions: true, + }) + + const teamWallet = otherAccounts[10] + const user = otherAccounts[11] + const amount = ethers.parseEther("100") + + await b3tr.connect(minterAccount).mint(owner.address, amount) + + await x2EarnApps.addApp(teamWallet.address, owner.address, "My app", "metadataURI") + const appId = await x2EarnApps.hashAppName("My app") + + await x2EarnApps.connect(owner).addRewardDistributor(appId, owner.address) + expect(await x2EarnApps.isRewardDistributor(appId, owner.address)).to.equal(true) + + // fill the pool + await b3tr.connect(owner).approve(await x2EarnRewardsPool.getAddress(), amount) + await x2EarnRewardsPool.connect(owner).deposit(amount, appId) + + const tx = await x2EarnRewardsPool + .connect(owner) + .distributeRewardWithProof( + appId, + ethers.parseEther("1"), + user.address, + ["image", "link"], + ["https://image.png", "https://twitter.com/tweet/1"], + [], + [], + "The description of the action", + ) + + const receipt = await tx.wait() + + expect(await b3tr.balanceOf(user.address)).to.equal(ethers.parseEther("1")) + expect(await b3tr.balanceOf(await x2EarnRewardsPool.getAddress())).to.equal(ethers.parseEther("99")) + + // event emitted + if (!receipt) throw new Error("No receipt") + + let event = filterEventsByName(receipt.logs, "RewardDistributed") + + expect(event).not.to.eql([]) + expect(event[0].args[0]).to.equal(ethers.parseEther("1")) + expect(event[0].args[1]).to.equal(appId) + expect(event[0].args[2]).to.equal(user.address) + + const emittedProof = JSON.parse(event[0].args[3]) + + expect(emittedProof).to.have.property("version") + expect(emittedProof.version).to.equal(2) + expect(emittedProof).to.have.deep.property("proof", { + image: "https://image.png", + link: "https://twitter.com/tweet/1", + }) + expect(emittedProof).to.have.property("description") + expect(emittedProof.description).to.equal("The description of the action") + + expect(emittedProof).to.not.have.property("impact") + }) + + it("App can provide only impact without proofs", async function () { + const { x2EarnRewardsPool, x2EarnApps, b3tr, owner, otherAccounts, minterAccount } = + await getOrDeployContractInstances({ + forceDeploy: true, + bootstrapAndStartEmissions: true, + }) + + const teamWallet = otherAccounts[10] + const user = otherAccounts[11] + const amount = ethers.parseEther("100") + + await b3tr.connect(minterAccount).mint(owner.address, amount) + + await x2EarnApps.addApp(teamWallet.address, owner.address, "My app", "metadataURI") + const appId = await x2EarnApps.hashAppName("My app") + + await x2EarnApps.connect(owner).addRewardDistributor(appId, owner.address) + expect(await x2EarnApps.isRewardDistributor(appId, owner.address)).to.equal(true) + + // fill the pool + await b3tr.connect(owner).approve(await x2EarnRewardsPool.getAddress(), amount) + await x2EarnRewardsPool.connect(owner).deposit(amount, appId) + + const tx = await x2EarnRewardsPool + .connect(owner) + .distributeRewardWithProof( + appId, + ethers.parseEther("1"), + user.address, + [], + [], + ["carbon", "water"], + [100, 200], + "The description of the action", + ) + + const receipt = await tx.wait() + + expect(await b3tr.balanceOf(user.address)).to.equal(ethers.parseEther("1")) + expect(await b3tr.balanceOf(await x2EarnRewardsPool.getAddress())).to.equal(ethers.parseEther("99")) + + // event emitted + if (!receipt) throw new Error("No receipt") + + let event = filterEventsByName(receipt.logs, "RewardDistributed") + + expect(event).not.to.eql([]) + expect(event[0].args[0]).to.equal(ethers.parseEther("1")) + expect(event[0].args[1]).to.equal(appId) + expect(event[0].args[2]).to.equal(user.address) + + const emittedProof = JSON.parse(event[0].args[3]) + + expect(emittedProof).to.have.property("version") + expect(emittedProof.version).to.equal(2) + expect(emittedProof).to.have.property("description") + expect(emittedProof.description).to.equal("The description of the action") + expect(emittedProof).to.have.deep.property("impact", { carbon: 100, water: 200 }) + + expect(emittedProof).to.not.have.property("proof") + }) + + it("If only description is passed, without proofs and impact, nothing is emitted", async function () { + const { x2EarnRewardsPool, x2EarnApps, b3tr, owner, otherAccounts, minterAccount } = + await getOrDeployContractInstances({ + forceDeploy: true, + bootstrapAndStartEmissions: true, + }) + + const teamWallet = otherAccounts[10] + const user = otherAccounts[11] + const amount = ethers.parseEther("100") + + await b3tr.connect(minterAccount).mint(owner.address, amount) + + await x2EarnApps.addApp(teamWallet.address, owner.address, "My app", "metadataURI") + const appId = await x2EarnApps.hashAppName("My app") + + await x2EarnApps.connect(owner).addRewardDistributor(appId, owner.address) + expect(await x2EarnApps.isRewardDistributor(appId, owner.address)).to.equal(true) + + // fill the pool + await b3tr.connect(owner).approve(await x2EarnRewardsPool.getAddress(), amount) + await x2EarnRewardsPool.connect(owner).deposit(amount, appId) + + const tx = await x2EarnRewardsPool + .connect(owner) + .distributeRewardWithProof( + appId, + ethers.parseEther("1"), + user.address, + [], + [], + [], + [], + "The description of the action", + ) + + const receipt = await tx.wait() + + expect(await b3tr.balanceOf(user.address)).to.equal(ethers.parseEther("1")) + expect(await b3tr.balanceOf(await x2EarnRewardsPool.getAddress())).to.equal(ethers.parseEther("99")) + + // event emitted + if (!receipt) throw new Error("No receipt") + + let event = filterEventsByName(receipt.logs, "RewardDistributed") + + expect(event).not.to.eql([]) + expect(event[0].args[0]).to.equal(ethers.parseEther("1")) + expect(event[0].args[1]).to.equal(appId) + expect(event[0].args[2]).to.equal(user.address) + expect(event[0].args[3]).to.equal("") + }) + + it("Description is not mandatory if proof or impact is passed", async function () { + const { x2EarnRewardsPool, x2EarnApps, b3tr, owner, otherAccounts, minterAccount } = + await getOrDeployContractInstances({ + forceDeploy: true, + bootstrapAndStartEmissions: true, + }) + + const teamWallet = otherAccounts[10] + const user = otherAccounts[11] + const amount = ethers.parseEther("100") + + await b3tr.connect(minterAccount).mint(owner.address, amount) + + await x2EarnApps.addApp(teamWallet.address, owner.address, "My app", "metadataURI") + const appId = await x2EarnApps.hashAppName("My app") + + await x2EarnApps.connect(owner).addRewardDistributor(appId, owner.address) + expect(await x2EarnApps.isRewardDistributor(appId, owner.address)).to.equal(true) + + // fill the pool + await b3tr.connect(owner).approve(await x2EarnRewardsPool.getAddress(), amount) + await x2EarnRewardsPool.connect(owner).deposit(amount, appId) + + const tx = await x2EarnRewardsPool + .connect(owner) + .distributeRewardWithProof( + appId, + ethers.parseEther("1"), + user.address, + ["image", "link"], + ["https://image.png", "https://twitter.com/tweet/1"], + ["carbon", "water"], + [100, 200], + "", + ) + + const receipt = await tx.wait() + + expect(await b3tr.balanceOf(user.address)).to.equal(ethers.parseEther("1")) + expect(await b3tr.balanceOf(await x2EarnRewardsPool.getAddress())).to.equal(ethers.parseEther("99")) + + // event emitted + if (!receipt) throw new Error("No receipt") + + let event = filterEventsByName(receipt.logs, "RewardDistributed") + + expect(event).not.to.eql([]) + expect(event[0].args[0]).to.equal(ethers.parseEther("1")) + expect(event[0].args[1]).to.equal(appId) + expect(event[0].args[2]).to.equal(user.address) + + const emittedProof = JSON.parse(event[0].args[3]) + expect(emittedProof).to.have.property("version") + expect(emittedProof.version).to.equal(2) + expect(emittedProof).to.have.deep.property("proof", { + image: "https://image.png", + link: "https://twitter.com/tweet/1", + }) + expect(emittedProof).to.not.have.property("description") + expect(emittedProof).to.have.deep.property("impact", { carbon: 100, water: 200 }) + }) + + it("If no proof, nor impact, nor description is passed, nothing is emitted", async function () { + const { x2EarnRewardsPool, x2EarnApps, b3tr, owner, otherAccounts, minterAccount } = + await getOrDeployContractInstances({ + forceDeploy: true, + bootstrapAndStartEmissions: true, + }) + + const teamWallet = otherAccounts[10] + const user = otherAccounts[11] + const amount = ethers.parseEther("100") + + await b3tr.connect(minterAccount).mint(owner.address, amount) + + await x2EarnApps.addApp(teamWallet.address, owner.address, "My app", "metadataURI") + const appId = await x2EarnApps.hashAppName("My app") + + await x2EarnApps.connect(owner).addRewardDistributor(appId, owner.address) + expect(await x2EarnApps.isRewardDistributor(appId, owner.address)).to.equal(true) + + // fill the pool + await b3tr.connect(owner).approve(await x2EarnRewardsPool.getAddress(), amount) + await x2EarnRewardsPool.connect(owner).deposit(amount, appId) + + const tx = await x2EarnRewardsPool + .connect(owner) + .distributeRewardWithProof(appId, ethers.parseEther("1"), user.address, [], [], [], [], "") + + const receipt = await tx.wait() + + expect(await b3tr.balanceOf(user.address)).to.equal(ethers.parseEther("1")) + expect(await b3tr.balanceOf(await x2EarnRewardsPool.getAddress())).to.equal(ethers.parseEther("99")) + + // event emitted + if (!receipt) throw new Error("No receipt") + + let event = filterEventsByName(receipt.logs, "RewardDistributed") + + expect(event).not.to.eql([]) + expect(event[0].args[0]).to.equal(ethers.parseEther("1")) + expect(event[0].args[1]).to.equal(appId) + expect(event[0].args[2]).to.equal(user.address) + expect(event[0].args[3]).to.equal("") + }) + + it("If a non valid proof type is passed, it reverts", async function () { + const { x2EarnRewardsPool, x2EarnApps, owner } = await getOrDeployContractInstances({ + forceDeploy: true, + bootstrapAndStartEmissions: true, + }) + + await catchRevert( + x2EarnRewardsPool + .connect(owner) + .distributeRewardWithProof( + await x2EarnApps.hashAppName("My app"), + ethers.parseEther("1"), + owner.address, + ["invalid"], + ["https://image.png"], + ["carbon", "water"], + [100, 200], + "The description of the action", + ), + ) + }) + + it("Only valid proofs are image, text, link, video", async function () { + const { x2EarnRewardsPool, x2EarnApps, b3tr, owner, otherAccounts, minterAccount } = + await getOrDeployContractInstances({ + forceDeploy: true, + bootstrapAndStartEmissions: true, + }) + + const teamWallet = otherAccounts[10] + const user = otherAccounts[11] + const amount = ethers.parseEther("100") + + await b3tr.connect(minterAccount).mint(owner.address, amount) + + await x2EarnApps.addApp(teamWallet.address, owner.address, "My app", "metadataURI") + const appId = await x2EarnApps.hashAppName("My app") + + await x2EarnApps.connect(owner).addRewardDistributor(appId, owner.address) + expect(await x2EarnApps.isRewardDistributor(appId, owner.address)).to.equal(true) + + // fill the pool + await b3tr.connect(owner).approve(await x2EarnRewardsPool.getAddress(), amount) + await x2EarnRewardsPool.connect(owner).deposit(amount, appId) + + await catchRevert( + x2EarnRewardsPool + .connect(owner) + .distributeRewardWithProof( + appId, + ethers.parseEther("1"), + user.address, + ["invalid"], + ["https://image.png"], + ["carbon", "water"], + [100, 200], + "The description of the action", + ), + ) + + await expect( + x2EarnRewardsPool + .connect(owner) + .distributeRewardWithProof( + appId, + ethers.parseEther("1"), + user.address, + ["video"], + ["https://image.png"], + ["carbon", "water"], + [100, 200], + "The description of the action", + ), + ).not.to.be.reverted + + await expect( + x2EarnRewardsPool + .connect(owner) + .distributeRewardWithProof( + appId, + ethers.parseEther("1"), + user.address, + ["image"], + ["https://image.png"], + ["carbon", "water"], + [100, 200], + "The description of the action", + ), + ).not.to.be.reverted + + await expect( + x2EarnRewardsPool + .connect(owner) + .distributeRewardWithProof( + appId, + ethers.parseEther("1"), + user.address, + ["link"], + ["https://image.png"], + ["carbon", "water"], + [100, 200], + "The description of the action", + ), + ).not.to.be.reverted + }) + + it("If a non valid impact type is passed, it reverts", async function () { + const { x2EarnRewardsPool, x2EarnApps, owner } = await getOrDeployContractInstances({ + forceDeploy: true, + bootstrapAndStartEmissions: true, + }) + + await catchRevert( + x2EarnRewardsPool + .connect(owner) + .distributeRewardWithProof( + await x2EarnApps.hashAppName("My app"), + ethers.parseEther("1"), + owner.address, + ["image"], + ["https://image.png"], + ["invalid"], + [100, 200], + "The description of the action", + ), + ) + }) + + it("If impact values length differs from codes length, it reverts", async function () { + const { x2EarnRewardsPool, x2EarnApps, owner } = await getOrDeployContractInstances({ + forceDeploy: true, + bootstrapAndStartEmissions: true, + }) + + await catchRevert( + x2EarnRewardsPool + .connect(owner) + .distributeRewardWithProof( + await x2EarnApps.hashAppName("My app"), + ethers.parseEther("1"), + owner.address, + ["image"], + ["https://image.png"], + ["carbon"], + [100, 200], + "The description of the action", + ), + ) + }) + + it("If proof values length differs from types length, it reverts", async function () { + const { x2EarnRewardsPool, x2EarnApps, owner } = await getOrDeployContractInstances({ + forceDeploy: true, + bootstrapAndStartEmissions: true, + }) + + await catchRevert( + x2EarnRewardsPool + .connect(owner) + .distributeRewardWithProof( + await x2EarnApps.hashAppName("My app"), + ethers.parseEther("1"), + owner.address, + ["image", "link"], + ["https://image.png"], + ["carbon", "water"], + [100, 200], + "The description of the action", + ), + ) + }) + + it("Anyone can index available impact codes", async function () { + const { x2EarnRewardsPool } = await getOrDeployContractInstances({ + forceDeploy: true, + bootstrapAndStartEmissions: true, + }) + + const impactCodes = await x2EarnRewardsPool.getAllowedImpactKeys() + + expect(impactCodes).to.eql([ + "carbon", + "water", + "energy", + "waste_mass", + "education_time", + "timber", + "plastic", + "trees_planted", + ]) + }) + + it("IMPACT_KEY_MANAGER_ROLE and DEFAULT_ADMIN can remove an impact code", async function () { + const { x2EarnRewardsPool, owner, otherAccount } = await getOrDeployContractInstances({ + forceDeploy: true, + bootstrapAndStartEmissions: true, + }) + + expect(await x2EarnRewardsPool.hasRole(await x2EarnRewardsPool.DEFAULT_ADMIN_ROLE(), owner.address)).to.equal( + true, + ) + + await x2EarnRewardsPool.connect(owner).removeImpactKey("carbon") + + const impactCodes = await x2EarnRewardsPool.getAllowedImpactKeys() + + expect(impactCodes).to.eql([ + "trees_planted", + "water", + "energy", + "waste_mass", + "education_time", + "timber", + "plastic", + ]) + + await x2EarnRewardsPool + .connect(owner) + .grantRole(await x2EarnRewardsPool.IMPACT_KEY_MANAGER_ROLE(), otherAccount.address) + + await x2EarnRewardsPool.connect(otherAccount).removeImpactKey("water") + + const impactCodes2 = await x2EarnRewardsPool.getAllowedImpactKeys() + + expect(impactCodes2).to.eql(["trees_planted", "plastic", "energy", "waste_mass", "education_time", "timber"]) + }) + + it("IMPACT_KEY_MANAGER_ROLE and DEFAULT_ADMIN can add an impact code", async function () { + const { x2EarnRewardsPool, owner, otherAccount } = await getOrDeployContractInstances({ + forceDeploy: true, + bootstrapAndStartEmissions: true, + }) + + expect(await x2EarnRewardsPool.hasRole(await x2EarnRewardsPool.DEFAULT_ADMIN_ROLE(), owner.address)).to.equal( + true, + ) + + await x2EarnRewardsPool.connect(owner).addImpactKey("new_impact") + + const impactCodes = await x2EarnRewardsPool.getAllowedImpactKeys() + + expect(impactCodes).to.eql([ + "carbon", + "water", + "energy", + "waste_mass", + "education_time", + "timber", + "plastic", + "trees_planted", + "new_impact", + ]) + + await x2EarnRewardsPool + .connect(owner) + .grantRole(await x2EarnRewardsPool.IMPACT_KEY_MANAGER_ROLE(), otherAccount.address) + + await x2EarnRewardsPool.connect(otherAccount).addImpactKey("new_impact_2") + + const impactCodes2 = await x2EarnRewardsPool.getAllowedImpactKeys() + + expect(impactCodes2).to.eql([ + "carbon", + "water", + "energy", + "waste_mass", + "education_time", + "timber", + "plastic", + "trees_planted", + "new_impact", + "new_impact_2", + ]) + }) + + it("Non admin users cannot add and remove impact codes", async function () { + const { x2EarnRewardsPool, otherAccounts } = await getOrDeployContractInstances({ + forceDeploy: true, + bootstrapAndStartEmissions: true, + }) + + expect( + await x2EarnRewardsPool.hasRole(await x2EarnRewardsPool.DEFAULT_ADMIN_ROLE(), otherAccounts[10].address), + ).to.equal(false) + expect( + await x2EarnRewardsPool.hasRole(await x2EarnRewardsPool.IMPACT_KEY_MANAGER_ROLE(), otherAccounts[10].address), + ).to.equal(false) + + await catchRevert(x2EarnRewardsPool.connect(otherAccounts[10]).addImpactKey("new_impact")) + await catchRevert(x2EarnRewardsPool.connect(otherAccounts[10]).removeImpactKey("carbon")) + }) + + it("Deprecated: can distribute rewards with a self-provided proof and impact", async function () { + const { x2EarnRewardsPool, x2EarnApps, b3tr, owner, otherAccounts, minterAccount } = + await getOrDeployContractInstances({ + forceDeploy: true, + bootstrapAndStartEmissions: true, + }) + + const teamWallet = otherAccounts[10] + const user = otherAccounts[11] + const amount = ethers.parseEther("100") + + await b3tr.connect(minterAccount).mint(owner.address, amount) + + await x2EarnApps.addApp(teamWallet.address, owner.address, "My app", "metadataURI") + const appId = await x2EarnApps.hashAppName("My app") + + await x2EarnApps.connect(owner).addRewardDistributor(appId, owner.address) + expect(await x2EarnApps.isRewardDistributor(appId, owner.address)).to.equal(true) + + // fill the pool + await b3tr.connect(owner).approve(await x2EarnRewardsPool.getAddress(), amount) + await x2EarnRewardsPool.connect(owner).deposit(amount, appId) + + const proof = { mycustomproof: "https://image.png" } + + const tx = await x2EarnRewardsPool + .connect(owner) + .distributeRewardDeprecated(appId, ethers.parseEther("1"), user.address, JSON.stringify(proof)) + + const receipt = await tx.wait() + + expect(await b3tr.balanceOf(user.address)).to.equal(ethers.parseEther("1")) + expect(await b3tr.balanceOf(await x2EarnRewardsPool.getAddress())).to.equal(ethers.parseEther("99")) + + // event emitted + if (!receipt) throw new Error("No receipt") + + let event = filterEventsByName(receipt.logs, "RewardDistributed") + + expect(event).not.to.eql([]) + expect(event[0].args[0]).to.equal(ethers.parseEther("1")) + expect(event[0].args[1]).to.equal(appId) + expect(event[0].args[2]).to.equal(user.address) + + const emittedProof = JSON.parse(event[0].args[3]) + + expect(emittedProof).to.have.property("mycustomproof") + expect(emittedProof.mycustomproof).to.equal("https://image.png") + }) + + it("I should be able to preview the proof and impact of a reward distribution", async function () { + const { x2EarnRewardsPool, x2EarnApps, b3tr, owner, otherAccounts, minterAccount } = + await getOrDeployContractInstances({ + forceDeploy: true, + bootstrapAndStartEmissions: true, + }) + + const teamWallet = otherAccounts[10] + const amount = ethers.parseEther("100") + + await b3tr.connect(minterAccount).mint(owner.address, amount) + + await x2EarnApps.addApp(teamWallet.address, owner.address, "My app", "metadataURI") + const appId = await x2EarnApps.hashAppName("My app") + + await x2EarnApps.connect(owner).addRewardDistributor(appId, owner.address) + expect(await x2EarnApps.isRewardDistributor(appId, owner.address)).to.equal(true) + + // fill the pool + await b3tr.connect(owner).approve(await x2EarnRewardsPool.getAddress(), amount) + await x2EarnRewardsPool.connect(owner).deposit(amount, appId) + + const onchainGeneratedProof = JSON.parse( + await x2EarnRewardsPool.buildProof( + ["image"], + ["https://image.png"], + ["carbon", "water"], + [100, 200], + "The description of the action", + ), + ) + + expect(onchainGeneratedProof).to.have.property("version") + expect(onchainGeneratedProof.version).to.equal(2) + expect(onchainGeneratedProof).to.have.deep.property("proof", { + image: "https://image.png", + }) + expect(onchainGeneratedProof).to.have.property("description") + expect(onchainGeneratedProof).to.have.deep.property("impact", { carbon: 100, water: 200 }) + }) + }) }) diff --git a/test/helpers/config.ts b/test/helpers/config.ts index cf4c546..91b4c0c 100644 --- a/test/helpers/config.ts +++ b/test/helpers/config.ts @@ -89,5 +89,17 @@ export function createTestConfig() { // Migration MIGRATION_ADDRESS: "0x865306084235Bf804c8Bba8a8d56890940ca8F0b", // 10th account from mnemonic of solo network MIGRATION_AMOUNT: BigInt("3750000000000000000000000"), // 3.75 million B3TR tokens from pilot show + + // X 2 Earn Rewards Pool + X_2_EARN_INITIAL_IMPACT_KEYS: [ + "carbon", + "water", + "energy", + "waste_mass", + "education_time", + "timber", + "plastic", + "trees_planted", + ], }) } diff --git a/test/helpers/deploy.ts b/test/helpers/deploy.ts index f1806f9..3bfa267 100644 --- a/test/helpers/deploy.ts +++ b/test/helpers/deploy.ts @@ -26,6 +26,7 @@ import { MyERC721, MyERC1155, VoterRewardsV1, + X2EarnRewardsPoolV1, } from "../../typechain-types" import { createLocalConfig } from "../../config/contracts/envs/local" import { deployProxy, upgradeProxy } from "../../scripts/helpers" @@ -216,14 +217,23 @@ export const getOrDeployContractInstances = async ({ owner.address, ])) as X2EarnApps - // Deploy X2EarnRewardsPool - const x2EarnRewardsPool = (await deployProxy("X2EarnRewardsPool", [ + const x2EarnRewardsPoolV1 = (await deployProxy("X2EarnRewardsPoolV1", [ owner.address, owner.address, owner.address, await b3tr.getAddress(), await x2EarnApps.getAddress(), - ])) as X2EarnRewardsPool + ])) as X2EarnRewardsPoolV1 + + const x2EarnRewardsPool = (await upgradeProxy( + "X2EarnRewardsPoolV1", + "X2EarnRewardsPool", + await x2EarnRewardsPoolV1.getAddress(), + [owner.address, config.X_2_EARN_INITIAL_IMPACT_KEYS], + { + version: 2, + }, + )) as X2EarnRewardsPool // Deploy XAllocationPool const xAllocationPool = (await deployProxy("XAllocationPool", [ @@ -454,4 +464,4 @@ export const getOrDeployContractInstances = async ({ myErc1155: myErc1155, } return cachedDeployInstance -} \ No newline at end of file +}