diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index ef8ff11..714d0a8 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -60,7 +60,7 @@ representative at an online or offline event. Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at -contact@angle.money. +. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the @@ -116,7 +116,7 @@ the community. This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at -https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. +. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). @@ -124,5 +124,5 @@ enforcement ladder](https://github.com/mozilla/diversity). [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see the FAQ at -https://www.contributor-covenant.org/faq. Translations are available at -https://www.contributor-covenant.org/translations. +. Translations are available at +. diff --git a/README.md b/README.md index a721612..ede2730 100644 --- a/README.md +++ b/README.md @@ -124,4 +124,4 @@ The Merkl smart contracts have been audited by Code4rena, find the audit report ## Media -Don't hesitate to reach out on [Twitter](https://x.com/merkl_xyz) +Don't hesitate to reach out on [Twitter](https://x.com/merkl_xyz) 🐦 diff --git a/contracts/DistributionCreator.sol b/contracts/DistributionCreator.sol index 53bb322..8c16f8a 100644 --- a/contracts/DistributionCreator.sol +++ b/contracts/DistributionCreator.sol @@ -47,15 +47,16 @@ import { Errors } from "./utils/Errors.sol"; import { CampaignParameters } from "./struct/CampaignParameters.sol"; import { DistributionParameters } from "./struct/DistributionParameters.sol"; import { RewardTokenAmounts } from "./struct/RewardTokenAmounts.sol"; +import { Distributor } from "./Distributor.sol"; /// @title DistributionCreator /// @author Angle Labs, Inc. /// @notice Manages the distribution of rewards through the Merkl system /// @dev This contract is mostly a helper for APIs built on top of Merkl -/// @dev This contract is an upgraded version and distinguishes two types of different rewards: +/// @dev This contract distinguishes two types of different rewards: /// - distributions: type of campaign for concentrated liquidity pools created before Feb 15 2024, /// now deprecated -/// - campaigns: the new more global name to describe any reward program on top of Merkl +/// - campaigns: the more global name to describe any reward program on top of Merkl //solhint-disable contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { using SafeERC20 for IERC20; @@ -127,6 +128,18 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { /// @notice Maps a campaign type to the fees for this specific campaign mapping(uint32 => uint256) public campaignSpecificFees; + /// @notice Maps a campaignId to a potential override written + mapping(bytes32 => CampaignParameters) public campaignOverrides; + + /// @notice Maps a campaignId to the block numbers at which it's been updated + mapping(bytes32 => uint256[]) public campaignOverridesTimestamp; + + /// @notice Maps one address to another one to reallocate rewards for a given campaign + mapping(bytes32 => mapping(address => address)) public campaignReallocation; + + /// @notice List all reallocated address for a given campaign + mapping(bytes32 => address[]) public campaignListReallocation; + /*////////////////////////////////////////////////////////////////////////////////////////////////////////////////// EVENTS //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ @@ -135,6 +148,8 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { event FeeRebateUpdated(address indexed user, uint256 userFeeRebate); event FeeRecipientUpdated(address indexed _feeRecipient); event FeesSet(uint256 _fees); + event CampaignOverride(bytes32 _campaignId, CampaignParameters campaign); + event CampaignReallocation(bytes32 _campaignId, address[] indexed from, address indexed to); event CampaignSpecificFeesSet(uint32 campaignType, uint256 _fees); event MessageUpdated(bytes32 _messageHash); event NewCampaign(CampaignParameters campaign); @@ -267,6 +282,64 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { return distributionAmounts; } + /// @notice Overrides a campaign with new parameters + /// @dev Some overrides maybe incorrect, but their correctness cannot be checked onchain. It is up to the Merkl + /// engine to check the validity of the override. If the override is invalid, then the first campaign details + /// will still apply. + /// @dev Some fields in the new campaign parameters will be disregarded anyway (like the amount) + function overrideCampaign(bytes32 _campaignId, CampaignParameters memory newCampaign) external { + CampaignParameters memory _campaign = campaign(_campaignId); + if ( + _campaign.creator != msg.sender || + newCampaign.rewardToken != _campaign.rewardToken || + newCampaign.amount != _campaign.amount || + (newCampaign.startTimestamp != _campaign.startTimestamp && block.timestamp > _campaign.startTimestamp) || // Allow to update startTimestamp before campaign start + // End timestamp should be in the future + newCampaign.duration + _campaign.startTimestamp <= block.timestamp + ) revert Errors.InvalidOverride(); + + // Take a new fee to not trick the system by creating a campaign with the smallest fee + // and then overriding it with a campaign with a bigger fee + _computeFees(newCampaign.campaignType, newCampaign.amount, newCampaign.rewardToken); + + newCampaign.campaignId = _campaignId; + newCampaign.creator = msg.sender; + campaignOverrides[_campaignId] = newCampaign; + campaignOverridesTimestamp[_campaignId].push(block.timestamp); + emit CampaignOverride(_campaignId, newCampaign); + } + + /// @notice Reallocates rewards of a given campaign from one address to another + /// @dev To prevent manipulations by campaign creators, this function can only be called by the + /// initial campaign creator if the `from` address has never claimed any reward on the chain + /// @dev Compute engine should also make sure when reallocating rewards that `from` claimed amount + /// is still 0 - otherwise double allocation can happen + /// @dev It is meant to be used for the case of addresses accruing rewards but unable to claim them + function reallocateCampaignRewards(bytes32 _campaignId, address[] memory froms, address to) external { + CampaignParameters memory _campaign = campaign(_campaignId); + if (_campaign.creator != msg.sender || block.timestamp < _campaign.startTimestamp + _campaign.duration) + revert Errors.InvalidOverride(); + + uint256 fromsLength = froms.length; + address[] memory successfullFrom = new address[](fromsLength); + uint256 count = 0; + for (uint256 i; i < fromsLength; i++) { + (uint208 amount, uint48 timestamp, ) = Distributor(distributor).claimed(froms[i], _campaign.rewardToken); + if (amount == 0 && timestamp == 0) { + successfullFrom[count] = froms[i]; + campaignReallocation[_campaignId][froms[i]] = to; + campaignListReallocation[_campaignId].push(froms[i]); + count++; + } + } + assembly { + mstore(successfullFrom, count) + } + + if (count == 0) revert Errors.InvalidOverride(); + emit CampaignReallocation(_campaignId, successfullFrom, to); + } + /*////////////////////////////////////////////////////////////////////////////////////////////////////////////////// GETTERS //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ @@ -284,12 +357,14 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { } /// @notice Returns the campaign parameters of a given campaignId - function campaign(bytes32 _campaignId) external view returns (CampaignParameters memory) { + /// @dev If a campaign has been overriden, this function still shows the original state of the campaign + function campaign(bytes32 _campaignId) public view returns (CampaignParameters memory) { return campaignList[campaignLookup(_campaignId)]; } /// @notice Returns the campaign ID for a given campaign /// @dev The campaign ID is computed as the hash of the following parameters: + /// - `campaign.chainId` /// - `campaign.creator` /// - `campaign.rewardToken` /// - `campaign.campaignType` @@ -346,20 +421,12 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { return _getCampaignsBetween(start, end, skip, first); } - /// @notice Gets all the distributions which were live at some point between `start` and `end` timestamp - /// @param skip Disregard distibutions with a global index lower than `skip` - /// @param first Limit the length of the returned array to `first` - /// @return searchDistributions Eligible distributions - /// @return lastIndexDistribution Index of the last distribution assessed in the list of all distributions - /// @dev For pagniation purpose, in case of out of gas, you can call back the same function but with `skip` set to `lastIndexDistribution` - /// @dev Not to be queried on-chain and hence not optimized for gas consumption - function getDistributionsBetweenEpochs( - uint32 epochStart, - uint32 epochEnd, - uint32 skip, - uint32 first - ) external view returns (DistributionParameters[] memory, uint256 lastIndexDistribution) { - return _getDistributionsBetweenEpochs(_getRoundedEpoch(epochStart), _getRoundedEpoch(epochEnd), skip, first); + function getCampaignOverridesTimestamp(bytes32 _campaignId) external view returns (uint256[] memory) { + return campaignOverridesTimestamp[_campaignId]; + } + + function getCampaignListReallocation(bytes32 _campaignId) external view returns (address[] memory) { + return campaignListReallocation[_campaignId]; } /*////////////////////////////////////////////////////////////////////////////////////////////////////////////////// @@ -380,6 +447,31 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { emit FeesSet(_defaultFees); } + /// @notice Recovers fees accrued on the contract for a list of `tokens` + function recoverFees(IERC20[] calldata tokens, address to) external onlyGovernor { + uint256 tokensLength = tokens.length; + for (uint256 i; i < tokensLength; ) { + tokens[i].safeTransfer(to, tokens[i].balanceOf(address(this))); + unchecked { + ++i; + } + } + } + + /// @notice Sets a new address to receive fees + function setFeeRecipient(address _feeRecipient) external onlyGovernor { + feeRecipient = _feeRecipient; + emit FeeRecipientUpdated(_feeRecipient); + } + + /// @notice Sets the message that needs to be signed by users before posting rewards + function setMessage(string memory _message) external onlyGovernor { + message = _message; + bytes32 _messageHash = ECDSA.toEthSignedMessageHash(bytes(_message)); + messageHash = _messageHash; + emit MessageUpdated(_messageHash); + } + /// @notice Sets the fees specific for a campaign /// @dev To waive the fees for a campaign, set its fees to 1 function setCampaignFees(uint32 campaignType, uint256 _fees) external onlyGovernorOrGuardian { @@ -395,17 +487,6 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { emit TokenWhitelistToggled(token, toggleStatus); } - /// @notice Recovers fees accrued on the contract for a list of `tokens` - function recoverFees(IERC20[] calldata tokens, address to) external onlyGovernor { - uint256 tokensLength = tokens.length; - for (uint256 i; i < tokensLength; ) { - tokens[i].safeTransfer(to, tokens[i].balanceOf(address(this))); - unchecked { - ++i; - } - } - } - /// @notice Sets fee rebates for a given user function setUserFeeRebate(address user, uint256 userFeeRebate) external onlyGovernorOrGuardian { feeRebate[user] = userFeeRebate; @@ -423,26 +504,12 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { uint256 amount = amounts[i]; // Basic logic check to make sure there are no duplicates in the `rewardTokens` table. If a token is // removed then re-added, it will appear as a duplicate in the list - if (amount > 0 && rewardTokenMinAmounts[tokens[i]] == 0) rewardTokens.push(tokens[i]); + if (amount != 0 && rewardTokenMinAmounts[tokens[i]] == 0) rewardTokens.push(tokens[i]); rewardTokenMinAmounts[tokens[i]] = amount; emit RewardTokenMinimumAmountUpdated(tokens[i], amount); } } - /// @notice Sets a new address to receive fees - function setFeeRecipient(address _feeRecipient) external onlyGovernor { - feeRecipient = _feeRecipient; - emit FeeRecipientUpdated(_feeRecipient); - } - - /// @notice Sets the message that needs to be signed by users before posting rewards - function setMessage(string memory _message) external onlyGovernor { - message = _message; - bytes32 _messageHash = ECDSA.toEthSignedMessageHash(bytes(_message)); - messageHash = _messageHash; - emit MessageUpdated(_messageHash); - } - /// @notice Toggles the whitelist status for `user` when it comes to signing messages before depositing rewards. function toggleSigningWhitelist(address user) external onlyGovernorOrGuardian { uint256 whitelistStatus = 1 - userSignatureWhitelist[user]; @@ -470,12 +537,13 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { if (newCampaign.creator == address(0)) newCampaign.creator = msg.sender; // Computing fees: these are waived for whitelisted addresses and if there is a whitelisted token in a pool - uint256 _fees = campaignSpecificFees[newCampaign.campaignType]; - if (_fees == 1) _fees = 0; - else if (_fees == 0) _fees = defaultFees; - uint256 campaignAmountMinusFees = _computeFees(_fees, newCampaign.amount, newCampaign.rewardToken); + uint256 campaignAmountMinusFees = _computeFees( + newCampaign.campaignType, + newCampaign.amount, + newCampaign.rewardToken + ); + IERC20(newCampaign.rewardToken).safeTransferFrom(msg.sender, distributor, campaignAmountMinusFees); newCampaign.amount = campaignAmountMinusFees; - newCampaign.campaignId = campaignId(newCampaign); if (_campaignLookup[newCampaign.campaignId] != 0) revert Errors.CampaignAlreadyExists(); @@ -544,10 +612,14 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { /// @notice Computes the fees to be taken on a campaign and transfers them to the fee recipient function _computeFees( - uint256 baseFeesValue, + uint32 campaignType, uint256 distributionAmount, address rewardToken ) internal returns (uint256 distributionAmountMinusFees) { + uint256 baseFeesValue = campaignSpecificFees[campaignType]; + if (baseFeesValue == 1) baseFeesValue = 0; + else if (baseFeesValue == 0) baseFeesValue = defaultFees; + uint256 _fees = (baseFeesValue * (BASE_9 - feeRebate[msg.sender])) / BASE_9; distributionAmountMinusFees = distributionAmount; if (_fees != 0) { @@ -560,7 +632,6 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { distributionAmount - distributionAmountMinusFees ); } - IERC20(rewardToken).safeTransferFrom(msg.sender, distributor, distributionAmountMinusFees); } /// @notice Internal version of the `sign` function @@ -609,35 +680,6 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { return (activeRewards, i); } - /// @notice Internal version of `getDistributionsBetweenEpochs` - function _getDistributionsBetweenEpochs( - uint32 epochStart, - uint32 epochEnd, - uint32 skip, - uint32 first - ) internal view returns (DistributionParameters[] memory, uint256) { - uint256 length; - uint256 distributionListLength = distributionList.length; - uint256 returnSize = first > distributionListLength ? distributionListLength : first; - DistributionParameters[] memory activeRewards = new DistributionParameters[](returnSize); - uint32 i = skip; - while (i < distributionListLength) { - DistributionParameters memory d = distributionList[i]; - if (d.epochStart + d.numEpoch * HOUR > epochStart && d.epochStart < epochEnd) { - activeRewards[length] = d; - length += 1; - } - unchecked { - ++i; - } - if (length == returnSize) break; - } - assembly { - mstore(activeRewards, length) - } - return (activeRewards, i); - } - /// @notice Builds the list of valid reward tokens function _getValidRewardTokens( uint32 skip, @@ -671,5 +713,5 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { * variables without shifting down storage in the inheritance chain. * See https://docs.openzeppelin.com/contracts/4.x/upgradeable#storage_gaps */ - uint256[33] private __gap; + uint256[31] private __gap; } diff --git a/contracts/Distributor.sol b/contracts/Distributor.sol index f7d2b76..8423962 100644 --- a/contracts/Distributor.sol +++ b/contracts/Distributor.sol @@ -59,16 +59,26 @@ struct Claim { bytes32 merkleRoot; } +interface IClaimRecipient { + /// @notice Hook to call within contracts receiving token rewards on behalf of users + function onClaim(address user, address token, uint256 amount, bytes memory data) external returns (bytes32); +} + /// @title Distributor /// @notice Allows to claim rewards distributed to them through Merkl /// @author Angle Labs. Inc contract Distributor is UUPSHelper { using SafeERC20 for IERC20; - /// @notice Epoch duration + /// @notice Default epoch duration uint32 internal constant _EPOCH_DURATION = 3600; - // ================================= VARIABLES ================================= + /// @notice Success message received when calling a `ClaimRecipient` contract + bytes32 public constant CALLBACK_SUCCESS = keccak256("IClaimRecipient.onClaim"); + + /*////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + VARIABLES + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ /// @notice Tree of claimable tokens through this contract MerkleTree public tree; @@ -82,11 +92,11 @@ contract Distributor is UUPSHelper { /// @notice `AccessControlManager` contract handling access control IAccessControlManager public accessControlManager; - /// @notice Address which created the dispute + /// @notice Address which created the last dispute /// @dev Used to store if there is an ongoing dispute address public disputer; - /// @notice When the current tree will become valid + /// @notice When the current tree becomes valid uint48 public endOfDisputePeriod; /// @notice Time after which a change in a tree becomes effective, in EPOCH_DURATION @@ -101,38 +111,52 @@ contract Distributor is UUPSHelper { /// @notice Trusted EOAs to update the Merkle root mapping(address => uint256) public canUpdateMerkleRoot; - /// @notice Whether or not to disable permissionless claiming + /// @notice Deprecated mapping mapping(address => uint256) public onlyOperatorCanClaim; - /// @notice user -> operator -> authorisation to claim + /// @notice User -> Operator -> authorisation to claim on behalf of the user mapping(address => mapping(address => uint256)) public operators; - uint256[38] private __gap; + /// @notice Whether the contract has been made non upgradeable or not + uint128 public upgradeabilityDeactivated; + + /// @notice Reentrancy status + uint96 private _status; + + /// @notice Epoch duration for dispute periods (in seconds) + uint32 internal _epochDuration; - // =================================== EVENTS ================================== + /// @notice user -> token -> recipient address for when user claims `token` + /// @dev If the mapping is empty, by default rewards will accrue on the user address + mapping(address => mapping(address => address)) public claimRecipient; + + uint256[36] private __gap; + + /*////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + EVENTS + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ event Claimed(address indexed user, address indexed token, uint256 amount); + event ClaimRecipientUpdated(address indexed user, address indexed token, address indexed recipient); event DisputeAmountUpdated(uint256 _disputeAmount); event Disputed(string reason); event DisputePeriodUpdated(uint48 _disputePeriod); event DisputeResolved(bool valid); event DisputeTokenUpdated(address indexed _disputeToken); + event EpochDurationUpdated(uint32 newEpochDuration); event OperatorClaimingToggled(address indexed user, bool isEnabled); event OperatorToggled(address indexed user, address indexed operator, bool isWhitelisted); event Recovered(address indexed token, address indexed to, uint256 amount); event Revoked(); // With this event an indexer could maintain a table (timestamp, merkleRootUpdate) event TreeUpdated(bytes32 merkleRoot, bytes32 ipfsHash, uint48 endOfDisputePeriod); event TrustedToggled(address indexed eoa, bool trust); + event UpgradeabilityRevoked(); - // ================================= MODIFIERS ================================= + /*////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + MODIFIERS + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ - /// @notice Checks whether the `msg.sender` has the governor role or the guardian role - modifier onlyGovernorOrGuardian() { - if (!accessControlManager.isGovernorOrGuardian(msg.sender)) revert Errors.NotGovernorOrGuardian(); - _; - } - - /// @notice Checks whether the `msg.sender` has the governor role or the guardian role + /// @notice Checks whether the `msg.sender` has the governor role modifier onlyGovernor() { if (!accessControlManager.isGovernor(msg.sender)) revert Errors.NotGovernor(); _; @@ -148,7 +172,30 @@ contract Distributor is UUPSHelper { _; } - // ================================ CONSTRUCTOR ================================ + /// @notice Checks whether the contract is upgradeable or whether the caller is allowed to upgrade the contract + modifier onlyUpgradeableInstance() { + if (upgradeabilityDeactivated == 1) revert Errors.NotUpgradeable(); + else if (!accessControlManager.isGovernor(msg.sender)) revert Errors.NotGovernor(); + _; + } + + /// @notice Checks whether a call is reentrant or not + modifier nonReentrant() { + if (_status == 2) revert Errors.ReentrantCall(); + + // Any calls to nonReentrant after this point will fail + _status = 2; + + _; + + // By storing the original value once again, a refund is triggered (see + // https://eips.ethereum.org/EIPS/eip-2200) + _status = 1; + } + + /*////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + CONSTRUCTOR + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ constructor() initializer {} @@ -158,15 +205,16 @@ contract Distributor is UUPSHelper { } /// @inheritdoc UUPSHelper - function _authorizeUpgrade(address) internal view override onlyGovernorUpgrader(accessControlManager) {} + function _authorizeUpgrade(address) internal view override onlyUpgradeableInstance {} - // =============================== MAIN FUNCTION =============================== + /*////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + MAIN FUNCTIONS + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ /// @notice Claims rewards for a given set of users - /// @dev Anyone may call this function for anyone else, funds go to destination regardless, it's just a question of - /// who provides the proof and pays the gas: `msg.sender` is used only for addresses that require a trusted operator - /// @param users Recipient of tokens - /// @param tokens ERC20 claimed + /// @dev Unless another address has been approved for claiming, only an address can claim for itself + /// @param users Addresses for which claiming is taking place + /// @param tokens ERC20 token claimed /// @param amounts Amount of tokens that will be sent to the corresponding users /// @param proofs Array of hashes bridging from a leaf `(hash of user | token | amount)` to the Merkle root function claim( @@ -175,55 +223,76 @@ contract Distributor is UUPSHelper { uint256[] calldata amounts, bytes32[][] calldata proofs ) external { - uint256 usersLength = users.length; - if ( - usersLength == 0 || - usersLength != tokens.length || - usersLength != amounts.length || - usersLength != proofs.length - ) revert Errors.InvalidLengths(); - - for (uint256 i; i < usersLength; ) { - address user = users[i]; - address token = tokens[i]; - uint256 amount = amounts[i]; - - // Only approved operator can claim for `user` - if (msg.sender != user && tx.origin != user && operators[user][msg.sender] == 0) - revert Errors.NotWhitelisted(); - - // Verifying proof - bytes32 leaf = keccak256(abi.encode(user, token, amount)); - if (!_verifyProof(leaf, proofs[i])) revert Errors.InvalidProof(); - - // Closing reentrancy gate here - uint256 toSend = amount - claimed[user][token].amount; - claimed[user][token] = Claim(SafeCast.toUint208(amount), uint48(block.timestamp), getMerkleRoot()); + address[] memory recipients = new address[](users.length); + bytes[] memory datas = new bytes[](users.length); + _claim(users, tokens, amounts, proofs, recipients, datas); + } - IERC20(token).safeTransfer(user, toSend); - emit Claimed(user, token, toSend); - unchecked { - ++i; - } - } + /// @notice Same as the function above except that for each token claimed, the caller may set different + /// recipients for rewards and pass arbitrary data to the reward recipient on claim + /// @dev Only a `msg.sender` calling for itself can set a different recipient for the token rewards + /// within the context of a call to claim + /// @dev Non-zero recipient addresses given by the `msg.sender` can override any previously set reward address + function claimWithRecipient( + address[] calldata users, + address[] calldata tokens, + uint256[] calldata amounts, + bytes32[][] calldata proofs, + address[] calldata recipients, + bytes[] memory datas + ) external { + _claim(users, tokens, amounts, proofs, recipients, datas); } - /// @notice Returns the MerkleRoot that is currently live for the contract + /// @notice Returns the Merkle root that is currently live for the contract function getMerkleRoot() public view returns (bytes32) { if (block.timestamp >= endOfDisputePeriod && disputer == address(0)) return tree.merkleRoot; else return lastTree.merkleRoot; } - // ============================ GOVERNANCE FUNCTIONS =========================== + function getEpochDuration() public view returns (uint32 epochDuration) { + epochDuration = _epochDuration; + if (epochDuration == 0) epochDuration = _EPOCH_DURATION; + } + + /*////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + USER ADMIN FUNCTIONS + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ + + /// @notice Toggles whitelisting for a given user and a given operator + /// @dev When an operator is whitelisted for a user, the operator can claim rewards on behalf of the user + function toggleOperator(address user, address operator) external onlyTrustedOrUser(user) { + uint256 oldValue = operators[user][operator]; + operators[user][operator] = 1 - oldValue; + emit OperatorToggled(user, operator, oldValue == 0); + } + + /// @notice Sets a recipient for a user claiming rewards for a token + /// @dev This is an optional functionality and if the `recipient` is set to the zero address, then + /// the user will still accrue all rewards to its address + /// @dev Users may still specify a different recipient when they claim token rewards with the + /// `claimWithRecipient` function + function setClaimRecipient(address recipient, address token) external { + claimRecipient[msg.sender][token] = recipient; + emit ClaimRecipientUpdated(msg.sender, recipient, token); + } - /// @notice Adds or removes EOAs which are trusted to update the Merkle root - function toggleTrusted(address eoa) external onlyGovernor { - uint256 trustedStatus = 1 - canUpdateMerkleRoot[eoa]; - canUpdateMerkleRoot[eoa] = trustedStatus; - emit TrustedToggled(eoa, trustedStatus == 1); + /// @notice Freezes the Merkle tree update until the dispute is resolved + /// @dev Requires a deposit of `disputeToken` that'll be slashed if the dispute is not accepted + /// @dev It is only possible to create a dispute within `disputePeriod` after each tree update + function disputeTree(string memory reason) external { + if (disputer != address(0)) revert Errors.UnresolvedDispute(); + if (block.timestamp >= endOfDisputePeriod) revert Errors.InvalidDispute(); + IERC20(disputeToken).safeTransferFrom(msg.sender, address(this), disputeAmount); + disputer = msg.sender; + emit Disputed(reason); } - /// @notice Updates Merkle Tree + /*////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + GOVERNANCE FUNCTIONS + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ + + /// @notice Updates the Merkle tree function updateTree(MerkleTree calldata _tree) external { if ( disputer != address(0) || @@ -241,15 +310,23 @@ contract Distributor is UUPSHelper { emit TreeUpdated(_tree.merkleRoot, _tree.ipfsHash, _endOfPeriod); } - /// @notice Freezes the Merkle tree update until the dispute is resolved - /// @dev Requires a deposit of `disputeToken` that'll be slashed if the dispute is not accepted - /// @dev It is only possible to create a dispute within `disputePeriod` after each tree update - function disputeTree(string memory reason) external { - if (disputer != address(0)) revert Errors.UnresolvedDispute(); - if (block.timestamp >= endOfDisputePeriod) revert Errors.InvalidDispute(); - IERC20(disputeToken).safeTransferFrom(msg.sender, address(this), disputeAmount); - disputer = msg.sender; - emit Disputed(reason); + /// @notice Adds or removes addresses which are trusted to update the Merkle root + function toggleTrusted(address trustAddress) external onlyGovernor { + uint256 trustedStatus = 1 - canUpdateMerkleRoot[trustAddress]; + canUpdateMerkleRoot[trustAddress] = trustedStatus; + emit TrustedToggled(trustAddress, trustedStatus == 1); + } + + /// @notice Prevents future contract upgrades + function revokeUpgradeability() external onlyGovernor { + upgradeabilityDeactivated = 1; + emit UpgradeabilityRevoked(); + } + + /// @notice Updates the epoch duration period + function setEpochDuration(uint32 epochDuration) external onlyGovernor { + _epochDuration = epochDuration; + emit EpochDurationUpdated(epochDuration); } /// @notice Resolve the ongoing dispute, if any @@ -275,22 +352,7 @@ contract Distributor is UUPSHelper { _revokeTree(); } - /// @notice Toggles permissioned claiming for a given user - /// @dev deprecated - function toggleOnlyOperatorCanClaim(address user) external onlyTrustedOrUser(user) { - uint256 oldValue = onlyOperatorCanClaim[user]; - onlyOperatorCanClaim[user] = 1 - oldValue; - emit OperatorClaimingToggled(user, oldValue == 0); - } - - /// @notice Toggles whitelisting for a given user and a given operator - function toggleOperator(address user, address operator) external onlyTrustedOrUser(user) { - uint256 oldValue = operators[user][operator]; - operators[user][operator] = 1 - oldValue; - emit OperatorToggled(user, operator, oldValue == 0); - } - - /// @notice Recovers any ERC20 token + /// @notice Recovers any ERC20 token left on the contract function recoverERC20(address tokenAddress, address to, uint256 amountToRecover) external onlyGovernor { IERC20(tokenAddress).safeTransfer(to, amountToRecover); emit Recovered(tokenAddress, to, amountToRecover); @@ -316,25 +378,92 @@ contract Distributor is UUPSHelper { emit DisputeAmountUpdated(_disputeAmount); } - // ============================= INTERNAL FUNCTIONS ============================ + /*////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + INTERNAL HELPERS + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ + + /// @notice Internal version of `claimWithRecipient` + function _claim( + address[] calldata users, + address[] calldata tokens, + uint256[] calldata amounts, + bytes32[][] calldata proofs, + address[] memory recipients, + bytes[] memory datas + ) internal nonReentrant { + uint256 usersLength = users.length; + if ( + usersLength == 0 || + usersLength != tokens.length || + usersLength != amounts.length || + usersLength != proofs.length || + usersLength != recipients.length || + usersLength != datas.length + ) revert Errors.InvalidLengths(); + + for (uint256 i; i < usersLength; ) { + address user = users[i]; + address token = tokens[i]; + uint256 amount = amounts[i]; + bytes memory data = datas[i]; + + // Only approved operator can claim for `user` + if (msg.sender != user && tx.origin != user && operators[user][msg.sender] == 0) + revert Errors.NotWhitelisted(); + + // Verifying proof + bytes32 leaf = keccak256(abi.encode(user, token, amount)); + if (!_verifyProof(leaf, proofs[i])) revert Errors.InvalidProof(); + + // Closing reentrancy gate here + uint256 toSend = amount - claimed[user][token].amount; + claimed[user][token] = Claim(SafeCast.toUint208(amount), uint48(block.timestamp), getMerkleRoot()); + emit Claimed(user, token, toSend); + + address recipient = recipients[i]; + // Only `msg.sender` can set a different recipient for itself within the context of a call to claim + // The recipient set in the context of the call to `claim` can override the default recipient set by the user + if (msg.sender != user || recipient == address(0)) { + address userSetRecipient = claimRecipient[user][token]; + if (userSetRecipient == address(0)) recipient = user; + else recipient = userSetRecipient; + } + + if (toSend != 0) { + IERC20(token).safeTransfer(recipient, toSend); + if (data.length != 0) { + try IClaimRecipient(recipient).onClaim(user, token, amount, data) returns ( + bytes32 callbackSuccess + ) { + if (callbackSuccess != CALLBACK_SUCCESS) revert Errors.InvalidReturnMessage(); + } catch {} + } + } + unchecked { + ++i; + } + } + } /// @notice Fallback to the last version of the tree function _revokeTree() internal { MerkleTree memory _tree = lastTree; endOfDisputePeriod = 0; tree = _tree; + uint32 epochDuration = getEpochDuration(); emit Revoked(); emit TreeUpdated( _tree.merkleRoot, _tree.ipfsHash, - (uint48(block.timestamp) / _EPOCH_DURATION) * (_EPOCH_DURATION) // Last hour + (uint48(block.timestamp) / epochDuration) * (epochDuration) // Last hour ); } /// @notice Returns the end of the dispute period /// @dev treeUpdate is rounded up to next hour and then `disputePeriod` hours are added function _endOfDisputePeriod(uint48 treeUpdate) internal view returns (uint48) { - return ((treeUpdate - 1) / _EPOCH_DURATION + 1 + disputePeriod) * (_EPOCH_DURATION); + uint32 epochDuration = getEpochDuration(); + return ((treeUpdate - 1) / epochDuration + 1 + disputePeriod) * (epochDuration); } /// @notice Checks the validity of a proof diff --git a/contracts/interfaces/IAccessControlManager.sol b/contracts/interfaces/IAccessControlManager.sol index 0dec0b0..ef1ac3d 100644 --- a/contracts/interfaces/IAccessControlManager.sol +++ b/contracts/interfaces/IAccessControlManager.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.17; /// @title IAccessControlManager /// @author Angle Labs, Inc. -/// @notice Interface for the `IAccessControlManager` contracts used in Merkl contracts +/// @notice Interface for the `AccessControlManager` contracts of Merkl contracts interface IAccessControlManager { /// @notice Checks whether an address is governor /// @param admin Address to check diff --git a/contracts/partners/tokenWrappers/BaseTokenWrapper.sol b/contracts/partners/tokenWrappers/BaseTokenWrapper.sol index 3db9ceb..6e4b829 100644 --- a/contracts/partners/tokenWrappers/BaseTokenWrapper.sol +++ b/contracts/partners/tokenWrappers/BaseTokenWrapper.sol @@ -12,6 +12,7 @@ import { Errors } from "../../utils/Errors.sol"; interface IDistributionCreator { function distributor() external view returns (address); + function feeRecipient() external view returns (address); } diff --git a/contracts/struct/CampaignParameters.sol b/contracts/struct/CampaignParameters.sol index 4f75f4e..b5164ad 100644 --- a/contracts/struct/CampaignParameters.sol +++ b/contracts/struct/CampaignParameters.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: BUSL-1.1 -pragma solidity ^0.8.17; +pragma solidity >=0.8.0; struct CampaignParameters { // POPULATED ONCE CREATED diff --git a/contracts/struct/DistributionParameters.sol b/contracts/struct/DistributionParameters.sol index 8efdf8f..20e0621 100644 --- a/contracts/struct/DistributionParameters.sol +++ b/contracts/struct/DistributionParameters.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: BUSL-1.1 -pragma solidity ^0.8.17; +pragma solidity >=0.8.0; struct DistributionParameters { // ID of the reward (populated once created). This can be left as a null bytes32 when creating distributions diff --git a/contracts/struct/ExtensiveDistributionParameters.sol b/contracts/struct/ExtensiveDistributionParameters.sol deleted file mode 100644 index 582e762..0000000 --- a/contracts/struct/ExtensiveDistributionParameters.sol +++ /dev/null @@ -1,23 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 - -pragma solidity ^0.8.17; - -import { DistributionParameters } from "./DistributionParameters.sol"; - -struct UniswapTokenData { - address add; - uint8 decimals; - string symbol; - uint256 poolBalance; -} - -struct ExtensiveDistributionParameters { - DistributionParameters base; - // Uniswap pool data - uint24 poolFee; - UniswapTokenData token0; - UniswapTokenData token1; - // rewardToken data - string rewardTokenSymbol; - uint8 rewardTokenDecimals; -} diff --git a/contracts/struct/RewardTokenAmounts.sol b/contracts/struct/RewardTokenAmounts.sol index 1a1b7e4..b00443a 100644 --- a/contracts/struct/RewardTokenAmounts.sol +++ b/contracts/struct/RewardTokenAmounts.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: BUSL-1.1 -pragma solidity ^0.8.17; +pragma solidity >=0.8.0; struct RewardTokenAmounts { address token; diff --git a/contracts/utils/Errors.sol b/contracts/utils/Errors.sol index 6f4883f..19c3e1c 100644 --- a/contracts/utils/Errors.sol +++ b/contracts/utils/Errors.sol @@ -11,23 +11,28 @@ library Errors { error CampaignShouldStartInFuture(); error InvalidDispute(); error InvalidLengths(); + error InvalidOverride(); error InvalidParam(); error InvalidParams(); error InvalidProof(); error InvalidUninitializedRoot(); + error InvalidReturnMessage(); error InvalidReward(); error InvalidSignature(); error NoDispute(); + error NoOverrideForCampaign(); error NotAllowed(); error NotGovernor(); error NotGovernorOrGuardian(); error NotSigned(); error NotTrusted(); + error NotUpgradeable(); error NotWhitelisted(); error UnresolvedDispute(); error ZeroAddress(); error DisputeFundsTransferFailed(); error EthNotAccepted(); + error ReentrantCall(); error WithdrawalFailed(); error InvalidClaim(); } diff --git a/foundry.toml b/foundry.toml index 52d236a..a7c4397 100644 --- a/foundry.toml +++ b/foundry.toml @@ -7,7 +7,7 @@ libs = ['node_modules'] script = 'scripts' cache_path = 'cache-forge' gas_reports = ["*"] -optimizer_runs=100 +optimizer_runs = 100 fs_permissions = [{ access = "read", path = "./node_modules/@angleprotocol/sdk/dist/src/registry/registry.json" }] solc = "0.8.24" diff --git a/scripts/DistributionCreator.s.sol b/scripts/DistributionCreator.s.sol index aa46fd1..309b897 100644 --- a/scripts/DistributionCreator.s.sol +++ b/scripts/DistributionCreator.s.sol @@ -10,6 +10,7 @@ import { JsonReader } from "./utils/JsonReader.sol"; import { DistributionCreator } from "../contracts/DistributionCreator.sol"; import { IAccessControlManager } from "../contracts/interfaces/IAccessControlManager.sol"; import { CampaignParameters } from "../contracts/struct/CampaignParameters.sol"; +import { MockToken } from "../contracts/mock/MockToken.sol"; // Base contract with shared utilities contract DistributionCreatorScript is BaseScript, JsonReader { @@ -419,6 +420,61 @@ contract CreateCampaigns is DistributionCreatorScript { } } +// CreateCampaign script +contract CreateCampaignTest is DistributionCreatorScript { + function run() external { + vm.createSelectFork(vm.envString("BASE_NODE_URI")); + uint256 chainId = block.chainid; + + /// TODO: COMPLETE + IERC20 rewardToken = IERC20(0xC011882d0f7672D8942e7fE2248C174eeD640c8f); + uint256 amount = 100 ether; + /// END + + address creatorAddress = readAddress(chainId, "Merkl.DistributionCreator"); + DistributionCreator distributionCreator = DistributionCreator(creatorAddress); + + vm.startBroadcast(broadcaster); + + MockToken(address(rewardToken)).mint(broadcaster, amount); + rewardToken.approve(address(distributionCreator), amount); + + uint32 startTimestamp = uint32(block.timestamp + 600); + + bytes32 campaignId = distributionCreator.createCampaign( + CampaignParameters({ + campaignId: bytes32(0), + creator: broadcaster, + rewardToken: address(rewardToken), + amount: amount, + campaignType: 1, + startTimestamp: startTimestamp, + duration: 3600 * 24, + campaignData: abi.encode( + 0xbEEfa1aBfEbE621DF50ceaEF9f54FdB73648c92C, + new address[](0), + new address[](0), + "", + new bytes[](0), + new bytes[](0), + hex"" + ) + }) + ); + vm.stopBroadcast(); + + CampaignParameters memory campaign = distributionCreator.campaign(campaignId); + require(campaign.creator == broadcaster, "Invalid creator"); + require(campaign.rewardToken == address(rewardToken), "Invalid reward token"); + require(campaign.amount == (amount * (1e9 - distributionCreator.defaultFees())) / 1e9, "Invalid amount"); + require(campaign.campaignType == 1, "Invalid campaign type"); + require(campaign.startTimestamp == startTimestamp, "Invalid start timestamp"); + require(campaign.duration == 3600 * 24, "Invalid duration"); + + console.log("Campaign created with ID:", vm.toString(campaignId)); + } +} + // SignAndCreateCampaign script contract SignAndCreateCampaign is DistributionCreatorScript { function run() external broadcast { diff --git a/scripts/Distributor.s.sol b/scripts/Distributor.s.sol index 2d6cfb6..96caab5 100644 --- a/scripts/Distributor.s.sol +++ b/scripts/Distributor.s.sol @@ -295,25 +295,3 @@ contract Claim is DistributorScript { console.log("Claimed rewards for", users.length, "users"); } } - -// ToggleOnlyOperatorCanClaim script -contract ToggleOnlyOperatorCanClaim is DistributorScript { - function run() external broadcast { - // MODIFY THIS VALUE TO SET YOUR DESIRED USER - address user = address(0); - _run(user); - } - - function run(address user) external broadcast { - _run(user); - } - - function _run(address user) internal { - uint256 chainId = block.chainid; - address distributorAddress = readAddress(chainId, "Merkl.Distributor"); - - Distributor(distributorAddress).toggleOnlyOperatorCanClaim(user); - - console.log("Toggled operator-only claiming for user:", user); - } -} diff --git a/test/DistributionCreator.t.sol b/test/DistributionCreator.t.sol index a32bb79..84d57d8 100644 --- a/test/DistributionCreator.t.sol +++ b/test/DistributionCreator.t.sol @@ -2,11 +2,18 @@ pragma solidity ^0.8.17; import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { Test } from "forge-std/Test.sol"; +import { console } from "forge-std/console.sol"; -import { DistributionParameters } from "../contracts/DistributionCreator.sol"; +import { DistributionCreator, DistributionParameters, CampaignParameters } from "../contracts/DistributionCreator.sol"; +import { Distributor, MerkleTree } from "../contracts/Distributor.sol"; import { Fixture, IERC20 } from "./Fixture.t.sol"; +import { Errors } from "../contracts/utils/Errors.sol"; +import { IAccessControlManager } from "../contracts/interfaces/IAccessControlManager.sol"; +import { JsonReader } from "../scripts/utils/JsonReader.sol"; +import { MockToken } from "../contracts/mock/MockToken.sol"; -contract DistributionCreatorTest is Fixture { +contract DistributionCreatorCreateCampaignTest is Fixture { using SafeERC20 for IERC20; uint256 constant maxDistribForOOG = 1e4; @@ -24,6 +31,9 @@ contract DistributionCreatorTest is Fixture { numEpoch = 25; initEndTime = startTime + numEpoch * EPOCH_DURATION; + vm.prank(governor); + creator.setFeeRecipient(dylan); + vm.startPrank(guardian); creator.toggleSigningWhitelist(alice); creator.toggleTokenWhitelist(address(agEUR)); @@ -38,115 +48,1615 @@ contract DistributionCreatorTest is Fixture { vm.prank(alice); angle.approve(address(creator), type(uint256).max); - address[] memory positionWrappers = new address[](3); - uint32[] memory wrapperTypes = new uint32[](3); - positionWrappers[0] = alice; - positionWrappers[1] = bob; - positionWrappers[2] = charlie; - wrapperTypes[0] = 0; - wrapperTypes[1] = 1; - wrapperTypes[2] = 2; - - vm.startPrank(alice); - // struct DistributionParameters memory - // create a bunch of distributions to make the view function call fail - DistributionParameters memory params = DistributionParameters({ - uniV3Pool: address(pool), - rewardToken: address(angle), - positionWrappers: positionWrappers, - wrapperTypes: wrapperTypes, - amount: 1e10, - propToken0: 4000, - propToken1: 2000, - propFees: 4000, - isOutOfRangeIncentivized: 0, - epochStart: initStartTime, - numEpoch: 25, - boostedReward: 0, - boostingAddress: address(0), - rewardId: keccak256("TEST"), - additionalData: hex"" - }); - // create a first distrib way before the others - creator.createDistribution(params); - - vm.warp(startTime + 3600 * 24 * 1000); - startTime = uint32(block.timestamp); - endTime = startTime + numEpoch * EPOCH_DURATION; - params.epochStart = startTime; - for (uint256 i; i < nbrDistrib; i++) creator.createDistribution(params); - - vm.stopPrank(); - } - - /* - // Commented because of an update in Foundry which does not handle well out of gas issues - function testFuzz_GetDistributionsOutOfGas() public { - address[] memory positionWrappers = new address[](3); - uint32[] memory wrapperTypes = new uint32[](3); - positionWrappers[0] = alice; - positionWrappers[1] = bob; - positionWrappers[2] = charlie; - wrapperTypes[0] = 0; - wrapperTypes[1] = 1; - wrapperTypes[2] = 2; - - vm.startPrank(alice); - // struct DistributionParameters memory - // create a bunch of distributions to make the view function call fail - DistributionParameters memory params = DistributionParameters({ - uniV3Pool: address(pool), - rewardToken: address(angle), - positionWrappers: positionWrappers, - wrapperTypes: wrapperTypes, - amount: 1e10, - propToken0: 4000, - propToken1: 2000, - propFees: 4000, - isOutOfRangeIncentivized: 0, - epochStart: startTime, - numEpoch: 25, - boostedReward: 0, - boostingAddress: address(0), - rewardId: keccak256("TEST"), - additionalData: hex"" - }); + vm.stopPrank(); + } + + function testUnit_CreateCampaignWithDefaultFees() public { + IERC20 rewardToken = IERC20(address(angle)); + uint256 amount = 100 ether; + uint256 amountAfterFees = 90 ether; + uint32 startTimestamp = uint32(block.timestamp + 600); + + vm.prank(alice); + bytes32 campaignId = creator.createCampaign( + CampaignParameters({ + campaignId: bytes32(0), + creator: alice, + rewardToken: address(rewardToken), + amount: amount, + campaignType: 1, + startTimestamp: startTimestamp, + duration: 3600 * 24, + campaignData: abi.encode( + 0xbEEfa1aBfEbE621DF50ceaEF9f54FdB73648c92C, + new address[](0), + new address[](0), + "", + new bytes[](0), + new bytes[](0), + hex"" + ) + }) + ); + + CampaignParameters memory campaign = creator.campaign(campaignId); + assertEq(campaign.creator, alice); + assertEq(campaign.rewardToken, address(rewardToken)); + assertEq(campaign.amount, (amount * (1e9 - creator.defaultFees())) / 1e9); + assertEq(campaign.campaignType, 1); + assertEq(campaign.startTimestamp, startTimestamp); + assertEq(campaign.duration, 3600 * 24); + } + + function testUnit_CreateCampaignWithSetFees() public { + uint32 campaignType = 1; + vm.prank(guardian); + creator.setCampaignFees(campaignType, 1e7); + + IERC20 rewardToken = IERC20(address(angle)); + uint256 amount = 100 ether; + uint256 amountAfterFees = 90 ether; + uint32 startTimestamp = uint32(block.timestamp + 600); + + uint256 prevBalance = rewardToken.balanceOf(alice); + + vm.prank(alice); + bytes32 campaignId = creator.createCampaign( + CampaignParameters({ + campaignId: bytes32(0), + creator: alice, + rewardToken: address(rewardToken), + amount: amount, + campaignType: campaignType, + startTimestamp: startTimestamp, + duration: 3600 * 24, + campaignData: abi.encode( + 0xbEEfa1aBfEbE621DF50ceaEF9f54FdB73648c92C, + new address[](0), + new address[](0), + "", + new bytes[](0), + new bytes[](0), + hex"" + ) + }) + ); + + CampaignParameters memory campaign = creator.campaign(campaignId); + assertEq(campaign.creator, alice); + assertEq(campaign.rewardToken, address(rewardToken)); + assertEq(campaign.amount, (amount * (1e9 - 1e7)) / 1e9); + assertEq(campaign.campaignType, 1); + assertEq(campaign.startTimestamp, startTimestamp); + assertEq(campaign.duration, 3600 * 24); + assertEq(rewardToken.balanceOf(alice), prevBalance - amount); + assertEq(rewardToken.balanceOf(dylan), (amount * 1e7) / 1e9); + } +} + +contract DistributionCreatorCreateReallocationTest is Fixture { + using SafeERC20 for IERC20; - vm.warp(startTime + 3600 * 24 * 10); - startTime = uint32(block.timestamp); - endTime = startTime + numEpoch * EPOCH_DURATION; - params.epochStart = startTime; - for (uint256 i; i < maxDistribForOOG; i++) creator.createDistribution(params); + Distributor public distributor; + Distributor public distributorImpl; + uint32 initStartTime; + uint32 initEndTime; + uint32 startTime; + uint32 endTime; + uint32 numEpoch; + function setUp() public override { + super.setUp(); + + distributorImpl = new Distributor(); + distributor = Distributor(deployUUPS(address(distributorImpl), hex"")); + distributor.initialize(IAccessControlManager(address(accessControlManager))); + + vm.startPrank(governor); + distributor.setDisputeAmount(1e18); + distributor.setDisputePeriod(1 days); + distributor.setDisputeToken(angle); vm.stopPrank(); - // All calls will revert because it is oog - vm.expectRevert(); - creator.getActiveDistributions(); - vm.expectRevert(); - creator.getDistributionsForEpoch(startTime); + initStartTime = uint32(block.timestamp); + numEpoch = 25; + initEndTime = startTime + numEpoch * EPOCH_DURATION; - vm.expectRevert(); - creator.getDistributionsBetweenEpochs(startTime, endTime); + vm.startPrank(governor); + creator.setNewDistributor(address(distributor)); + creator.setFeeRecipient(dylan); + vm.stopPrank(); - vm.expectRevert(); - creator.getDistributionsAfterEpoch(startTime); + vm.startPrank(guardian); + creator.toggleSigningWhitelist(alice); + creator.toggleTokenWhitelist(address(agEUR)); + address[] memory tokens = new address[](2); + uint256[] memory amounts = new uint256[](2); + tokens[0] = address(angle); + amounts[0] = 1e8; + tokens[1] = address(agEUR); + amounts[1] = 1e8; + creator.setRewardTokenMinAmounts(tokens, amounts); + vm.stopPrank(); + + angle.mint(address(alice), 1e22); + vm.prank(alice); + angle.approve(address(creator), type(uint256).max); + + agEUR.mint(address(alice), 1e22); + vm.prank(alice); + agEUR.approve(address(creator), type(uint256).max); + + vm.stopPrank(); + } + + function testUnit_ReallocationCampaignRewards_revertWhen_TooSoon() public { + IERC20 rewardToken = IERC20(address(agEUR)); + uint256 amount = 100 ether; + uint32 startTimestamp = uint32(block.timestamp + 600); + + vm.prank(alice); + bytes32 campaignId = creator.createCampaign( + CampaignParameters({ + campaignId: bytes32(0), + creator: alice, + rewardToken: address(rewardToken), + amount: amount, + campaignType: 1, + startTimestamp: startTimestamp, + duration: 3600 * 48, + campaignData: abi.encode( + 0xbEEfa1aBfEbE621DF50ceaEF9f54FdB73648c92C, + new address[](0), + new address[](0), + "", + new bytes[](0), + new bytes[](0), + hex"" + ) + }) + ); + + vm.prank(governor); + // Create false tree + distributor.updateTree( + MerkleTree({ + merkleRoot: bytes32(0x0b70a97c062cb747158b89e27df5bbda859ba072232efcbe92e383e9d74b8555), + ipfsHash: keccak256("IPFS_HASH") + }) + ); + + angle.mint(address(distributor), 1e18); + agEUR.mint(address(distributor), 5e17); + + vm.warp(distributor.endOfDisputePeriod() + 1); + { + bytes32[][] memory proofs = new bytes32[][](1); + address[] memory users = new address[](1); + address[] memory tokens = new address[](1); + uint256[] memory amounts = new uint256[](1); + // proofs[0] = new bytes32[](1); + // proofs[0][0] = bytes32(0x6f46ee2909b99367a0d9932a11f1bdb85c9354480c9de277d21086f9a8925c0a); + // users[0] = alice; + // tokens[0] = address(angle); + // amounts[0] = 1e18; + proofs[0] = new bytes32[](1); + proofs[0][0] = bytes32(0x3a64e591d79db8530701e6f3dbdd95dc74681291b327d0ce4acc97024a61430c); + users[0] = bob; + tokens[0] = address(agEUR); + amounts[0] = 5e17; + vm.prank(bob); + distributor.claim(users, tokens, amounts, proofs); + } + + { + address[] memory users = new address[](1); + users[0] = bob; + + vm.prank(alice); + vm.expectRevert(Errors.InvalidOverride.selector); + creator.reallocateCampaignRewards(campaignId, users, address(governor)); + + assertEq(creator.campaignReallocation(campaignId, alice), address(0)); + address[] memory listReallocation = creator.getCampaignListReallocation(campaignId); + assertEq(listReallocation.length, 0); + } + } + + function testUnit_ReallocationCampaignRewards_revertWhen_AlreadyClaimed() public { + IERC20 rewardToken = IERC20(address(agEUR)); + uint256 amount = 100 ether; + uint32 startTimestamp = uint32(block.timestamp + 600); + + vm.prank(alice); + bytes32 campaignId = creator.createCampaign( + CampaignParameters({ + campaignId: bytes32(0), + creator: alice, + rewardToken: address(rewardToken), + amount: amount, + campaignType: 1, + startTimestamp: startTimestamp, + duration: 3600 * 24, + campaignData: abi.encode( + 0xbEEfa1aBfEbE621DF50ceaEF9f54FdB73648c92C, + new address[](0), + new address[](0), + "", + new bytes[](0), + new bytes[](0), + hex"" + ) + }) + ); + + vm.prank(governor); + // Create false tree + distributor.updateTree( + MerkleTree({ + merkleRoot: bytes32(0x0b70a97c062cb747158b89e27df5bbda859ba072232efcbe92e383e9d74b8555), + ipfsHash: keccak256("IPFS_HASH") + }) + ); + + angle.mint(address(distributor), 1e18); + agEUR.mint(address(distributor), 5e17); + + vm.warp(distributor.endOfDisputePeriod() + 1); + { + bytes32[][] memory proofs = new bytes32[][](1); + address[] memory users = new address[](1); + address[] memory tokens = new address[](1); + uint256[] memory amounts = new uint256[](1); + // proofs[0] = new bytes32[](1); + // proofs[0][0] = bytes32(0x6f46ee2909b99367a0d9932a11f1bdb85c9354480c9de277d21086f9a8925c0a); + // users[0] = alice; + // tokens[0] = address(angle); + // amounts[0] = 1e18; + proofs[0] = new bytes32[](1); + proofs[0][0] = bytes32(0x3a64e591d79db8530701e6f3dbdd95dc74681291b327d0ce4acc97024a61430c); + users[0] = bob; + tokens[0] = address(agEUR); + amounts[0] = 5e17; + vm.prank(bob); + distributor.claim(users, tokens, amounts, proofs); + } + + { + address[] memory users = new address[](1); + users[0] = bob; + + vm.prank(alice); + vm.expectRevert(Errors.InvalidOverride.selector); + creator.reallocateCampaignRewards(campaignId, users, address(governor)); + + assertEq(creator.campaignReallocation(campaignId, alice), address(0)); + address[] memory listReallocation = creator.getCampaignListReallocation(campaignId); + assertEq(listReallocation.length, 0); + } + } + + function testUnit_ReallocationCampaignRewards_Success() public { + IERC20 rewardToken = IERC20(address(angle)); + uint256 amount = 100 ether; + uint32 startTimestamp = uint32(block.timestamp + 600); + + vm.prank(alice); + bytes32 campaignId = creator.createCampaign( + CampaignParameters({ + campaignId: bytes32(0), + creator: alice, + rewardToken: address(rewardToken), + amount: amount, + campaignType: 1, + startTimestamp: startTimestamp, + duration: 3600 * 24, + campaignData: abi.encode( + 0xbEEfa1aBfEbE621DF50ceaEF9f54FdB73648c92C, + new address[](0), + new address[](0), + "", + new bytes[](0), + new bytes[](0), + hex"" + ) + }) + ); + + vm.prank(governor); + // Create false tree + distributor.updateTree( + MerkleTree({ + merkleRoot: bytes32(0x0b70a97c062cb747158b89e27df5bbda859ba072232efcbe92e383e9d74b8555), + ipfsHash: keccak256("IPFS_HASH") + }) + ); + + angle.mint(address(distributor), 1e18); + agEUR.mint(address(distributor), 5e17); + + vm.warp(distributor.endOfDisputePeriod() + 1); + { + bytes32[][] memory proofs = new bytes32[][](1); + address[] memory users = new address[](1); + address[] memory tokens = new address[](1); + uint256[] memory amounts = new uint256[](1); + // proofs[0] = new bytes32[](1); + // proofs[0][0] = bytes32(0x6f46ee2909b99367a0d9932a11f1bdb85c9354480c9de277d21086f9a8925c0a); + // users[0] = alice; + // tokens[0] = address(angle); + // amounts[0] = 1e18; + proofs[0] = new bytes32[](1); + proofs[0][0] = bytes32(0x3a64e591d79db8530701e6f3dbdd95dc74681291b327d0ce4acc97024a61430c); + users[0] = bob; + tokens[0] = address(agEUR); + amounts[0] = 5e17; + uint256 aliceBalance = angle.balanceOf(address(alice)); + uint256 bobBalance = agEUR.balanceOf(address(bob)); + + vm.prank(bob); + distributor.claim(users, tokens, amounts, proofs); + } + + { + address[] memory users = new address[](1); + users[0] = alice; + + vm.prank(bob); + vm.expectRevert(Errors.InvalidOverride.selector); + creator.reallocateCampaignRewards(campaignId, users, address(governor)); + + assertEq(creator.campaignReallocation(campaignId, alice), address(0)); + } + + { + address[] memory users = new address[](1); + users[0] = alice; + + vm.prank(alice); + creator.reallocateCampaignRewards(campaignId, users, address(governor)); + + assertEq(creator.campaignReallocation(campaignId, alice), address(governor)); + + address[] memory listReallocation = creator.getCampaignListReallocation(campaignId); + assertEq(listReallocation.length, 1); + assertEq(listReallocation[0], alice); + } + } +} + +contract DistributionCreatorOverrideTest is Fixture { + using SafeERC20 for IERC20; + + uint256 constant maxDistribForOOG = 1e4; + uint256 constant nbrDistrib = 10; + uint32 initStartTime; + uint32 initEndTime; + uint32 startTime; + uint32 endTime; + uint32 numEpoch; + + bytes32 campaignId; + address campaignCreator; + address campaignRewardToken; + uint256 campaignAmount; + uint256 campaignType; + uint32 campaignStartTimestamp; + uint32 campaignDuration; + bytes campaignCampaignData; + uint256[] timestamps; + + uint256 amount; + uint256 amountAfterFees; + uint32 startTimestamp; + bytes campaignData; + + function setUp() public override { + super.setUp(); + + initStartTime = uint32(block.timestamp); + numEpoch = 25; + initEndTime = startTime + numEpoch * EPOCH_DURATION; + + vm.startPrank(guardian); + creator.toggleSigningWhitelist(alice); + creator.toggleTokenWhitelist(address(agEUR)); + address[] memory tokens = new address[](1); + uint256[] memory amounts = new uint256[](1); + tokens[0] = address(angle); + amounts[0] = 1e8; + creator.setRewardTokenMinAmounts(tokens, amounts); + vm.stopPrank(); + + angle.mint(address(alice), 1e22); + vm.prank(alice); + angle.approve(address(creator), type(uint256).max); + + vm.stopPrank(); + } + + function testUnit_OverrideCampaignData_RevertWhen_IncorrectCampaignId() public { + amount = 100 ether; + amountAfterFees = 90 ether; + startTimestamp = uint32(block.timestamp + 600); + + campaignData = abi.encode( + 0xbEEfa1aBfEbE621DF50ceaEF9f54FdB73648c92C, + new address[](0), + new address[](0), + "", + new bytes[](0), + new bytes[](0), + hex"" + ); + + vm.prank(alice); + campaignId = creator.createCampaign( + CampaignParameters({ + campaignId: bytes32(0), + creator: alice, + rewardToken: address(angle), + amount: amount, + campaignType: 1, + startTimestamp: startTimestamp, + duration: 3600 * 24, + campaignData: campaignData + }) + ); + + vm.warp(block.timestamp + 1000); + vm.roll(4); + + // Silo distrib data + address[] memory whitelist = new address[](1); + whitelist[0] = 0x8095806d8753C0443C118D1C5e5eEC472e30BFeC; + bytes memory overrideCampaignData = abi.encode( + 0x04C0599Ae5A44757c0af6F9eC3b93da8976c150A, + 2, + 0xa42001D6d2237d2c74108FE360403C4b796B7170, + whitelist, + new address[](0), + hex"" + ); + + vm.expectRevert(Errors.CampaignDoesNotExist.selector); + vm.prank(alice); + creator.overrideCampaign( + keccak256("test"), + CampaignParameters({ + campaignId: campaignId, + creator: alice, + rewardToken: address(angle), + amount: amountAfterFees, + campaignType: 5, + startTimestamp: startTimestamp, + duration: 3600 * 24, + campaignData: overrideCampaignData + }) + ); + } + + function testUnit_OverrideCampaignData() public { + amount = 100 ether; + amountAfterFees = 90 ether; + startTimestamp = uint32(block.timestamp + 600); + + campaignData = abi.encode( + 0xbEEfa1aBfEbE621DF50ceaEF9f54FdB73648c92C, + new address[](0), + new address[](0), + "", + new bytes[](0), + new bytes[](0), + hex"" + ); + + vm.prank(alice); + campaignId = creator.createCampaign( + CampaignParameters({ + campaignId: bytes32(0), + creator: alice, + rewardToken: address(angle), + amount: amount, + campaignType: 1, + startTimestamp: startTimestamp, + duration: 3600 * 24, + campaignData: campaignData + }) + ); + + vm.warp(block.timestamp + 1000); + vm.roll(4); + + // Silo distrib + address[] memory whitelist = new address[](1); + whitelist[0] = 0x8095806d8753C0443C118D1C5e5eEC472e30BFeC; + bytes memory overrideCampaignData = abi.encode( + 0x04C0599Ae5A44757c0af6F9eC3b93da8976c150A, + 2, + 0xa42001D6d2237d2c74108FE360403C4b796B7170, + whitelist, + new address[](0), + hex"" + ); + + vm.prank(alice); + creator.overrideCampaign( + campaignId, + CampaignParameters({ + campaignId: campaignId, + creator: alice, + rewardToken: address(angle), + amount: amountAfterFees, + campaignType: 5, + startTimestamp: startTimestamp, + duration: 3600 * 24, + campaignData: overrideCampaignData + }) + ); + + ( + , + campaignCreator, + campaignRewardToken, + campaignAmount, + campaignType, + campaignStartTimestamp, + campaignDuration, + campaignCampaignData + ) = creator.campaignOverrides(campaignId); + + assertEq(campaignCreator, alice); + assertEq(campaignRewardToken, address(angle)); + assertEq(campaignAmount, amountAfterFees); + assertEq(campaignType, 5); + assertEq(campaignStartTimestamp, startTimestamp); + assertEq(campaignDuration, 3600 * 24); + assertEq(campaignCampaignData, overrideCampaignData); + assertGe(creator.campaignOverridesTimestamp(campaignId, 0), startTimestamp); vm.expectRevert(); - creator.getActivePoolDistributions(address(pool)); + creator.campaignOverridesTimestamp(campaignId, 1); + } + + function testUnit_OverrideCampaignData_RevertWhen_IncorrectCreator() public { + IERC20 rewardToken = IERC20(address(angle)); + amount = 100 ether; + amountAfterFees = 90 ether; + startTimestamp = uint32(block.timestamp + 600); + + vm.prank(alice); + campaignId = creator.createCampaign( + CampaignParameters({ + campaignId: bytes32(0), + creator: alice, + rewardToken: address(rewardToken), + amount: amount, + campaignType: 1, + startTimestamp: startTimestamp, + duration: 3600 * 24, + campaignData: abi.encode( + 0xbEEfa1aBfEbE621DF50ceaEF9f54FdB73648c92C, + new address[](0), + new address[](0), + "", + new bytes[](0), + new bytes[](0), + hex"" + ) + }) + ); + + vm.warp(block.timestamp + 1000); + vm.roll(4); + // override + + // Silo distrib + address[] memory whitelist = new address[](1); + whitelist[0] = 0x8095806d8753C0443C118D1C5e5eEC472e30BFeC; + campaignData = abi.encode( + 0x04C0599Ae5A44757c0af6F9eC3b93da8976c150A, + 2, + 0xa42001D6d2237d2c74108FE360403C4b796B7170, + whitelist, + new address[](0), + hex"" + ); + + vm.expectRevert(Errors.InvalidOverride.selector); + vm.prank(bob); + creator.overrideCampaign( + campaignId, + CampaignParameters({ + campaignId: campaignId, + creator: bob, + rewardToken: address(rewardToken), + amount: amountAfterFees, + campaignType: 5, + startTimestamp: startTimestamp, + duration: 3600 * 24, + campaignData: campaignData + }) + ); + + vm.expectRevert(Errors.InvalidOverride.selector); + vm.prank(bob); + creator.overrideCampaign( + campaignId, + CampaignParameters({ + campaignId: campaignId, + creator: alice, + rewardToken: address(rewardToken), + amount: amountAfterFees, + campaignType: 5, + startTimestamp: startTimestamp, + duration: 3600 * 24, + campaignData: campaignData + }) + ); + } + + function testUnit_OverrideCampaignData_RevertWhen_IncorrectRewardToken() public { + IERC20 rewardToken = IERC20(address(angle)); + amount = 100 ether; + amountAfterFees = 90 ether; + startTimestamp = uint32(block.timestamp + 600); + + vm.prank(alice); + campaignId = creator.createCampaign( + CampaignParameters({ + campaignId: bytes32(0), + creator: alice, + rewardToken: address(rewardToken), + amount: amount, + campaignType: 1, + startTimestamp: startTimestamp, + duration: 3600 * 24, + campaignData: abi.encode( + 0xbEEfa1aBfEbE621DF50ceaEF9f54FdB73648c92C, + new address[](0), + new address[](0), + "", + new bytes[](0), + new bytes[](0), + hex"" + ) + }) + ); + + vm.warp(block.timestamp + 1000); + vm.roll(4); + // override + + // Silo distrib + address[] memory whitelist = new address[](1); + whitelist[0] = 0x8095806d8753C0443C118D1C5e5eEC472e30BFeC; + campaignData = abi.encode( + 0x04C0599Ae5A44757c0af6F9eC3b93da8976c150A, + 2, + 0xa42001D6d2237d2c74108FE360403C4b796B7170, + whitelist, + new address[](0), + hex"" + ); + vm.expectRevert(Errors.InvalidOverride.selector); + vm.prank(alice); + creator.overrideCampaign( + campaignId, + CampaignParameters({ + campaignId: campaignId, + creator: alice, + rewardToken: address(alice), + amount: amountAfterFees, + campaignType: 5, + startTimestamp: startTimestamp, + duration: 3600 * 24, + campaignData: campaignData + }) + ); + } + + function testUnit_OverrideCampaignData_RevertWhen_IncorrectRewardAmount() public { + IERC20 rewardToken = IERC20(address(angle)); + amount = 100 ether; + amountAfterFees = 90 ether; + startTimestamp = uint32(block.timestamp + 600); + + vm.prank(alice); + campaignId = creator.createCampaign( + CampaignParameters({ + campaignId: bytes32(0), + creator: alice, + rewardToken: address(rewardToken), + amount: amount, + campaignType: 1, + startTimestamp: startTimestamp, + duration: 3600 * 24, + campaignData: abi.encode( + 0xbEEfa1aBfEbE621DF50ceaEF9f54FdB73648c92C, + new address[](0), + new address[](0), + "", + new bytes[](0), + new bytes[](0), + hex"" + ) + }) + ); + + vm.warp(block.timestamp + 1000); + vm.roll(4); + // override + + // Silo distrib + address[] memory whitelist = new address[](1); + whitelist[0] = 0x8095806d8753C0443C118D1C5e5eEC472e30BFeC; + campaignData = abi.encode( + 0x04C0599Ae5A44757c0af6F9eC3b93da8976c150A, + 2, + 0xa42001D6d2237d2c74108FE360403C4b796B7170, + whitelist, + new address[](0), + hex"" + ); + + vm.expectRevert(Errors.InvalidOverride.selector); + vm.prank(alice); + creator.overrideCampaign( + campaignId, + CampaignParameters({ + campaignId: campaignId, + creator: alice, + rewardToken: address(alice), + amount: amount, + campaignType: 5, + startTimestamp: startTimestamp, + duration: 3600 * 24, + campaignData: campaignData + }) + ); + } + + function testUnit_OverrideCampaignData_RevertWhen_IncorrectStartTimestamp() public { + IERC20 rewardToken = IERC20(address(angle)); + amount = 100 ether; + amountAfterFees = 90 ether; + startTimestamp = uint32(block.timestamp + 600); + + vm.prank(alice); + campaignId = creator.createCampaign( + CampaignParameters({ + campaignId: bytes32(0), + creator: alice, + rewardToken: address(rewardToken), + amount: amount, + campaignType: 1, + startTimestamp: startTimestamp, + duration: 3600 * 24, + campaignData: abi.encode( + 0xbEEfa1aBfEbE621DF50ceaEF9f54FdB73648c92C, + new address[](0), + new address[](0), + "", + new bytes[](0), + new bytes[](0), + hex"" + ) + }) + ); + + vm.warp(block.timestamp + 1000); + vm.roll(4); + // override + + // Silo distrib + address[] memory whitelist = new address[](1); + whitelist[0] = 0x8095806d8753C0443C118D1C5e5eEC472e30BFeC; + bytes memory campaignData = abi.encode( + 0x04C0599Ae5A44757c0af6F9eC3b93da8976c150A, + 2, + 0xa42001D6d2237d2c74108FE360403C4b796B7170, + whitelist, + new address[](0), + hex"" + ); + + vm.expectRevert(Errors.InvalidOverride.selector); + vm.prank(alice); + creator.overrideCampaign( + campaignId, + CampaignParameters({ + campaignId: campaignId, + creator: alice, + rewardToken: address(rewardToken), + amount: amount, + campaignType: 5, + startTimestamp: startTimestamp + 1, + duration: 3600 * 24, + campaignData: campaignData + }) + ); + } + + function testUnit_OverrideCampaignData_RevertWhen_IncorrectDuration() public { + IERC20 rewardToken = IERC20(address(angle)); + amount = 100 ether; + amountAfterFees = 90 ether; + startTimestamp = uint32(block.timestamp + 600); + + vm.prank(alice); + campaignId = creator.createCampaign( + CampaignParameters({ + campaignId: bytes32(0), + creator: alice, + rewardToken: address(rewardToken), + amount: amount, + campaignType: 1, + startTimestamp: startTimestamp, + duration: 3600 * 24, + campaignData: abi.encode( + 0xbEEfa1aBfEbE621DF50ceaEF9f54FdB73648c92C, + new address[](0), + new address[](0), + "", + new bytes[](0), + new bytes[](0), + hex"" + ) + }) + ); + + vm.warp(block.timestamp + 1000); + vm.roll(4); + // override + + // Silo distrib + address[] memory whitelist = new address[](1); + whitelist[0] = 0x8095806d8753C0443C118D1C5e5eEC472e30BFeC; + campaignData = abi.encode( + 0x04C0599Ae5A44757c0af6F9eC3b93da8976c150A, + 2, + 0xa42001D6d2237d2c74108FE360403C4b796B7170, + whitelist, + new address[](0), + hex"" + ); + + vm.expectRevert(Errors.InvalidOverride.selector); + vm.prank(alice); + creator.overrideCampaign( + campaignId, + CampaignParameters({ + campaignId: campaignId, + creator: alice, + rewardToken: address(rewardToken), + amount: amount, + campaignType: 5, + startTimestamp: startTimestamp, + duration: 399, + campaignData: campaignData + }) + ); + } + + function testUnit_OverrideCampaignDuration() public { + IERC20 rewardToken = IERC20(address(angle)); + amount = 100 ether; + amountAfterFees = 90 ether; + startTimestamp = uint32(block.timestamp + 600); + uint32 duration = 3600 * 24; + uint32 durationAfterOverride = 3600 * 12; + + campaignData = abi.encode( + 0xbEEfa1aBfEbE621DF50ceaEF9f54FdB73648c92C, + new address[](0), + new address[](0), + "", + new bytes[](0), + new bytes[](0), + hex"" + ); + vm.prank(alice); + campaignId = creator.createCampaign( + CampaignParameters({ + campaignId: bytes32(0), + creator: alice, + rewardToken: address(rewardToken), + amount: amount, + campaignType: 1, + startTimestamp: startTimestamp, + duration: duration, + campaignData: campaignData + }) + ); + + CampaignParameters memory campaign = creator.campaign(campaignId); + assertEq(campaign.creator, alice); + assertEq(campaign.rewardToken, address(rewardToken)); + assertEq(campaign.amount, (amount * (1e9 - creator.defaultFees())) / 1e9); + assertEq(campaign.campaignType, 1); + assertEq(campaign.startTimestamp, startTimestamp); + assertEq(campaign.duration, duration); + + vm.warp(block.timestamp + 1000); + vm.roll(4); + // override + + vm.prank(alice); + creator.overrideCampaign( + campaignId, + CampaignParameters({ + campaignId: campaignId, + creator: alice, + rewardToken: address(rewardToken), + amount: amountAfterFees, + campaignType: 1, + startTimestamp: startTimestamp, + duration: durationAfterOverride, + campaignData: campaignData + }) + ); + + ( + , + campaignCreator, + campaignRewardToken, + campaignAmount, + campaignType, + campaignStartTimestamp, + campaignDuration, + campaignCampaignData + ) = creator.campaignOverrides(campaignId); + assertEq(campaignCreator, alice); + assertEq(campaignRewardToken, address(rewardToken)); + assertEq(campaignAmount, amountAfterFees); + assertEq(campaignType, 1); + assertEq(campaignStartTimestamp, startTimestamp); + assertEq(campaignDuration, durationAfterOverride); + assertEq(campaignCampaignData, campaignData); + assertGe(creator.campaignOverridesTimestamp(campaignId, 0), startTimestamp); vm.expectRevert(); - creator.getPoolDistributionsForEpoch(address(pool), startTime); + creator.campaignOverridesTimestamp(campaignId, 1); + } + function testUnit_GetCampaignOverridesTimestamp() public { + IERC20 rewardToken = IERC20(address(angle)); + amount = 100 ether; + amountAfterFees = 90 ether; + startTimestamp = uint32(block.timestamp + 600); + uint32 duration = 3600 * 24; + uint32 durationAfterOverride = 3600 * 12; + + campaignData = abi.encode( + 0xbEEfa1aBfEbE621DF50ceaEF9f54FdB73648c92C, + new address[](0), + new address[](0), + "", + new bytes[](0), + new bytes[](0), + hex"" + ); + vm.prank(alice); + campaignId = creator.createCampaign( + CampaignParameters({ + campaignId: bytes32(0), + creator: alice, + rewardToken: address(rewardToken), + amount: amount, + campaignType: 1, + startTimestamp: startTimestamp, + duration: duration, + campaignData: campaignData + }) + ); + + vm.warp(block.timestamp + 1000); + vm.roll(4); + // override + + vm.prank(alice); + creator.overrideCampaign( + campaignId, + CampaignParameters({ + campaignId: campaignId, + creator: alice, + rewardToken: address(rewardToken), + amount: amountAfterFees, + campaignType: 10, + startTimestamp: startTimestamp, + duration: durationAfterOverride * 10, + campaignData: campaignData + }) + ); + + timestamps = creator.getCampaignOverridesTimestamp(campaignId); + assertEq(timestamps.length, 1); + assertEq(timestamps[0], 1001); + } + + function testUnit_OverrideCampaignAdditionalFee() public { + vm.prank(governor); + creator.setCampaignFees(3, 1e7); + + IERC20 rewardToken = IERC20(address(angle)); + amount = 100 ether; + amountAfterFees = 90 ether; + startTimestamp = uint32(block.timestamp + 600); + uint32 duration = 3600 * 24; + uint32 durationAfterOverride = 3600 * 12; + + uint256 prevBalance = rewardToken.balanceOf(alice); + + campaignData = abi.encode( + 0xbEEfa1aBfEbE621DF50ceaEF9f54FdB73648c92C, + new address[](0), + new address[](0), + "", + new bytes[](0), + new bytes[](0), + hex"" + ); + vm.prank(alice); + campaignId = creator.createCampaign( + CampaignParameters({ + campaignId: bytes32(0), + creator: alice, + rewardToken: address(rewardToken), + amount: amount, + campaignType: 1, + startTimestamp: startTimestamp, + duration: duration, + campaignData: campaignData + }) + ); + + assertEq(rewardToken.balanceOf(alice), prevBalance - amount); + + vm.warp(block.timestamp + 1000); + vm.roll(4); + // override + vm.prank(alice); + creator.overrideCampaign( + campaignId, + CampaignParameters({ + campaignId: campaignId, + creator: alice, + rewardToken: address(rewardToken), + amount: amountAfterFees, + campaignType: 3, + startTimestamp: startTimestamp, + duration: durationAfterOverride, + campaignData: campaignData + }) + ); + + assertEq(rewardToken.balanceOf(alice), prevBalance - amount - (amountAfterFees * 1e7) / 1e9); + } +} + +contract UpgradeDistributionCreatorTest is Test, JsonReader { + DistributionCreator public distributionCreator; + Distributor public distributor; + IAccessControlManager public accessControlManager; + address public deployer; + address public governor; + IERC20 public rewardToken; + uint256 public chainId; + bytes32 public campaignId0; + bytes32 public campaignId73; + bytes32 public testCampaignId; + + function setUp() public { + // Setup environment variables + deployer = 0xA9DdD91249DFdd450E81E1c56Ab60E1A62651701; // deploy + vm.createSelectFork(vm.envString("BASE_NODE_URI")); + chainId = block.chainid; + + // Load existing contracts + distributor = Distributor(this.readAddress(chainId, "Merkl.Distributor")); + distributionCreator = DistributionCreator(this.readAddress(chainId, "Merkl.DistributionCreator")); + governor = this.readAddress(chainId, "AngleLabs"); + accessControlManager = IAccessControlManager(this.readAddress(chainId, "Merkl.CoreMerkl")); + rewardToken = IERC20(0xC011882d0f7672D8942e7fE2248C174eeD640c8f); // aglaMerkl + + // Setup test campaign parameters + uint256 amount = 10 ether; + uint32 startTimestamp = uint32(block.timestamp + 3600); // 1 hour from now + uint32 duration = 3600 * 6; + uint32 campaignType = 1; + bytes memory campaignData = abi.encode( + 0x70F796946eD919E4Bc6cD506F8dACC45E4539771, + new address[](0), + new address[](0), + "", + new bytes[](0), + new bytes[](0), + hex"" + ); + + // Mint tokens and approve for test campaign + vm.startPrank(deployer); + MockToken(address(rewardToken)).mint(deployer, amount); + rewardToken.approve(address(distributionCreator), amount); + + // Create test campaign + testCampaignId = distributionCreator.createCampaign( + CampaignParameters({ + campaignId: bytes32(0), + creator: deployer, + rewardToken: address(rewardToken), + amount: amount, + campaignType: campaignType, + startTimestamp: startTimestamp, + duration: duration, + campaignData: campaignData + }) + ); + vm.stopPrank(); + + // Deploy new implementation + vm.startPrank(deployer); + address creatorImpl = address(new DistributionCreator()); + vm.stopPrank(); + + // Upgrade + vm.startPrank(governor); + distributionCreator.upgradeTo(address(creatorImpl)); + vm.stopPrank(); + } + + function test_VerifyStorageSlots_Success() public { + // Verify storage slots remain unchanged + assertEq(address(distributionCreator.accessControlManager()), this.readAddress(chainId, "Merkl.CoreMerkl")); + assertEq(address(distributionCreator.distributor()), this.readAddress(chainId, "Merkl.Distributor")); + assertEq(distributionCreator.defaultFees(), 0.03e9); + + // Verify message and hash + assertEq(distributionCreator.messageHash(), 0x08dabc24dcfcb230453d08bce47c730ed6f1cce205bc153680488959b503644e); + + // Verify distribution list entries + + CampaignParameters memory distribution0 = distributionCreator.distribution(0); + assertEq(distribution0.campaignId, 0xb3fc2abc303c70a16ab9d5fc38d7e8aeae66593a87a3d971b024dd34b97e94b1); + + CampaignParameters memory distribution73 = distributionCreator.distribution(73); + assertEq(distribution73.campaignId, 0x157a32c11ce34030465e1c28c309f38c18161028355f3446f54b677d11ceb63a); + + // Verify fee and whitelist settings + address testAddr = 0xfdA462548Ce04282f4B6D6619823a7C64Fdc0185; + assertEq(distributionCreator.feeRebate(testAddr), 0); + assertEq(distributionCreator.isWhitelistedToken(this.readAddress(chainId, "EUR.AgToken")), 1); + assertEq(distributionCreator._nonces(testAddr), 4); + assertEq( + distributionCreator.userSignatures(testAddr), + 0x08dabc24dcfcb230453d08bce47c730ed6f1cce205bc153680488959b503644e + ); + assertEq(distributionCreator.userSignatureWhitelist(testAddr), 0); + + // Verify reward tokens + assertEq(distributionCreator.rewardTokens(0), 0x7D49a065D17d6d4a55dc13649901fdBB98B2AFBA); + assertEq(distributionCreator.rewardTokens(21), 0xF734eFdE0C424BA2B547b186586dE417b0954802); + assertEq(distributionCreator.rewardTokenMinAmounts(0x7D49a065D17d6d4a55dc13649901fdBB98B2AFBA), 1 ether); + + // Verify campaign list + { + (bytes32 campaignId, , , , , , , ) = distributionCreator.campaignList(0); + assertEq(campaignId, 0x4e2bf13f682a244a80e0f25e1545fc8ad3a181d60658d22a3d347ee493e2a740); + } + { + (bytes32 campaignId, , , , , , , ) = distributionCreator.campaignList(67); + assertEq(campaignId, 0xf7d416acc480a41cd4cbb1bd68941f2f585adb659bd95d45e193589175356972); + } + + // Verify campaign fees + assertEq(distributionCreator.campaignSpecificFees(4), 0.005e9); + + // Verify campaign overrides + { + (bytes32 campaignId, , , , , , , ) = distributionCreator.campaignOverrides( + 0xf7d416acc480a41cd4cbb1bd68941f2f585adb659bd95d45e193589175356972 + ); + assertEq(campaignId, bytes32(0)); + } + + // Verify revert on invalid campaign override timestamp vm.expectRevert(); - creator.getPoolDistributionsBetweenEpochs(address(pool), startTime, endTime); + distributionCreator.campaignOverridesTimestamp( + 0x4e2bf13f682a244a80e0f25e1545fc8ad3a181d60658d22a3d347ee493e2a740, + 0 + ); + } + + function test_UpgradeTo_Revert_WhenNonGovernor() public { + vm.startPrank(deployer); + address creatorImpl = address(new DistributionCreator()); + vm.stopPrank(); + // Should revert when non-governor tries to upgrade + address nonGovernor = makeAddr("nonGovernor"); + vm.startPrank(nonGovernor); vm.expectRevert(); - creator.getPoolDistributionsAfterEpoch(address(pool), startTime); + distributionCreator.upgradeTo(address(creatorImpl)); + vm.stopPrank(); + } + + function test_Claim_Success() public { + address updater = 0x435046800Fb9149eE65159721A92cB7d50a7534b; + + MerkleTree memory newTree = MerkleTree({ + merkleRoot: 0xb402de8ed2f573c780a39e6d41aa5276706c439849d1e4925d379f2aa8913577, + ipfsHash: bytes32(0) + }); + + // Perform tree update + vm.startPrank(updater); + vm.warp(distributor.endOfDisputePeriod() + 1); // can't update tree before dispute period is over + assertEq(distributor.canUpdateMerkleRoot(updater), 1); + distributor.updateTree(newTree); + vm.stopPrank(); + + vm.warp(distributor.endOfDisputePeriod() + 1); + + // Verify tree update + (bytes32 currentRoot, bytes32 currentHash) = distributor.tree(); + assertEq(currentRoot, newTree.merkleRoot); + assertEq(currentHash, newTree.ipfsHash); + + address claimer = 0x15775b23340C0f50E0428D674478B0e9D3D0a759; + uint256 balanceToClaim = 1918683165360; + + // Setup claim parameters + bytes32[][] memory proofs = new bytes32[][](1); + address[] memory users = new address[](1); + address[] memory tokens = new address[](1); + uint256[] memory amounts = new uint256[](1); + + proofs[0] = new bytes32[](17); + proofs[0][0] = 0xb4273243bd0ec5add5e6d803f13bf6866ed1904d24626766ab2836454ba1ec0a; + proofs[0][1] = 0x3ee0ead23e2fe3f664ccb5e13683f27e27a4d7fefa8405545fb6421244630375; + proofs[0][2] = 0x69f54e33351af15236b33bb4695470f1af96cd1a9f154aa511ff16faa6886791; + proofs[0][3] = 0xa9d77ad46850fbfb8c196c693acdbb0c6241a2e561a8b0073ec71297a565673d; + proofs[0][4] = 0xe1b57f280e556c7f217e8d375f0cef7977a9467d5496d32bb8ec461f0d4c4f19; + proofs[0][5] = 0x0fc7ddc7cc9ecc7f7b0be5692f671394f6245ffdabe5c0fd2062eb71b7c11826; + proofs[0][6] = 0x94445a98fe6679760e5ac2edeacfe0bfa397f805c7adeaf3558a82accb78f201; + proofs[0][7] = 0x14a6fec66cdfece5c73ec44196f1414326236131ff9a60350cca603e54985c4e; + proofs[0][8] = 0x84679751230af3e3242ea1cecfc8daee3d2187ab647281cbf8c52e649a43e84c; + proofs[0][9] = 0xc0fc15960178fe4d542c93e64ec58648e5ff17bd02b27f841bd6ab838fc5ee67; + proofs[0][10] = 0x9b84efe5d11bc4de32ecd204c3962dd9270694d93a50e2840d763eaeac6c194b; + proofs[0][11] = 0x5c8025dbe663cf4b4e19fbc7b1e54259af5822fd774fd60a98e7c7a60112efe0; + proofs[0][12] = 0x301b573f9a6503ebe00ff7031a33cd41170d8b4c09a31fcafb9feb7529400a79; + proofs[0][13] = 0xc89942ad2dcb0ac96d2620ef9475945bdbe6d40a9f6c4e9f6d9437a953bf881c; + proofs[0][14] = 0xce6ca90077dc547f9a52a24d2636d659642fbae1d16c81c9e47c5747a472c63f; + proofs[0][15] = 0xe34667d2e10b515dd1f7b29dcd7990d25ea9caa7a7de571c4fb221c0a8fc82a1; + proofs[0][16] = 0x8316d6488fd22b823cc35ee673297ea2a753f0a89e5384ef20b38d053c881628; + + users[0] = claimer; + tokens[0] = address(rewardToken); + amounts[0] = balanceToClaim; + + // Record initial balance + uint256 initialBalance = rewardToken.balanceOf(claimer); + + // Perform claim + vm.prank(claimer); + distributor.claim(users, tokens, amounts, proofs); + + // Verify claim result + assertEq(rewardToken.balanceOf(claimer), initialBalance + balanceToClaim); + } + + function test_ReallocateCampaignRewards_Success_ReallocateCampaignRewards() public { + address to = 0xA9DdD91249DFdd450E81E1c56Ab60E1A62651701; + + address[] memory froms = new address[](2); + froms[0] = 0x15775b23340C0f50E0428D674478B0e9D3D0a759; + froms[1] = 0xe4BB74804edf5280c9203f034036f7CB15196078; + + vm.warp( + distributionCreator.campaign(testCampaignId).startTimestamp + + distributionCreator.campaign(testCampaignId).duration + + 1 + ); + + // Perform reallocation + vm.prank(deployer); + distributionCreator.reallocateCampaignRewards(testCampaignId, froms, to); + + // Verify reallocation results + assertEq(distributionCreator.campaignReallocation(testCampaignId, froms[0]), to); + assertEq(distributionCreator.campaignListReallocation(testCampaignId, 0), froms[0]); + assertEq(distributionCreator.campaignListReallocation(testCampaignId, 1), froms[1]); + } + + function test_OverrideCampaign_Success_UpdateCampaign() public { + uint256 amount = distributionCreator.campaign(testCampaignId).amount; + uint32 startTimestamp = distributionCreator.campaign(testCampaignId).startTimestamp; + uint32 duration = distributionCreator.campaign(testCampaignId).duration + 3600; + + // Setup campaign data + uint32 campaignType = 1; + bytes memory campaignData = abi.encode( + 0x70F796946eD919E4Bc6cD506F8dACC45E4539771, + new address[](0), + new address[](0), + "", + new bytes[](0), + new bytes[](0), + hex"" + ); + + // Mint tokens for test + vm.startPrank(deployer); + MockToken(address(rewardToken)).mint(deployer, amount); + rewardToken.approve(address(distributionCreator), amount); + + // Perform campaign update + distributionCreator.overrideCampaign( + testCampaignId, + CampaignParameters({ + campaignId: testCampaignId, + creator: deployer, + rewardToken: address(rewardToken), + amount: amount, + campaignType: campaignType, + startTimestamp: startTimestamp, + duration: duration, + campaignData: campaignData + }) + ); + + // Verify campaign update + ( + , + address campaignCreator, + address campaignRewardToken, + uint256 campaignAmount, + uint256 campaignType_, + uint32 campaignStartTimestamp, + uint32 campaignDuration, + bytes memory campaignData_ + ) = distributionCreator.campaignOverrides(testCampaignId); + + assertEq(campaignCreator, deployer); + assertEq(campaignRewardToken, address(rewardToken)); + assertEq(campaignAmount, amount); + assertEq(campaignType_, campaignType); + assertEq(campaignStartTimestamp, startTimestamp); + assertEq(campaignDuration, duration); + assertEq(campaignData_, campaignData); + + vm.stopPrank(); + } + + function test_OverrideCampaign_Success_WhenCreator() public { + vm.startPrank(distributionCreator.campaign(testCampaignId).creator); + + IERC20(address(rewardToken)).approve( + address(distributionCreator), + distributionCreator.campaign(testCampaignId).amount + ); + CampaignParameters memory newCampaign = CampaignParameters({ + campaignId: testCampaignId, + creator: distributionCreator.campaign(testCampaignId).creator, + rewardToken: distributionCreator.campaign(testCampaignId).rewardToken, + amount: distributionCreator.campaign(testCampaignId).amount, + campaignType: distributionCreator.campaign(testCampaignId).campaignType, + startTimestamp: distributionCreator.campaign(testCampaignId).startTimestamp, + duration: distributionCreator.campaign(testCampaignId).duration, + campaignData: distributionCreator.campaign(testCampaignId).campaignData + }); + + distributionCreator.overrideCampaign(testCampaignId, newCampaign); + vm.stopPrank(); } - */ - /*////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - WITH DIFFERENT POOLS - //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ + function test_OverrideCampaign_Success_WhenCreator_UpdateStartTimestampBeforeCampaignStart() public { + vm.startPrank(distributionCreator.campaign(testCampaignId).creator); + + vm.warp(distributionCreator.campaign(testCampaignId).startTimestamp - 2); + IERC20(address(rewardToken)).approve( + address(distributionCreator), + distributionCreator.campaign(testCampaignId).amount + ); + CampaignParameters memory newCampaign = CampaignParameters({ + campaignId: testCampaignId, + creator: distributionCreator.campaign(testCampaignId).creator, + rewardToken: distributionCreator.campaign(testCampaignId).rewardToken, + amount: distributionCreator.campaign(testCampaignId).amount, + campaignType: distributionCreator.campaign(testCampaignId).campaignType, + startTimestamp: distributionCreator.campaign(testCampaignId).startTimestamp + 3600, + duration: distributionCreator.campaign(testCampaignId).duration, + campaignData: distributionCreator.campaign(testCampaignId).campaignData + }); + + distributionCreator.overrideCampaign(testCampaignId, newCampaign); + vm.stopPrank(); + } + + function test_OverrideCampaign_Revert_WhenCreator_UpdateStartTimestampAfterCampaignStart() public { + vm.startPrank(distributionCreator.campaign(testCampaignId).creator); + + vm.warp(distributionCreator.campaign(testCampaignId).startTimestamp + 1); + IERC20(address(rewardToken)).approve( + address(distributionCreator), + distributionCreator.campaign(testCampaignId).amount + ); + CampaignParameters memory newCampaign = CampaignParameters({ + campaignId: testCampaignId, + creator: distributionCreator.campaign(testCampaignId).creator, + rewardToken: distributionCreator.campaign(testCampaignId).rewardToken, + amount: distributionCreator.campaign(testCampaignId).amount, + campaignType: distributionCreator.campaign(testCampaignId).campaignType, + startTimestamp: distributionCreator.campaign(testCampaignId).startTimestamp + 3600, + duration: distributionCreator.campaign(testCampaignId).duration + 3600, + campaignData: distributionCreator.campaign(testCampaignId).campaignData + }); + + vm.expectRevert(Errors.InvalidOverride.selector); + distributionCreator.overrideCampaign(testCampaignId, newCampaign); + vm.stopPrank(); + } + + function test_OverrideCampaign_Revert_WhenNonCreator() public { + address bob = makeAddr("bob"); + vm.startPrank(bob); + + CampaignParameters memory newCampaign = CampaignParameters({ + campaignId: testCampaignId, + creator: distributionCreator.campaign(testCampaignId).creator, + rewardToken: distributionCreator.campaign(testCampaignId).rewardToken, + amount: distributionCreator.campaign(testCampaignId).amount, + campaignType: distributionCreator.campaign(testCampaignId).campaignType, + startTimestamp: distributionCreator.campaign(testCampaignId).startTimestamp, + duration: distributionCreator.campaign(testCampaignId).duration, + campaignData: distributionCreator.campaign(testCampaignId).campaignData + }); + vm.expectRevert(Errors.InvalidOverride.selector); + distributionCreator.overrideCampaign(testCampaignId, newCampaign); + vm.stopPrank(); + } } + +// Commented out as it requires the DistributionCreator to be upgraded -- TODO: uncomment once upgraded +// contract Test_DistributionCreator_UpdateCampaign_BaseFork is Test, JsonReader { +// DistributionCreator public distributionCreator; +// IERC20 public rewardToken; +// address public deployer; +// bytes32 public campaignId; +// bytes public campaignData; + +// uint256 public amount; +// uint32 public startTimestamp; +// uint32 public duration; +// uint32 public campaignType; + +// function setUp() public { +// // Setup environment variables +// uint256 deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY"); +// require(deployerPrivateKey != 0, "Missing DEPLOYER_PRIVATE_KEY"); +// deployer = vm.addr(deployerPrivateKey); + +// // Fork setup +// vm.createSelectFork(vm.envString("BASE_NODE_URI")); +// uint256 chainId = block.chainid; + +// // Contract setup +// distributionCreator = DistributionCreator(this.readAddress(chainId, "Merkl.DistributionCreator")); +// require(address(distributionCreator) != address(0), "Invalid DistributionCreator address"); + +// // Token setup +// rewardToken = IERC20(0xC011882d0f7672D8942e7fE2248C174eeD640c8f); +// require(address(rewardToken) != address(0), "Invalid reward token"); + +// // Test parameters +// amount = 97 ether; +// startTimestamp = uint32(block.timestamp + 3600); +// duration = 3600 * 10; +// campaignType = 2; + +// // CLAMM campaign data +// campaignData = abi.encode( +// 0x5280d5E63b416277d0F81FAe54Bb1e0444cAbDAA, +// 5100, +// 1700, +// 3200, +// false, +// address(0), +// 1, +// new address[](0), +// new address[](0), +// "", +// new bytes[](0), +// hex"" +// ); +// } + +// function _createCampaign() internal { +// uint256 initialBalance = rewardToken.balanceOf(deployer); +// require(initialBalance >= amount, "Insufficient reward token balance"); + +// rewardToken.approve(address(distributionCreator), amount); +// require(rewardToken.allowance(deployer, address(distributionCreator)) >= amount, "Approval failed"); + +// campaignId = distributionCreator.createCampaign( +// CampaignParameters({ +// campaignId: bytes32(0), +// creator: deployer, +// rewardToken: address(rewardToken), +// amount: amount, +// campaignType: campaignType, +// startTimestamp: startTimestamp, +// duration: duration, +// campaignData: campaignData +// }) +// ); +// require(campaignId != bytes32(0), "Campaign creation failed"); +// } + +// function _verifyCampaignOverride(uint256 newAmount, uint32 newStartTimestamp, uint32 newDuration) internal { +// ( +// , +// address campaignCreator, +// address campaignRewardToken, +// uint256 campaignAmount, +// uint256 campaignCampaignType, +// uint32 campaignStartTimestamp, +// uint32 campaignDuration, +// bytes memory campaignCampaignData +// ) = distributionCreator.campaignOverrides(campaignId); + +// assertEq(campaignCreator, deployer, "Invalid creator"); +// assertEq(campaignRewardToken, address(rewardToken), "Invalid reward token"); +// assertEq(campaignAmount, newAmount, "Invalid amount"); +// assertEq(campaignCampaignType, campaignType, "Invalid campaign type"); +// assertEq(campaignStartTimestamp, newStartTimestamp, "Invalid start timestamp"); +// assertEq(campaignDuration, newDuration, "Invalid duration"); +// assertEq(campaignCampaignData, campaignData, "Invalid campaign data"); +// } + +// function test_updateCampaign() public { +// vm.startBroadcast(deployer); + +// // Create initial campaign +// _createCampaign(); + +// // Time progression +// vm.warp(block.timestamp + 1800); + +// // Override setup +// uint32 newStartTimestamp = startTimestamp + 3600; +// uint32 newDuration = duration + 3600; +// uint256 newAmount = amount + 1 ether; + +// // Approve additional amount for the override +// rewardToken.approve(address(distributionCreator), newAmount); + +// // Perform override +// distributionCreator.overrideCampaign( +// campaignId, +// CampaignParameters({ +// campaignId: campaignId, +// creator: deployer, +// rewardToken: address(rewardToken), +// amount: newAmount, +// campaignType: campaignType, +// startTimestamp: newStartTimestamp, +// duration: newDuration, +// campaignData: campaignData +// }) +// ); + +// // Verify override results +// _verifyCampaignOverride(newAmount, newStartTimestamp, newDuration); + +// // Verify timestamps +// uint256 overrideTimestamp = distributionCreator.campaignOverridesTimestamp(campaignId, 0); +// assertGe(overrideTimestamp, block.timestamp - 1, "Invalid override timestamp"); + +// vm.expectRevert(); +// distributionCreator.campaignOverridesTimestamp(campaignId, 1); + +// vm.stopBroadcast(); +// } +// } diff --git a/test/Fixture.t.sol b/test/Fixture.t.sol index 2e94236..ba70cd9 100644 --- a/test/Fixture.t.sol +++ b/test/Fixture.t.sol @@ -26,7 +26,7 @@ contract Fixture is Test { MockTokenPermit public token0; MockTokenPermit public token1; - MockAccessControl public AccessControlManager; + MockAccessControl public accessControlManager; MockUniswapV3Pool public pool; DistributionCreator public creatorImpl; DistributionCreator public creator; @@ -64,7 +64,7 @@ contract Fixture is Test { token1 = MockTokenPermit(address(new MockTokenPermit("token1", "TOKEN1", 18))); // side - AccessControlManager = new MockAccessControl(); + accessControlManager = new MockAccessControl(); pool = new MockUniswapV3Pool(); // DistributionCreator @@ -74,9 +74,9 @@ contract Fixture is Test { // Set pool.setToken(address(token0), 0); pool.setToken(address(token1), 1); - AccessControlManager.toggleGuardian(address(guardian)); - AccessControlManager.toggleGovernor(address(governor)); - creator.initialize(IAccessControlManager(address(AccessControlManager)), address(bob), 1e8); + accessControlManager.toggleGuardian(address(guardian)); + accessControlManager.toggleGovernor(address(governor)); + creator.initialize(IAccessControlManager(address(accessControlManager)), address(bob), 1e8); } /*////////////////////////////////////////////////////////////////////////////////////////////////////////////////// diff --git a/test/unit/Disputer.t.sol b/test/unit/Disputer.t.sol index 7933611..5bc139d 100644 --- a/test/unit/Disputer.t.sol +++ b/test/unit/Disputer.t.sol @@ -9,6 +9,7 @@ import { Disputer } from "../../contracts/Disputer.sol"; import { DistributorTest } from "./Distributor.t.sol"; import { IAccessControlManager } from "../../contracts/interfaces/IAccessControlManager.sol"; import { Errors } from "../../contracts/utils/Errors.sol"; + contract DisputerTest is DistributorTest { Disputer public disputer; @@ -75,7 +76,7 @@ contract DisputerTest is DistributorTest { // set up new distributor distributorImpl = new Distributor(); distributor = Distributor(deployUUPS(address(distributorImpl), hex"")); - distributor.initialize(IAccessControlManager(address(AccessControlManager))); + distributor.initialize(IAccessControlManager(address(accessControlManager))); distributor.setDisputeAmount(1e18); distributor.setDisputePeriod(1 days); diff --git a/test/unit/DistributionCreator.t.sol b/test/unit/DistributionCreator.t.sol index 04d5155..5930d63 100644 --- a/test/unit/DistributionCreator.t.sol +++ b/test/unit/DistributionCreator.t.sol @@ -8,6 +8,7 @@ import { DistributionCreator, DistributionParameters, CampaignParameters, Reward import { Errors } from "../../contracts/utils/Errors.sol"; import { Fixture, IERC20 } from "../Fixture.t.sol"; import { IAccessControlManager } from "../../contracts/interfaces/IAccessControlManager.sol"; +import { JsonReader } from "../../scripts/utils/JsonReader.sol"; contract DistributionCreatorTest is Fixture { using SafeERC20 for IERC20; @@ -108,19 +109,19 @@ contract Test_DistributionCreator_Initialize is DistributionCreatorTest { d.initialize(IAccessControlManager(address(0)), address(bob), 1e8); vm.expectRevert(Errors.ZeroAddress.selector); - d.initialize(IAccessControlManager(address(AccessControlManager)), address(0), 1e8); + d.initialize(IAccessControlManager(address(accessControlManager)), address(0), 1e8); } function test_RevertWhen_InvalidParam() public { vm.expectRevert(Errors.InvalidParam.selector); - d.initialize(IAccessControlManager(address(AccessControlManager)), address(bob), 1e9); + d.initialize(IAccessControlManager(address(accessControlManager)), address(bob), 1e9); } function test_Success() public { - d.initialize(IAccessControlManager(address(AccessControlManager)), address(bob), 1e8); + d.initialize(IAccessControlManager(address(accessControlManager)), address(bob), 1e8); assertEq(address(d.distributor()), address(bob)); - assertEq(address(d.accessControlManager()), address(AccessControlManager)); + assertEq(address(d.accessControlManager()), address(accessControlManager)); assertEq(d.defaultFees(), 1e8); } } @@ -952,37 +953,3 @@ contract Test_DistributionCreator_distribution is DistributionCreatorForkTest { ); } } - -contract Test_DistributionCreator_getDistributionsBetweenEpochs is DistributionCreatorForkTest { - function test_Success() public view { - (DistributionParameters[] memory distributions, ) = creator.getDistributionsBetweenEpochs( - 1681380000, - 1681380000 + 3600, - 0, - 1 // get only the first distribution (instead of type(uint32).max), as there are too many which makes the test fail because of rpc archive limit - ); - - assertEq(distributions.length, 1); - assertEq(distributions[0].uniV3Pool, address(0x149e36E72726e0BceA5c59d40df2c43F60f5A22D)); - assertEq(distributions[0].rewardToken, address(0xE0688A2FE90d0f93F17f273235031062a210d691)); - assertEq(distributions[0].amount, 9700000000000000000000); - assertEq(distributions[0].positionWrappers.length, 0); - assertEq(distributions[0].wrapperTypes.length, 0); - assertEq(distributions[0].propToken0, 2000); - assertEq(distributions[0].propToken1, 5000); - assertEq(distributions[0].propFees, 3000); - assertEq(distributions[0].isOutOfRangeIncentivized, 0); - assertEq(distributions[0].epochStart, 1681380000); - assertEq(distributions[0].numEpoch, 24); - assertEq(distributions[0].boostedReward, 0); - assertEq(distributions[0].boostingAddress, address(0)); - assertEq( - distributions[0].rewardId, - bytes32(0x7570c9deb1660ed82ff01f760b2883edb9bdb881933b0e4085854d0d717ea268) - ); - assertEq( - distributions[0].additionalData, - hex"290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563" - ); - } -} diff --git a/test/unit/Distributor.t.sol b/test/unit/Distributor.t.sol index 22a39ed..a2427b2 100644 --- a/test/unit/Distributor.t.sol +++ b/test/unit/Distributor.t.sol @@ -18,7 +18,7 @@ contract DistributorTest is Fixture { distributorImpl = new Distributor(); distributor = Distributor(deployUUPS(address(distributorImpl), hex"")); - distributor.initialize(IAccessControlManager(address(AccessControlManager))); + distributor.initialize(IAccessControlManager(address(accessControlManager))); vm.startPrank(governor); distributor.setDisputeAmount(1e18); @@ -53,9 +53,9 @@ contract Test_Distributor_Initialize is DistributorTest { } function test_Success() public { - d.initialize(IAccessControlManager(address(AccessControlManager))); + d.initialize(IAccessControlManager(address(accessControlManager))); - assertEq(address(AccessControlManager), address(d.accessControlManager())); + assertEq(address(accessControlManager), address(d.accessControlManager())); } } @@ -73,6 +73,24 @@ contract Test_Distributor_toggleTrusted is DistributorTest { assertEq(distributor.canUpdateMerkleRoot(bob), 0); vm.stopPrank(); } + + function test_Success_ShouldUpdateTree() public { + vm.startPrank(governor); + distributor.toggleTrusted(bob); + assertEq(distributor.canUpdateMerkleRoot(bob), 1); + vm.stopPrank(); + + assertEq(distributor.getMerkleRoot(), bytes32(0)); + + bytes32 root = getRoot(); + vm.startPrank(bob); + distributor.updateTree(MerkleTree({ merkleRoot: root, ipfsHash: keccak256("IPFS_HASH") })); + vm.stopPrank(); + + vm.warp(distributor.endOfDisputePeriod() + 1); + + assertEq(distributor.getMerkleRoot(), root); + } } contract Test_Distributor_toggleOperator is DistributorTest { @@ -93,23 +111,6 @@ contract Test_Distributor_toggleOperator is DistributorTest { } } -contract Test_Distributor_toggleOnlyOperatorCanClaim is DistributorTest { - function test_RevertWhen_NotTrusted() public { - vm.expectRevert(Errors.NotTrusted.selector); - distributor.toggleOnlyOperatorCanClaim(bob); - } - - function test_Success() public { - vm.prank(governor); - distributor.toggleOnlyOperatorCanClaim(bob); - assertEq(distributor.onlyOperatorCanClaim(bob), 1); - - vm.prank(bob); - distributor.toggleOnlyOperatorCanClaim(bob); - assertEq(distributor.onlyOperatorCanClaim(bob), 0); - } -} - contract Test_Distributor_recoverERC20 is DistributorTest { function test_RevertWhen_NotGovernor() public { vm.expectRevert(Errors.NotGovernor.selector); @@ -379,9 +380,6 @@ contract Test_Distributor_claim is DistributorTest { vm.warp(distributor.endOfDisputePeriod() + 1); - vm.prank(bob); - distributor.toggleOnlyOperatorCanClaim(bob); - bytes32[][] memory proofs = new bytes32[][](1); address[] memory users = new address[](1); address[] memory tokens = new address[](1); @@ -534,3 +532,107 @@ contract Test_Distributor_claim is DistributorTest { assertEq(agEUR.balanceOf(address(bob)), bobBalance + 5e17); } } + +contract Test_Distributor_claimWithRecipient is DistributorTest { + function test_RevertWhen_NotWhitelisted() public { + vm.prank(governor); + distributor.updateTree(MerkleTree({ merkleRoot: getRoot(), ipfsHash: keccak256("IPFS_HASH") })); + + vm.warp(distributor.endOfDisputePeriod() + 1); + + bytes32[][] memory proofs = new bytes32[][](1); + address[] memory users = new address[](1); + address[] memory tokens = new address[](1); + uint256[] memory amounts = new uint256[](1); + address[] memory recipients = new address[](1); + bytes[] memory datas = new bytes[](1); + proofs[0] = new bytes32[](1); + users[0] = bob; + tokens[0] = address(angle); + amounts[0] = 1e18; + recipients[0] = alice; + + vm.expectRevert(Errors.NotWhitelisted.selector); + vm.prank(alice); + distributor.claimWithRecipient(users, tokens, amounts, proofs, recipients, datas); + } + + function test_Success_WithCustomRecipient() public { + vm.prank(governor); + distributor.updateTree( + MerkleTree({ + merkleRoot: bytes32(0x0b70a97c062cb747158b89e27df5bbda859ba072232efcbe92e383e9d74b8555), + ipfsHash: keccak256("IPFS_HASH") + }) + ); + + angle.mint(address(distributor), 1e18); + + vm.warp(distributor.endOfDisputePeriod() + 1); + + bytes32[][] memory proofs = new bytes32[][](1); + address[] memory users = new address[](1); + address[] memory tokens = new address[](1); + uint256[] memory amounts = new uint256[](1); + address[] memory recipients = new address[](1); + bytes[] memory datas = new bytes[](1); + proofs[0] = new bytes32[](1); + proofs[0][0] = bytes32(0x6f46ee2909b99367a0d9932a11f1bdb85c9354480c9de277d21086f9a8925c0a); + users[0] = alice; + tokens[0] = address(angle); + amounts[0] = 1e18; + recipients[0] = bob; + + uint256 bobBalance = angle.balanceOf(address(bob)); + + vm.prank(alice); + distributor.claimWithRecipient(users, tokens, amounts, proofs, recipients, datas); + + assertEq(angle.balanceOf(address(bob)), bobBalance + 1e18); + } +} + +contract Test_Distributor_revokeUpgradeability is DistributorTest { + function test_RevertWhen_NotGovernor() public { + vm.expectRevert(Errors.NotGovernor.selector); + distributor.revokeUpgradeability(); + } + + function test_Success() public { + vm.prank(governor); + distributor.revokeUpgradeability(); + assertEq(distributor.upgradeabilityDeactivated(), 1); + + // Verify that upgrades are no longer possible + vm.startPrank(governor); + Distributor impl = new Distributor(); + vm.expectRevert(Errors.NotUpgradeable.selector); + distributor.upgradeTo(address(impl)); + vm.stopPrank(); + } +} + +contract Test_Distributor_setEpochDuration is DistributorTest { + function test_RevertWhen_NotGovernor() public { + vm.expectRevert(Errors.NotGovernor.selector); + distributor.setEpochDuration(7200); + } + + function test_Success() public { + // Default epoch duration should be 3600 + assertEq(distributor.getEpochDuration(), 3600); + + vm.prank(governor); + distributor.setEpochDuration(7200); + + assertEq(distributor.getEpochDuration(), 7200); + + // Verify that the new epoch duration affects dispute period calculations + vm.prank(governor); + distributor.updateTree(MerkleTree({ merkleRoot: getRoot(), ipfsHash: keccak256("IPFS_HASH") })); + + // End of dispute period should be rounded up to next 2-hour mark (7200 seconds) plus dispute period + uint256 expectedEnd = ((block.timestamp - 1) / 7200 + 1 + distributor.disputePeriod()) * 7200; + assertEq(distributor.endOfDisputePeriod(), expectedEnd); + } +}