From af5e8c56a829bc78d208093815485cb0b5530334 Mon Sep 17 00:00:00 2001 From: Pablo Veyrat Date: Wed, 15 Oct 2025 19:29:25 +0200 Subject: [PATCH 01/27] first draft --- LICENSE | 4 +- contracts/AccessControlManager.sol | 2 +- contracts/DistributionCreator.sol | 336 +++++------------- .../DistributionCreatorWithDistributions.sol | 102 ++++++ contracts/Distributor.sol | 55 ++- .../interfaces/IAccessControlManager.sol | 2 +- .../mock/DistributionCreatorUpdatable.sol | 2 +- .../partners/tokenWrappers/PointToken.sol | 2 +- .../partners/tokenWrappers/SonicFragment.sol | 2 +- 9 files changed, 243 insertions(+), 264 deletions(-) create mode 100644 contracts/DistributionCreatorWithDistributions.sol diff --git a/LICENSE b/LICENSE index 9fd406ff..e686f174 100644 --- a/LICENSE +++ b/LICENSE @@ -7,10 +7,10 @@ License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. Parameters -Licensor: Angle Labs, Inc. +Licensor: Merkl SAS. Licensed Work: Merkl Smart Contracts -The Licensed Work is (c) 2025 Angle Labs, Inc. +The Licensed Work is (c) 2025 Merkl SAS. Additional Use Grant: Any uses listed and defined at merkl-license-grants.angle-labs.eth diff --git a/contracts/AccessControlManager.sol b/contracts/AccessControlManager.sol index 700873c3..bc0353d4 100644 --- a/contracts/AccessControlManager.sol +++ b/contracts/AccessControlManager.sol @@ -8,7 +8,7 @@ import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/I import { IAccessControlManager } from "./interfaces/IAccessControlManager.sol"; /// @title AccessControlManager -/// @author Angle Labs, Inc. +/// @author Merkl SAS /// @notice This contract handles the access control across all contracts contract AccessControlManager is IAccessControlManager, Initializable, AccessControlEnumerableUpgradeable { /// @notice Role for guardians diff --git a/contracts/DistributionCreator.sol b/contracts/DistributionCreator.sol index a8615e1a..1964f0de 100644 --- a/contracts/DistributionCreator.sol +++ b/contracts/DistributionCreator.sol @@ -6,7 +6,6 @@ import { ReentrancyGuardUpgradeable } from "@openzeppelin/contracts-upgradeable/ import { IERC20 } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; -import { SignatureChecker } from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; import { UUPSHelper } from "./utils/UUPSHelper.sol"; import { IAccessControlManager } from "./interfaces/IAccessControlManager.sol"; @@ -14,16 +13,12 @@ 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. +/// @author Merkl SAS /// @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 distinguishes two types of different rewards: -/// - distributions: type of campaign for concentrated liquidity pools created before Feb 15 2024, -/// now deprecated -/// - campaigns: the more global name to describe any reward program on top of Merkl +/// @dev The deprecated variables in this contract are kept for storage layout compatibility //solhint-disable contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { using SafeERC20 for IERC20; @@ -54,27 +49,25 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { /// @notice Message that needs to be acknowledged by users creating a campaign string public message; - /// @notice Hash of the message that needs to be signed + /// @notice Hash of the message that needs to be signed or accepted bytes32 public messageHash; - /// @notice List of all rewards distributed in the contract on campaigns created before mid Feb 2024 - /// for concentrated liquidity pools + /// @notice Deprecated DistributionParameters[] public distributionList; /// @notice Maps an address to its fee rebate mapping(address => uint256) public feeRebate; - /// @notice Maps a token to whether it is whitelisted or not. No fees are to be paid for incentives given - /// on pools with whitelisted tokens + /// @notice Deprecated mapping(address => uint256) public isWhitelistedToken; - /// @notice Deprecated, kept for storage compatibility + /// @notice Deprecated mapping(address => uint256) public _nonces; - /// @notice Maps an address to the last valid hash signed + /// @notice Deprecated mapping(address => bytes32) public userSignatures; - /// @notice Maps a user to whether it is whitelisted for not signing + /// @notice Deprecated mapping(address => uint256) public userSignatureWhitelist; /// @notice Maps a token to the minimum amount that must be sent per epoch for a distribution to be valid @@ -107,10 +100,22 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { /// @notice List all reallocated address for a given campaign mapping(bytes32 => address[]) public campaignListReallocation; + /// @notice Maps a creator address to an operator to a reward token to an amount that can be pulled from the creator + mapping(address => mapping(address => mapping(address => uint256))) public creatorTokenAllowance; + + /// @notice Maps a creator to a campaign operator to the ability to manage the campaign on behalf of the creator + mapping(address => mapping(address => uint256)) public creatorCampaignOperators; + /*////////////////////////////////////////////////////////////////////////////////////////////////////////////////// EVENTS //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ + event CreatorAllowanceUpdated( + address indexed user, + address indexed operator, + address indexed token, + uint256 amount + ); event DistributorUpdated(address indexed _distributor); event FeeRebateUpdated(address indexed user, uint256 userFeeRebate); event FeeRecipientUpdated(address indexed _feeRecipient); @@ -120,11 +125,7 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { event CampaignSpecificFeesSet(uint32 campaignType, uint256 _fees); event MessageUpdated(bytes32 _messageHash); event NewCampaign(CampaignParameters campaign); - event NewDistribution(DistributionParameters distribution, address indexed sender); event RewardTokenMinimumAmountUpdated(address indexed token, uint256 amount); - event TokenWhitelistToggled(address indexed token, uint256 toggleStatus); - event UserSigned(bytes32 messageHash, address indexed user); - event UserSigningWhitelistToggled(address indexed user, uint256 toggleStatus); /*////////////////////////////////////////////////////////////////////////////////////////////////////////////////// MODIFIERS @@ -146,8 +147,8 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { modifier hasSigned() { if ( userSignatureWhitelist[msg.sender] == 0 && - userSignatures[msg.sender] != messageHash && userSignatureWhitelist[tx.origin] == 0 && + userSignatures[msg.sender] != messageHash && userSignatures[tx.origin] != messageHash ) revert Errors.NotSigned(); _; @@ -204,51 +205,11 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { return campaignIds; } - /// @notice Allows a user to accept the conditions without signing the message - /// @dev Users may either call `acceptConditions` here or `sign` the message + /// @notice Allows a user to accept the Merkl conditions (expressed in the messageHash) in order to start their campaigns function acceptConditions() external { userSignatureWhitelist[msg.sender] = 1; } - /// @notice Checks whether the `msg.sender`'s `signature` is compatible with the message - /// to sign and stores the signature - /// @dev If you signed the message once, and the message has not been modified, then you do not - /// need to sign again - function sign(bytes calldata signature) external { - _sign(signature); - } - - /// @notice Combines signing the message and creating a campaign - function signAndCreateCampaign( - CampaignParameters memory newCampaign, - bytes calldata signature - ) external returns (bytes32) { - _sign(signature); - return _createCampaign(newCampaign); - } - - /// @notice Creates a `distribution` to incentivize a given pool for a specific period of time - function createDistribution( - DistributionParameters memory newDistribution - ) external nonReentrant hasSigned returns (uint256 distributionAmount) { - return _createDistribution(newDistribution); - } - - /// @notice Same as the function above but for multiple distributions at once - function createDistributions( - DistributionParameters[] memory distributions - ) external nonReentrant hasSigned returns (uint256[] memory) { - uint256 distributionsLength = distributions.length; - uint256[] memory distributionAmounts = new uint256[](distributionsLength); - for (uint256 i; i < distributionsLength; ) { - distributionAmounts[i] = _createDistribution(distributions[i]); - unchecked { - ++i; - } - } - 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 @@ -265,10 +226,6 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { 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; @@ -277,45 +234,59 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { } /// @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 + /// @dev While this function may execute successfully, the reallocation may not be valid in the Merkl engine 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++; + for (uint256 i; i < fromsLength; ) { + campaignReallocation[_campaignId][froms[i]] = to; + campaignListReallocation[_campaignId].push(froms[i]); + unchecked { + ++i; } } - assembly { - mstore(successfullFrom, count) - } + emit CampaignReallocation(_campaignId, froms, to); + } + + /// @dev If a governor address calls this function, the user MUST have transferred the funds to the contract beforehand + function increaseCreatorTokenAllowance( + address user, + address operator, + address rewardToken, + uint256 amount + ) external { + if (operator == address(0)) revert Errors.ZeroAddress(); + if (user == msg.sender) IERC20(rewardToken).safeTransferFrom(msg.sender, address(this), amount); + else if (!accessControlManager.isGovernor(msg.sender)) revert Errors.NotGovernor(); + + uint256 currentAllowance = creatorTokenAllowance[user][operator][rewardToken]; + creatorTokenAllowance[user][operator][rewardToken] = currentAllowance + amount; + emit CreatorAllowanceUpdated(user, operator, rewardToken, currentAllowance + amount); + } + + function decreaseCreatorTokenAllowance( + address user, + address operator, + address rewardToken, + uint256 amount + ) external { + if (operator == address(0)) revert Errors.ZeroAddress(); + uint256 currentAllowance = creatorTokenAllowance[user][operator][rewardToken]; + uint256 updateAmount = amount > currentAllowance ? currentAllowance : amount; + creatorTokenAllowance[user][operator][rewardToken] = currentAllowance - updateAmount; + if (user == msg.sender) IERC20(rewardToken).safeTransfer(msg.sender, updateAmount); + else if (!accessControlManager.isGovernor(msg.sender)) revert Errors.NotGovernor(); - if (count == 0) revert Errors.InvalidOverride(); - emit CampaignReallocation(_campaignId, successfullFrom, to); + emit CreatorAllowanceUpdated(user, operator, rewardToken, currentAllowance - updateAmount); } /*////////////////////////////////////////////////////////////////////////////////////////////////////////////////// GETTERS //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ - /// @notice Returns the distribution at a given index converted into a campaign - function distribution(uint256 index) external view returns (CampaignParameters memory) { - return _convertDistribution(distributionList[index]); - } - /// @notice Returns the index of a campaign in the campaign list function campaignLookup(bytes32 _campaignId) public view returns (uint256) { uint256 index = _campaignLookup[_campaignId]; @@ -330,16 +301,7 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { } /// @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` - /// - `campaign.startTimestamp` - /// - `campaign.duration` - /// - `campaign.campaignData` - /// This prevents the creation by the same account of two campaigns with the same parameters - /// which is not a huge issue + /// @dev The campaign ID is computed as the hash of various parameters function campaignId(CampaignParameters memory campaignData) public view returns (bytes32) { return bytes32( @@ -372,26 +334,12 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { return _getValidRewardTokens(skip, first); } - /// @notice Gets all the campaigns 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 searchCampaigns Eligible campaigns - /// @return lastIndexCampaign Index of the last campaign assessed in the list of all campaigns - /// @dev For pagniation purpose, in case of out of gas, you can call back the same function but with `skip` set to `lastIndexCampaign` - /// @dev Not to be queried on-chain and hence not optimized for gas consumption - function getCampaignsBetween( - uint32 start, - uint32 end, - uint32 skip, - uint32 first - ) external view returns (CampaignParameters[] memory, uint256 lastIndexCampaign) { - return _getCampaignsBetween(start, end, skip, first); - } - + /// @notice Gets the list of timestamps for when a campaign was overridden function getCampaignOverridesTimestamp(bytes32 _campaignId) external view returns (uint256[] memory) { return campaignOverridesTimestamp[_campaignId]; } + /// @notice Gets the list of addresses from which rewards were reallocated for a given campaign function getCampaignListReallocation(bytes32 _campaignId) external view returns (address[] memory) { return campaignListReallocation[_campaignId]; } @@ -431,7 +379,7 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { emit FeeRecipientUpdated(_feeRecipient); } - /// @notice Sets the message that needs to be signed by users before posting rewards + /// @notice Sets the message that needs to be accepted by users before posting rewards function setMessage(string memory _message) external onlyGovernor { message = _message; bytes32 _messageHash = ECDSA.toEthSignedMessageHash(bytes(_message)); @@ -447,13 +395,6 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { emit CampaignSpecificFeesSet(campaignType, _fees); } - /// @notice Toggles the fee whitelist for `token` - function toggleTokenWhitelist(address token) external onlyGovernorOrGuardian { - uint256 toggleStatus = 1 - isWhitelistedToken[token]; - isWhitelistedToken[token] = toggleStatus; - emit TokenWhitelistToggled(token, toggleStatus); - } - /// @notice Sets fee rebates for a given user function setUserFeeRebate(address user, uint256 userFeeRebate) external onlyGovernorOrGuardian { feeRebate[user] = userFeeRebate; @@ -467,23 +408,19 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { ) external onlyGovernorOrGuardian { uint256 tokensLength = tokens.length; if (tokensLength != amounts.length) revert Errors.InvalidLengths(); - for (uint256 i; i < tokensLength; ++i) { + for (uint256 i; i < tokensLength; ) { 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]); rewardTokenMinAmounts[tokens[i]] = amount; emit RewardTokenMinimumAmountUpdated(tokens[i], amount); + unchecked { + ++i; + } } } - /// @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]; - userSignatureWhitelist[user] = whitelistStatus; - emit UserSigningWhitelistToggled(user, whitelistStatus); - } - /*////////////////////////////////////////////////////////////////////////////////////////////////////////////////// INTERNAL //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ @@ -502,12 +439,8 @@ 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 campaignAmountMinusFees = _computeFees( - newCampaign.campaignType, - newCampaign.amount, - newCampaign.rewardToken - ); - IERC20(newCampaign.rewardToken).safeTransferFrom(msg.sender, distributor, campaignAmountMinusFees); + uint256 campaignAmountMinusFees = _computeFees(newCampaign.campaignType, newCampaign.amount); + _pullTokens(newCampaign.creator, newCampaign.rewardToken, newCampaign.amount, campaignAmountMinusFees); newCampaign.amount = campaignAmountMinusFees; newCampaign.campaignId = campaignId(newCampaign); @@ -519,68 +452,32 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { return newCampaign.campaignId; } - /// @notice Creates a distribution from a deprecated distribution type - function _createDistribution(DistributionParameters memory newDistribution) internal returns (uint256) { - _createCampaign(_convertDistribution(newDistribution)); - // Not gas efficient but deprecated - return campaignList[campaignList.length - 1].amount; - } - - /// @notice Converts the deprecated distribution type into a campaign - function _convertDistribution( - DistributionParameters memory distributionToConvert - ) internal view returns (CampaignParameters memory) { - uint256 wrapperLength = distributionToConvert.wrapperTypes.length; - address[] memory whitelist = new address[](wrapperLength); - address[] memory blacklist = new address[](wrapperLength); - uint256 whitelistLength; - uint256 blacklistLength; - for (uint256 k = 0; k < wrapperLength; k++) { - if (distributionToConvert.wrapperTypes[k] == 0) { - whitelist[whitelistLength] = (distributionToConvert.positionWrappers[k]); - whitelistLength += 1; - } - if (distributionToConvert.wrapperTypes[k] == 3) { - blacklist[blacklistLength] = (distributionToConvert.positionWrappers[k]); - blacklistLength += 1; - } - } - - assembly { - mstore(whitelist, whitelistLength) - mstore(blacklist, blacklistLength) + function _pullTokens( + address creator, + address rewardToken, + uint256 campaignAmount, + uint256 campaignAmountMinusFees + ) internal { + uint256 senderAllowance = creatorTokenAllowance[creator][msg.sender][rewardToken]; + uint256 fees = campaignAmount - campaignAmountMinusFees; + address _feeRecipient = feeRecipient; + _feeRecipient = _feeRecipient == address(0) ? address(this) : _feeRecipient; + if (senderAllowance > campaignAmount) { + creatorTokenAllowance[creator][msg.sender][rewardToken] = senderAllowance - campaignAmount; + emit CreatorAllowanceUpdated(creator, msg.sender, rewardToken, senderAllowance - campaignAmount); + if (fees > 0) IERC20(rewardToken).safeTransfer(_feeRecipient, fees); + IERC20(rewardToken).safeTransfer(distributor, campaignAmountMinusFees); + } else { + if (fees > 0) IERC20(rewardToken).safeTransferFrom(msg.sender, _feeRecipient, fees); + IERC20(rewardToken).safeTransferFrom(msg.sender, distributor, campaignAmountMinusFees); } - - return - CampaignParameters({ - campaignId: distributionToConvert.rewardId, - creator: msg.sender, - rewardToken: distributionToConvert.rewardToken, - amount: distributionToConvert.amount, - campaignType: 2, - startTimestamp: distributionToConvert.epochStart, - duration: distributionToConvert.numEpoch * HOUR, - campaignData: abi.encode( - distributionToConvert.uniV3Pool, - distributionToConvert.propFees, // eg. 6000 - distributionToConvert.propToken0, // eg. 3000 - distributionToConvert.propToken1, // eg. 1000 - distributionToConvert.isOutOfRangeIncentivized, // eg. 0 - distributionToConvert.boostingAddress, // eg. NULL_ADDRESS - distributionToConvert.boostedReward, // eg. 0 - whitelist, // eg. [] - blacklist, // eg. [] - "0x" - ) - }); } /// @notice Computes the fees to be taken on a campaign and transfers them to the fee recipient function _computeFees( uint32 campaignType, - uint256 distributionAmount, - address rewardToken - ) internal returns (uint256 distributionAmountMinusFees) { + uint256 distributionAmount + ) internal view returns (uint256 distributionAmountMinusFees) { uint256 baseFeesValue = campaignSpecificFees[campaignType]; if (baseFeesValue == 1) baseFeesValue = 0; else if (baseFeesValue == 0) baseFeesValue = defaultFees; @@ -589,60 +486,7 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { distributionAmountMinusFees = distributionAmount; if (_fees != 0) { distributionAmountMinusFees = (distributionAmount * (BASE_9 - _fees)) / BASE_9; - address _feeRecipient = feeRecipient; - _feeRecipient = _feeRecipient == address(0) ? address(this) : _feeRecipient; - IERC20(rewardToken).safeTransferFrom( - msg.sender, - _feeRecipient, - distributionAmount - distributionAmountMinusFees - ); - } - } - - /// @notice Internal version of the `sign` function - function _sign(bytes calldata signature) internal { - bytes32 _messageHash = messageHash; - if (!SignatureChecker.isValidSignatureNow(msg.sender, _messageHash, signature)) - revert Errors.InvalidSignature(); - userSignatures[msg.sender] = _messageHash; - emit UserSigned(_messageHash, msg.sender); - } - - /// @notice Rounds an `epoch` timestamp to the start of the corresponding period - function _getRoundedEpoch(uint32 epoch) internal pure returns (uint32) { - return (epoch / HOUR) * HOUR; - } - - /// @notice Internal version of `getCampaignsBetween` - function _getCampaignsBetween( - uint32 start, - uint32 end, - uint32 skip, - uint32 first - ) internal view returns (CampaignParameters[] memory, uint256) { - uint256 length; - uint256 campaignListLength = campaignList.length; - uint256 returnSize = first > campaignListLength ? campaignListLength : first; - CampaignParameters[] memory activeRewards = new CampaignParameters[](returnSize); - uint32 i = skip; - while (i < campaignListLength) { - CampaignParameters memory campaignToProcess = campaignList[i]; - if ( - campaignToProcess.startTimestamp + campaignToProcess.duration > start && - campaignToProcess.startTimestamp < end - ) { - activeRewards[length] = campaignToProcess; - length += 1; - } - unchecked { - ++i; - } - if (length == returnSize) break; - } - assembly { - mstore(activeRewards, length) } - return (activeRewards, i); } /// @notice Builds the list of valid reward tokens diff --git a/contracts/DistributionCreatorWithDistributions.sol b/contracts/DistributionCreatorWithDistributions.sol new file mode 100644 index 00000000..35a6c1e1 --- /dev/null +++ b/contracts/DistributionCreatorWithDistributions.sol @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: BUSL-1.1 + +pragma solidity ^0.8.17; + +import { ReentrancyGuardUpgradeable } from "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import { SignatureChecker } from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; + +import { UUPSHelper } from "./utils/UUPSHelper.sol"; +import { IAccessControlManager } from "./interfaces/IAccessControlManager.sol"; +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"; +import { DistributionCreator } from "./DistributionCreator.sol"; + +/// @title DistributionCreatorWithDistributions +/// @author Merkl SAS +/// @notice Version of the DistributionCreator contract with the ability to create campaigns following the old +/// standard +/// @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 more global name to describe any reward program on top of Merkl +/// @dev Useful notably on Polygon where some creators still use the old distribution model +//solhint-disable +contract DistributionCreatorWithDistributions is DistributionCreator { + using SafeERC20 for IERC20; + + /// @notice Creates a `distribution` to incentivize a given pool for a specific period of time + function createDistribution( + DistributionParameters memory newDistribution + ) external nonReentrant hasSigned returns (uint256 distributionAmount) { + return _createDistribution(newDistribution); + } + + /// @notice Creates a distribution from a deprecated distribution type + function _createDistribution(DistributionParameters memory newDistribution) internal returns (uint256) { + _createCampaign(_convertDistribution(newDistribution)); + // Not gas efficient but deprecated + return campaignList[campaignList.length - 1].amount; + } + + /// @notice Converts the deprecated distribution type into a campaign + function _convertDistribution( + DistributionParameters memory distributionToConvert + ) internal view returns (CampaignParameters memory) { + uint256 wrapperLength = distributionToConvert.wrapperTypes.length; + address[] memory whitelist = new address[](wrapperLength); + address[] memory blacklist = new address[](wrapperLength); + uint256 whitelistLength; + uint256 blacklistLength; + for (uint256 k = 0; k < wrapperLength; k++) { + if (distributionToConvert.wrapperTypes[k] == 0) { + whitelist[whitelistLength] = (distributionToConvert.positionWrappers[k]); + whitelistLength += 1; + } + if (distributionToConvert.wrapperTypes[k] == 3) { + blacklist[blacklistLength] = (distributionToConvert.positionWrappers[k]); + blacklistLength += 1; + } + } + + assembly { + mstore(whitelist, whitelistLength) + mstore(blacklist, blacklistLength) + } + + return + CampaignParameters({ + campaignId: distributionToConvert.rewardId, + creator: msg.sender, + rewardToken: distributionToConvert.rewardToken, + amount: distributionToConvert.amount, + campaignType: 2, + startTimestamp: distributionToConvert.epochStart, + duration: distributionToConvert.numEpoch * HOUR, + campaignData: abi.encode( + distributionToConvert.uniV3Pool, + distributionToConvert.propFees, // eg. 6000 + distributionToConvert.propToken0, // eg. 3000 + distributionToConvert.propToken1, // eg. 1000 + distributionToConvert.isOutOfRangeIncentivized, // eg. 0 + distributionToConvert.boostingAddress, // eg. NULL_ADDRESS + distributionToConvert.boostedReward, // eg. 0 + whitelist, // eg. [] + blacklist, // eg. [] + "0x" + ) + }); + } + + /** + * @dev This empty reserved space is put in place to allow future versions to add new + * variables without shifting down storage in the inheritance chain. + * See https://docs.openzeppelin.com/contracts/4.x/upgradeable#storage_gaps + */ + uint256[31] private __gap; +} diff --git a/contracts/Distributor.sol b/contracts/Distributor.sol index 60c8d50b..475fa33d 100644 --- a/contracts/Distributor.sol +++ b/contracts/Distributor.sol @@ -97,7 +97,10 @@ contract Distributor is UUPSHelper { /// @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; + /// @notice User -> Token -> authorisation to claim on behalf of every user for this token + mapping(address => mapping(address => uint256)) public mainOperators; + + uint256[35] private __gap; /*////////////////////////////////////////////////////////////////////////////////////////////////////////////////// EVENTS @@ -111,6 +114,7 @@ contract Distributor is UUPSHelper { event DisputeResolved(bool valid); event DisputeTokenUpdated(address indexed _disputeToken); event EpochDurationUpdated(uint32 newEpochDuration); + event MainOperatorStatusUpdated(address indexed operator, address indexed token, bool isWhitelisted); 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); @@ -129,13 +133,15 @@ contract Distributor is UUPSHelper { _; } + /// @notice Checks whether the `msg.sender` has the guardian role + modifier onlyGuardian() { + if (!accessControlManager.isGovernorOrGuardian(msg.sender)) revert Errors.NotGovernorOrGuardian(); + _; + } + /// @notice Checks whether the `msg.sender` is the `user` address or is a trusted address modifier onlyTrustedOrUser(address user) { - if ( - user != msg.sender && - canUpdateMerkleRoot[msg.sender] != 1 && - !accessControlManager.isGovernorOrGuardian(msg.sender) - ) revert Errors.NotTrusted(); + if (user != msg.sender && !accessControlManager.isGovernorOrGuardian(msg.sender)) revert Errors.NotTrusted(); _; } @@ -228,6 +234,7 @@ contract Distributor is UUPSHelper { /// @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 + /// @dev Setting the operator address to the zero address enables any function toggleOperator(address user, address operator) external onlyTrustedOrUser(user) { uint256 oldValue = operators[user][operator]; operators[user][operator] = 1 - oldValue; @@ -239,9 +246,22 @@ contract Distributor is UUPSHelper { /// 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 + /// @dev Setting the zero address for a token will set the default recipient for all tokens function setClaimRecipient(address recipient, address token) external { - claimRecipient[msg.sender][token] = recipient; - emit ClaimRecipientUpdated(msg.sender, recipient, token); + _setClaimRecipient(msg.sender, recipient, token); + } + + /// @notice Sets a recipient for a user claiming rewards for a token, through governance + /// @dev This is a sensitive operation so can only be performed by an address with governor rights + /// @dev Setting the zero address for a token will set the default recipient for all tokens + function setClaimRecipientWithGov(address user, address recipient, address token) external onlyGovernor { + _setClaimRecipient(user, recipient, token); + } + + function toggleMainOperatorStatus(address operator, address token) external onlyGuardian { + uint256 oldValue = mainOperators[operator][token]; + mainOperators[operator][token] = 1 - oldValue; + emit MainOperatorStatusUpdated(operator, token, oldValue == 0); } /// @notice Freezes the Merkle tree update until the dispute is resolved @@ -374,9 +394,15 @@ contract Distributor is UUPSHelper { 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(); + // Only approved operators can claim for `user` + if ( + msg.sender != user && + tx.origin != user && + mainOperators[msg.sender][token] == 0 && + mainOperators[msg.sender][address(0)] == 0 && + operators[user][msg.sender] == 0 && + operators[user][address(0)] == 0 + ) revert Errors.NotWhitelisted(); // Verifying proof bytes32 leaf = keccak256(abi.encode(user, token, amount)); @@ -392,6 +418,7 @@ contract Distributor is UUPSHelper { // 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)) userSetRecipient = claimRecipient[user][address(0)]; if (userSetRecipient == address(0)) recipient = user; else recipient = userSetRecipient; } @@ -454,4 +481,10 @@ contract Distributor is UUPSHelper { if (root == bytes32(0)) revert Errors.InvalidUninitializedRoot(); return currentHash == root; } + + /// @notice Internal version of `setClaimRecipient` and `setClaimRecipientWithGov` + function _setClaimRecipient(address user, address recipient, address token) internal { + claimRecipient[user][token] = recipient; + emit ClaimRecipientUpdated(user, recipient, token); + } } diff --git a/contracts/interfaces/IAccessControlManager.sol b/contracts/interfaces/IAccessControlManager.sol index ef1ac3d4..c7707748 100644 --- a/contracts/interfaces/IAccessControlManager.sol +++ b/contracts/interfaces/IAccessControlManager.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.17; /// @title IAccessControlManager -/// @author Angle Labs, Inc. +/// @author Merkl SAS /// @notice Interface for the `AccessControlManager` contracts of Merkl contracts interface IAccessControlManager { /// @notice Checks whether an address is governor diff --git a/contracts/mock/DistributionCreatorUpdatable.sol b/contracts/mock/DistributionCreatorUpdatable.sol index b42342b9..10087a82 100644 --- a/contracts/mock/DistributionCreatorUpdatable.sol +++ b/contracts/mock/DistributionCreatorUpdatable.sol @@ -38,7 +38,7 @@ pragma solidity ^0.8.17; import { DistributionCreator } from "../DistributionCreator.sol"; import { IAccessControlManager } from "../interfaces/IAccessControlManager.sol"; /// @title DistributionCreatorUpdatable -/// @author Angle Labs, Inc. +/// @author Merkl SAS //solhint-disable contract DistributionCreatorUpdatable is DistributionCreator { uint8 public accessControlManagerUpdated; diff --git a/contracts/partners/tokenWrappers/PointToken.sol b/contracts/partners/tokenWrappers/PointToken.sol index 7c2a3f3a..82447049 100644 --- a/contracts/partners/tokenWrappers/PointToken.sol +++ b/contracts/partners/tokenWrappers/PointToken.sol @@ -7,7 +7,7 @@ import { IAccessControlManager } from "../../interfaces/IAccessControlManager.so import "../../utils/Errors.sol"; /// @title PointToken -/// @author Angle Labs, Inc. +/// @author Merkl SAS /// @notice Reference contract for points systems within Merkl contract PointToken is ERC20 { mapping(address => bool) public minters; diff --git a/contracts/partners/tokenWrappers/SonicFragment.sol b/contracts/partners/tokenWrappers/SonicFragment.sol index 12aa0357..95a29409 100644 --- a/contracts/partners/tokenWrappers/SonicFragment.sol +++ b/contracts/partners/tokenWrappers/SonicFragment.sol @@ -11,7 +11,7 @@ import { Errors } from "../../utils/Errors.sol"; /// @title SonicFragment /// @notice Contract for Sonic fragments which can be converted upon activation into S tokens -/// @author Angle Labs, Inc. +/// @author Merkl SAS contract SonicFragment is ERC20 { using SafeERC20 for IERC20; From 5cc1b1b2d29c8343c8c4471d134e6a56f89fed03 Mon Sep 17 00:00:00 2001 From: Pablo Veyrat Date: Thu, 16 Oct 2025 18:09:15 +0200 Subject: [PATCH 02/27] fix: compile for the code --- README.md | 3 - contracts/DistributionCreator.sol | 40 +++++- .../DistributionCreatorWithDistributions.sol | 5 + contracts/utils/Errors.sol | 2 + scripts/DistributionCreator.s.sol | 98 ------------- test/DistributionCreator.t.sol | 12 -- test/Fixture.t.sol | 10 +- test/unit/DistributionCreator.t.sol | 135 +----------------- 8 files changed, 52 insertions(+), 253 deletions(-) diff --git a/README.md b/README.md index 083ee925..62059f73 100644 --- a/README.md +++ b/README.md @@ -100,9 +100,6 @@ forge script scripts/DistributionCreator.s.sol:SetRewardTokenMinAmounts --rpc-ur forge script scripts/DistributionCreator.s.sol:SetCampaignFees --rpc-url --sender
--broadcast \ --sig "run(uint32,uint256)" -# Toggle token whitelist status -forge script scripts/DistributionCreator.s.sol:ToggleTokenWhitelist --rpc-url --sender
--broadcast \ - --sig "run(address)" ``` For scripts without parameters, you can modify the default values directly in the script file: diff --git a/contracts/DistributionCreator.sol b/contracts/DistributionCreator.sol index 1964f0de..445bf87a 100644 --- a/contracts/DistributionCreator.sol +++ b/contracts/DistributionCreator.sol @@ -104,7 +104,7 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { mapping(address => mapping(address => mapping(address => uint256))) public creatorTokenAllowance; /// @notice Maps a creator to a campaign operator to the ability to manage the campaign on behalf of the creator - mapping(address => mapping(address => uint256)) public creatorCampaignOperators; + mapping(address => mapping(address => uint256)) public campaignOperators; /*////////////////////////////////////////////////////////////////////////////////////////////////////////////////// EVENTS @@ -120,6 +120,7 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { event FeeRebateUpdated(address indexed user, uint256 userFeeRebate); event FeeRecipientUpdated(address indexed _feeRecipient); event FeesSet(uint256 _fees); + event CampaignOperatorToggled(address indexed user, address indexed operator, bool isWhitelisted); event CampaignOverride(bytes32 _campaignId, CampaignParameters campaign); event CampaignReallocation(bytes32 _campaignId, address[] indexed from, address indexed to); event CampaignSpecificFeesSet(uint32 campaignType, uint256 _fees); @@ -217,8 +218,8 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { /// @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); + _isValidOperator(_campaign.creator); 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 @@ -237,8 +238,8 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { /// @dev While this function may execute successfully, the reallocation may not be valid in the Merkl engine 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(); + _isValidOperator(_campaign.creator); + if (block.timestamp < _campaign.startTimestamp + _campaign.duration) revert Errors.InvalidReallocation(); uint256 fromsLength = froms.length; for (uint256 i; i < fromsLength; ) { @@ -251,6 +252,8 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { emit CampaignReallocation(_campaignId, froms, to); } + /// @notice Increases the token allowance of an `operator` for a `user` + /// @dev Only the user themselves or a governor can call this function /// @dev If a governor address calls this function, the user MUST have transferred the funds to the contract beforehand function increaseCreatorTokenAllowance( address user, @@ -267,6 +270,8 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { emit CreatorAllowanceUpdated(user, operator, rewardToken, currentAllowance + amount); } + /// @notice Decreases the token allowance of an `operator` for a `user` + /// @dev Only the user themselves or a governor can call this function function decreaseCreatorTokenAllowance( address user, address operator, @@ -277,12 +282,22 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { uint256 currentAllowance = creatorTokenAllowance[user][operator][rewardToken]; uint256 updateAmount = amount > currentAllowance ? currentAllowance : amount; creatorTokenAllowance[user][operator][rewardToken] = currentAllowance - updateAmount; - if (user == msg.sender) IERC20(rewardToken).safeTransfer(msg.sender, updateAmount); - else if (!accessControlManager.isGovernor(msg.sender)) revert Errors.NotGovernor(); + if (user == msg.sender && !accessControlManager.isGovernor(msg.sender)) revert Errors.NotAllowed(); + + IERC20(rewardToken).safeTransfer(msg.sender, updateAmount); emit CreatorAllowanceUpdated(user, operator, rewardToken, currentAllowance - updateAmount); } + /// @notice Toggles the ability of an `operator` to manage campaigns on behalf of a `user` + /// @dev Only the user themselves or a governor can call this function + function toggleCampaignOperator(address user, address operator) external { + if (user != msg.sender && !accessControlManager.isGovernor(msg.sender)) revert Errors.NotAllowed(); + uint256 currentStatus = campaignOperators[user][operator]; + campaignOperators[user][operator] = 1 - currentStatus; + emit CampaignOperatorToggled(user, operator, currentStatus == 0); + } + /*////////////////////////////////////////////////////////////////////////////////////////////////////////////////// GETTERS //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ @@ -452,6 +467,17 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { return newCampaign.campaignId; } + /// @notice Checks whether `msg.sender` is allowed to manage the campaign of `creator` + function _isValidOperator(address creator) internal view { + if ( + creator != msg.sender && + campaignOperators[creator][msg.sender] == 0 && + !accessControlManager.isGovernor(msg.sender) + ) { + revert Errors.OperatorNotAllowed(); + } + } + function _pullTokens( address creator, address rewardToken, @@ -462,7 +488,7 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { uint256 fees = campaignAmount - campaignAmountMinusFees; address _feeRecipient = feeRecipient; _feeRecipient = _feeRecipient == address(0) ? address(this) : _feeRecipient; - if (senderAllowance > campaignAmount) { + if (senderAllowance >= campaignAmount) { creatorTokenAllowance[creator][msg.sender][rewardToken] = senderAllowance - campaignAmount; emit CreatorAllowanceUpdated(creator, msg.sender, rewardToken, senderAllowance - campaignAmount); if (fees > 0) IERC20(rewardToken).safeTransfer(_feeRecipient, fees); diff --git a/contracts/DistributionCreatorWithDistributions.sol b/contracts/DistributionCreatorWithDistributions.sol index 35a6c1e1..2fdc42c0 100644 --- a/contracts/DistributionCreatorWithDistributions.sol +++ b/contracts/DistributionCreatorWithDistributions.sol @@ -30,6 +30,11 @@ import { DistributionCreator } from "./DistributionCreator.sol"; contract DistributionCreatorWithDistributions is DistributionCreator { using SafeERC20 for IERC20; + /// @notice Returns the distribution at a given index converted into a campaign + function distribution(uint256 index) external view returns (CampaignParameters memory) { + return _convertDistribution(distributionList[index]); + } + /// @notice Creates a `distribution` to incentivize a given pool for a specific period of time function createDistribution( DistributionParameters memory newDistribution diff --git a/contracts/utils/Errors.sol b/contracts/utils/Errors.sol index 774ab2b1..25742438 100644 --- a/contracts/utils/Errors.sol +++ b/contracts/utils/Errors.sol @@ -16,6 +16,7 @@ library Errors { error InvalidParams(); error InvalidProof(); error InvalidUninitializedRoot(); + error InvalidReallocation(); error InvalidReturnMessage(); error InvalidReward(); error InvalidSignature(); @@ -30,6 +31,7 @@ library Errors { error NotTrusted(); error NotUpgradeable(); error NotWhitelisted(); + error OperatorNotAllowed(); error UnresolvedDispute(); error ZeroAddress(); error DisputeFundsTransferFailed(); diff --git a/scripts/DistributionCreator.s.sol b/scripts/DistributionCreator.s.sol index 13ad76ba..db022bd0 100644 --- a/scripts/DistributionCreator.s.sol +++ b/scripts/DistributionCreator.s.sol @@ -134,28 +134,6 @@ contract SetCampaignFees is DistributionCreatorScript { } } -// ToggleTokenWhitelist script -contract ToggleTokenWhitelist is DistributionCreatorScript { - function run() external { - // MODIFY THIS VALUE TO SET YOUR DESIRED TOKEN ADDRESS - address token = address(0); - _run(token); - } - - function run(address token) external { - _run(token); - } - - function _run(address _token) internal broadcast { - uint256 chainId = block.chainid; - address creatorAddress = readAddress(chainId, "DistributionCreator"); - - DistributionCreator(creatorAddress).toggleTokenWhitelist(_token); - - console.log("Token whitelist toggled for:", _token); - } -} - // RecoverFees script contract RecoverFees is DistributionCreatorScript { function run() external { @@ -285,28 +263,6 @@ contract GetMessage is DistributionCreatorScript { } } -// ToggleSigningWhitelist script -contract ToggleSigningWhitelist is DistributionCreatorScript { - function run() external { - // MODIFY THIS VALUE TO SET YOUR DESIRED USER ADDRESS - address user = address(0); - _run(user); - } - - function run(address user) external { - _run(user); - } - - function _run(address _user) internal broadcast { - uint256 chainId = block.chainid; - address creatorAddress = readAddress(chainId, "DistributionCreator"); - - DistributionCreator(creatorAddress).toggleSigningWhitelist(_user); - - console.log("Signing whitelist toggled for user:", _user); - } -} - // AcceptConditions script contract AcceptConditions is DistributionCreatorScript { function run() external broadcast { @@ -319,28 +275,6 @@ contract AcceptConditions is DistributionCreatorScript { } } -// Sign script -contract Sign is DistributionCreatorScript { - function run() external { - // MODIFY THIS VALUE TO SET YOUR DESIRED SIGNATURE - bytes memory signature = ""; - _run(signature); - } - - function run(bytes calldata signature) external { - _run(signature); - } - - function _run(bytes memory _signature) internal broadcast { - uint256 chainId = block.chainid; - address creatorAddress = readAddress(chainId, "DistributionCreator"); - - DistributionCreator(creatorAddress).sign(_signature); - - console.log("Message signed by:", broadcaster); - } -} - // CreateCampaign script // @notice Example usage for CreateCampaign: // forge script scripts/DistributionCreator.s.sol:CreateCampaign \ @@ -716,38 +650,6 @@ contract CreateCampaignTest is DistributionCreatorScript { } } -// SignAndCreateCampaign script -contract SignAndCreateCampaign is DistributionCreatorScript { - function run() external broadcast { - // MODIFY THESE VALUES TO SET YOUR DESIRED CAMPAIGN PARAMETERS AND SIGNATURE - CampaignParameters memory campaign = CampaignParameters({ - campaignId: bytes32(0), - creator: address(0), - rewardToken: address(0), - amount: 0, - campaignType: 0, - startTimestamp: uint32(block.timestamp), - duration: 7 days, - campaignData: "" - }); - bytes memory signature = ""; - _run(campaign, signature); - } - - function run(CampaignParameters calldata campaign, bytes calldata signature) external broadcast { - _run(campaign, signature); - } - - function _run(CampaignParameters memory campaign, bytes memory signature) internal { - uint256 chainId = block.chainid; - address creatorAddress = readAddress(chainId, "DistributionCreator"); - - bytes32 campaignId = DistributionCreator(creatorAddress).signAndCreateCampaign(campaign, signature); - - console.log("Message signed and campaign created with ID:", vm.toString(campaignId)); - } -} - contract UpgradeAndBuildUpgradeToPayload is DistributionCreatorScript { function run() external broadcast { uint256 chainId = block.chainid; diff --git a/test/DistributionCreator.t.sol b/test/DistributionCreator.t.sol index 8f58fc1b..a7f33ff7 100644 --- a/test/DistributionCreator.t.sol +++ b/test/DistributionCreator.t.sol @@ -35,8 +35,6 @@ contract DistributionCreatorCreateCampaignTest is Fixture { creator.setFeeRecipient(dylan); 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); @@ -168,8 +166,6 @@ contract DistributionCreatorCreateReallocationTest is Fixture { vm.stopPrank(); 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); @@ -460,8 +456,6 @@ contract DistributionCreatorOverrideTest is Fixture { 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); @@ -1217,12 +1211,6 @@ contract UpgradeDistributionCreatorTest is Test, JsonReader { // 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); diff --git a/test/Fixture.t.sol b/test/Fixture.t.sol index ba70cd95..334b9efc 100644 --- a/test/Fixture.t.sol +++ b/test/Fixture.t.sol @@ -8,12 +8,12 @@ import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy import { console } from "forge-std/console.sol"; import { DistributionCreator } from "../contracts/DistributionCreator.sol"; +import { DistributionCreatorWithDistributions } from "../contracts/DistributionCreatorWithDistributions.sol"; import { MockTokenPermit } from "../contracts/mock/MockTokenPermit.sol"; import { MockUniswapV3Pool } from "../contracts/mock/MockUniswapV3Pool.sol"; import { MockAccessControl } from "../contracts/mock/MockAccessControl.sol"; import { IAccessControlManager } from "../contracts/interfaces/IAccessControlManager.sol"; import { UUPSHelper } from "../contracts/utils/UUPSHelper.sol"; -import { DistributionCreator } from "../contracts/DistributionCreator.sol"; import { MockTokenPermit } from "../contracts/mock/MockTokenPermit.sol"; import { MockUniswapV3Pool } from "../contracts/mock/MockUniswapV3Pool.sol"; import { MockAccessControl } from "../contracts/mock/MockAccessControl.sol"; @@ -28,8 +28,8 @@ contract Fixture is Test { MockAccessControl public accessControlManager; MockUniswapV3Pool public pool; - DistributionCreator public creatorImpl; - DistributionCreator public creator; + DistributionCreatorWithDistributions public creatorImpl; + DistributionCreatorWithDistributions public creator; address public alice; address public bob; @@ -68,8 +68,8 @@ contract Fixture is Test { pool = new MockUniswapV3Pool(); // DistributionCreator - creatorImpl = new DistributionCreator(); - creator = DistributionCreator(deployUUPS(address(creatorImpl), hex"")); + creatorImpl = new DistributionCreatorWithDistributions(); + creator = DistributionCreatorWithDistributions(deployUUPS(address(creatorImpl), hex"")); // Set pool.setToken(address(token0), 0); diff --git a/test/unit/DistributionCreator.t.sol b/test/unit/DistributionCreator.t.sol index 437dd833..20c109df 100644 --- a/test/unit/DistributionCreator.t.sol +++ b/test/unit/DistributionCreator.t.sol @@ -6,6 +6,7 @@ import { Test } from "forge-std/Test.sol"; import { JsonReader } from "@utils/JsonReader.sol"; import { DistributionCreator, DistributionParameters, CampaignParameters, RewardTokenAmounts } from "../../contracts/DistributionCreator.sol"; +import { DistributionCreatorWithDistributions } from "../../contracts/DistributionCreatorWithDistributions.sol"; import { Errors } from "../../contracts/utils/Errors.sol"; import { Fixture, IERC20 } from "../Fixture.t.sol"; import { IAccessControlManager } from "../../contracts/interfaces/IAccessControlManager.sol"; @@ -31,10 +32,8 @@ contract DistributionCreatorTest is Fixture { initEndTime = startTime + numEpoch * EPOCH_DURATION; vm.startPrank(guardian); - creator.toggleSigningWhitelist(alice); vm.startPrank(governor); - creator.toggleTokenWhitelist(address(agEUR)); address[] memory tokens = new address[](1); uint256[] memory amounts = new uint256[](1); tokens[0] = address(angle); @@ -92,11 +91,13 @@ contract DistributionCreatorTest is Fixture { } contract Test_DistributionCreator_Initialize is DistributionCreatorTest { - DistributionCreator d; + DistributionCreatorWithDistributions d; function setUp() public override { super.setUp(); - d = DistributionCreator(deployUUPS(address(new DistributionCreator()), hex"")); + d = DistributionCreatorWithDistributions( + deployUUPS(address(new DistributionCreatorWithDistributions()), hex"") + ); } function test_RevertWhen_CalledOnImplem() public { @@ -289,7 +290,6 @@ contract Test_DistributionCreator_CreateDistributions is DistributionCreatorTest distributions[0] = distribution; distributions[1] = distribution; vm.prank(alice); - creator.createDistributions(distributions); } function test_Success() public { @@ -329,7 +329,6 @@ contract Test_DistributionCreator_CreateDistributions is DistributionCreatorTest additionalData: hex"" }); vm.prank(alice); - creator.createDistributions(distributions); } } @@ -701,35 +700,6 @@ contract Test_DistributionCreator_setCampaignFees is DistributionCreatorTest { } } -contract Test_DistributionCreator_sign is DistributionCreatorTest { - function test_Success() public { - vm.prank(governor); - creator.setMessage("test"); - - assertEq("test", creator.message()); - assertEq(creator.userSignatures(alice), bytes32(0)); - - (uint8 v, bytes32 r, bytes32 s) = vm.sign(1, creator.messageHash()); - vm.prank(alice); - creator.sign(abi.encodePacked(r, s, v)); - - assertEq(creator.userSignatures(alice), creator.messageHash()); - } - - function test_RevertWith_InvalidSignature() public { - vm.prank(governor); - creator.setMessage("test"); - - assertEq("test", creator.message()); - assertEq(creator.userSignatures(alice), bytes32(0)); - - (uint8 v, bytes32 r, bytes32 s) = vm.sign(2, creator.messageHash()); - vm.prank(alice); - vm.expectRevert(Errors.InvalidSignature.selector); - creator.sign(abi.encodePacked(r, s, v)); - } -} - contract Test_DistributionCreator_acceptConditions is DistributionCreatorTest { function test_Success() public { assertEq(creator.userSignatureWhitelist(bob), 0); @@ -855,104 +825,13 @@ contract Test_DistributionCreator_getValidRewardTokens is DistributionCreatorTes } } -contract Test_DistributionCreator_signAndCreateCampaign is DistributionCreatorTest { - function test_Success() public { - CampaignParameters memory campaign = CampaignParameters({ - campaignId: keccak256("TEST"), - creator: address(0), - campaignData: hex"ab", - rewardToken: address(angle), - amount: 1e8, - campaignType: 0, - startTimestamp: uint32(block.timestamp + 1), - duration: 3600 - }); - - { - (uint8 v, bytes32 r, bytes32 s) = vm.sign(2, creator.messageHash()); - - vm.startPrank(bob); - - angle.approve(address(creator), 1e8); - creator.signAndCreateCampaign(campaign, abi.encodePacked(r, s, v)); - - vm.stopPrank(); - } - - address[] memory whitelist = new address[](1); - whitelist[0] = bob; - address[] memory blacklist = new address[](1); - blacklist[0] = charlie; - - bytes memory extraData = hex"ab"; - - // Additional asserts to check for correct behavior - bytes32 campaignId = bytes32( - keccak256( - abi.encodePacked( - block.chainid, - bob, - address(campaign.rewardToken), - uint32(campaign.campaignType), - uint32(campaign.startTimestamp), - uint32(campaign.duration), - campaign.campaignData - ) - ) - ); - ( - bytes32 fetchedCampaignId, - address fetchedCreator, - address fetchedRewardToken, - uint256 fetchedAmount, - uint32 fetchedCampaignType, - uint32 fetchedStartTimestamp, - uint32 fetchedDuration, - bytes memory fetchedCampaignData - ) = creator.campaignList(creator.campaignLookup(campaignId)); - assertEq(bob, fetchedCreator); - assertEq(address(angle), fetchedRewardToken); - assertEq(campaign.campaignType, fetchedCampaignType); - assertEq(campaign.startTimestamp, fetchedStartTimestamp); - assertEq(campaign.duration, fetchedDuration); - assertEq(extraData, fetchedCampaignData); - assertEq(campaignId, fetchedCampaignId); - assertEq(campaign.amount, (fetchedAmount * 10) / 9); - } - - function test_InvalidSignature() public { - CampaignParameters memory campaign = CampaignParameters({ - campaignId: keccak256("TEST"), - creator: address(0), - campaignData: hex"ab", - rewardToken: address(angle), - amount: 1e8, - campaignType: 0, - startTimestamp: uint32(block.timestamp + 1), - duration: 3600 - }); - - { - (uint8 v, bytes32 r, bytes32 s) = vm.sign(1, creator.messageHash()); - - vm.startPrank(bob); - - angle.approve(address(creator), 1e8); - vm.expectRevert(Errors.InvalidSignature.selector); - creator.signAndCreateCampaign(campaign, abi.encodePacked(r, s, v)); - - vm.stopPrank(); - } - } -} - contract DistributionCreatorForkTest is Test { - DistributionCreator public creator; + DistributionCreatorWithDistributions public creator; function setUp() public { vm.createSelectFork(vm.envString("ARBITRUM_NODE_URI")); - creator = DistributionCreator(0x8BB4C975Ff3c250e0ceEA271728547f3802B36Fd); + creator = DistributionCreatorWithDistributions(0x8BB4C975Ff3c250e0ceEA271728547f3802B36Fd); } } From d16640418e9478aa54ef53dfea296a3f2b837913 Mon Sep 17 00:00:00 2001 From: Pablo Veyrat Date: Thu, 16 Oct 2025 18:17:45 +0200 Subject: [PATCH 03/27] fixing lint --- contracts/DistributionCreatorWithDistributions.sol | 13 ------------- contracts/Distributor.sol | 8 ++------ contracts/interfaces/IClaimRecipient.sol | 11 +++++++++++ .../partners/tokenWrappers/BaseTokenWrapper.sol | 11 +++-------- .../partners/tokenWrappers/BobTokenWrapper.sol | 14 +++++--------- .../partners/tokenWrappers/EtherealWrapper.sol | 14 +++++--------- contracts/partners/tokenWrappers/PointToken.sol | 4 ++-- .../tokenWrappers/PufferPointTokenWrapper.sol | 14 +++++--------- .../partners/tokenWrappers/TokenTGEWrapper.sol | 14 +++++--------- scripts/deployReferralRegistry.s.sol | 9 +-------- 10 files changed, 39 insertions(+), 73 deletions(-) create mode 100644 contracts/interfaces/IClaimRecipient.sol diff --git a/contracts/DistributionCreatorWithDistributions.sol b/contracts/DistributionCreatorWithDistributions.sol index 2fdc42c0..10676841 100644 --- a/contracts/DistributionCreatorWithDistributions.sol +++ b/contracts/DistributionCreatorWithDistributions.sol @@ -2,19 +2,8 @@ pragma solidity ^0.8.17; -import { ReentrancyGuardUpgradeable } from "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; -import { IERC20 } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; -import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; -import { SignatureChecker } from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; - -import { UUPSHelper } from "./utils/UUPSHelper.sol"; -import { IAccessControlManager } from "./interfaces/IAccessControlManager.sol"; -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"; import { DistributionCreator } from "./DistributionCreator.sol"; /// @title DistributionCreatorWithDistributions @@ -28,8 +17,6 @@ import { DistributionCreator } from "./DistributionCreator.sol"; /// @dev Useful notably on Polygon where some creators still use the old distribution model //solhint-disable contract DistributionCreatorWithDistributions is DistributionCreator { - using SafeERC20 for IERC20; - /// @notice Returns the distribution at a given index converted into a campaign function distribution(uint256 index) external view returns (CampaignParameters memory) { return _convertDistribution(distributionList[index]); diff --git a/contracts/Distributor.sol b/contracts/Distributor.sol index 475fa33d..7eb19a4a 100644 --- a/contracts/Distributor.sol +++ b/contracts/Distributor.sol @@ -9,6 +9,7 @@ import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol"; import { UUPSHelper } from "./utils/UUPSHelper.sol"; import { IAccessControlManager } from "./interfaces/IAccessControlManager.sol"; import { Errors } from "./utils/Errors.sol"; +import { IClaimRecipient } from "./interfaces/IClaimRecipient.sol"; struct MerkleTree { // Root of a Merkle tree which leaves are `(address user, address token, uint amount)` @@ -26,11 +27,6 @@ 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 @@ -234,7 +230,7 @@ contract Distributor is UUPSHelper { /// @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 - /// @dev Setting the operator address to the zero address enables any + /// @dev Setting the operator address to the zero address enables any address to act as an operator for the user (i.e., it whitelists all operators for that user) function toggleOperator(address user, address operator) external onlyTrustedOrUser(user) { uint256 oldValue = operators[user][operator]; operators[user][operator] = 1 - oldValue; diff --git a/contracts/interfaces/IClaimRecipient.sol b/contracts/interfaces/IClaimRecipient.sol new file mode 100644 index 00000000..83cfe7f6 --- /dev/null +++ b/contracts/interfaces/IClaimRecipient.sol @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity ^0.8.17; + +/// @title IClaimRecipient +/// @author Merkl SAS +/// @notice Interface for the `ClaimRecipient` contracts expected by the `Distributor` contract +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); +} diff --git a/contracts/partners/tokenWrappers/BaseTokenWrapper.sol b/contracts/partners/tokenWrappers/BaseTokenWrapper.sol index 04f07271..0b3c5d33 100644 --- a/contracts/partners/tokenWrappers/BaseTokenWrapper.sol +++ b/contracts/partners/tokenWrappers/BaseTokenWrapper.sol @@ -9,20 +9,15 @@ import { IERC20, IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/exte import { UUPSHelper } from "../../utils/UUPSHelper.sol"; import { IAccessControlManager } from "../../interfaces/IAccessControlManager.sol"; import { Errors } from "../../utils/Errors.sol"; - -interface IDistributionCreator { - function distributor() external view returns (address); - - function feeRecipient() external view returns (address); -} +import { DistributionCreator } from "../../DistributionCreator.sol"; abstract contract BaseMerklTokenWrapper is UUPSHelper, ERC20Upgradeable { using SafeERC20 for IERC20; // ================================= CONSTANTS ================================= - IDistributionCreator public constant DISTRIBUTOR_CREATOR = - IDistributionCreator(0x8BB4C975Ff3c250e0ceEA271728547f3802B36Fd); + DistributionCreator public constant DISTRIBUTOR_CREATOR = + DistributionCreator(0x8BB4C975Ff3c250e0ceEA271728547f3802B36Fd); address public immutable DISTRIBUTOR = DISTRIBUTOR_CREATOR.distributor(); address public immutable FEE_RECIPIENT = DISTRIBUTOR_CREATOR.feeRecipient(); diff --git a/contracts/partners/tokenWrappers/BobTokenWrapper.sol b/contracts/partners/tokenWrappers/BobTokenWrapper.sol index 570129e4..c9c33b11 100644 --- a/contracts/partners/tokenWrappers/BobTokenWrapper.sol +++ b/contracts/partners/tokenWrappers/BobTokenWrapper.sol @@ -10,11 +10,7 @@ import { IAccessControlManager } from "./BaseTokenWrapper.sol"; import { UUPSHelper } from "../../utils/UUPSHelper.sol"; import { Errors } from "../../utils/Errors.sol"; - -interface IDistributionCreator { - function distributor() external view returns (address); - function feeRecipient() external view returns (address); -} +import { DistributionCreator } from "../../DistributionCreator.sol"; interface IStaker { function stake(uint256 _amount, address receiver) external; @@ -63,8 +59,8 @@ contract BobTokenWrapper is UUPSHelper, ERC20Upgradeable { accessControlManager = _accessControlManager; distributionCreator = _distributionCreator; staker = _staker; - distributor = IDistributionCreator(_distributionCreator).distributor(); - feeRecipient = IDistributionCreator(_distributionCreator).feeRecipient(); + distributor = DistributionCreator(_distributionCreator).distributor(); + feeRecipient = DistributionCreator(_distributionCreator).feeRecipient(); IERC20(underlying).safeApprove(_staker, type(uint256).max); } @@ -127,7 +123,7 @@ contract BobTokenWrapper is UUPSHelper, ERC20Upgradeable { } function setDistributor(address _distributionCreator) external onlyGovernor { - address _distributor = IDistributionCreator(_distributionCreator).distributor(); + address _distributor = DistributionCreator(_distributionCreator).distributor(); distributor = _distributor; distributionCreator = _distributionCreator; emit MerklAddressesUpdated(_distributionCreator, _distributor); @@ -139,7 +135,7 @@ contract BobTokenWrapper is UUPSHelper, ERC20Upgradeable { } function _setFeeRecipient() internal { - address _feeRecipient = IDistributionCreator(distributionCreator).feeRecipient(); + address _feeRecipient = DistributionCreator(distributionCreator).feeRecipient(); feeRecipient = _feeRecipient; emit FeeRecipientUpdated(_feeRecipient); } diff --git a/contracts/partners/tokenWrappers/EtherealWrapper.sol b/contracts/partners/tokenWrappers/EtherealWrapper.sol index 51b1332d..319f3b9b 100644 --- a/contracts/partners/tokenWrappers/EtherealWrapper.sol +++ b/contracts/partners/tokenWrappers/EtherealWrapper.sol @@ -10,11 +10,7 @@ import { IAccessControlManager } from "./BaseTokenWrapper.sol"; import { UUPSHelper } from "../../utils/UUPSHelper.sol"; import { Errors } from "../../utils/Errors.sol"; - -interface IDistributionCreator { - function distributor() external view returns (address); - function feeRecipient() external view returns (address); -} +import { DistributionCreator } from "../../DistributionCreator.sol"; interface IEtherealExchange { function depositOnBehalf(uint256 _amount, address receiver) external; @@ -64,8 +60,8 @@ contract EtherealWrapper is UUPSHelper, ERC20Upgradeable { accessControlManager = _accessControlManager; distributionCreator = _distributionCreator; etherealExchange = _etherealExchange; - distributor = IDistributionCreator(_distributionCreator).distributor(); - feeRecipient = IDistributionCreator(_distributionCreator).feeRecipient(); + distributor = DistributionCreator(_distributionCreator).distributor(); + feeRecipient = DistributionCreator(_distributionCreator).feeRecipient(); IERC20(underlying).safeApprove(_etherealExchange, type(uint256).max); } @@ -128,7 +124,7 @@ contract EtherealWrapper is UUPSHelper, ERC20Upgradeable { } function setDistributor(address _distributionCreator) external onlyGovernor { - address _distributor = IDistributionCreator(_distributionCreator).distributor(); + address _distributor = DistributionCreator(_distributionCreator).distributor(); distributor = _distributor; distributionCreator = _distributionCreator; emit MerklAddressesUpdated(_distributionCreator, _distributor); @@ -140,7 +136,7 @@ contract EtherealWrapper is UUPSHelper, ERC20Upgradeable { } function _setFeeRecipient() internal { - address _feeRecipient = IDistributionCreator(distributionCreator).feeRecipient(); + address _feeRecipient = DistributionCreator(distributionCreator).feeRecipient(); feeRecipient = _feeRecipient; emit FeeRecipientUpdated(_feeRecipient); } diff --git a/contracts/partners/tokenWrappers/PointToken.sol b/contracts/partners/tokenWrappers/PointToken.sol index 82447049..277a9d29 100644 --- a/contracts/partners/tokenWrappers/PointToken.sol +++ b/contracts/partners/tokenWrappers/PointToken.sol @@ -2,9 +2,9 @@ pragma solidity ^0.8.7; -import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import { IAccessControlManager } from "../../interfaces/IAccessControlManager.sol"; -import "../../utils/Errors.sol"; +import { Errors } from "../../utils/Errors.sol"; /// @title PointToken /// @author Merkl SAS diff --git a/contracts/partners/tokenWrappers/PufferPointTokenWrapper.sol b/contracts/partners/tokenWrappers/PufferPointTokenWrapper.sol index f9b7f4ed..01a6fcd3 100644 --- a/contracts/partners/tokenWrappers/PufferPointTokenWrapper.sol +++ b/contracts/partners/tokenWrappers/PufferPointTokenWrapper.sol @@ -10,6 +10,7 @@ import { IAccessControlManager } from "./BaseTokenWrapper.sol"; import { UUPSHelper } from "../../utils/UUPSHelper.sol"; import { Errors } from "../../utils/Errors.sol"; +import { DistributionCreator } from "../../DistributionCreator.sol"; struct VestingID { uint128 amount; @@ -21,11 +22,6 @@ struct VestingData { uint256 nextClaimIndex; } -interface IDistributionCreator { - function distributor() external view returns (address); - function feeRecipient() external view returns (address); -} - /// @title PufferPointTokenWrapper /// @dev This token can only be held by Merkl distributor /// @dev Transferring to the distributor will require transferring the underlying token to this contract @@ -75,8 +71,8 @@ contract PufferPointTokenWrapper is UUPSHelper, ERC20Upgradeable { accessControlManager = _accessControlManager; cliffDuration = _cliffDuration; distributionCreator = _distributionCreator; - distributor = IDistributionCreator(_distributionCreator).distributor(); - feeRecipient = IDistributionCreator(_distributionCreator).feeRecipient(); + distributor = DistributionCreator(_distributionCreator).distributor(); + feeRecipient = DistributionCreator(_distributionCreator).feeRecipient(); } function isTokenWrapper() external pure returns (bool) { @@ -193,7 +189,7 @@ contract PufferPointTokenWrapper is UUPSHelper, ERC20Upgradeable { } function setDistributor(address _distributionCreator) external onlyGovernor { - address _distributor = IDistributionCreator(_distributionCreator).distributor(); + address _distributor = DistributionCreator(_distributionCreator).distributor(); distributor = _distributor; distributionCreator = _distributionCreator; emit MerklAddressesUpdated(_distributionCreator, _distributor); @@ -211,7 +207,7 @@ contract PufferPointTokenWrapper is UUPSHelper, ERC20Upgradeable { } function _setFeeRecipient() internal { - address _feeRecipient = IDistributionCreator(distributionCreator).feeRecipient(); + address _feeRecipient = DistributionCreator(distributionCreator).feeRecipient(); feeRecipient = _feeRecipient; emit FeeRecipientUpdated(_feeRecipient); } diff --git a/contracts/partners/tokenWrappers/TokenTGEWrapper.sol b/contracts/partners/tokenWrappers/TokenTGEWrapper.sol index d7084c43..4a54c1d9 100644 --- a/contracts/partners/tokenWrappers/TokenTGEWrapper.sol +++ b/contracts/partners/tokenWrappers/TokenTGEWrapper.sol @@ -10,11 +10,7 @@ import { IAccessControlManager } from "./BaseTokenWrapper.sol"; import { UUPSHelper } from "../../utils/UUPSHelper.sol"; import { Errors } from "../../utils/Errors.sol"; - -interface IDistributionCreator { - function distributor() external view returns (address); - function feeRecipient() external view returns (address); -} +import { DistributionCreator } from "../../DistributionCreator.sol"; /// @title TokenTGEWrapper /// @dev This token can only be held by Merkl distributor @@ -63,8 +59,8 @@ contract TokenTGEWrapper is UUPSHelper, ERC20Upgradeable { accessControlManager = _accessControlManager; unlockTimestamp = _unlockTimestamp; distributionCreator = _distributionCreator; - distributor = IDistributionCreator(_distributionCreator).distributor(); - feeRecipient = IDistributionCreator(_distributionCreator).feeRecipient(); + distributor = DistributionCreator(_distributionCreator).distributor(); + feeRecipient = DistributionCreator(_distributionCreator).feeRecipient(); } function isTokenWrapper() external pure returns (bool) { @@ -127,7 +123,7 @@ contract TokenTGEWrapper is UUPSHelper, ERC20Upgradeable { } function setDistributor(address _distributionCreator) external onlyGovernor { - address _distributor = IDistributionCreator(_distributionCreator).distributor(); + address _distributor = DistributionCreator(_distributionCreator).distributor(); distributor = _distributor; distributionCreator = _distributionCreator; emit MerklAddressesUpdated(_distributionCreator, _distributor); @@ -144,7 +140,7 @@ contract TokenTGEWrapper is UUPSHelper, ERC20Upgradeable { } function _setFeeRecipient() internal { - address _feeRecipient = IDistributionCreator(distributionCreator).feeRecipient(); + address _feeRecipient = DistributionCreator(distributionCreator).feeRecipient(); feeRecipient = _feeRecipient; emit FeeRecipientUpdated(_feeRecipient); } diff --git a/scripts/deployReferralRegistry.s.sol b/scripts/deployReferralRegistry.s.sol index 23d1d2b5..cd68bd50 100644 --- a/scripts/deployReferralRegistry.s.sol +++ b/scripts/deployReferralRegistry.s.sol @@ -7,13 +7,6 @@ import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy import { ReferralRegistry } from "../contracts/ReferralRegistry.sol"; import { DistributionCreator } from "../contracts/DistributionCreator.sol"; import { IAccessControlManager } from "../contracts/interfaces/IAccessControlManager.sol"; -interface IDistributionCreator { - function distributor() external view returns (address); - - function feeRecipient() external view returns (address); - - function accessControlManager() external view returns (IAccessControlManager); -} contract DeployReferralRegistry is BaseScript { // forge script scripts/deployReferralRegistry.s.sol:DeployReferralRegistry --rpc-url avalanche --broadcast --verify -vvvv @@ -22,7 +15,7 @@ contract DeployReferralRegistry is BaseScript { vm.startBroadcast(deployerPrivateKey); uint256 feeSetup = 0; // uint32 cliffDuration = 1 weeks; - IDistributionCreator distributionCreator = IDistributionCreator(0x8BB4C975Ff3c250e0ceEA271728547f3802B36Fd); + DistributionCreator distributionCreator = DistributionCreator(0x8BB4C975Ff3c250e0ceEA271728547f3802B36Fd); address feeRecipient = distributionCreator.feeRecipient(); IAccessControlManager accessControlManager = distributionCreator.accessControlManager(); From f73a1aaa5f8cbb506bd4b7512c7ccdcd090d3163 Mon Sep 17 00:00:00 2001 From: Pablo Veyrat Date: Thu, 16 Oct 2025 18:56:17 +0200 Subject: [PATCH 04/27] fixing tests --- foundry.toml | 2 +- test/DistributionCreator.t.sol | 278 ++-------------------------- test/unit/DistributionCreator.t.sol | 70 ------- 3 files changed, 15 insertions(+), 335 deletions(-) diff --git a/foundry.toml b/foundry.toml index 9bb4b751..1562a0df 100644 --- a/foundry.toml +++ b/foundry.toml @@ -40,7 +40,7 @@ runs = 500 runs = 500 [profile.dev] -via_ir = true +via_ir = false [rpc_endpoints] localhost = "${LOCALHOST_NODE_URI}" diff --git a/test/DistributionCreator.t.sol b/test/DistributionCreator.t.sol index a7f33ff7..c5ab385d 100644 --- a/test/DistributionCreator.t.sol +++ b/test/DistributionCreator.t.sol @@ -4,7 +4,6 @@ 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 { JsonReader } from "@utils/JsonReader.sol"; import { DistributionCreator, DistributionParameters, CampaignParameters } from "../contracts/DistributionCreator.sol"; import { Distributor, MerkleTree } from "../contracts/Distributor.sol"; @@ -213,117 +212,12 @@ contract DistributionCreatorCreateReallocationTest is Fixture { }) ); - 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); + vm.expectRevert(Errors.InvalidReallocation.selector); creator.reallocateCampaignRewards(campaignId, users, address(governor)); assertEq(creator.campaignReallocation(campaignId, alice), address(0)); @@ -400,7 +294,7 @@ contract DistributionCreatorCreateReallocationTest is Fixture { users[0] = alice; vm.prank(bob); - vm.expectRevert(Errors.InvalidOverride.selector); + vm.expectRevert(Errors.OperatorNotAllowed.selector); creator.reallocateCampaignRewards(campaignId, users, address(governor)); assertEq(creator.campaignReallocation(campaignId, alice), address(0)); @@ -657,7 +551,7 @@ contract DistributionCreatorOverrideTest is Fixture { hex"" ); - vm.expectRevert(Errors.InvalidOverride.selector); + vm.expectRevert(Errors.OperatorNotAllowed.selector); vm.prank(bob); creator.overrideCampaign( campaignId, @@ -673,7 +567,7 @@ contract DistributionCreatorOverrideTest is Fixture { }) ); - vm.expectRevert(Errors.InvalidOverride.selector); + vm.expectRevert(Errors.OperatorNotAllowed.selector); vm.prank(bob); creator.overrideCampaign( campaignId, @@ -1125,11 +1019,11 @@ contract DistributionCreatorOverrideTest is Fixture { }) ); - assertEq(rewardToken.balanceOf(alice), prevBalance - amount - (amountAfterFees * 1e7) / 1e9); + assertEq(rewardToken.balanceOf(alice), prevBalance - amount); } } -contract UpgradeDistributionCreatorTest is Test, JsonReader { +contract UpgradeDistributionCreatorTest is Test { DistributionCreator public distributionCreator; Distributor public distributor; IAccessControlManager public accessControlManager; @@ -1146,12 +1040,11 @@ contract UpgradeDistributionCreatorTest is Test, JsonReader { deployer = 0xA9DdD91249DFdd450E81E1c56Ab60E1A62651701; // deploy vm.createSelectFork(vm.envString("BASE_NODE_URI")); chainId = block.chainid; - // Load existing contracts - distributor = Distributor(this.readAddress(chainId, "Distributor")); - distributionCreator = DistributionCreator(this.readAddress(chainId, "DistributionCreator")); - governor = this.readAddress(chainId, "Multisig"); - accessControlManager = IAccessControlManager(this.readAddress(chainId, "CoreMerkl")); + distributor = Distributor(0x3Ef3D8bA38EBe18DB133cEc108f4D14CE00Dd9Ae); + distributionCreator = DistributionCreator(0x8BB4C975Ff3c250e0ceEA271728547f3802B36Fd); + governor = 0x19c41F6607b2C0e80E84BaadaF886b17565F278e; + accessControlManager = IAccessControlManager(0xC16B81Af351BA9e64C1a069E3Ab18c244A1E3049); rewardToken = IERC20(0xC011882d0f7672D8942e7fE2248C174eeD640c8f); // aglaMerkl // Setup test campaign parameters @@ -1202,8 +1095,8 @@ contract UpgradeDistributionCreatorTest is Test, JsonReader { function test_VerifyStorageSlots_Success() public { // Verify storage slots remain unchanged - assertEq(address(distributionCreator.accessControlManager()), this.readAddress(chainId, "CoreMerkl")); - assertEq(address(distributionCreator.distributor()), this.readAddress(chainId, "Distributor")); + assertEq(address(distributionCreator.accessControlManager()), 0xC16B81Af351BA9e64C1a069E3Ab18c244A1E3049); + assertEq(address(distributionCreator.distributor()), 0x3Ef3D8bA38EBe18DB133cEc108f4D14CE00Dd9Ae); assertEq(distributionCreator.defaultFees(), 0.03e9); // Verify message and hash @@ -1214,7 +1107,7 @@ contract UpgradeDistributionCreatorTest is Test, JsonReader { // Verify fee and whitelist settings address testAddr = 0xfdA462548Ce04282f4B6D6619823a7C64Fdc0185; assertEq(distributionCreator.feeRebate(testAddr), 0); - assertEq(distributionCreator.isWhitelistedToken(this.readAddress(chainId, "EUR.AgToken")), 1); + assertEq(distributionCreator.isWhitelistedToken(0xA61BeB4A3d02decb01039e378237032B351125B4), 1); assertEq(distributionCreator._nonces(testAddr), 4); assertEq( distributionCreator.userSignatures(testAddr), @@ -1500,151 +1393,8 @@ contract UpgradeDistributionCreatorTest is Test, JsonReader { duration: distributionCreator.campaign(testCampaignId).duration, campaignData: distributionCreator.campaign(testCampaignId).campaignData }); - vm.expectRevert(Errors.InvalidOverride.selector); + vm.expectRevert(Errors.OperatorNotAllowed.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, "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/unit/DistributionCreator.t.sol b/test/unit/DistributionCreator.t.sol index 20c109df..e013e183 100644 --- a/test/unit/DistributionCreator.t.sol +++ b/test/unit/DistributionCreator.t.sol @@ -31,9 +31,6 @@ contract DistributionCreatorTest is Fixture { numEpoch = 25; initEndTime = startTime + numEpoch * EPOCH_DURATION; - vm.startPrank(guardian); - - vm.startPrank(governor); address[] memory tokens = new address[](1); uint256[] memory amounts = new uint256[](1); tokens[0] = address(angle); @@ -265,73 +262,6 @@ contract Test_DistributionCreator_CreateDistribution is DistributionCreatorTest } } -contract Test_DistributionCreator_CreateDistributions is DistributionCreatorTest { - function test_RevertWhen_CampaignAlreadyExists() public { - DistributionParameters memory distribution = DistributionParameters({ - uniV3Pool: address(pool), - rewardToken: address(angle), - positionWrappers: positionWrappers, - wrapperTypes: wrapperTypes, - amount: 1e10, - propToken0: 4000, - propToken1: 2000, - propFees: 4000, - isOutOfRangeIncentivized: 0, - epochStart: uint32(block.timestamp + 1), - numEpoch: 25, - boostedReward: 0, - boostingAddress: address(0), - rewardId: keccak256("TEST"), - additionalData: hex"" - }); - vm.expectRevert(Errors.CampaignAlreadyExists.selector); - - DistributionParameters[] memory distributions = new DistributionParameters[](2); - distributions[0] = distribution; - distributions[1] = distribution; - vm.prank(alice); - } - - function test_Success() public { - DistributionParameters[] memory distributions = new DistributionParameters[](2); - distributions[0] = DistributionParameters({ - uniV3Pool: address(pool), - rewardToken: address(angle), - positionWrappers: positionWrappers, - wrapperTypes: wrapperTypes, - amount: 1e10, - propToken0: 4000, - propToken1: 2000, - propFees: 4000, - isOutOfRangeIncentivized: 0, - epochStart: uint32(block.timestamp + 1), - numEpoch: 25, - boostedReward: 0, - boostingAddress: address(0), - rewardId: keccak256("TEST"), - additionalData: hex"" - }); - distributions[1] = DistributionParameters({ - uniV3Pool: address(pool), - rewardToken: address(angle), - positionWrappers: positionWrappers, - wrapperTypes: wrapperTypes, - amount: 2e10, - propToken0: 4000, - propToken1: 2000, - propFees: 4000, - isOutOfRangeIncentivized: 0, - epochStart: uint32(block.timestamp + 2), - numEpoch: 25, - boostedReward: 0, - boostingAddress: address(0), - rewardId: keccak256("TEST"), - additionalData: hex"" - }); - vm.prank(alice); - } -} - contract Test_DistributionCreator_CreateCampaign is DistributionCreatorTest { function test_RevertWhen_CampaignDurationIsZero() public { CampaignParameters memory campaign = CampaignParameters({ From fd6690184ca5e5048de749868acb8b970b8bd897 Mon Sep 17 00:00:00 2001 From: Pablo Veyrat Date: Thu, 16 Oct 2025 19:13:40 +0200 Subject: [PATCH 05/27] rm: modifier --- contracts/Distributor.sol | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/contracts/Distributor.sol b/contracts/Distributor.sol index 7eb19a4a..6f3eaf71 100644 --- a/contracts/Distributor.sol +++ b/contracts/Distributor.sol @@ -135,12 +135,6 @@ contract Distributor is UUPSHelper { _; } - /// @notice Checks whether the `msg.sender` is the `user` address or is a trusted address - modifier onlyTrustedOrUser(address user) { - if (user != msg.sender && !accessControlManager.isGovernorOrGuardian(msg.sender)) revert Errors.NotTrusted(); - _; - } - /// @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(); @@ -231,7 +225,8 @@ contract Distributor is UUPSHelper { /// @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 /// @dev Setting the operator address to the zero address enables any address to act as an operator for the user (i.e., it whitelists all operators for that user) - function toggleOperator(address user, address operator) external onlyTrustedOrUser(user) { + function toggleOperator(address user, address operator) external { + if (user != msg.sender && !accessControlManager.isGovernorOrGuardian(msg.sender)) revert Errors.NotTrusted(); uint256 oldValue = operators[user][operator]; operators[user][operator] = 1 - oldValue; emit OperatorToggled(user, operator, oldValue == 0); From 04d8c0c78efdbadf308851a37da9b6594259aba4 Mon Sep 17 00:00:00 2001 From: Pablo Veyrat Date: Thu, 16 Oct 2025 19:53:10 +0200 Subject: [PATCH 06/27] fix: balance system for predeposit --- contracts/DistributionCreator.sol | 66 +++++++++++++++++++++++-------- 1 file changed, 49 insertions(+), 17 deletions(-) diff --git a/contracts/DistributionCreator.sol b/contracts/DistributionCreator.sol index 445bf87a..3dbe53f7 100644 --- a/contracts/DistributionCreator.sol +++ b/contracts/DistributionCreator.sol @@ -100,7 +100,10 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { /// @notice List all reallocated address for a given campaign mapping(bytes32 => address[]) public campaignListReallocation; - /// @notice Maps a creator address to an operator to a reward token to an amount that can be pulled from the creator + /// @notice Maps a creator to a reward token to the balance pre-deposited by the creator for this token + mapping(address => mapping(address => uint256)) public creatorBalance; + + /// @notice Maps a creator address to an operator to a reward token to an amount that can be pulled from the creator's balance mapping(address => mapping(address => mapping(address => uint256))) public creatorTokenAllowance; /// @notice Maps a creator to a campaign operator to the ability to manage the campaign on behalf of the creator @@ -116,6 +119,7 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { address indexed token, uint256 amount ); + event CreatorBalanceUpdated(address indexed user, address indexed token, uint256 amount); event DistributorUpdated(address indexed _distributor); event FeeRebateUpdated(address indexed user, uint256 userFeeRebate); event FeeRecipientUpdated(address indexed _feeRecipient); @@ -155,6 +159,12 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { _; } + /// @notice Checks whether the `msg.sender` is the `user` address or is a governor + modifier onlyUserOrGovernor(address user) { + if (user != msg.sender && !accessControlManager.isGovernor(msg.sender)) revert Errors.NotAllowed(); + _; + } + /*////////////////////////////////////////////////////////////////////////////////////////////////////////////////// CONSTRUCTOR //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ @@ -253,40 +263,52 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { } /// @notice Increases the token allowance of an `operator` for a `user` - /// @dev Only the user themselves or a governor can call this function - /// @dev If a governor address calls this function, the user MUST have transferred the funds to the contract beforehand function increaseCreatorTokenAllowance( address user, address operator, address rewardToken, uint256 amount - ) external { + ) external onlyUserOrGovernor(user) { if (operator == address(0)) revert Errors.ZeroAddress(); - if (user == msg.sender) IERC20(rewardToken).safeTransferFrom(msg.sender, address(this), amount); - else if (!accessControlManager.isGovernor(msg.sender)) revert Errors.NotGovernor(); - uint256 currentAllowance = creatorTokenAllowance[user][operator][rewardToken]; creatorTokenAllowance[user][operator][rewardToken] = currentAllowance + amount; emit CreatorAllowanceUpdated(user, operator, rewardToken, currentAllowance + amount); } /// @notice Decreases the token allowance of an `operator` for a `user` - /// @dev Only the user themselves or a governor can call this function function decreaseCreatorTokenAllowance( address user, address operator, address rewardToken, uint256 amount - ) external { + ) external onlyUserOrGovernor(user) { if (operator == address(0)) revert Errors.ZeroAddress(); uint256 currentAllowance = creatorTokenAllowance[user][operator][rewardToken]; uint256 updateAmount = amount > currentAllowance ? currentAllowance : amount; creatorTokenAllowance[user][operator][rewardToken] = currentAllowance - updateAmount; - if (user == msg.sender && !accessControlManager.isGovernor(msg.sender)) revert Errors.NotAllowed(); + emit CreatorAllowanceUpdated(user, operator, rewardToken, currentAllowance - updateAmount); + } - IERC20(rewardToken).safeTransfer(msg.sender, updateAmount); + /// @dev If a governor is calling the function, the user must have sent the tokens beforehand + function preDepositTokens(address user, address rewardToken, uint256 amount) external { + if (!accessControlManager.isGovernor(msg.sender)) + IERC20(rewardToken).safeTransferFrom(msg.sender, address(this), amount); + uint256 userBalance = creatorBalance[user][rewardToken]; + creatorBalance[user][rewardToken] = userBalance + amount; + emit CreatorBalanceUpdated(user, rewardToken, userBalance + amount); + } - emit CreatorAllowanceUpdated(user, operator, rewardToken, currentAllowance - updateAmount); + function recoverPreDepositedTokens( + address user, + address rewardToken, + address to, + uint256 amount + ) external onlyUserOrGovernor(user) { + uint256 userBalance = creatorBalance[user][rewardToken]; + if (amount > userBalance) revert Errors.NotEnoughBalance(); + creatorBalance[user][rewardToken] = userBalance - amount; + IERC20(rewardToken).safeTransfer(to, amount); + emit CreatorBalanceUpdated(user, rewardToken, userBalance - amount); } /// @notice Toggles the ability of an `operator` to manage campaigns on behalf of a `user` @@ -484,13 +506,23 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { uint256 campaignAmount, uint256 campaignAmountMinusFees ) internal { - uint256 senderAllowance = creatorTokenAllowance[creator][msg.sender][rewardToken]; uint256 fees = campaignAmount - campaignAmountMinusFees; - address _feeRecipient = feeRecipient; _feeRecipient = _feeRecipient == address(0) ? address(this) : _feeRecipient; - if (senderAllowance >= campaignAmount) { - creatorTokenAllowance[creator][msg.sender][rewardToken] = senderAllowance - campaignAmount; - emit CreatorAllowanceUpdated(creator, msg.sender, rewardToken, senderAllowance - campaignAmount); + uint256 userBalance = creatorBalance[creator][rewardToken]; + if (userBalance > campaignAmount) { + if (msg.sender != creator) { + uint256 senderAllowance = creatorTokenAllowance[creator][msg.sender][rewardToken]; + if (senderAllowance >= campaignAmount) { + creatorTokenAllowance[creator][msg.sender][rewardToken] = senderAllowance - campaignAmount; + emit CreatorAllowanceUpdated(creator, msg.sender, rewardToken, senderAllowance - campaignAmount); + } else { + if (fees > 0) IERC20(rewardToken).safeTransferFrom(msg.sender, _feeRecipient, fees); + IERC20(rewardToken).safeTransferFrom(msg.sender, distributor, campaignAmountMinusFees); + return; + } + } + creatorBalance[creator][rewardToken] = userBalance - campaignAmount; + emit CreatorBalanceUpdated(creator, rewardToken, userBalance - campaignAmount); if (fees > 0) IERC20(rewardToken).safeTransfer(_feeRecipient, fees); IERC20(rewardToken).safeTransfer(distributor, campaignAmountMinusFees); } else { From eb58eaafe31c7a55b62387df2641d445d1d52e51 Mon Sep 17 00:00:00 2001 From: Pablo Veyrat Date: Thu, 16 Oct 2025 19:54:36 +0200 Subject: [PATCH 07/27] greater or equal --- contracts/DistributionCreator.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/DistributionCreator.sol b/contracts/DistributionCreator.sol index 3dbe53f7..5724884c 100644 --- a/contracts/DistributionCreator.sol +++ b/contracts/DistributionCreator.sol @@ -509,7 +509,7 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { uint256 fees = campaignAmount - campaignAmountMinusFees; _feeRecipient = _feeRecipient == address(0) ? address(this) : _feeRecipient; uint256 userBalance = creatorBalance[creator][rewardToken]; - if (userBalance > campaignAmount) { + if (userBalance >= campaignAmount) { if (msg.sender != creator) { uint256 senderAllowance = creatorTokenAllowance[creator][msg.sender][rewardToken]; if (senderAllowance >= campaignAmount) { From e133cd3942e60adc411e7010a29e0e754218883d Mon Sep 17 00:00:00 2001 From: Pablo Veyrat Date: Thu, 16 Oct 2025 19:55:33 +0200 Subject: [PATCH 08/27] fix: gap --- contracts/DistributionCreator.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/DistributionCreator.sol b/contracts/DistributionCreator.sol index 5724884c..8c67c5aa 100644 --- a/contracts/DistributionCreator.sol +++ b/contracts/DistributionCreator.sol @@ -580,5 +580,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[31] private __gap; + uint256[28] private __gap; } From b23e780ba04451937123b87feeede1575cfe1078 Mon Sep 17 00:00:00 2001 From: Pablo Veyrat Date: Fri, 17 Oct 2025 17:54:40 +0200 Subject: [PATCH 09/27] fix: balance update --- contracts/DistributionCreator.sol | 104 ++++++++++++++++-------------- contracts/utils/Errors.sol | 2 + 2 files changed, 56 insertions(+), 50 deletions(-) diff --git a/contracts/DistributionCreator.sol b/contracts/DistributionCreator.sol index 8c67c5aa..c335f946 100644 --- a/contracts/DistributionCreator.sol +++ b/contracts/DistributionCreator.sol @@ -85,7 +85,7 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { /// @notice Maps a campaignId to the ID of the campaign in the campaign list + 1 mapping(bytes32 => uint256) internal _campaignLookup; - /// @notice Maps a campaign type to the fees for this specific campaign + /// @notice Maps a campaign type to the fees for this specific campaign type mapping(uint32 => uint256) public campaignSpecificFees; /// @notice Maps a campaignId to a potential override written @@ -103,7 +103,8 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { /// @notice Maps a creator to a reward token to the balance pre-deposited by the creator for this token mapping(address => mapping(address => uint256)) public creatorBalance; - /// @notice Maps a creator address to an operator to a reward token to an amount that can be pulled from the creator's balance + /// @notice Maps a creator address to an operator to a reward token to an amount that can be pulled from the + /// creator's predeposited balance mapping(address => mapping(address => mapping(address => uint256))) public creatorTokenAllowance; /// @notice Maps a creator to a campaign operator to the ability to manage the campaign on behalf of the creator @@ -190,9 +191,9 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { USER FACING FUNCTIONS //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ - /// @notice Creates a `campaign` to incentivize a given pool for a specific period of time - /// @return The campaignId of the new campaign - /// @dev If the campaign is badly specified, it will not be handled by the campaign script and rewards may be lost + /// @notice Creates a `campaign` on Merkl + /// @return campaignId of the new campaign + /// @dev If the campaign is badly formatted, it will not be handled by the reward engine and rewards may be lost /// @dev Reward tokens sent as part of campaigns must have been whitelisted before and amounts /// sent should be bigger than a minimum amount specific to each token /// @dev This function reverts if the sender has not accepted the terms and conditions @@ -201,7 +202,7 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { } /// @notice Same as the function above but for multiple campaigns at once - /// @return List of all the campaign amounts actually deposited for each `campaign` in the `campaigns` list + /// @return List of all the campaignIds created function createCampaigns( CampaignParameters[] memory campaigns ) external nonReentrant hasSigned returns (bytes32[] memory) { @@ -262,59 +263,50 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { emit CampaignReallocation(_campaignId, froms, to); } - /// @notice Increases the token allowance of an `operator` for a `user` - function increaseCreatorTokenAllowance( + /// @notice Increases the predeposited token balance of a `user` for a given `rewardToken` + /// @dev If a governor is calling the function, the user must have sent the tokens beforehand + /// @dev This function can be used to deposit on behalf of another user + function increaseTokenBalance(address user, address rewardToken, uint256 amount) external { + if (!accessControlManager.isGovernor(msg.sender)) + IERC20(rewardToken).safeTransferFrom(msg.sender, address(this), amount); + _updateBalance(user, rewardToken, creatorBalance[user][rewardToken] + amount); + } + + /// @notice Decreases the predeposited token balance of a `user` for a given `rewardToken` + /// @dev Only the user themselves or a governor can call this function + function decreaseTokenBalance( address user, - address operator, address rewardToken, + address to, uint256 amount ) external onlyUserOrGovernor(user) { - if (operator == address(0)) revert Errors.ZeroAddress(); - uint256 currentAllowance = creatorTokenAllowance[user][operator][rewardToken]; - creatorTokenAllowance[user][operator][rewardToken] = currentAllowance + amount; - emit CreatorAllowanceUpdated(user, operator, rewardToken, currentAllowance + amount); + _updateBalance(user, rewardToken, creatorBalance[user][rewardToken] - amount); + IERC20(rewardToken).safeTransfer(to, amount); } - /// @notice Decreases the token allowance of an `operator` for a `user` - function decreaseCreatorTokenAllowance( + /// @notice Increases the token allowance of an `operator` for a `user` + function increaseCreatorTokenAllowance( address user, address operator, address rewardToken, uint256 amount ) external onlyUserOrGovernor(user) { - if (operator == address(0)) revert Errors.ZeroAddress(); - uint256 currentAllowance = creatorTokenAllowance[user][operator][rewardToken]; - uint256 updateAmount = amount > currentAllowance ? currentAllowance : amount; - creatorTokenAllowance[user][operator][rewardToken] = currentAllowance - updateAmount; - emit CreatorAllowanceUpdated(user, operator, rewardToken, currentAllowance - updateAmount); - } - - /// @dev If a governor is calling the function, the user must have sent the tokens beforehand - function preDepositTokens(address user, address rewardToken, uint256 amount) external { - if (!accessControlManager.isGovernor(msg.sender)) - IERC20(rewardToken).safeTransferFrom(msg.sender, address(this), amount); - uint256 userBalance = creatorBalance[user][rewardToken]; - creatorBalance[user][rewardToken] = userBalance + amount; - emit CreatorBalanceUpdated(user, rewardToken, userBalance + amount); + _updateAllowance(user, operator, rewardToken, creatorTokenAllowance[user][operator][rewardToken] + amount); } - function recoverPreDepositedTokens( + /// @notice Decreases the token allowance of an `operator` for a `user` + function decreaseCreatorTokenAllowance( address user, + address operator, address rewardToken, - address to, uint256 amount ) external onlyUserOrGovernor(user) { - uint256 userBalance = creatorBalance[user][rewardToken]; - if (amount > userBalance) revert Errors.NotEnoughBalance(); - creatorBalance[user][rewardToken] = userBalance - amount; - IERC20(rewardToken).safeTransfer(to, amount); - emit CreatorBalanceUpdated(user, rewardToken, userBalance - amount); + _updateAllowance(user, operator, rewardToken, creatorTokenAllowance[user][operator][rewardToken] - amount); } /// @notice Toggles the ability of an `operator` to manage campaigns on behalf of a `user` /// @dev Only the user themselves or a governor can call this function - function toggleCampaignOperator(address user, address operator) external { - if (user != msg.sender && !accessControlManager.isGovernor(msg.sender)) revert Errors.NotAllowed(); + function toggleCampaignOperator(address user, address operator) external onlyUserOrGovernor(user) { uint256 currentStatus = campaignOperators[user][operator]; campaignOperators[user][operator] = 1 - currentStatus; emit CampaignOperatorToggled(user, operator, currentStatus == 0); @@ -392,13 +384,6 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { emit DistributorUpdated(_distributor); } - /// @notice Sets the defaultFees on deposit - function setFees(uint256 _defaultFees) external onlyGovernor { - if (_defaultFees >= BASE_9) revert Errors.InvalidParam(); - defaultFees = _defaultFees; - 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; @@ -417,13 +402,20 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { } /// @notice Sets the message that needs to be accepted by users before posting rewards - function setMessage(string memory _message) external onlyGovernor { + function setMessage(string memory _message) external onlyGovernorOrGuardian { message = _message; bytes32 _messageHash = ECDSA.toEthSignedMessageHash(bytes(_message)); messageHash = _messageHash; emit MessageUpdated(_messageHash); } + /// @notice Sets the defaultFees on deposit + function setFees(uint256 _defaultFees) external onlyGovernorOrGuardian { + if (_defaultFees >= BASE_9) revert Errors.InvalidParam(); + defaultFees = _defaultFees; + emit FeesSet(_defaultFees); + } + /// @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 { @@ -500,6 +492,19 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { } } + /// @notice Internal helper to update the allowance of an operator for a user + function _updateAllowance(address user, address operator, address rewardToken, uint256 newAllowance) internal { + creatorTokenAllowance[user][operator][rewardToken] = newAllowance; + emit CreatorAllowanceUpdated(user, operator, rewardToken, newAllowance); + } + + /// @notice Internal helper to update the balance of a user for a reward token + function _updateBalance(address user, address rewardToken, uint256 newBalance) internal { + creatorBalance[user][rewardToken] = newBalance; + emit CreatorBalanceUpdated(user, rewardToken, newBalance); + } + + /// @notice Pulls tokens from either the predeposited balance of a creator or from the `msg.sender` function _pullTokens( address creator, address rewardToken, @@ -507,22 +512,21 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { uint256 campaignAmountMinusFees ) internal { uint256 fees = campaignAmount - campaignAmountMinusFees; + address _feeRecipient = feeRecipient; _feeRecipient = _feeRecipient == address(0) ? address(this) : _feeRecipient; uint256 userBalance = creatorBalance[creator][rewardToken]; if (userBalance >= campaignAmount) { if (msg.sender != creator) { uint256 senderAllowance = creatorTokenAllowance[creator][msg.sender][rewardToken]; if (senderAllowance >= campaignAmount) { - creatorTokenAllowance[creator][msg.sender][rewardToken] = senderAllowance - campaignAmount; - emit CreatorAllowanceUpdated(creator, msg.sender, rewardToken, senderAllowance - campaignAmount); + _updateAllowance(creator, msg.sender, rewardToken, senderAllowance - campaignAmount); } else { if (fees > 0) IERC20(rewardToken).safeTransferFrom(msg.sender, _feeRecipient, fees); IERC20(rewardToken).safeTransferFrom(msg.sender, distributor, campaignAmountMinusFees); return; } } - creatorBalance[creator][rewardToken] = userBalance - campaignAmount; - emit CreatorBalanceUpdated(creator, rewardToken, userBalance - campaignAmount); + _updateBalance(creator, rewardToken, userBalance - campaignAmount); if (fees > 0) IERC20(rewardToken).safeTransfer(_feeRecipient, fees); IERC20(rewardToken).safeTransfer(distributor, campaignAmountMinusFees); } else { diff --git a/contracts/utils/Errors.sol b/contracts/utils/Errors.sol index 25742438..78a44c31 100644 --- a/contracts/utils/Errors.sol +++ b/contracts/utils/Errors.sol @@ -24,6 +24,8 @@ library Errors { error NoDispute(); error NoOverrideForCampaign(); error NotAllowed(); + error NotEnoughAllowance(); + error NotEnoughBalance(); error NotEnoughPayment(); error NotGovernor(); error NotGovernorOrGuardian(); From fc63e80e4b519cfc8623e05c64e0795aa7408742 Mon Sep 17 00:00:00 2001 From: Pablo Veyrat Date: Fri, 17 Oct 2025 19:01:16 +0200 Subject: [PATCH 10/27] adding some tests --- contracts/DistributionCreator.sol | 17 +- test/DistributionCreator.t.sol | 387 ++++++++++++++++++++++++++++ test/unit/DistributionCreator.t.sol | 175 +++++++++++++ 3 files changed, 571 insertions(+), 8 deletions(-) diff --git a/contracts/DistributionCreator.sol b/contracts/DistributionCreator.sol index c335f946..8837fe1c 100644 --- a/contracts/DistributionCreator.sol +++ b/contracts/DistributionCreator.sol @@ -105,7 +105,7 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { /// @notice Maps a creator address to an operator to a reward token to an amount that can be pulled from the /// creator's predeposited balance - mapping(address => mapping(address => mapping(address => uint256))) public creatorTokenAllowance; + mapping(address => mapping(address => mapping(address => uint256))) public creatorAllowance; /// @notice Maps a creator to a campaign operator to the ability to manage the campaign on behalf of the creator mapping(address => mapping(address => uint256)) public campaignOperators; @@ -239,7 +239,8 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { ) revert Errors.InvalidOverride(); newCampaign.campaignId = _campaignId; - newCampaign.creator = msg.sender; + // The creator address cannot be changed + newCampaign.creator = _campaign.creator; campaignOverrides[_campaignId] = newCampaign; campaignOverridesTimestamp[_campaignId].push(block.timestamp); emit CampaignOverride(_campaignId, newCampaign); @@ -285,23 +286,23 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { } /// @notice Increases the token allowance of an `operator` for a `user` - function increaseCreatorTokenAllowance( + function increaseTokenAllowance( address user, address operator, address rewardToken, uint256 amount ) external onlyUserOrGovernor(user) { - _updateAllowance(user, operator, rewardToken, creatorTokenAllowance[user][operator][rewardToken] + amount); + _updateAllowance(user, operator, rewardToken, creatorAllowance[user][operator][rewardToken] + amount); } /// @notice Decreases the token allowance of an `operator` for a `user` - function decreaseCreatorTokenAllowance( + function decreaseTokenAllowance( address user, address operator, address rewardToken, uint256 amount ) external onlyUserOrGovernor(user) { - _updateAllowance(user, operator, rewardToken, creatorTokenAllowance[user][operator][rewardToken] - amount); + _updateAllowance(user, operator, rewardToken, creatorAllowance[user][operator][rewardToken] - amount); } /// @notice Toggles the ability of an `operator` to manage campaigns on behalf of a `user` @@ -494,7 +495,7 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { /// @notice Internal helper to update the allowance of an operator for a user function _updateAllowance(address user, address operator, address rewardToken, uint256 newAllowance) internal { - creatorTokenAllowance[user][operator][rewardToken] = newAllowance; + creatorAllowance[user][operator][rewardToken] = newAllowance; emit CreatorAllowanceUpdated(user, operator, rewardToken, newAllowance); } @@ -517,7 +518,7 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { uint256 userBalance = creatorBalance[creator][rewardToken]; if (userBalance >= campaignAmount) { if (msg.sender != creator) { - uint256 senderAllowance = creatorTokenAllowance[creator][msg.sender][rewardToken]; + uint256 senderAllowance = creatorAllowance[creator][msg.sender][rewardToken]; if (senderAllowance >= campaignAmount) { _updateAllowance(creator, msg.sender, rewardToken, senderAllowance - campaignAmount); } else { diff --git a/test/DistributionCreator.t.sol b/test/DistributionCreator.t.sol index c5ab385d..2aeef188 100644 --- a/test/DistributionCreator.t.sol +++ b/test/DistributionCreator.t.sol @@ -313,6 +313,210 @@ contract DistributionCreatorCreateReallocationTest is Fixture { assertEq(listReallocation.length, 1); assertEq(listReallocation[0], alice); } + + { + address[] memory users = new address[](3); + users[0] = alice; + users[1] = bob; + users[2] = dylan; + + vm.prank(alice); + creator.reallocateCampaignRewards(campaignId, users, address(guardian)); + + assertEq(creator.campaignReallocation(campaignId, alice), address(guardian)); + assertEq(creator.campaignReallocation(campaignId, bob), address(guardian)); + assertEq(creator.campaignReallocation(campaignId, dylan), address(guardian)); + + address[] memory listReallocation = creator.getCampaignListReallocation(campaignId); + assertEq(listReallocation.length, 4); + assertEq(listReallocation[0], alice); + assertEq(listReallocation[1], alice); + assertEq(listReallocation[2], bob); + assertEq(listReallocation[3], dylan); + } + } + + function testUnit_ReallocationCampaignRewards_SuccessWhenOperator() 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.OperatorNotAllowed.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.toggleCampaignOperator(alice, bob); + vm.prank(dylan); + vm.expectRevert(); + creator.reallocateCampaignRewards(campaignId, users, address(governor)); + vm.prank(bob); + 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); + } + } + + function testUnit_ReallocationCampaignRewards_SuccessWhenGovernor() 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.OperatorNotAllowed.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(governor); + 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); + } } } @@ -425,6 +629,189 @@ contract DistributionCreatorOverrideTest is Fixture { ); } + function testUnit_OverrideCampaignDataFromOperator() 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.toggleCampaignOperator(alice, bob); + + vm.prank(dylan); + vm.expectRevert(Errors.OperatorNotAllowed.selector); + creator.overrideCampaign( + campaignId, + CampaignParameters({ + campaignId: campaignId, + creator: alice, + rewardToken: address(angle), + amount: amountAfterFees, + campaignType: 5, + startTimestamp: startTimestamp, + duration: 3600 * 24, + campaignData: overrideCampaignData + }) + ); + + vm.prank(bob); + 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.campaignOverridesTimestamp(campaignId, 1); + } + + function testUnit_OverrideCampaignDataFromGovernor() 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(governor); + 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.campaignOverridesTimestamp(campaignId, 1); + } + function testUnit_OverrideCampaignData() public { amount = 100 ether; amountAfterFees = 90 ether; diff --git a/test/unit/DistributionCreator.t.sol b/test/unit/DistributionCreator.t.sol index e013e183..714d79ac 100644 --- a/test/unit/DistributionCreator.t.sol +++ b/test/unit/DistributionCreator.t.sol @@ -782,3 +782,178 @@ contract Test_DistributionCreator_distribution is DistributionCreatorForkTest { ); } } + +contract Test_DistributionCreator_adjustTokenBalance is DistributionCreatorTest { + function test_SuccessWhenUser() public { + uint256 balance = angle.balanceOf(address(alice)); + uint256 creatorBalance = angle.balanceOf(address(creator)); + vm.startPrank(alice); + creator.increaseTokenBalance(address(bob), address(angle), 1e9); + assertEq(creator.creatorBalance(address(bob), address(angle)), 1e9); + assertEq(angle.balanceOf(address(alice)), balance - 1e9); + assertEq(angle.balanceOf(address(creator)), creatorBalance + 1e9); + creator.increaseTokenBalance(address(alice), address(angle), 1e10); + assertEq(creator.creatorBalance(address(alice), address(angle)), 1e10); + assertEq(angle.balanceOf(address(alice)), balance - 1e9 - 1e10); + assertEq(angle.balanceOf(address(creator)), creatorBalance + 1e9 + 1e10); + creator.decreaseTokenBalance(address(alice), address(angle), address(alice), 1e10 / 2); + assertEq(creator.creatorBalance(address(alice), address(angle)), 1e10 / 2); + assertEq(angle.balanceOf(address(alice)), balance - 1e9 - 1e10 + 1e10 / 2); + assertEq(angle.balanceOf(address(creator)), creatorBalance + 1e9 + 1e10 - 1e10 / 2); + creator.decreaseTokenBalance(address(alice), address(angle), address(dylan), 1e9); + assertEq(creator.creatorBalance(address(alice), address(angle)), 1e10 / 2 - 1e9); + assertEq(angle.balanceOf(address(dylan)), 1e9); + vm.stopPrank(); + } + + function test_SuccessWhenGovernor() public { + uint256 balance = angle.balanceOf(address(alice)); + uint256 balance2 = angle.balanceOf(address(governor)); + uint256 balance3 = angle.balanceOf(address(bob)); + uint256 creatorBalance = angle.balanceOf(address(creator)); + vm.startPrank(governor); + creator.increaseTokenBalance(address(bob), address(angle), 1e9); + assertEq(creator.creatorBalance(address(bob), address(angle)), 1e9); + assertEq(angle.balanceOf(address(governor)), balance2); + assertEq(angle.balanceOf(address(alice)), balance); + assertEq(angle.balanceOf(address(bob)), balance3); + assertEq(angle.balanceOf(address(creator)), creatorBalance); + creator.increaseTokenBalance(address(alice), address(angle), 1e10); + assertEq(creator.creatorBalance(address(alice), address(angle)), 1e10); + assertEq(angle.balanceOf(address(governor)), balance2); + assertEq(angle.balanceOf(address(alice)), balance); + assertEq(angle.balanceOf(address(bob)), balance3); + assertEq(angle.balanceOf(address(creator)), creatorBalance); + + IERC20[] memory tokens = new IERC20[](1); + tokens[0] = angle; + creator.recoverFees(tokens, address(bob)); + vm.expectRevert(); + creator.decreaseTokenBalance(address(alice), address(angle), address(alice), 1e10 / 2); + vm.stopPrank(); + } + function test_RevertWhenNotUserOrGovernor() public { + uint256 balance = angle.balanceOf(address(alice)); + uint256 creatorBalance = angle.balanceOf(address(creator)); + vm.startPrank(alice); + creator.increaseTokenBalance(address(bob), address(angle), 1e9); + assertEq(creator.creatorBalance(address(bob), address(angle)), 1e9); + assertEq(angle.balanceOf(address(alice)), balance - 1e9); + assertEq(angle.balanceOf(address(creator)), creatorBalance + 1e9); + vm.expectRevert(Errors.NotAllowed.selector); + creator.decreaseTokenBalance(address(bob), address(angle), address(alice), 1e9); + vm.stopPrank(); + } + + function test_RevertWhenNotEnoughBalance() public { + uint256 balance = angle.balanceOf(address(alice)); + uint256 creatorBalance = angle.balanceOf(address(creator)); + vm.startPrank(alice); + creator.increaseTokenBalance(address(alice), address(angle), 1e9); + assertEq(creator.creatorBalance(address(alice), address(angle)), 1e9); + assertEq(angle.balanceOf(address(alice)), balance - 1e9); + assertEq(angle.balanceOf(address(creator)), creatorBalance + 1e9); + vm.expectRevert(); + creator.decreaseTokenBalance(address(alice), address(angle), address(alice), 1e10); + vm.stopPrank(); + } +} + +contract Test_DistributionCreator_adjustTokenAllowance is DistributionCreatorTest { + function test_SuccessWhenUser() public { + vm.startPrank(alice); + creator.increaseTokenAllowance(address(alice), address(bob), address(angle), 1e9); + assertEq(creator.creatorAllowance(address(alice), address(bob), address(angle)), 1e9); + + creator.increaseTokenAllowance(address(alice), address(bob), address(angle), 1e5); + assertEq(creator.creatorAllowance(address(alice), address(bob), address(angle)), 1e9 + 1e5); + + creator.increaseTokenAllowance(address(alice), address(dylan), address(angle), 1e10); + assertEq(creator.creatorAllowance(address(alice), address(dylan), address(angle)), 1e10); + + creator.increaseTokenAllowance(address(alice), address(dylan), address(angle), 1e6); + assertEq(creator.creatorAllowance(address(alice), address(dylan), address(angle)), 1e10 + 1e6); + + creator.decreaseTokenAllowance(address(alice), address(dylan), address(angle), 1e8); + assertEq(creator.creatorAllowance(address(alice), address(dylan), address(angle)), 1e10 + 1e6 - 1e8); + + creator.decreaseTokenAllowance(address(alice), address(bob), address(angle), 1e7); + assertEq(creator.creatorAllowance(address(alice), address(bob), address(angle)), 1e9 + 1e5 - 1e7); + + vm.stopPrank(); + } + + function test_SuccessWhenGovernor() public { + vm.startPrank(governor); + creator.increaseTokenAllowance(address(alice), address(bob), address(angle), 1e9); + assertEq(creator.creatorAllowance(address(alice), address(bob), address(angle)), 1e9); + + creator.increaseTokenAllowance(address(alice), address(bob), address(angle), 1e5); + assertEq(creator.creatorAllowance(address(alice), address(bob), address(angle)), 1e9 + 1e5); + + creator.increaseTokenAllowance(address(alice), address(dylan), address(angle), 1e10); + assertEq(creator.creatorAllowance(address(alice), address(dylan), address(angle)), 1e10); + + creator.increaseTokenAllowance(address(alice), address(dylan), address(angle), 1e6); + assertEq(creator.creatorAllowance(address(alice), address(dylan), address(angle)), 1e10 + 1e6); + + creator.decreaseTokenAllowance(address(alice), address(dylan), address(angle), 1e8); + assertEq(creator.creatorAllowance(address(alice), address(dylan), address(angle)), 1e10 + 1e6 - 1e8); + + creator.decreaseTokenAllowance(address(alice), address(bob), address(angle), 1e7); + assertEq(creator.creatorAllowance(address(alice), address(bob), address(angle)), 1e9 + 1e5 - 1e7); + vm.stopPrank(); + } + + function test_RevertWhenNotUserOrGovernor() public { + vm.startPrank(bob); + vm.expectRevert(Errors.NotAllowed.selector); + creator.increaseTokenAllowance(address(alice), address(bob), address(angle), 1e9); + vm.stopPrank(); + } + + function test_RevertWhenNotEnoughAllowance() public { + vm.startPrank(alice); + creator.increaseTokenAllowance(address(alice), address(bob), address(angle), 1e9); + assertEq(creator.creatorAllowance(address(alice), address(bob), address(angle)), 1e9); + vm.expectRevert(); + creator.decreaseTokenAllowance(address(alice), address(bob), address(angle), 1e10); + + vm.expectRevert(); + creator.decreaseTokenAllowance(address(alice), address(dylan), address(angle), 1); + + vm.stopPrank(); + } +} + +contract Test_DistributionCreator_toggleCampaignOperator is DistributionCreatorTest { + function test_SuccessWhenUser() public { + vm.startPrank(alice); + creator.toggleCampaignOperator(address(alice), address(bob)); + assertEq(creator.campaignOperators(address(alice), address(bob)), 1); + creator.toggleCampaignOperator(address(alice), address(bob)); + assertEq(creator.campaignOperators(address(alice), address(bob)), 0); + creator.toggleCampaignOperator(address(alice), address(dylan)); + assertEq(creator.campaignOperators(address(alice), address(dylan)), 1); + + vm.stopPrank(); + } + + function test_SuccessWhenGovernor() public { + vm.startPrank(governor); + creator.toggleCampaignOperator(address(alice), address(bob)); + assertEq(creator.campaignOperators(address(alice), address(bob)), 1); + creator.toggleCampaignOperator(address(alice), address(bob)); + assertEq(creator.campaignOperators(address(alice), address(bob)), 0); + creator.toggleCampaignOperator(address(alice), address(dylan)); + assertEq(creator.campaignOperators(address(alice), address(dylan)), 1); + vm.stopPrank(); + } + + function test_RevertWhenNotUserOrGovernor() public { + vm.startPrank(bob); + vm.expectRevert(Errors.NotAllowed.selector); + creator.toggleCampaignOperator(address(alice), address(bob)); + vm.stopPrank(); + } +} From 74b784a30165d69a11d16c5d517479033ce6bfb6 Mon Sep 17 00:00:00 2001 From: Pablo Veyrat Date: Fri, 17 Oct 2025 19:36:50 +0200 Subject: [PATCH 11/27] fix: tests --- contracts/DistributionCreator.sol | 9 +- test/unit/DistributionCreator.t.sol | 227 +++++++++++++++++++++++++++- 2 files changed, 231 insertions(+), 5 deletions(-) diff --git a/contracts/DistributionCreator.sol b/contracts/DistributionCreator.sol index 8837fe1c..dbd33971 100644 --- a/contracts/DistributionCreator.sol +++ b/contracts/DistributionCreator.sol @@ -513,8 +513,11 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { uint256 campaignAmountMinusFees ) internal { uint256 fees = campaignAmount - campaignAmountMinusFees; - address _feeRecipient = feeRecipient; - _feeRecipient = _feeRecipient == address(0) ? address(this) : _feeRecipient; + address _feeRecipient; + if (fees > 0) { + _feeRecipient = feeRecipient; + _feeRecipient = _feeRecipient == address(0) ? address(this) : _feeRecipient; + } uint256 userBalance = creatorBalance[creator][rewardToken]; if (userBalance >= campaignAmount) { if (msg.sender != creator) { @@ -528,7 +531,7 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { } } _updateBalance(creator, rewardToken, userBalance - campaignAmount); - if (fees > 0) IERC20(rewardToken).safeTransfer(_feeRecipient, fees); + if (fees > 0 && _feeRecipient != address(this)) IERC20(rewardToken).safeTransfer(_feeRecipient, fees); IERC20(rewardToken).safeTransfer(distributor, campaignAmountMinusFees); } else { if (fees > 0) IERC20(rewardToken).safeTransferFrom(msg.sender, _feeRecipient, fees); diff --git a/test/unit/DistributionCreator.t.sol b/test/unit/DistributionCreator.t.sol index 714d79ac..6d89dd0b 100644 --- a/test/unit/DistributionCreator.t.sol +++ b/test/unit/DistributionCreator.t.sol @@ -428,6 +428,229 @@ contract Test_DistributionCreator_CreateCampaign is DistributionCreatorTest { assertEq(extraData, fetchedCampaignData); assertEq(campaignId, fetchedCampaignId); } + + function test_SuccessFromPreDepositedBalance() public { + uint256 amount = 1e8; + CampaignParameters memory campaign = CampaignParameters({ + campaignId: keccak256("TEST"), + creator: address(alice), + campaignData: hex"ab", + rewardToken: address(angle), + amount: amount, + campaignType: 0, + startTimestamp: uint32(block.timestamp + 1), + duration: 3600 + }); + + vm.prank(governor); + creator.setFeeRecipient(dylan); + + { + vm.startPrank(alice); + creator.increaseTokenBalance(alice, address(angle), 1e10); + assertEq(creator.creatorBalance(address(alice), address(angle)), 1e10); + address distributor = creator.distributor(); + uint256 balance = angle.balanceOf(address(alice)); + uint256 creatorBalance = angle.balanceOf(address(creator)); + uint256 distributorBalance = angle.balanceOf(address(distributor)); + uint256 fees = creator.defaultFees(); + creator.createCampaign(campaign); + assertEq(creator.creatorBalance(address(alice), address(angle)), 1e10 - amount); + assertEq(angle.balanceOf(address(alice)), balance); + assertEq(angle.balanceOf(address(creator)), creatorBalance - amount); + assertEq(angle.balanceOf(address(dylan)), (amount * fees) / 1e9); + assertEq(angle.balanceOf(address(distributor)), distributorBalance + amount - ((amount * fees) / 1e9)); + } + + address[] memory whitelist = new address[](1); + whitelist[0] = alice; + address[] memory blacklist = new address[](1); + blacklist[0] = charlie; + + bytes memory extraData = hex"ab"; + + // Additional asserts to check for correct behavior + bytes32 campaignId = bytes32( + keccak256( + abi.encodePacked( + block.chainid, + alice, + address(campaign.rewardToken), + uint32(campaign.campaignType), + uint32(campaign.startTimestamp), + uint32(campaign.duration), + campaign.campaignData + ) + ) + ); + ( + bytes32 fetchedCampaignId, + address fetchedCreator, + address fetchedRewardToken, + uint256 fetchedAmount, + uint32 fetchedCampaignType, + uint32 fetchedStartTimestamp, + uint32 fetchedDuration, + bytes memory fetchedCampaignData + ) = creator.campaignList(creator.campaignLookup(campaignId)); + assertEq(alice, fetchedCreator); + assertEq((amount * 9) / 10, fetchedAmount); // amount minus 10% fees + assertEq(address(angle), fetchedRewardToken); + assertEq(campaign.campaignType, fetchedCampaignType); + assertEq(campaign.startTimestamp, fetchedStartTimestamp); + assertEq(campaign.duration, fetchedDuration); + assertEq(extraData, fetchedCampaignData); + assertEq(campaignId, fetchedCampaignId); + } + + function test_SuccessFromPreDepositedBalanceWithNoFeeRecipient() public { + uint256 amount = 1e8; + CampaignParameters memory campaign = CampaignParameters({ + campaignId: keccak256("TEST"), + creator: address(alice), + campaignData: hex"ab", + rewardToken: address(angle), + amount: amount, + campaignType: 0, + startTimestamp: uint32(block.timestamp + 1), + duration: 3600 + }); + + vm.prank(governor); + creator.setFeeRecipient(address(0)); + + { + vm.startPrank(alice); + creator.increaseTokenBalance(alice, address(angle), 1e10); + assertEq(creator.creatorBalance(address(alice), address(angle)), 1e10); + address distributor = creator.distributor(); + uint256 balance = angle.balanceOf(address(alice)); + uint256 creatorBalance = angle.balanceOf(address(creator)); + uint256 distributorBalance = angle.balanceOf(address(distributor)); + uint256 fees = creator.defaultFees(); + creator.createCampaign(campaign); + assertEq(creator.creatorBalance(address(alice), address(angle)), 1e10 - amount); + assertEq(angle.balanceOf(address(alice)), balance); + assertEq(angle.balanceOf(address(creator)), creatorBalance - amount + ((amount * fees) / 1e9)); + assertEq(angle.balanceOf(address(distributor)), distributorBalance + amount - ((amount * fees) / 1e9)); + } + + address[] memory whitelist = new address[](1); + whitelist[0] = alice; + address[] memory blacklist = new address[](1); + blacklist[0] = charlie; + + bytes memory extraData = hex"ab"; + + // Additional asserts to check for correct behavior + bytes32 campaignId = bytes32( + keccak256( + abi.encodePacked( + block.chainid, + alice, + address(campaign.rewardToken), + uint32(campaign.campaignType), + uint32(campaign.startTimestamp), + uint32(campaign.duration), + campaign.campaignData + ) + ) + ); + ( + bytes32 fetchedCampaignId, + address fetchedCreator, + address fetchedRewardToken, + uint256 fetchedAmount, + uint32 fetchedCampaignType, + uint32 fetchedStartTimestamp, + uint32 fetchedDuration, + bytes memory fetchedCampaignData + ) = creator.campaignList(creator.campaignLookup(campaignId)); + assertEq(alice, fetchedCreator); + assertEq((amount * 9) / 10, fetchedAmount); // amount minus 10% fees + assertEq(address(angle), fetchedRewardToken); + assertEq(campaign.campaignType, fetchedCampaignType); + assertEq(campaign.startTimestamp, fetchedStartTimestamp); + assertEq(campaign.duration, fetchedDuration); + assertEq(extraData, fetchedCampaignData); + assertEq(campaignId, fetchedCampaignId); + } + + function test_SuccessFromPreDepositedBalanceWithNoFees() public { + uint256 amount = 1e8; + CampaignParameters memory campaign = CampaignParameters({ + campaignId: keccak256("TEST"), + creator: address(alice), + campaignData: hex"ab", + rewardToken: address(angle), + amount: amount, + campaignType: 0, + startTimestamp: uint32(block.timestamp + 1), + duration: 3600 + }); + + vm.prank(governor); + creator.setFees(0); + + vm.prank(governor); + creator.setFeeRecipient(dylan); + + { + vm.startPrank(alice); + creator.increaseTokenBalance(alice, address(angle), 1e10); + assertEq(creator.creatorBalance(address(alice), address(angle)), 1e10); + address distributor = creator.distributor(); + uint256 balance = angle.balanceOf(address(alice)); + uint256 creatorBalance = angle.balanceOf(address(creator)); + uint256 distributorBalance = angle.balanceOf(address(distributor)); + creator.createCampaign(campaign); + assertEq(creator.creatorBalance(address(alice), address(angle)), 1e10 - amount); + assertEq(angle.balanceOf(address(alice)), balance); + assertEq(angle.balanceOf(address(creator)), creatorBalance - amount); + assertEq(angle.balanceOf(address(dylan)), 0); + assertEq(angle.balanceOf(address(distributor)), distributorBalance + amount); + } + + address[] memory whitelist = new address[](1); + whitelist[0] = alice; + address[] memory blacklist = new address[](1); + blacklist[0] = charlie; + + bytes memory extraData = hex"ab"; + + // Additional asserts to check for correct behavior + bytes32 campaignId = bytes32( + keccak256( + abi.encodePacked( + block.chainid, + alice, + address(campaign.rewardToken), + uint32(campaign.campaignType), + uint32(campaign.startTimestamp), + uint32(campaign.duration), + campaign.campaignData + ) + ) + ); + ( + bytes32 fetchedCampaignId, + address fetchedCreator, + address fetchedRewardToken, + uint256 fetchedAmount, + uint32 fetchedCampaignType, + uint32 fetchedStartTimestamp, + uint32 fetchedDuration, + bytes memory fetchedCampaignData + ) = creator.campaignList(creator.campaignLookup(campaignId)); + assertEq(alice, fetchedCreator); + assertEq(amount, fetchedAmount); + assertEq(address(angle), fetchedRewardToken); + assertEq(campaign.campaignType, fetchedCampaignType); + assertEq(campaign.startTimestamp, fetchedStartTimestamp); + assertEq(campaign.duration, fetchedDuration); + assertEq(extraData, fetchedCampaignData); + assertEq(campaignId, fetchedCampaignId); + } } contract Test_DistributionCreator_CreateCampaigns is DistributionCreatorTest { @@ -642,8 +865,8 @@ contract Test_DistributionCreator_acceptConditions is DistributionCreatorTest { } contract Test_DistributionCreator_setFees is DistributionCreatorTest { - function test_RevertWhen_NotGovernor() public { - vm.expectRevert(Errors.NotGovernor.selector); + function test_RevertWhen_NotGuardian() public { + vm.expectRevert(Errors.NotGovernorOrGuardian.selector); vm.prank(alice); creator.setFees(1e8); } From 01d804cc7a7e87efaae4179320d0c384f4ab7106 Mon Sep 17 00:00:00 2001 From: Pablo Veyrat Date: Mon, 20 Oct 2025 18:46:10 +0200 Subject: [PATCH 12/27] rebase --- scripts/DistributionCreator.s.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/DistributionCreator.s.sol b/scripts/DistributionCreator.s.sol index db022bd0..cc17c718 100644 --- a/scripts/DistributionCreator.s.sol +++ b/scripts/DistributionCreator.s.sol @@ -199,6 +199,7 @@ contract SetRewardTokenMinAmounts is DistributionCreatorScript { function _run(address[] memory _tokens, uint256[] memory _amounts) internal broadcast { uint256 chainId = block.chainid; // address creatorAddress = readAddress(chainId, "DistributionCreator"); + address creatorAddress = 0x8BB4C975Ff3c250e0ceEA271728547f3802B36Fd; DistributionCreator(creatorAddress).setRewardTokenMinAmounts(_tokens, _amounts); From 4dd8f28217c91d7efdde9e7558500510d2ebc915 Mon Sep 17 00:00:00 2001 From: Pablo Veyrat Date: Tue, 21 Oct 2025 18:29:52 +0200 Subject: [PATCH 13/27] fix: signatures whitelist --- contracts/DistributionCreator.sol | 8 +- scripts/deployPullTokenWrapper.s.sol | 12 +-- test/unit/DistributionCreator.t.sol | 133 +++++++++++++++++++++++++++ 3 files changed, 142 insertions(+), 11 deletions(-) diff --git a/contracts/DistributionCreator.sol b/contracts/DistributionCreator.sol index dbd33971..93c94566 100644 --- a/contracts/DistributionCreator.sol +++ b/contracts/DistributionCreator.sol @@ -465,11 +465,9 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { // if the amount distributed is too small with respect to what is allowed if ((newCampaign.amount * HOUR) / newCampaign.duration < rewardTokenMinAmount) revert Errors.CampaignRewardTooLow(); - - 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 + // Computing fees and pulling tokens uint256 campaignAmountMinusFees = _computeFees(newCampaign.campaignType, newCampaign.amount); + if (newCampaign.creator == address(0)) newCampaign.creator = msg.sender; _pullTokens(newCampaign.creator, newCampaign.rewardToken, newCampaign.amount, campaignAmountMinusFees); newCampaign.amount = campaignAmountMinusFees; newCampaign.campaignId = campaignId(newCampaign); @@ -547,7 +545,7 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { uint256 baseFeesValue = campaignSpecificFees[campaignType]; if (baseFeesValue == 1) baseFeesValue = 0; else if (baseFeesValue == 0) baseFeesValue = defaultFees; - + // Fee rebates are applied to the msg.sender and not to the creator of the campaign uint256 _fees = (baseFeesValue * (BASE_9 - feeRebate[msg.sender])) / BASE_9; distributionAmountMinusFees = distributionAmount; if (_fees != 0) { diff --git a/scripts/deployPullTokenWrapper.s.sol b/scripts/deployPullTokenWrapper.s.sol index 7db59f79..40c46a67 100644 --- a/scripts/deployPullTokenWrapper.s.sol +++ b/scripts/deployPullTokenWrapper.s.sol @@ -18,19 +18,19 @@ import { IAccessControlManager } from "../contracts/interfaces/IAccessControlMan import { MockToken } from "../contracts/mock/MockToken.sol"; contract DeployPullTokenWrapper is BaseScript { - // forge script scripts/deployPullTokenWrapper.s.sol --rpc-url mainnet --sender 0xA9DdD91249DFdd450E81E1c56Ab60E1A62651701 --broadcast --verify + // forge script scripts/deployPullTokenWrapper.s.sol --rpc-url celo --sender 0xA9DdD91249DFdd450E81E1c56Ab60E1A62651701 --broadcast --verify function run() public { uint256 deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY"); vm.startBroadcast(deployerPrivateKey); address distributionCreator = 0x8BB4C975Ff3c250e0ceEA271728547f3802B36Fd; // ------------------------------------------------------------------------ // TO EDIT - address underlying = 0xEc4ef66D4fCeEba34aBB4dE69dB391Bc5476ccc8; + address underlying = 0xBba98352628B0B0c4b40583F593fFCb630935a45; address holder = 0xdef1FA4CEfe67365ba046a7C630D6B885298E210; // Need to choose the implementation type and if implementation needs to be deployed - address implementation = address(new PullTokenWrapperWithdraw()); - // address implementation = address(new PullTokenWrapper()); + // address implementation = address(new PullTokenWrapperWithdraw()); + address implementation = address(new PullTokenWrapper()); // Ethereum implementation of PullTokenWrapper // address implementation = 0x979a04fd2f3A6a2B3945A715e24b974323E93567; // Ethereum implementation of PullTokenWrapperWithdraw @@ -42,8 +42,8 @@ contract DeployPullTokenWrapper is BaseScript { string memory symbol = IERC20Metadata(underlying).symbol(); // Names to override if deploying a PullTokenWrapperWithdraw implementation - name = "USDtb (wrapped)"; - symbol = "USDtb"; + // name = "USDtb (wrapped)"; + // symbol = "USDtb"; console.log("PullTokenWrapper Implementation:", address(implementation)); diff --git a/test/unit/DistributionCreator.t.sol b/test/unit/DistributionCreator.t.sol index 6d89dd0b..3943b87f 100644 --- a/test/unit/DistributionCreator.t.sol +++ b/test/unit/DistributionCreator.t.sol @@ -372,6 +372,63 @@ contract Test_DistributionCreator_CreateCampaign is DistributionCreatorTest { assertEq(campaignId, fetchedCampaignId); } + function test_SuccessDifferentCreator() public { + uint256 amount = 1e8; + CampaignParameters memory campaign = CampaignParameters({ + campaignId: keccak256("TEST"), + creator: address(bob), + campaignData: hex"ab", + rewardToken: address(angle), + amount: amount, + campaignType: 0, + startTimestamp: uint32(block.timestamp + 1), + duration: 3600 + }); + + vm.prank(alice); + creator.createCampaign(campaign); + + address[] memory whitelist = new address[](1); + whitelist[0] = alice; + address[] memory blacklist = new address[](1); + blacklist[0] = charlie; + + bytes memory extraData = hex"ab"; + + // Additional asserts to check for correct behavior + bytes32 campaignId = bytes32( + keccak256( + abi.encodePacked( + block.chainid, + bob, + address(campaign.rewardToken), + uint32(campaign.campaignType), + uint32(campaign.startTimestamp), + uint32(campaign.duration), + campaign.campaignData + ) + ) + ); + ( + bytes32 fetchedCampaignId, + address fetchedCreator, + address fetchedRewardToken, + uint256 fetchedAmount, + uint32 fetchedCampaignType, + uint32 fetchedStartTimestamp, + uint32 fetchedDuration, + bytes memory fetchedCampaignData + ) = creator.campaignList(creator.campaignLookup(campaignId)); + assertEq(bob, fetchedCreator); + assertEq((amount * 9) / 10, fetchedAmount); // amount minus 10% fees + assertEq(address(angle), fetchedRewardToken); + assertEq(campaign.campaignType, fetchedCampaignType); + assertEq(campaign.startTimestamp, fetchedStartTimestamp); + assertEq(campaign.duration, fetchedDuration); + assertEq(extraData, fetchedCampaignData); + assertEq(campaignId, fetchedCampaignId); + } + function test_Succeed_CampaignStartInThePast() public { uint256 amount = 1e8; CampaignParameters memory campaign = CampaignParameters({ @@ -651,6 +708,82 @@ contract Test_DistributionCreator_CreateCampaign is DistributionCreatorTest { assertEq(extraData, fetchedCampaignData); assertEq(campaignId, fetchedCampaignId); } + + function test_SuccessFromPreDepositedBalanceWithNoFees() public { + uint256 amount = 1e8; + CampaignParameters memory campaign = CampaignParameters({ + campaignId: keccak256("TEST"), + creator: address(alice), + campaignData: hex"ab", + rewardToken: address(angle), + amount: amount, + campaignType: 0, + startTimestamp: uint32(block.timestamp + 1), + duration: 3600 + }); + + vm.prank(governor); + creator.setFees(0); + + vm.prank(governor); + creator.setFeeRecipient(dylan); + + { + vm.startPrank(alice); + creator.increaseTokenBalance(alice, address(angle), 1e10); + assertEq(creator.creatorBalance(address(alice), address(angle)), 1e10); + address distributor = creator.distributor(); + uint256 balance = angle.balanceOf(address(alice)); + uint256 creatorBalance = angle.balanceOf(address(creator)); + uint256 distributorBalance = angle.balanceOf(address(distributor)); + creator.createCampaign(campaign); + assertEq(creator.creatorBalance(address(alice), address(angle)), 1e10 - amount); + assertEq(angle.balanceOf(address(alice)), balance); + assertEq(angle.balanceOf(address(creator)), creatorBalance - amount); + assertEq(angle.balanceOf(address(dylan)), 0); + assertEq(angle.balanceOf(address(distributor)), distributorBalance + amount); + } + + address[] memory whitelist = new address[](1); + whitelist[0] = alice; + address[] memory blacklist = new address[](1); + blacklist[0] = charlie; + + bytes memory extraData = hex"ab"; + + // Additional asserts to check for correct behavior + bytes32 campaignId = bytes32( + keccak256( + abi.encodePacked( + block.chainid, + alice, + address(campaign.rewardToken), + uint32(campaign.campaignType), + uint32(campaign.startTimestamp), + uint32(campaign.duration), + campaign.campaignData + ) + ) + ); + ( + bytes32 fetchedCampaignId, + address fetchedCreator, + address fetchedRewardToken, + uint256 fetchedAmount, + uint32 fetchedCampaignType, + uint32 fetchedStartTimestamp, + uint32 fetchedDuration, + bytes memory fetchedCampaignData + ) = creator.campaignList(creator.campaignLookup(campaignId)); + assertEq(alice, fetchedCreator); + assertEq(amount, fetchedAmount); + assertEq(address(angle), fetchedRewardToken); + assertEq(campaign.campaignType, fetchedCampaignType); + assertEq(campaign.startTimestamp, fetchedStartTimestamp); + assertEq(campaign.duration, fetchedDuration); + assertEq(extraData, fetchedCampaignData); + assertEq(campaignId, fetchedCampaignId); + } } contract Test_DistributionCreator_CreateCampaigns is DistributionCreatorTest { From 7374b05a7354769c3330f5fd5efc3284e10f7866 Mon Sep 17 00:00:00 2001 From: Pablo Veyrat Date: Tue, 21 Oct 2025 19:04:19 +0200 Subject: [PATCH 14/27] fix: all tests --- contracts/DistributionCreator.sol | 8 + test/DistributionCreator.t.sol | 1 + test/unit/DistributionCreator.t.sol | 343 +++++++++++++++++++++++++++- 3 files changed, 351 insertions(+), 1 deletion(-) diff --git a/contracts/DistributionCreator.sol b/contracts/DistributionCreator.sol index 93c94566..16964651 100644 --- a/contracts/DistributionCreator.sol +++ b/contracts/DistributionCreator.sol @@ -132,6 +132,7 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { event MessageUpdated(bytes32 _messageHash); event NewCampaign(CampaignParameters campaign); event RewardTokenMinimumAmountUpdated(address indexed token, uint256 amount); + event UserSigningWhitelistToggled(address indexed user, uint256 toggleStatus); /*////////////////////////////////////////////////////////////////////////////////////////////////////////////////// MODIFIERS @@ -431,6 +432,13 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { emit FeeRebateUpdated(user, userFeeRebate); } + /// @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]; + userSignatureWhitelist[user] = whitelistStatus; + emit UserSigningWhitelistToggled(user, whitelistStatus); + } + /// @notice Sets the minimum amounts per distribution epoch for different reward tokens function setRewardTokenMinAmounts( address[] calldata tokens, diff --git a/test/DistributionCreator.t.sol b/test/DistributionCreator.t.sol index 2aeef188..50dd4ac5 100644 --- a/test/DistributionCreator.t.sol +++ b/test/DistributionCreator.t.sol @@ -1453,6 +1453,7 @@ contract UpgradeDistributionCreatorTest is Test { vm.startPrank(deployer); MockToken(address(rewardToken)).mint(deployer, amount); rewardToken.approve(address(distributionCreator), amount); + distributionCreator.acceptConditions(); // Create test campaign testCampaignId = distributionCreator.createCampaign( diff --git a/test/unit/DistributionCreator.t.sol b/test/unit/DistributionCreator.t.sol index 3943b87f..a694b3e5 100644 --- a/test/unit/DistributionCreator.t.sol +++ b/test/unit/DistributionCreator.t.sol @@ -709,7 +709,7 @@ contract Test_DistributionCreator_CreateCampaign is DistributionCreatorTest { assertEq(campaignId, fetchedCampaignId); } - function test_SuccessFromPreDepositedBalanceWithNoFees() public { + function test_SuccessFromPreDepositedBalanceAndAllowanceWithNoFees() public { uint256 amount = 1e8; CampaignParameters memory campaign = CampaignParameters({ campaignId: keccak256("TEST"), @@ -728,17 +728,29 @@ contract Test_DistributionCreator_CreateCampaign is DistributionCreatorTest { vm.prank(governor); creator.setFeeRecipient(dylan); + angle.mint(address(charlie), 1e10); + vm.prank(charlie); + angle.approve(address(creator), type(uint256).max); + { vm.startPrank(alice); creator.increaseTokenBalance(alice, address(angle), 1e10); + creator.increaseTokenAllowance(alice, charlie, address(angle), 1e11); assertEq(creator.creatorBalance(address(alice), address(angle)), 1e10); + assertEq(creator.creatorAllowance(address(alice), address(charlie), address(angle)), 1e11); + vm.stopPrank(); + address distributor = creator.distributor(); uint256 balance = angle.balanceOf(address(alice)); + uint256 charlieBalance = angle.balanceOf(address(charlie)); uint256 creatorBalance = angle.balanceOf(address(creator)); uint256 distributorBalance = angle.balanceOf(address(distributor)); + vm.prank(charlie); creator.createCampaign(campaign); assertEq(creator.creatorBalance(address(alice), address(angle)), 1e10 - amount); + assertEq(creator.creatorAllowance(address(alice), address(charlie), address(angle)), 1e11 - amount); assertEq(angle.balanceOf(address(alice)), balance); + assertEq(angle.balanceOf(address(charlie)), charlieBalance); assertEq(angle.balanceOf(address(creator)), creatorBalance - amount); assertEq(angle.balanceOf(address(dylan)), 0); assertEq(angle.balanceOf(address(distributor)), distributorBalance + amount); @@ -784,6 +796,335 @@ contract Test_DistributionCreator_CreateCampaign is DistributionCreatorTest { assertEq(extraData, fetchedCampaignData); assertEq(campaignId, fetchedCampaignId); } + + function test_SuccessFromPreDepositedBalanceAndNoAllowanceWithNoFees() public { + uint256 amount = 1e8; + CampaignParameters memory campaign = CampaignParameters({ + campaignId: keccak256("TEST"), + creator: address(alice), + campaignData: hex"ab", + rewardToken: address(angle), + amount: amount, + campaignType: 0, + startTimestamp: uint32(block.timestamp + 1), + duration: 3600 + }); + + vm.prank(governor); + creator.setFees(0); + + vm.prank(governor); + creator.setFeeRecipient(dylan); + + angle.mint(address(charlie), 1e10); + vm.prank(charlie); + angle.approve(address(creator), type(uint256).max); + + { + vm.startPrank(alice); + creator.increaseTokenBalance(alice, address(angle), 1e10); + creator.increaseTokenAllowance(alice, charlie, address(angle), 1e7); + assertEq(creator.creatorBalance(address(alice), address(angle)), 1e10); + assertEq(creator.creatorAllowance(address(alice), address(charlie), address(angle)), 1e7); + vm.stopPrank(); + + address distributor = creator.distributor(); + uint256 balance = angle.balanceOf(address(alice)); + uint256 creatorBalance = angle.balanceOf(address(creator)); + uint256 charlieBalance = angle.balanceOf(address(charlie)); + uint256 distributorBalance = angle.balanceOf(address(distributor)); + vm.prank(charlie); + creator.createCampaign(campaign); + assertEq(creator.creatorBalance(address(alice), address(angle)), 1e10); + assertEq(creator.creatorAllowance(address(alice), address(charlie), address(angle)), 1e7); + assertEq(angle.balanceOf(address(alice)), balance); + assertEq(angle.balanceOf(address(charlie)), charlieBalance - amount); + assertEq(angle.balanceOf(address(creator)), creatorBalance); + assertEq(angle.balanceOf(address(dylan)), 0); + assertEq(angle.balanceOf(address(distributor)), distributorBalance + amount); + } + + address[] memory whitelist = new address[](1); + whitelist[0] = alice; + address[] memory blacklist = new address[](1); + blacklist[0] = charlie; + + bytes memory extraData = hex"ab"; + + // Additional asserts to check for correct behavior + bytes32 campaignId = bytes32( + keccak256( + abi.encodePacked( + block.chainid, + alice, + address(campaign.rewardToken), + uint32(campaign.campaignType), + uint32(campaign.startTimestamp), + uint32(campaign.duration), + campaign.campaignData + ) + ) + ); + ( + bytes32 fetchedCampaignId, + address fetchedCreator, + address fetchedRewardToken, + uint256 fetchedAmount, + uint32 fetchedCampaignType, + uint32 fetchedStartTimestamp, + uint32 fetchedDuration, + bytes memory fetchedCampaignData + ) = creator.campaignList(creator.campaignLookup(campaignId)); + assertEq(alice, fetchedCreator); + assertEq(amount, fetchedAmount); + assertEq(address(angle), fetchedRewardToken); + assertEq(campaign.campaignType, fetchedCampaignType); + assertEq(campaign.startTimestamp, fetchedStartTimestamp); + assertEq(campaign.duration, fetchedDuration); + assertEq(extraData, fetchedCampaignData); + assertEq(campaignId, fetchedCampaignId); + } + + function test_SuccessFromPreDepositedBalanceAndNoAllowanceWithFees() public { + uint256 amount = 1e8; + CampaignParameters memory campaign = CampaignParameters({ + campaignId: keccak256("TEST"), + creator: address(alice), + campaignData: hex"ab", + rewardToken: address(angle), + amount: amount, + campaignType: 0, + startTimestamp: uint32(block.timestamp + 1), + duration: 3600 + }); + + vm.prank(governor); + creator.setFeeRecipient(dylan); + + angle.mint(address(charlie), 1e10); + vm.prank(charlie); + angle.approve(address(creator), type(uint256).max); + + { + vm.startPrank(alice); + creator.increaseTokenBalance(alice, address(angle), 1e10); + creator.increaseTokenAllowance(alice, charlie, address(angle), 1e7); + assertEq(creator.creatorBalance(address(alice), address(angle)), 1e10); + assertEq(creator.creatorAllowance(address(alice), address(charlie), address(angle)), 1e7); + vm.stopPrank(); + + address distributor = creator.distributor(); + uint256 balance = angle.balanceOf(address(alice)); + uint256 creatorBalance = angle.balanceOf(address(creator)); + uint256 charlieBalance = angle.balanceOf(address(charlie)); + uint256 distributorBalance = angle.balanceOf(address(distributor)); + vm.prank(charlie); + creator.createCampaign(campaign); + assertEq(creator.creatorBalance(address(alice), address(angle)), 1e10); + assertEq(creator.creatorAllowance(address(alice), address(charlie), address(angle)), 1e7); + assertEq(angle.balanceOf(address(alice)), balance); + assertEq(angle.balanceOf(address(charlie)), charlieBalance - amount); + assertEq(angle.balanceOf(address(creator)), creatorBalance); + assertEq(angle.balanceOf(address(dylan)), amount / 10); + assertEq(angle.balanceOf(address(distributor)), distributorBalance + amount - amount / 10); + } + + address[] memory whitelist = new address[](1); + whitelist[0] = alice; + address[] memory blacklist = new address[](1); + blacklist[0] = charlie; + + bytes memory extraData = hex"ab"; + + // Additional asserts to check for correct behavior + bytes32 campaignId = bytes32( + keccak256( + abi.encodePacked( + block.chainid, + alice, + address(campaign.rewardToken), + uint32(campaign.campaignType), + uint32(campaign.startTimestamp), + uint32(campaign.duration), + campaign.campaignData + ) + ) + ); + ( + bytes32 fetchedCampaignId, + address fetchedCreator, + address fetchedRewardToken, + uint256 fetchedAmount, + uint32 fetchedCampaignType, + uint32 fetchedStartTimestamp, + uint32 fetchedDuration, + bytes memory fetchedCampaignData + ) = creator.campaignList(creator.campaignLookup(campaignId)); + assertEq(alice, fetchedCreator); + assertEq((amount * 9) / 10, fetchedAmount); + assertEq(address(angle), fetchedRewardToken); + assertEq(campaign.campaignType, fetchedCampaignType); + assertEq(campaign.startTimestamp, fetchedStartTimestamp); + assertEq(campaign.duration, fetchedDuration); + assertEq(extraData, fetchedCampaignData); + assertEq(campaignId, fetchedCampaignId); + } + + function test_SuccessFromPreDepositedBalanceAndNoFeeRecipient() public { + uint256 amount = 1e8; + CampaignParameters memory campaign = CampaignParameters({ + campaignId: keccak256("TEST"), + creator: address(alice), + campaignData: hex"ab", + rewardToken: address(angle), + amount: amount, + campaignType: 0, + startTimestamp: uint32(block.timestamp + 1), + duration: 3600 + }); + + vm.prank(governor); + creator.setFeeRecipient(address(0)); + + { + vm.startPrank(alice); + creator.increaseTokenBalance(alice, address(angle), 1e10); + assertEq(creator.creatorBalance(address(alice), address(angle)), 1e10); + address distributor = creator.distributor(); + uint256 balance = angle.balanceOf(address(alice)); + uint256 creatorBalance = angle.balanceOf(address(creator)); + uint256 distributorBalance = angle.balanceOf(address(distributor)); + creator.createCampaign(campaign); + assertEq(creator.creatorBalance(address(alice), address(angle)), 1e10 - amount); + assertEq(angle.balanceOf(address(alice)), balance); + assertEq(angle.balanceOf(address(creator)), creatorBalance - amount + amount / 10); + assertEq(angle.balanceOf(address(distributor)), distributorBalance + amount - amount / 10); + } + + address[] memory whitelist = new address[](1); + whitelist[0] = alice; + address[] memory blacklist = new address[](1); + blacklist[0] = charlie; + + bytes memory extraData = hex"ab"; + + // Additional asserts to check for correct behavior + bytes32 campaignId = bytes32( + keccak256( + abi.encodePacked( + block.chainid, + alice, + address(campaign.rewardToken), + uint32(campaign.campaignType), + uint32(campaign.startTimestamp), + uint32(campaign.duration), + campaign.campaignData + ) + ) + ); + ( + bytes32 fetchedCampaignId, + address fetchedCreator, + address fetchedRewardToken, + uint256 fetchedAmount, + uint32 fetchedCampaignType, + uint32 fetchedStartTimestamp, + uint32 fetchedDuration, + bytes memory fetchedCampaignData + ) = creator.campaignList(creator.campaignLookup(campaignId)); + assertEq(alice, fetchedCreator); + assertEq((amount * 9) / 10, fetchedAmount); + assertEq(address(angle), fetchedRewardToken); + assertEq(campaign.campaignType, fetchedCampaignType); + assertEq(campaign.startTimestamp, fetchedStartTimestamp); + assertEq(campaign.duration, fetchedDuration); + assertEq(extraData, fetchedCampaignData); + assertEq(campaignId, fetchedCampaignId); + } + + function test_SuccessFromPreDepositedBalanceAndNoAllowanceWithFeesButNoRecipient() public { + uint256 amount = 1e8; + CampaignParameters memory campaign = CampaignParameters({ + campaignId: keccak256("TEST"), + creator: address(alice), + campaignData: hex"ab", + rewardToken: address(angle), + amount: amount, + campaignType: 0, + startTimestamp: uint32(block.timestamp + 1), + duration: 3600 + }); + + vm.prank(governor); + creator.setFeeRecipient(address(0)); + + angle.mint(address(charlie), 1e10); + vm.prank(charlie); + angle.approve(address(creator), type(uint256).max); + + { + vm.startPrank(alice); + creator.increaseTokenBalance(alice, address(angle), 1e10); + creator.increaseTokenAllowance(alice, charlie, address(angle), 1e7); + assertEq(creator.creatorBalance(address(alice), address(angle)), 1e10); + assertEq(creator.creatorAllowance(address(alice), address(charlie), address(angle)), 1e7); + vm.stopPrank(); + + address distributor = creator.distributor(); + uint256 balance = angle.balanceOf(address(alice)); + uint256 creatorBalance = angle.balanceOf(address(creator)); + uint256 charlieBalance = angle.balanceOf(address(charlie)); + uint256 distributorBalance = angle.balanceOf(address(distributor)); + vm.prank(charlie); + creator.createCampaign(campaign); + assertEq(creator.creatorBalance(address(alice), address(angle)), 1e10); + assertEq(creator.creatorAllowance(address(alice), address(charlie), address(angle)), 1e7); + assertEq(angle.balanceOf(address(alice)), balance); + assertEq(angle.balanceOf(address(charlie)), charlieBalance - amount); + assertEq(angle.balanceOf(address(creator)), creatorBalance + amount / 10); + assertEq(angle.balanceOf(address(distributor)), distributorBalance + amount - amount / 10); + } + + address[] memory whitelist = new address[](1); + whitelist[0] = alice; + address[] memory blacklist = new address[](1); + blacklist[0] = charlie; + + bytes memory extraData = hex"ab"; + + // Additional asserts to check for correct behavior + bytes32 campaignId = bytes32( + keccak256( + abi.encodePacked( + block.chainid, + alice, + address(campaign.rewardToken), + uint32(campaign.campaignType), + uint32(campaign.startTimestamp), + uint32(campaign.duration), + campaign.campaignData + ) + ) + ); + ( + bytes32 fetchedCampaignId, + address fetchedCreator, + address fetchedRewardToken, + uint256 fetchedAmount, + uint32 fetchedCampaignType, + uint32 fetchedStartTimestamp, + uint32 fetchedDuration, + bytes memory fetchedCampaignData + ) = creator.campaignList(creator.campaignLookup(campaignId)); + assertEq(alice, fetchedCreator); + assertEq((amount * 9) / 10, fetchedAmount); + assertEq(address(angle), fetchedRewardToken); + assertEq(campaign.campaignType, fetchedCampaignType); + assertEq(campaign.startTimestamp, fetchedStartTimestamp); + assertEq(campaign.duration, fetchedDuration); + assertEq(extraData, fetchedCampaignData); + assertEq(campaignId, fetchedCampaignId); + } } contract Test_DistributionCreator_CreateCampaigns is DistributionCreatorTest { From eda0bc158b3c4a9bfa1993c6b5ddd31b1eb6ba3d Mon Sep 17 00:00:00 2001 From: Pablo Veyrat Date: Wed, 22 Oct 2025 18:03:47 +0200 Subject: [PATCH 15/27] fix: tests --- contracts/DistributionCreator.sol | 1 + contracts/mock/MockClaimRecipient.sol | 36 ++ scripts/PointToken.s.sol | 12 +- test/unit/Distributor.t.sol | 691 +++++++++++++++++++++++++- 4 files changed, 732 insertions(+), 8 deletions(-) create mode 100644 contracts/mock/MockClaimRecipient.sol diff --git a/contracts/DistributionCreator.sol b/contracts/DistributionCreator.sol index 16964651..fdd76720 100644 --- a/contracts/DistributionCreator.sol +++ b/contracts/DistributionCreator.sol @@ -268,6 +268,7 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { /// @notice Increases the predeposited token balance of a `user` for a given `rewardToken` /// @dev If a governor is calling the function, the user must have sent the tokens beforehand /// @dev This function can be used to deposit on behalf of another user + /// @dev This function MUST NOT be used to deposit a rebasing token function increaseTokenBalance(address user, address rewardToken, uint256 amount) external { if (!accessControlManager.isGovernor(msg.sender)) IERC20(rewardToken).safeTransferFrom(msg.sender, address(this), amount); diff --git a/contracts/mock/MockClaimRecipient.sol b/contracts/mock/MockClaimRecipient.sol new file mode 100644 index 00000000..7d136c42 --- /dev/null +++ b/contracts/mock/MockClaimRecipient.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.17; + +import { IClaimRecipient } from "../interfaces/IClaimRecipient.sol"; + +/// @notice Mock contract that implements IClaimRecipient correctly +contract MockClaimRecipient is IClaimRecipient { + bytes32 public constant CALLBACK_SUCCESS = keccak256("IClaimRecipient.onClaim"); + + address public lastUser; + address public lastToken; + uint256 public lastAmount; + bytes public lastData; + uint256 public callCount; + + function onClaim(address user, address token, uint256 amount, bytes memory data) external returns (bytes32) { + lastUser = user; + lastToken = token; + lastAmount = amount; + lastData = data; + callCount++; + return CALLBACK_SUCCESS; + } +} + +/// @notice Mock contract that implements IClaimRecipient incorrectly (returns wrong bytes32) +contract MockClaimRecipientWrongReturn is IClaimRecipient { + function onClaim(address, address, uint256, bytes memory) external pure returns (bytes32) { + return bytes32(0); + } +} + +/// @notice Mock contract without the IClaimRecipient interface +contract MockNonClaimRecipient { + // No onClaim function +} diff --git a/scripts/PointToken.s.sol b/scripts/PointToken.s.sol index e9c85488..2f20d939 100644 --- a/scripts/PointToken.s.sol +++ b/scripts/PointToken.s.sol @@ -18,18 +18,18 @@ contract PointTokenScript is BaseScript, JsonReader { // Deploy script contract DeployPointToken is PointTokenScript { function run() external broadcast { - // forge script scripts/PointToken.s.sol:DeployPointToken --rpc-url hyperevm --broadcast --verify -vvvv + // forge script scripts/PointToken.s.sol:DeployPointToken --rpc-url gnosis --broadcast --verify -vvvv uint256 chainId = block.chainid; // MODIFY THESE VALUES TO SET YOUR DESIRED TOKEN PARAMETERS - string memory name = "cHIPs"; - string memory symbol = "cHIPs"; + string memory name = "IPOR Fusion Points"; + string memory symbol = "ipor-fusion-points"; address minter = 0xA9DdD91249DFdd450E81E1c56Ab60E1A62651701; uint256 amount = 1_000_000_000 * 1e18; address creator = 0xA9DdD91249DFdd450E81E1c56Ab60E1A62651701; uint8 decimals = 18; // address accessControlManager = readAddress(chainId, "Merkl.CoreMerkl"); - address accessControlManager = 0x9a0F97FAC6154d9233A0FDFcE4Dc27dCB48b95ff; + address accessControlManager = 0xFD0DFC837Fe7ED19B23df589b6F6Da5a775F99E0; _run(name, symbol, minter, accessControlManager, amount, creator); } @@ -64,8 +64,8 @@ contract DeployPointToken is PointTokenScript { token.toggleWhitelistedRecipient(0x3Ef3D8bA38EBe18DB133cEc108f4D14CE00Dd9Ae); token.toggleWhitelistedRecipient(0x8BB4C975Ff3c250e0ceEA271728547f3802B36Fd); token.toggleWhitelistedRecipient(0xeaC6A75e19beB1283352d24c0311De865a867DAB); - token.toggleWhitelistedRecipient(0x79b42b18c4479a6F0f0cf398CFF5674896A12AD1); - token.transfer(0x79b42b18c4479a6F0f0cf398CFF5674896A12AD1, 1e9 * 1e18); + token.toggleWhitelistedRecipient(0x1384Fa5187D946F9639Afaa391287E0b86B31708); + token.transfer(0x1384Fa5187D946F9639Afaa391287E0b86B31708, 1e9 * 1e18); console.log("Whitelisted recipients:"); // transfer to the SAFE diff --git a/test/unit/Distributor.t.sol b/test/unit/Distributor.t.sol index a2427b27..8091b26e 100644 --- a/test/unit/Distributor.t.sol +++ b/test/unit/Distributor.t.sol @@ -8,6 +8,7 @@ import { Distributor, MerkleTree } from "../../contracts/Distributor.sol"; import { Fixture } from "../Fixture.t.sol"; import { IAccessControlManager } from "../../contracts/interfaces/IAccessControlManager.sol"; import { Errors } from "../../contracts/utils/Errors.sol"; +import { MockClaimRecipient, MockClaimRecipientWrongReturn, MockNonClaimRecipient } from "../../contracts/mock/MockClaimRecipient.sol"; contract DistributorTest is Fixture { Distributor public distributor; @@ -449,7 +450,6 @@ contract Test_Distributor_claim is DistributorTest { } function test_SuccessGovernor() public { - console.log(alice, bob, address(angle), address(agEUR)); vm.prank(governor); distributor.updateTree( MerkleTree({ @@ -490,7 +490,6 @@ contract Test_Distributor_claim is DistributorTest { } function test_SuccessOperator() public { - console.log(alice, bob, address(angle), address(agEUR)); vm.prank(governor); distributor.updateTree( MerkleTree({ @@ -590,6 +589,340 @@ contract Test_Distributor_claimWithRecipient is DistributorTest { assertEq(angle.balanceOf(address(bob)), bobBalance + 1e18); } + + function test_Success_UserCanOverrideDefaultRecipient() public { + // Setup merkle tree + vm.prank(governor); + distributor.updateTree( + MerkleTree({ + merkleRoot: bytes32(0x0b70a97c062cb747158b89e27df5bbda859ba072232efcbe92e383e9d74b8555), + ipfsHash: keccak256("IPFS_HASH") + }) + ); + + angle.mint(address(distributor), 1e18); + vm.warp(distributor.endOfDisputePeriod() + 1); + + // Alice sets bob as default recipient + vm.prank(alice); + distributor.setClaimRecipient(bob, address(angle)); + assertEq(distributor.claimRecipient(alice, address(angle)), bob); + + // Setup claim data with charlie as recipient + address charlie = address(0x999); + 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] = charlie; + + uint256 bobBalance = angle.balanceOf(bob); + uint256 charlieBalance = angle.balanceOf(charlie); + + // Alice claims with charlie as recipient (should override default) + vm.prank(alice); + distributor.claimWithRecipient(users, tokens, amounts, proofs, recipients, datas); + + // Verify rewards went to charlie, not bob + assertEq(angle.balanceOf(bob), bobBalance); + assertEq(angle.balanceOf(charlie), charlieBalance + 1e18); + } + + function test_Success_OperatorCannotOverrideRecipient() public { + // Setup merkle tree + vm.prank(governor); + distributor.updateTree( + MerkleTree({ + merkleRoot: bytes32(0x0b70a97c062cb747158b89e27df5bbda859ba072232efcbe92e383e9d74b8555), + ipfsHash: keccak256("IPFS_HASH") + }) + ); + + angle.mint(address(distributor), 1e18); + vm.warp(distributor.endOfDisputePeriod() + 1); + + // Alice authorizes bob as operator + vm.prank(alice); + distributor.toggleOperator(alice, bob); + + // Setup claim data with charlie as recipient + address charlie = address(0x999); + 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] = charlie; // Bob tries to set charlie as recipient + + uint256 aliceBalance = angle.balanceOf(alice); + uint256 charlieBalance = angle.balanceOf(charlie); + + // Bob claims for alice but cannot override recipient (should go to alice) + vm.prank(bob); + distributor.claimWithRecipient(users, tokens, amounts, proofs, recipients, datas); + + // Verify rewards went to alice, not charlie + assertEq(angle.balanceOf(alice), aliceBalance + 1e18); + assertEq(angle.balanceOf(charlie), charlieBalance); + } + + function test_Success_OperatorCannotOverrideDefaultRecipient() public { + // Setup merkle tree + vm.prank(governor); + distributor.updateTree( + MerkleTree({ + merkleRoot: bytes32(0x0b70a97c062cb747158b89e27df5bbda859ba072232efcbe92e383e9d74b8555), + ipfsHash: keccak256("IPFS_HASH") + }) + ); + + angle.mint(address(distributor), 1e18); + vm.warp(distributor.endOfDisputePeriod() + 1); + + // Alice sets a default recipient (charlie) + address charlie = address(0x999); + vm.prank(alice); + distributor.setClaimRecipient(charlie, address(angle)); + assertEq(distributor.claimRecipient(alice, address(angle)), charlie); + + // Alice authorizes bob as operator + vm.prank(alice); + distributor.toggleOperator(alice, bob); + + // Setup claim data with bob trying to set himself as recipient + 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; // Bob tries to set himself as recipient + + uint256 bobBalance = angle.balanceOf(bob); + uint256 charlieBalance = angle.balanceOf(charlie); + + // Bob claims for alice but cannot override default recipient (should go to charlie) + vm.prank(bob); + distributor.claimWithRecipient(users, tokens, amounts, proofs, recipients, datas); + + // Verify rewards went to charlie (default recipient), not bob + assertEq(angle.balanceOf(bob), bobBalance); + assertEq(angle.balanceOf(charlie), charlieBalance + 1e18); + } + + function test_Success_CallbackTriggeredWithData() public { + // Deploy mock claim recipient + MockClaimRecipient mockRecipient = new MockClaimRecipient(); + + // Setup merkle tree + vm.prank(governor); + distributor.updateTree( + MerkleTree({ + merkleRoot: bytes32(0x0b70a97c062cb747158b89e27df5bbda859ba072232efcbe92e383e9d74b8555), + ipfsHash: keccak256("IPFS_HASH") + }) + ); + + angle.mint(address(distributor), 1e18); + vm.warp(distributor.endOfDisputePeriod() + 1); + + // Alice sets mock recipient as default + vm.prank(alice); + distributor.setClaimRecipient(address(mockRecipient), address(angle)); + + // Setup claim data with custom data + bytes memory customData = abi.encode("test", 12345); + 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] = address(0); // Zero address means use default + datas[0] = customData; + + uint256 recipientBalance = angle.balanceOf(address(mockRecipient)); + assertEq(mockRecipient.callCount(), 0); + + // Alice claims with data + vm.prank(alice); + distributor.claimWithRecipient(users, tokens, amounts, proofs, recipients, datas); + + // Verify rewards went to mock recipient + assertEq(angle.balanceOf(address(mockRecipient)), recipientBalance + 1e18); + + // Verify callback was triggered + assertEq(mockRecipient.callCount(), 1); + assertEq(mockRecipient.lastUser(), alice); + assertEq(mockRecipient.lastToken(), address(angle)); + assertEq(mockRecipient.lastAmount(), 1e18); + assertEq(mockRecipient.lastData(), customData); + } + + function test_Success_CallbackWithWrongReturnReverts() public { + // Deploy mock claim recipient with wrong return + MockClaimRecipientWrongReturn mockRecipient = new MockClaimRecipientWrongReturn(); + + // Setup merkle tree + vm.prank(governor); + distributor.updateTree( + MerkleTree({ + merkleRoot: bytes32(0x0b70a97c062cb747158b89e27df5bbda859ba072232efcbe92e383e9d74b8555), + ipfsHash: keccak256("IPFS_HASH") + }) + ); + + angle.mint(address(distributor), 1e18); + vm.warp(distributor.endOfDisputePeriod() + 1); + + // Alice sets mock recipient as default + vm.prank(alice); + distributor.setClaimRecipient(address(mockRecipient), address(angle)); + + // Setup claim data with custom data + bytes memory customData = abi.encode("test", 12345); + 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] = address(0); + datas[0] = customData; + + uint256 recipientBalance = angle.balanceOf(address(mockRecipient)); + + // Alice claims with data - should revert due to wrong return value + vm.prank(alice); + vm.expectRevert(Errors.InvalidReturnMessage.selector); + distributor.claimWithRecipient(users, tokens, amounts, proofs, recipients, datas); + + // Verify no rewards were transferred due to revert + assertEq(angle.balanceOf(address(mockRecipient)), recipientBalance); + } + + function test_Success_CallbackWithNonImplementingContractDoesNotRevert() public { + // Deploy mock non-recipient contract + MockNonClaimRecipient mockRecipient = new MockNonClaimRecipient(); + + // Setup merkle tree + vm.prank(governor); + distributor.updateTree( + MerkleTree({ + merkleRoot: bytes32(0x0b70a97c062cb747158b89e27df5bbda859ba072232efcbe92e383e9d74b8555), + ipfsHash: keccak256("IPFS_HASH") + }) + ); + + angle.mint(address(distributor), 1e18); + vm.warp(distributor.endOfDisputePeriod() + 1); + + // Alice sets mock recipient as default + vm.prank(alice); + distributor.setClaimRecipient(address(mockRecipient), address(angle)); + + // Setup claim data with custom data + bytes memory customData = abi.encode("test", 12345); + 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] = address(0); + datas[0] = customData; + + uint256 recipientBalance = angle.balanceOf(address(mockRecipient)); + + // Alice claims with data - should NOT revert (catch block handles it) + vm.prank(alice); + distributor.claimWithRecipient(users, tokens, amounts, proofs, recipients, datas); + + // Verify rewards were still transferred despite callback failure + assertEq(angle.balanceOf(address(mockRecipient)), recipientBalance + 1e18); + } + + function test_Success_NoCallbackWhenNoData() public { + // Deploy mock claim recipient + MockClaimRecipient mockRecipient = new MockClaimRecipient(); + + // Setup merkle tree + vm.prank(governor); + distributor.updateTree( + MerkleTree({ + merkleRoot: bytes32(0x0b70a97c062cb747158b89e27df5bbda859ba072232efcbe92e383e9d74b8555), + ipfsHash: keccak256("IPFS_HASH") + }) + ); + + angle.mint(address(distributor), 1e18); + vm.warp(distributor.endOfDisputePeriod() + 1); + + // Alice sets mock recipient as default + vm.prank(alice); + distributor.setClaimRecipient(address(mockRecipient), address(angle)); + + // Setup claim data without custom data + 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] = address(0); + datas[0] = ""; // Empty data + + uint256 recipientBalance = angle.balanceOf(address(mockRecipient)); + assertEq(mockRecipient.callCount(), 0); + + // Alice claims without data + vm.prank(alice); + distributor.claimWithRecipient(users, tokens, amounts, proofs, recipients, datas); + + // Verify rewards went to mock recipient + assertEq(angle.balanceOf(address(mockRecipient)), recipientBalance + 1e18); + + // Verify callback was NOT triggered (no data) + assertEq(mockRecipient.callCount(), 0); + } } contract Test_Distributor_revokeUpgradeability is DistributorTest { @@ -636,3 +969,357 @@ contract Test_Distributor_setEpochDuration is DistributorTest { assertEq(distributor.endOfDisputePeriod(), expectedEnd); } } + +contract Test_Distributor_toggleMainOperatorStatus is DistributorTest { + function test_RevertWhen_NotGovernorOrGuardian() public { + vm.prank(alice); + vm.expectRevert(Errors.NotGovernorOrGuardian.selector); + distributor.toggleMainOperatorStatus(bob, address(angle)); + } + + function test_Success_Governor() public { + // Initial state - operator should not be whitelisted + assertEq(distributor.mainOperators(bob, address(angle)), 0); + + // Governor toggles operator on + vm.prank(governor); + distributor.toggleMainOperatorStatus(bob, address(angle)); + assertEq(distributor.mainOperators(bob, address(angle)), 1); + + // Governor toggles operator off + vm.prank(governor); + distributor.toggleMainOperatorStatus(bob, address(angle)); + assertEq(distributor.mainOperators(bob, address(angle)), 0); + } + + function test_Success_Guardian() public { + // Initial state - operator should not be whitelisted + assertEq(distributor.mainOperators(bob, address(angle)), 0); + + // Guardian toggles operator on + vm.prank(guardian); + distributor.toggleMainOperatorStatus(bob, address(angle)); + assertEq(distributor.mainOperators(bob, address(angle)), 1); + + // Guardian toggles operator off + vm.prank(guardian); + distributor.toggleMainOperatorStatus(bob, address(angle)); + assertEq(distributor.mainOperators(bob, address(angle)), 0); + } + + function test_Success_WithZeroAddress() public { + // Test with zero address for token (applies to all tokens) + assertEq(distributor.mainOperators(bob, address(0)), 0); + + vm.prank(governor); + distributor.toggleMainOperatorStatus(bob, address(0)); + assertEq(distributor.mainOperators(bob, address(0)), 1); + + vm.prank(governor); + distributor.toggleMainOperatorStatus(bob, address(0)); + assertEq(distributor.mainOperators(bob, address(0)), 0); + } + + function test_Success_AllowsClaimingWhenEnabled() public { + // Setup merkle tree + vm.prank(governor); + distributor.updateTree( + MerkleTree({ + merkleRoot: bytes32(0x0b70a97c062cb747158b89e27df5bbda859ba072232efcbe92e383e9d74b8555), + ipfsHash: keccak256("IPFS_HASH") + }) + ); + + angle.mint(address(distributor), 1e18); + vm.warp(distributor.endOfDisputePeriod() + 1); + + // Setup claim data + 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; + + // Bob cannot claim for alice initially + vm.prank(bob); + vm.expectRevert(Errors.NotWhitelisted.selector); + distributor.claim(users, tokens, amounts, proofs); + + // Enable bob as main operator for angle token + vm.prank(governor); + distributor.toggleMainOperatorStatus(bob, address(angle)); + + uint256 aliceBalance = angle.balanceOf(address(alice)); + + // Now bob can claim for alice + vm.prank(bob); + distributor.claim(users, tokens, amounts, proofs); + + assertEq(angle.balanceOf(address(alice)), aliceBalance + 1e18); + } + function test_Success_AllowsClaimingWhenEnabledForAll() public { + // Setup merkle tree + vm.prank(governor); + distributor.updateTree( + MerkleTree({ + merkleRoot: bytes32(0x0b70a97c062cb747158b89e27df5bbda859ba072232efcbe92e383e9d74b8555), + ipfsHash: keccak256("IPFS_HASH") + }) + ); + + angle.mint(address(distributor), 1e18); + vm.warp(distributor.endOfDisputePeriod() + 1); + + // Setup claim data + 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; + + // Bob cannot claim for alice initially + vm.prank(bob); + vm.expectRevert(Errors.NotWhitelisted.selector); + distributor.claim(users, tokens, amounts, proofs); + + // Enable bob as main operator for angle token + vm.prank(governor); + distributor.toggleMainOperatorStatus(bob, address(0)); + + uint256 aliceBalance = angle.balanceOf(address(alice)); + + // Now bob can claim for alice + vm.prank(bob); + distributor.claim(users, tokens, amounts, proofs); + + assertEq(angle.balanceOf(address(alice)), aliceBalance + 1e18); + } + function test_Success_AllowsClaimingWhenEnabledForOne() public { + // Setup merkle tree + vm.prank(governor); + distributor.updateTree( + MerkleTree({ + merkleRoot: bytes32(0x0b70a97c062cb747158b89e27df5bbda859ba072232efcbe92e383e9d74b8555), + ipfsHash: keccak256("IPFS_HASH") + }) + ); + + angle.mint(address(distributor), 1e18); + vm.warp(distributor.endOfDisputePeriod() + 1); + + // Setup claim data + 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; + + // Bob cannot claim for alice initially + vm.prank(bob); + vm.expectRevert(Errors.NotWhitelisted.selector); + distributor.claim(users, tokens, amounts, proofs); + + // Enable bob as main operator for angle token + vm.prank(alice); + distributor.toggleOperator(alice, address(bob)); + + uint256 aliceBalance = angle.balanceOf(address(alice)); + + // Now bob can claim for alice + vm.prank(bob); + distributor.claim(users, tokens, amounts, proofs); + + assertEq(angle.balanceOf(address(alice)), aliceBalance + 1e18); + } + function test_Success_AllowsClaimingWhenEnabledForAllByUser() public { + // Setup merkle tree + vm.prank(governor); + distributor.updateTree( + MerkleTree({ + merkleRoot: bytes32(0x0b70a97c062cb747158b89e27df5bbda859ba072232efcbe92e383e9d74b8555), + ipfsHash: keccak256("IPFS_HASH") + }) + ); + + angle.mint(address(distributor), 1e18); + vm.warp(distributor.endOfDisputePeriod() + 1); + + // Setup claim data + 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; + + // Bob cannot claim for alice initially + vm.prank(bob); + vm.expectRevert(Errors.NotWhitelisted.selector); + distributor.claim(users, tokens, amounts, proofs); + + // Enable bob as main operator for angle token + vm.prank(alice); + distributor.toggleOperator(alice, address(0)); + + uint256 aliceBalance = angle.balanceOf(address(alice)); + + // Now bob can claim for alice + vm.prank(bob); + distributor.claim(users, tokens, amounts, proofs); + + assertEq(angle.balanceOf(address(alice)), aliceBalance + 1e18); + } +} + +contract Test_Distributor_setClaimRecipientWithGov is DistributorTest { + function test_RevertWhen_NotGovernor() public { + vm.prank(alice); + vm.expectRevert(Errors.NotGovernor.selector); + distributor.setClaimRecipientWithGov(bob, alice, address(angle)); + } + + function test_Success_SetRecipientForUser() public { + // Initial state - no recipient set + assertEq(distributor.claimRecipient(bob, address(angle)), address(0)); + + // Governor sets recipient + vm.prank(governor); + distributor.setClaimRecipientWithGov(bob, alice, address(angle)); + + // Verify recipient was set + assertEq(distributor.claimRecipient(bob, address(angle)), alice); + } + + function test_Success_SetDefaultRecipient() public { + // Test with zero address for token (default for all tokens) + assertEq(distributor.claimRecipient(bob, address(0)), address(0)); + + // Governor sets default recipient + vm.prank(governor); + distributor.setClaimRecipientWithGov(bob, alice, address(0)); + + // Verify default recipient was set + assertEq(distributor.claimRecipient(bob, address(0)), alice); + } + + function test_Success_ChangeRecipient() public { + // Set initial recipient + vm.prank(governor); + distributor.setClaimRecipientWithGov(bob, alice, address(angle)); + assertEq(distributor.claimRecipient(bob, address(angle)), alice); + + // Change recipient to someone else + vm.prank(governor); + distributor.setClaimRecipientWithGov(bob, address(0x123), address(angle)); + assertEq(distributor.claimRecipient(bob, address(angle)), address(0x123)); + } + + function test_Success_RecipientReceivesRewards() public { + // Setup merkle tree + vm.prank(governor); + distributor.updateTree( + MerkleTree({ + merkleRoot: bytes32(0x0b70a97c062cb747158b89e27df5bbda859ba072232efcbe92e383e9d74b8555), + ipfsHash: keccak256("IPFS_HASH") + }) + ); + + angle.mint(address(distributor), 1e18); + vm.warp(distributor.endOfDisputePeriod() + 1); + + // Governor sets bob as recipient for alice's claims + address customRecipient = address(0x456); + vm.prank(governor); + distributor.setClaimRecipientWithGov(alice, customRecipient, address(angle)); + + // Setup claim data + 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; + + uint256 recipientBalance = angle.balanceOf(customRecipient); + uint256 aliceBalance = angle.balanceOf(alice); + + // Alice claims and rewards should go to custom recipient + vm.prank(alice); + distributor.claim(users, tokens, amounts, proofs); + + // Verify rewards went to custom recipient, not alice + assertEq(angle.balanceOf(customRecipient), recipientBalance + 1e18); + assertEq(angle.balanceOf(alice), aliceBalance); + } + + function test_Success_RecipientReceivesRewardsWhenGlobal() public { + // Setup merkle tree + vm.prank(governor); + distributor.updateTree( + MerkleTree({ + merkleRoot: bytes32(0x0b70a97c062cb747158b89e27df5bbda859ba072232efcbe92e383e9d74b8555), + ipfsHash: keccak256("IPFS_HASH") + }) + ); + + angle.mint(address(distributor), 1e18); + vm.warp(distributor.endOfDisputePeriod() + 1); + + // Governor sets bob as recipient for alice's claims + address customRecipient = address(0x456); + vm.prank(governor); + distributor.setClaimRecipientWithGov(alice, customRecipient, address(0)); + + // Setup claim data + 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; + + uint256 recipientBalance = angle.balanceOf(customRecipient); + uint256 aliceBalance = angle.balanceOf(alice); + + // Alice claims and rewards should go to custom recipient + vm.prank(alice); + distributor.claim(users, tokens, amounts, proofs); + + // Verify rewards went to custom recipient, not alice + assertEq(angle.balanceOf(customRecipient), recipientBalance + 1e18); + assertEq(angle.balanceOf(alice), aliceBalance); + } + + function test_Success_ClearRecipient() public { + // Set recipient + vm.prank(governor); + distributor.setClaimRecipientWithGov(bob, alice, address(angle)); + assertEq(distributor.claimRecipient(bob, address(angle)), alice); + + // Clear recipient by setting to zero address + vm.prank(governor); + distributor.setClaimRecipientWithGov(bob, address(0), address(angle)); + assertEq(distributor.claimRecipient(bob, address(angle)), address(0)); + } +} From 6ba9347a8ee63df6783fa5d376d7dbcc491815d4 Mon Sep 17 00:00:00 2001 From: Pablo Veyrat Date: Wed, 22 Oct 2025 18:53:17 +0200 Subject: [PATCH 16/27] feat: deployment scripts --- UPGRADE_DEPLOYMENT.md | 326 ++++++++++++++++++ helpers/deployUpgradeImplementations.sh | 148 ++++++++ helpers/generateUpgradeSummary.sh | 106 ++++++ scripts/deployUpgradeImplementations.s.sol | 218 ++++++++++++ .../deployUpgradeImplementationsSingle.s.sol | 61 ++++ scripts/utils/UpgradeDeploymentBase.s.sol | 175 ++++++++++ 6 files changed, 1034 insertions(+) create mode 100644 UPGRADE_DEPLOYMENT.md create mode 100755 helpers/deployUpgradeImplementations.sh create mode 100755 helpers/generateUpgradeSummary.sh create mode 100644 scripts/deployUpgradeImplementations.s.sol create mode 100644 scripts/deployUpgradeImplementationsSingle.s.sol create mode 100644 scripts/utils/UpgradeDeploymentBase.s.sol diff --git a/UPGRADE_DEPLOYMENT.md b/UPGRADE_DEPLOYMENT.md new file mode 100644 index 00000000..c265ab68 --- /dev/null +++ b/UPGRADE_DEPLOYMENT.md @@ -0,0 +1,326 @@ +# Upgrade Implementation Deployment Guide + +This guide explains how to deploy new implementations of `DistributionCreator` and `Distributor` contracts across all supported chains for upgrading existing proxies. + +## 🚀 Quick Start (TL;DR) + +Deploy new implementations across all chains and generate summary for Gnosis Safe transactions: + +```bash +# 1. Deploy to all chains +./helpers/deployUpgradeImplementations.sh + +# 2. Generate summary report +./helpers/generateUpgradeSummary.sh + +# 3. Check results +cat deployments/upgrade-summary.md +``` + +### Single Chain Deployment + +```bash +forge script scripts/deployUpgradeImplementationsSingle.s.sol \ + --rpc-url \ + --broadcast \ + --verify +``` + +### Common Chains Examples + +```bash +# Arbitrum +forge script scripts/deployUpgradeImplementationsSingle.s.sol --rpc-url arbitrum --broadcast --verify + +# Base +forge script scripts/deployUpgradeImplementationsSingle.s.sol --rpc-url base --broadcast --verify + +# Polygon +forge script scripts/deployUpgradeImplementationsSingle.s.sol --rpc-url polygon --broadcast --verify + +# Optimism +forge script scripts/deployUpgradeImplementationsSingle.s.sol --rpc-url optimism --broadcast --verify + +# Mainnet +forge script scripts/deployUpgradeImplementationsSingle.s.sol --rpc-url mainnet --broadcast --verify +``` + +--- + +## 📋 Overview + +The deployment process consists of: + +1. Deploying new implementation contracts on each chain +2. Verifying the contracts on block explorers +3. Saving deployment addresses to JSON files per chain +4. Using these addresses to create Gnosis Safe upgrade transactions + +## 📁 Files + +- `scripts/deployUpgradeImplementationsSingle.s.sol` - Foundry script for single chain deployment +- `helpers/deployUpgradeImplementations.sh` - Bash script for automated multi-chain deployment +- `helpers/generateUpgradeSummary.sh` - Script to generate summary reports from deployments +- `deployments/*.json` - Generated JSON files with deployment addresses per chain +- `deployments/upgrade-summary.csv` - CSV summary of all deployments +- `deployments/upgrade-summary.md` - Markdown summary with Gnosis Safe templates + +## ⚙️ Prerequisites + +1. **Environment Variables**: Ensure your `.env` file contains: + + ```bash + DEPLOYER_PRIVATE_KEY=your_private_key_here + + # RPC URLs for each chain + MAINNET_NODE_URI=https://... + POLYGON_NODE_URI=https://... + # ... and so on for all chains + + # Etherscan API keys for verification + MAINNET_ETHERSCAN_API_KEY=... + POLYGON_ETHERSCAN_API_KEY=... + # ... and so on + ``` + +2. **Dependencies**: Make sure you have: + - Foundry installed and updated (`foundryup`) + - Sufficient balance on deployer address for gas on each chain + +## 🎯 Deployment Methods + +### Option 1: Deploy to All Chains (Automated) + +Run the bash script to deploy across all chains: + +```bash +./helpers/deployUpgradeImplementations.sh +``` + +This will: + +- Iterate through all supported chains +- Skip chains without configured RPC URLs +- Handle failures gracefully and continue +- Generate logs for each chain in `deployments/` +- Create a summary file with results + +### Option 2: Deploy to Single Chain + +Deploy to a specific chain: + +```bash +forge script scripts/deployUpgradeImplementationsSingle.s.sol \ + --rpc-url \ + --broadcast \ + --verify +``` + +Examples: + +```bash +# Deploy to Arbitrum +forge script scripts/deployUpgradeImplementationsSingle.s.sol \ + --rpc-url arbitrum \ + --broadcast \ + --verify + +# Deploy to Base +forge script scripts/deployUpgradeImplementationsSingle.s.sol \ + --rpc-url base \ + --broadcast \ + --verify +``` + +### Option 3: Deploy Without Verification + +If verification fails or you want to verify manually later: + +```bash +forge script scripts/deployUpgradeImplementationsSingle.s.sol \ + --rpc-url \ + --broadcast +``` + +## 📊 Output Files + +After deployment, you'll find the following files in the `deployments/` directory: + +### Per-Chain JSON Files + +Example: `deployments/arbitrum-upgrade-implementations.json` + +```json +{ + "chainId": 42161, + "chainName": "arbitrum", + "distributionCreatorImplementation": "0x...", + "distributorImplementation": "0x...", + "timestamp": 1234567890, + "deployer": "0x..." +} +``` + +### Summary Files + +- `deployments/-upgrade-implementations.json` - Individual deployment data +- `deployments/-deployment.log` - Deployment logs +- `deployments/upgrade-summary.csv` - CSV summary of all deployments +- `deployments/upgrade-summary.md` - Markdown summary with Gnosis Safe templates +- `deployments/deployment-summary-.txt` - Overall deployment status + +## ✅ Manual Verification + +If automatic verification fails, verify manually: + +```bash +# Verify DistributionCreator +forge verify-contract \ + \ + contracts/DistributionCreator.sol:DistributionCreator \ + --chain \ + --watch + +# Verify Distributor +forge verify-contract \ + \ + contracts/Distributor.sol:Distributor \ + --chain \ + --watch +``` + +## 🔐 Creating Gnosis Safe Upgrade Transactions + +After deploying implementations, create upgrade transactions: + +1. **Review the summary**: Check `deployments/upgrade-summary.md` +2. **For each chain**, navigate to the Gnosis Safe UI +3. **Create a new transaction** to the proxy contract +4. **Call `upgradeTo(address)`** or `upgradeToAndCall(address,bytes)` function +5. **Use the implementation address** from the JSON file +6. **Get multiple signers to review** the transaction +7. **Execute** the upgrade transaction +8. **Monitor** contract behavior after upgrade + +### Example Upgrade Transaction Data + +For a UUPS proxy: + +```solidity +// Function: upgradeTo(address) +// Implementation: 0x... (from JSON file) +``` + +### Example Transactions + +``` +To: 0x8BB4C975Ff3c250e0ceEA271728547f3802B36Fd (DistributionCreator Proxy) +Value: 0 ETH +Function: upgradeTo(address) +Parameter: 0x +``` + +``` +To: 0x3Ef3D8bA38EBe18DB133cEc108f4D14CE00Dd9Ae (Distributor Proxy) +Value: 0 ETH +Function: upgradeTo(address) +Parameter: 0x +``` + +## 🔧 Troubleshooting + +### RPC Errors + +- Check RPC URL is correct and accessible +- Try with a different RPC provider +- Some chains may have rate limits + +### Verification Failures + +- Verify manually using the commands above +- Check Etherscan API key is correct +- Some explorers may have delays - try again later + +### Gas Issues + +- Ensure deployer has sufficient native token balance +- Adjust gas price if needed: `--with-gas-price ` +- Use `--legacy` flag for chains without EIP-1559 + +### Chain-Specific Issues + +**ZKSync**: Requires special compilation + +```bash +forge script --zksync --system-mode=true ... +``` + +**Skale**: May need custom gas settings + +```bash +forge script --legacy ... +``` + +**Chain not supported**: Deploy manually and add to JSON format + +## ✓ Pre-Deployment Checklist + +Before upgrading proxies, verify: + +- [ ] All implementations deployed successfully +- [ ] All contracts verified on block explorers +- [ ] JSON files saved with correct addresses +- [ ] Deployment address matches expected deployer +- [ ] Storage layout compatible with previous version +- [ ] No constructor initializes state (use `initialize()` instead) +- [ ] Tested on testnet first +- [ ] Multiple signers reviewed transactions +- [ ] Monitoring plan in place + +## ⚠️ Safety Notes + +**IMPORTANT**: + +- Test upgrades on testnets first +- Verify storage layout compatibility +- Check for breaking changes in new implementation +- Have multiple signers review upgrade transactions +- Monitor contract behavior after upgrade +- Always upgrade DistributionCreator first, then Distributor +- Keep backup of all implementation addresses + +## 🌐 Chain-Specific Notes + +### Mainnet + +- High gas costs - deploy during low activity periods +- Use gas estimation tools + +### L2s (Arbitrum, Optimism, Base, etc.) + +- Lower gas costs +- Faster confirmation times + +### Alternative L1s/L2s + +- May have different gas mechanics +- Check block explorer supports verification +- Some may require manual verification + +## 📝 Post-Deployment Tasks + +1. Save all JSON files to secure location +2. Document implementation addresses in internal docs +3. Create upgrade proposals for each chain +4. Schedule upgrade transactions +5. Monitor contracts after upgrades +6. Update documentation with new version +7. Generate summary report: `./helpers/generateUpgradeSummary.sh` + +## 📚 Support + +For issues or questions: + +- Check Foundry documentation: +- Review deployment logs in `deployments/` folder +- Contact team for chain-specific issues diff --git a/helpers/deployUpgradeImplementations.sh b/helpers/deployUpgradeImplementations.sh new file mode 100755 index 00000000..1754808b --- /dev/null +++ b/helpers/deployUpgradeImplementations.sh @@ -0,0 +1,148 @@ +#!/bin/bash + +# Deployment script for upgrading DistributionCreator and Distributor implementations +# across all supported chains + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Counter for results +SUCCESS_COUNT=0 +FAILED_COUNT=0 +SKIPPED_COUNT=0 + +# Create deployments directory if it doesn't exist +mkdir -p deployments + +# Array of chains to deploy to +CHAINS=( + "mainnet" + "polygon" + "fantom" + "optimism" + "arbitrum" + "avalanche" + "bsc" + "gnosis" + "polygonzkevm" + "base" + "bob" + "linea" + "mantle" + "blast" + "mode" + "thundercore" + "coredao" + "xlayer" + "taiko" + "fuse" + "immutable" + "scroll" + "manta" + "sei" + "celo" + "fraxtal" + "astar" + "rootstock" + "moonbeam" + "skale" + "worldchain" + "lisk" + "etherlink" + "swell" + "sonic" + "corn" + "ink" + "ronin" + "flow" + "berachain" + "nibiru" + "zircuit" + "apechain" + "hyperevm" + "hemi" + "xdc" + "katana" + "tac" + "plasma" + "mezo" + "redbelly" + "saga" +) + +echo -e "${BLUE}========================================${NC}" +echo -e "${BLUE}Merkl Upgrade Implementations Deployment${NC}" +echo -e "${BLUE}========================================${NC}" +echo "" +echo -e "Deploying to ${#CHAINS[@]} chains..." +echo "" + +# Create summary file +SUMMARY_FILE="deployments/deployment-summary-$(date +%Y%m%d-%H%M%S).txt" +echo "Deployment Summary - $(date)" > "$SUMMARY_FILE" +echo "===========================================" >> "$SUMMARY_FILE" +echo "" >> "$SUMMARY_FILE" + +# Deploy to each chain +for CHAIN in "${CHAINS[@]}"; do + echo -e "${YELLOW}------------------------------------------${NC}" + echo -e "${YELLOW}Deploying to: $CHAIN${NC}" + echo -e "${YELLOW}------------------------------------------${NC}" + + # Check if RPC URL is configured + RPC_VAR="${CHAIN^^}_NODE_URI" + if [ -z "${!RPC_VAR}" ]; then + echo -e "${YELLOW}⚠ Skipping $CHAIN: RPC URL not configured${NC}" + echo "❌ SKIPPED: $CHAIN - RPC URL not configured" >> "$SUMMARY_FILE" + ((SKIPPED_COUNT++)) + echo "" + continue + fi + + # Try to deploy + if forge script scripts/deployUpgradeImplementationsSingle.s.sol \ + --rpc-url "$CHAIN" \ + --broadcast \ + --verify \ + --skip-simulation \ + --slow \ + 2>&1 | tee "deployments/${CHAIN}-deployment.log"; then + + echo -e "${GREEN}✅ Successfully deployed to $CHAIN${NC}" + echo "✅ SUCCESS: $CHAIN" >> "$SUMMARY_FILE" + ((SUCCESS_COUNT++)) + else + echo -e "${RED}❌ Failed to deploy to $CHAIN${NC}" + echo "❌ FAILED: $CHAIN" >> "$SUMMARY_FILE" + ((FAILED_COUNT++)) + fi + + echo "" + + # Small delay to avoid rate limiting + sleep 2 +done + +echo "" +echo -e "${BLUE}========================================${NC}" +echo -e "${BLUE}Deployment Complete!${NC}" +echo -e "${BLUE}========================================${NC}" +echo "" +echo -e "${GREEN}Successful: $SUCCESS_COUNT${NC}" +echo -e "${RED}Failed: $FAILED_COUNT${NC}" +echo -e "${YELLOW}Skipped: $SKIPPED_COUNT${NC}" +echo "" +echo "Summary saved to: $SUMMARY_FILE" +echo "" +echo -e "${BLUE}Next steps:${NC}" +echo "1. Review deployment logs in ./deployments/" +echo "2. Check individual chain JSON files for implementation addresses" +echo "3. Create Gnosis Safe transactions using the implementation addresses" +echo "4. For failed deployments, check logs and retry manually if needed" +echo "" diff --git a/helpers/generateUpgradeSummary.sh b/helpers/generateUpgradeSummary.sh new file mode 100755 index 00000000..d129c91e --- /dev/null +++ b/helpers/generateUpgradeSummary.sh @@ -0,0 +1,106 @@ +#!/bin/bash + +# Script to generate a summary CSV/Markdown file from all deployment JSONs +# This makes it easy to create Gnosis Safe transactions + +set -e + +DEPLOYMENTS_DIR="deployments" +OUTPUT_CSV="deployments/upgrade-summary.csv" +OUTPUT_MD="deployments/upgrade-summary.md" + +echo "Generating upgrade summary..." + +# Create CSV header +echo "Chain,Chain ID,DistributionCreator Implementation,Distributor Implementation,Deployer,Timestamp" > "$OUTPUT_CSV" + +# Create Markdown header +cat > "$OUTPUT_MD" << 'EOF' +# Upgrade Implementation Addresses + +This file contains all deployed implementation addresses for upgrading DistributionCreator and Distributor contracts. + +## Summary Table + +| Chain | Chain ID | DistributionCreator Impl | Distributor Impl | Deployer | Timestamp | +|-------|----------|--------------------------|------------------|----------|-----------| +EOF + +# Process each JSON file +for json_file in "$DEPLOYMENTS_DIR"/*-upgrade-implementations.json; do + if [ -f "$json_file" ]; then + # Extract data using jq + if command -v jq &> /dev/null; then + CHAIN=$(jq -r '.chainName' "$json_file") + CHAIN_ID=$(jq -r '.chainId' "$json_file") + DC_IMPL=$(jq -r '.distributionCreatorImplementation' "$json_file") + DIST_IMPL=$(jq -r '.distributorImplementation' "$json_file") + DEPLOYER=$(jq -r '.deployer' "$json_file") + TIMESTAMP=$(jq -r '.timestamp' "$json_file") + + # Add to CSV + echo "$CHAIN,$CHAIN_ID,$DC_IMPL,$DIST_IMPL,$DEPLOYER,$TIMESTAMP" >> "$OUTPUT_CSV" + + # Add to Markdown + echo "| $CHAIN | $CHAIN_ID | \`$DC_IMPL\` | \`$DIST_IMPL\` | \`$DEPLOYER\` | $TIMESTAMP |" >> "$OUTPUT_MD" + else + echo "Warning: jq not installed, skipping $json_file" + fi + fi +done + +# Add Gnosis Safe transaction template to Markdown +cat >> "$OUTPUT_MD" << 'EOF' + +## Gnosis Safe Transaction Template + +For each chain, create a transaction with the following details: + +### UUPS Upgrade Transaction + +**To**: `` (DistributionCreator or Distributor proxy) +**Value**: 0 +**Data**: +``` +Function: upgradeTo(address newImplementation) +newImplementation: +``` + +### Verification Steps + +1. ✅ Verify implementation address matches table above +2. ✅ Verify proxy address is correct for the chain +3. ✅ Verify transaction data is correct +4. ✅ Simulate transaction before executing +5. ✅ Have multiple signers review + +### Example Transaction (Arbitrum) + +``` +To: 0x8BB4C975Ff3c250e0ceEA271728547f3802B36Fd (DistributionCreator Proxy) +Value: 0 ETH +Function: upgradeTo(address) +Parameter: 0x +``` + +``` +To: 0x3Ef3D8bA38EBe18DB133cEc108f4D14CE00Dd9Ae (Distributor Proxy) +Value: 0 ETH +Function: upgradeTo(address) +Parameter: 0x +``` + +## Notes + +- Always upgrade DistributionCreator first, then Distributor +- Test on a testnet proxy first if possible +- Monitor contract behavior after upgrade +- Keep these addresses for future reference + +EOF + +echo "✅ Summary generated:" +echo " CSV: $OUTPUT_CSV" +echo " Markdown: $OUTPUT_MD" +echo "" +echo "You can now use these files to create Gnosis Safe upgrade transactions." diff --git a/scripts/deployUpgradeImplementations.s.sol b/scripts/deployUpgradeImplementations.s.sol new file mode 100644 index 00000000..f70c6ccd --- /dev/null +++ b/scripts/deployUpgradeImplementations.s.sol @@ -0,0 +1,218 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.17; + +import { console } from "forge-std/console.sol"; +import { UpgradeDeploymentBase } from "./utils/UpgradeDeploymentBase.s.sol"; +import { DistributionCreator } from "../contracts/DistributionCreator.sol"; +import { Distributor } from "../contracts/Distributor.sol"; + +/// @title DeployUpgradeImplementations +/// @notice Deploys new implementations of DistributionCreator and Distributor for upgrades +/// @dev This script deploys new implementation contracts across all chains and saves the addresses +/// to separate JSON files per chain for easy Gnosis Safe transaction drafting +contract DeployUpgradeImplementations is UpgradeDeploymentBase { + // All supported chains from foundry.toml + ChainConfig[] public chains; + + function setUp() public { + // Initialize chain configurations from base + ChainConfig[] memory configs = _getChainConfigs(); + for (uint256 i = 0; i < configs.length; i++) { + chains.push(configs[i]); + } + } + + /// @notice Main deployment function + function run() external { + uint256 deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY"); + address deployer = vm.addr(deployerPrivateKey); + + console.log("=========================================================="); + console.log("Deploying Upgrade Implementations"); + console.log("Deployer:", deployer); + console.log("=========================================================="); + console.log(""); + + // Deploy on all chains + for (uint256 i = 0; i < chains.length; i++) { + _deployOnChain(chains[i], deployerPrivateKey, deployer); + } + + console.log(""); + console.log("=========================================================="); + console.log("Deployment Complete!"); + console.log("Check ./deployments/ folder for individual chain results"); + console.log("=========================================================="); + } + + /// @notice Public wrapper for _deployImplementations to enable try/catch + function deployImplementationsWrapper() external returns (address, address) { + return _deployImplementations(); + } + + /// @notice Deploy on a specific chain with error handling + function _deployOnChain(ChainConfig memory chainConfig, uint256 privateKey, address deployer) internal { + console.log("----------------------------------------------------------"); + console.log(string.concat("Chain: ", chainConfig.name)); + console.log("Chain ID:", chainConfig.chainId); + + // Fork the chain + string memory rpcEnvVar = string.concat(_toUpperCase(chainConfig.name), "_NODE_URI"); + string memory rpcUrl; + + try vm.envString(rpcEnvVar) returns (string memory url) { + rpcUrl = url; + } catch { + console.log("SKIPPED: RPC URL not configured"); + _saveDeploymentResult( + DeploymentResult({ + distributionCreatorImpl: address(0), + distributorImpl: address(0), + timestamp: block.timestamp, + chainId: chainConfig.chainId, + chainName: chainConfig.name, + deployer: deployer, + status: "SKIPPED", + error: "RPC URL not configured" + }) + ); + console.log(""); + return; + } + + // Create fork + uint256 forkId; + try vm.createFork(rpcUrl) returns (uint256 id) { + forkId = id; + vm.selectFork(forkId); + } catch { + console.log("ERROR: Failed to create fork"); + _saveDeploymentResult( + DeploymentResult({ + distributionCreatorImpl: address(0), + distributorImpl: address(0), + timestamp: block.timestamp, + chainId: chainConfig.chainId, + chainName: chainConfig.name, + deployer: deployer, + status: "ERROR", + error: "Failed to create fork - RPC may be down" + }) + ); + console.log(""); + return; + } + + // Verify chain ID matches + if (block.chainid != chainConfig.chainId) { + console.log("ERROR: Chain ID mismatch"); + console.log("Expected:", chainConfig.chainId); + console.log("Got:", block.chainid); + _saveDeploymentResult( + DeploymentResult({ + distributionCreatorImpl: address(0), + distributorImpl: address(0), + timestamp: block.timestamp, + chainId: chainConfig.chainId, + chainName: chainConfig.name, + deployer: deployer, + status: "ERROR", + error: "Chain ID mismatch" + }) + ); + console.log(""); + return; + } + + // Start broadcasting transactions + vm.startBroadcast(privateKey); + + address distributionCreatorImpl; + address distributorImpl; + string memory errorMsg = ""; + + // Deploy implementations using base contract function + try this.deployImplementationsWrapper() returns (address dcImpl, address dImpl) { + distributionCreatorImpl = dcImpl; + distributorImpl = dImpl; + } catch Error(string memory reason) { + errorMsg = string.concat("Failed to deploy: ", reason); + console.log("ERROR:", errorMsg); + } catch (bytes memory lowLevelData) { + errorMsg = "Failed to deploy: Low-level error"; + console.log("ERROR:", errorMsg); + console.logBytes(lowLevelData); + } + + vm.stopBroadcast(); + + // Determine status + string memory status; + if (bytes(errorMsg).length > 0) { + status = "ERROR"; + } else if (distributionCreatorImpl != address(0) && distributorImpl != address(0)) { + status = "SUCCESS"; + console.log("SUCCESS: Both implementations deployed"); + + // Verify contracts if not skipped + if (!chainConfig.skipVerification) { + _verifyContracts(chainConfig.name, distributionCreatorImpl, distributorImpl); + } + } else { + status = "PARTIAL"; + errorMsg = "Some deployments failed"; + } + + // Save deployment result + _saveDeploymentResult( + DeploymentResult({ + distributionCreatorImpl: distributionCreatorImpl, + distributorImpl: distributorImpl, + timestamp: block.timestamp, + chainId: chainConfig.chainId, + chainName: chainConfig.name, + deployer: deployer, + status: status, + error: errorMsg + }) + ); + + console.log(""); + } + + /// @notice Verify contracts on block explorer + function _verifyContracts(string memory chainName, address distributionCreator, address distributor) internal { + console.log("Verifying contracts..."); + + // Verify DistributionCreator + try vm.tryFfi(_buildVerifyCommand(chainName, distributionCreator, "DistributionCreator")) { + console.log("DistributionCreator verified"); + } catch { + console.log("Warning: DistributionCreator verification failed (run manually if needed)"); + } + + // Verify Distributor + try vm.tryFfi(_buildVerifyCommand(chainName, distributor, "Distributor")) { + console.log("Distributor verified"); + } catch { + console.log("Warning: Distributor verification failed (run manually if needed)"); + } + } + + /// @notice Build verification command + function _buildVerifyCommand( + string memory chainName, + address contractAddress, + string memory contractName + ) internal pure returns (string[] memory) { + string[] memory args = new string[](7); + args[0] = "forge"; + args[1] = "verify-contract"; + args[2] = vm.toString(contractAddress); + args[3] = string.concat("contracts/", contractName, ".sol:", contractName); + args[4] = "--chain"; + args[5] = chainName; + args[6] = "--watch"; + return args; + } +} diff --git a/scripts/deployUpgradeImplementationsSingle.s.sol b/scripts/deployUpgradeImplementationsSingle.s.sol new file mode 100644 index 00000000..7cf4b08d --- /dev/null +++ b/scripts/deployUpgradeImplementationsSingle.s.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.17; + +import { console } from "forge-std/console.sol"; +import { UpgradeDeploymentBase } from "./utils/UpgradeDeploymentBase.s.sol"; + +/// @title DeployUpgradeImplementationsSingle +/// @notice Deploys new implementations of DistributionCreator and Distributor for a single chain +/// @dev Run with: forge script scripts/deployUpgradeImplementationsSingle.s.sol --rpc-url --broadcast --verify +contract DeployUpgradeImplementationsSingle is UpgradeDeploymentBase { + function run() external { + uint256 deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY"); + address deployer = vm.addr(deployerPrivateKey); + uint256 chainId = block.chainid; + + console.log("=========================================================="); + console.log("Deploying Upgrade Implementations"); + console.log("=========================================================="); + console.log("Chain ID:", chainId); + console.log("Deployer:", deployer); + console.log(""); + + vm.startBroadcast(deployerPrivateKey); + + // Deploy implementations using base contract function + (address distributionCreatorImpl, address distributorImpl) = _deployImplementations(); + + vm.stopBroadcast(); + + // Get chain name from base contract + string memory chainName = _getChainName(chainId); + + // Save deployment results using base contract function + DeploymentResult memory result = DeploymentResult({ + distributionCreatorImpl: distributionCreatorImpl, + distributorImpl: distributorImpl, + timestamp: block.timestamp, + chainId: chainId, + chainName: chainName, + deployer: deployer, + status: "SUCCESS", + error: "" + }); + + _saveDeploymentResult(result); + + console.log(""); + console.log("=========================================================="); + console.log("Deployment Complete!"); + console.log("=========================================================="); + console.log( + "Deployment file saved to:", + string.concat("./deployments/", chainName, "-upgrade-implementations.json") + ); + console.log(""); + console.log("Next steps:"); + console.log("1. Verify the implementations on block explorer if not auto-verified"); + console.log("2. Use these addresses to create Gnosis Safe upgrade transactions"); + console.log("=========================================================="); + } +} diff --git a/scripts/utils/UpgradeDeploymentBase.s.sol b/scripts/utils/UpgradeDeploymentBase.s.sol new file mode 100644 index 00000000..8f463ad2 --- /dev/null +++ b/scripts/utils/UpgradeDeploymentBase.s.sol @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.17; + +import { console } from "forge-std/console.sol"; +import { Script } from "forge-std/Script.sol"; +import { stdJson } from "forge-std/StdJson.sol"; + +import { DistributionCreator } from "../../contracts/DistributionCreator.sol"; +import { Distributor } from "../../contracts/Distributor.sol"; + +/// @title UpgradeDeploymentBase +/// @notice Base contract for upgrade implementation deployment scripts +/// @dev Contains shared structs, chain configurations, and utility functions +abstract contract UpgradeDeploymentBase is Script { + using stdJson for string; + + // Shared structs + struct ChainConfig { + string name; + uint256 chainId; + bool skipVerification; + } + + struct DeploymentResult { + address distributionCreatorImpl; + address distributorImpl; + uint256 timestamp; + uint256 chainId; + string chainName; + address deployer; + string status; + string error; + } + + /// @notice Get all supported chain configurations (SINGLE SOURCE OF TRUTH) + /// @return Array of ChainConfig structs + function _getChainConfigs() internal pure returns (ChainConfig[] memory) { + ChainConfig[] memory configs = new ChainConfig[](52); + + configs[0] = ChainConfig("mainnet", 1, false); + configs[1] = ChainConfig("polygon", 137, false); + configs[2] = ChainConfig("fantom", 250, false); + configs[3] = ChainConfig("optimism", 10, false); + configs[4] = ChainConfig("arbitrum", 42161, false); + configs[5] = ChainConfig("avalanche", 43114, false); + configs[6] = ChainConfig("bsc", 56, false); + configs[7] = ChainConfig("gnosis", 100, false); + configs[8] = ChainConfig("polygonzkevm", 1101, false); + configs[9] = ChainConfig("base", 8453, false); + configs[10] = ChainConfig("bob", 60808, false); + configs[11] = ChainConfig("linea", 59144, false); + configs[12] = ChainConfig("mantle", 5000, false); + configs[13] = ChainConfig("blast", 81457, false); + configs[14] = ChainConfig("mode", 34443, false); + configs[15] = ChainConfig("thundercore", 108, false); + configs[16] = ChainConfig("coredao", 1116, false); + configs[17] = ChainConfig("xlayer", 196, false); + configs[18] = ChainConfig("taiko", 167000, false); + configs[19] = ChainConfig("fuse", 122, false); + configs[20] = ChainConfig("immutable", 13371, false); + configs[21] = ChainConfig("scroll", 534352, false); + configs[22] = ChainConfig("manta", 169, false); + configs[23] = ChainConfig("sei", 1329, false); + configs[24] = ChainConfig("celo", 42220, false); + configs[25] = ChainConfig("fraxtal", 252, false); + configs[26] = ChainConfig("astar", 592, false); + configs[27] = ChainConfig("rootstock", 30, false); + configs[28] = ChainConfig("moonbeam", 1284, false); + configs[29] = ChainConfig("skale", 2046399126, false); + configs[30] = ChainConfig("worldchain", 480, false); + configs[31] = ChainConfig("lisk", 1135, false); + configs[32] = ChainConfig("etherlink", 42793, false); + configs[33] = ChainConfig("swell", 1923, false); + configs[34] = ChainConfig("sonic", 146, false); + configs[35] = ChainConfig("corn", 21000000, false); + configs[36] = ChainConfig("ink", 57073, false); + configs[37] = ChainConfig("ronin", 2020, false); + configs[38] = ChainConfig("flow", 747, false); + configs[39] = ChainConfig("berachain", 80094, true); // Often testnet + configs[40] = ChainConfig("nibiru", 6900, false); + configs[41] = ChainConfig("zircuit", 48900, false); + configs[42] = ChainConfig("apechain", 33139, false); + configs[43] = ChainConfig("hyperevm", 999, false); + configs[44] = ChainConfig("hemi", 43111, false); + configs[45] = ChainConfig("xdc", 50, false); + configs[46] = ChainConfig("katana", 747474, true); + configs[47] = ChainConfig("tac", 239, false); + configs[48] = ChainConfig("plasma", 9745, false); + configs[49] = ChainConfig("mezo", 31612, false); + configs[50] = ChainConfig("redbelly", 151, false); + configs[51] = ChainConfig("saga", 5464, false); + + return configs; + } + + /// @notice Get chain name from chain ID by looking up in chain configs + /// @param chainId The chain ID to look up + /// @return The chain name, or "chain-" if not found + function _getChainName(uint256 chainId) internal pure returns (string memory) { + ChainConfig[] memory configs = _getChainConfigs(); + + for (uint256 i = 0; i < configs.length; i++) { + if (configs[i].chainId == chainId) { + return configs[i].name; + } + } + + return string.concat("chain-", vm.toString(chainId)); + } + + /// @notice Deploy implementations on the current chain + /// @return distributionCreatorImpl The deployed DistributionCreator implementation address + /// @return distributorImpl The deployed Distributor implementation address + function _deployImplementations() internal returns (address distributionCreatorImpl, address distributorImpl) { + console.log("Deploying DistributionCreator implementation..."); + DistributionCreator dcImpl = new DistributionCreator(); + distributionCreatorImpl = address(dcImpl); + console.log("DistributionCreator Implementation:", distributionCreatorImpl); + + console.log("Deploying Distributor implementation..."); + Distributor dImpl = new Distributor(); + distributorImpl = address(dImpl); + console.log("Distributor Implementation:", distributorImpl); + + return (distributionCreatorImpl, distributorImpl); + } + + /// @notice Save deployment results to JSON file + /// @param result The deployment result to save + function _saveDeploymentResult(DeploymentResult memory result) internal { + string memory obj = "deployment"; + + vm.serializeUint(obj, "chainId", result.chainId); + vm.serializeString(obj, "chainName", result.chainName); + vm.serializeAddress(obj, "distributionCreatorImplementation", result.distributionCreatorImpl); + vm.serializeAddress(obj, "distributorImplementation", result.distributorImpl); + vm.serializeUint(obj, "timestamp", result.timestamp); + vm.serializeAddress(obj, "deployer", result.deployer); + + // Optional fields for multi-chain deployment + if (bytes(result.status).length > 0) { + vm.serializeString(obj, "status", result.status); + } + if (bytes(result.error).length > 0) { + vm.serializeString(obj, "error", result.error); + } + + string memory finalJson = vm.serializeString(obj, "_note", "Upgrade implementation deployment"); + + // Write to file + string memory fileName = string.concat("./deployments/", result.chainName, "-upgrade-implementations.json"); + vm.writeJson(finalJson, fileName); + + console.log("Deployment data saved to:", fileName); + } + + /// @notice Convert string to uppercase (for environment variable names) + /// @param str The string to convert + /// @return The uppercase string + function _toUpperCase(string memory str) internal pure returns (string memory) { + bytes memory bStr = bytes(str); + bytes memory bUpper = new bytes(bStr.length); + + for (uint256 i = 0; i < bStr.length; i++) { + // Convert lowercase letters to uppercase + if (uint8(bStr[i]) >= 97 && uint8(bStr[i]) <= 122) { + bUpper[i] = bytes1(uint8(bStr[i]) - 32); + } else { + bUpper[i] = bStr[i]; + } + } + + return string(bUpper); + } +} From 3e5e64b53b59e5561f941f30d2c86729b2791074 Mon Sep 17 00:00:00 2001 From: Pablo Veyrat Date: Fri, 24 Oct 2025 12:01:21 +0200 Subject: [PATCH 17/27] fix: guardian claim --- contracts/Distributor.sol | 3 ++- scripts/PointToken.s.sol | 14 ++++++++------ test/unit/Distributor.t.sol | 9 ++++----- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/contracts/Distributor.sol b/contracts/Distributor.sol index 6f3eaf71..fa0bd6a2 100644 --- a/contracts/Distributor.sol +++ b/contracts/Distributor.sol @@ -392,7 +392,8 @@ contract Distributor is UUPSHelper { mainOperators[msg.sender][token] == 0 && mainOperators[msg.sender][address(0)] == 0 && operators[user][msg.sender] == 0 && - operators[user][address(0)] == 0 + operators[user][address(0)] == 0 && + !accessControlManager.isGovernorOrGuardian(msg.sender) ) revert Errors.NotWhitelisted(); // Verifying proof diff --git a/scripts/PointToken.s.sol b/scripts/PointToken.s.sol index 2f20d939..cf5c7d20 100644 --- a/scripts/PointToken.s.sol +++ b/scripts/PointToken.s.sol @@ -18,18 +18,20 @@ contract PointTokenScript is BaseScript, JsonReader { // Deploy script contract DeployPointToken is PointTokenScript { function run() external broadcast { - // forge script scripts/PointToken.s.sol:DeployPointToken --rpc-url gnosis --broadcast --verify -vvvv + // forge script scripts/PointToken.s.sol:DeployPointToken --rpc-url avalanche --broadcast --verify -vvvv uint256 chainId = block.chainid; // MODIFY THESE VALUES TO SET YOUR DESIRED TOKEN PARAMETERS - string memory name = "IPOR Fusion Points"; - string memory symbol = "ipor-fusion-points"; + string memory name = "AvantPoints"; + string memory symbol = "AvantPoints"; address minter = 0xA9DdD91249DFdd450E81E1c56Ab60E1A62651701; uint256 amount = 1_000_000_000 * 1e18; address creator = 0xA9DdD91249DFdd450E81E1c56Ab60E1A62651701; uint8 decimals = 18; // address accessControlManager = readAddress(chainId, "Merkl.CoreMerkl"); - address accessControlManager = 0xFD0DFC837Fe7ED19B23df589b6F6Da5a775F99E0; + address accessControlManager = address( + DistributionCreator(0x8BB4C975Ff3c250e0ceEA271728547f3802B36Fd).accessControlManager() + ); _run(name, symbol, minter, accessControlManager, amount, creator); } @@ -64,8 +66,8 @@ contract DeployPointToken is PointTokenScript { token.toggleWhitelistedRecipient(0x3Ef3D8bA38EBe18DB133cEc108f4D14CE00Dd9Ae); token.toggleWhitelistedRecipient(0x8BB4C975Ff3c250e0ceEA271728547f3802B36Fd); token.toggleWhitelistedRecipient(0xeaC6A75e19beB1283352d24c0311De865a867DAB); - token.toggleWhitelistedRecipient(0x1384Fa5187D946F9639Afaa391287E0b86B31708); - token.transfer(0x1384Fa5187D946F9639Afaa391287E0b86B31708, 1e9 * 1e18); + token.toggleWhitelistedRecipient(0xc22b79e6b94e80E732553554b4791A56aa121BB0); + token.transfer(0xc22b79e6b94e80E732553554b4791A56aa121BB0, 1e9 * 1e18); console.log("Whitelisted recipients:"); // transfer to the SAFE diff --git a/test/unit/Distributor.t.sol b/test/unit/Distributor.t.sol index 8091b26e..6e623cd0 100644 --- a/test/unit/Distributor.t.sol +++ b/test/unit/Distributor.t.sol @@ -478,15 +478,14 @@ contract Test_Distributor_claim is DistributorTest { tokens[1] = address(agEUR); amounts[1] = 5e17; - // uint256 aliceBalance = angle.balanceOf(address(alice)); - // uint256 bobBalance = agEUR.balanceOf(address(bob)); + uint256 aliceBalance = angle.balanceOf(address(alice)); + uint256 bobBalance = agEUR.balanceOf(address(bob)); vm.prank(governor); - vm.expectRevert(Errors.NotWhitelisted.selector); // governor not able to claim anymore distributor.claim(users, tokens, amounts, proofs); - // assertEq(angle.balanceOf(address(alice)), aliceBalance + 1e18); - // assertEq(agEUR.balanceOf(address(bob)), bobBalance + 5e17); + assertEq(angle.balanceOf(address(alice)), aliceBalance + 1e18); + assertEq(agEUR.balanceOf(address(bob)), bobBalance + 5e17); } function test_SuccessOperator() public { From 37bce27cad7990ff42bd1d0555d258cc19e2e194 Mon Sep 17 00:00:00 2001 From: Pablo Veyrat Date: Tue, 28 Oct 2025 16:16:33 +0100 Subject: [PATCH 18/27] fix: contracts --- contracts/DistributionCreator.sol | 8 ++----- .../DistributionCreatorWithDistributions.sol | 2 +- contracts/Distributor.sol | 3 +++ .../tokenWrappers/AaveTokenWrapper.sol | 2 +- contracts/struct/CampaignParameters.sol | 2 +- foundry.toml | 7 +++++- scripts/DistributionCreator.s.sol | 22 +++++++++++++++++++ test/DistributionCreator.t.sol | 8 ++++--- 8 files changed, 41 insertions(+), 13 deletions(-) diff --git a/contracts/DistributionCreator.sol b/contracts/DistributionCreator.sol index fdd76720..8c8fdf9e 100644 --- a/contracts/DistributionCreator.sol +++ b/contracts/DistributionCreator.sol @@ -327,7 +327,7 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { } /// @notice Returns the campaign parameters of a given campaignId - /// @dev If a campaign has been overriden, this function still shows the original state of the campaign + /// @dev If a campaign has been overridden, this function still shows the original state of the campaign function campaign(bytes32 _campaignId) public view returns (CampaignParameters memory) { return campaignList[campaignLookup(_campaignId)]; } @@ -491,11 +491,7 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { /// @notice Checks whether `msg.sender` is allowed to manage the campaign of `creator` function _isValidOperator(address creator) internal view { - if ( - creator != msg.sender && - campaignOperators[creator][msg.sender] == 0 && - !accessControlManager.isGovernor(msg.sender) - ) { + if (creator != msg.sender && campaignOperators[creator][msg.sender] == 0) { revert Errors.OperatorNotAllowed(); } } diff --git a/contracts/DistributionCreatorWithDistributions.sol b/contracts/DistributionCreatorWithDistributions.sol index 10676841..a795cbe3 100644 --- a/contracts/DistributionCreatorWithDistributions.sol +++ b/contracts/DistributionCreatorWithDistributions.sol @@ -90,5 +90,5 @@ contract DistributionCreatorWithDistributions is DistributionCreator { * variables without shifting down storage in the inheritance chain. * See https://docs.openzeppelin.com/contracts/4.x/upgradeable#storage_gaps */ - uint256[31] private __gap; + uint256[50] private __gap2; } diff --git a/contracts/Distributor.sol b/contracts/Distributor.sol index fa0bd6a2..69fc3adc 100644 --- a/contracts/Distributor.sol +++ b/contracts/Distributor.sol @@ -249,6 +249,9 @@ contract Distributor is UUPSHelper { _setClaimRecipient(user, recipient, token); } + /// @notice Updates the mainOperator status for `operator` on `token` + /// @dev Adding a mainOperator status on an address for the zero address gives the right to claim any token + /// on behalf of anyone on the chain function toggleMainOperatorStatus(address operator, address token) external onlyGuardian { uint256 oldValue = mainOperators[operator][token]; mainOperators[operator][token] = 1 - oldValue; diff --git a/contracts/partners/tokenWrappers/AaveTokenWrapper.sol b/contracts/partners/tokenWrappers/AaveTokenWrapper.sol index c3b190bd..6d0376e2 100644 --- a/contracts/partners/tokenWrappers/AaveTokenWrapper.sol +++ b/contracts/partners/tokenWrappers/AaveTokenWrapper.sol @@ -64,7 +64,7 @@ contract AaveTokenWrapper is UUPSHelper, ERC20Upgradeable { // Needs an approval before hand, this is how mints are done if (to == distributor) { IERC20(token).safeTransferFrom(from, address(this), amount); - _mint(from, amount); // These are then transfered to the distributor + _mint(from, amount); // These are then transferred to the distributor } else { if (to == _getFeeRecipient()) { IERC20(token).safeTransferFrom(from, to, amount); diff --git a/contracts/struct/CampaignParameters.sol b/contracts/struct/CampaignParameters.sol index 41e641cd..955d9981 100644 --- a/contracts/struct/CampaignParameters.sol +++ b/contracts/struct/CampaignParameters.sol @@ -10,7 +10,7 @@ struct CampaignParameters { bytes32 campaignId; // CHOSEN BY CAMPAIGN CREATOR - // Address of the campaign creator, if marked as address(0), it will be overriden with the + // Address of the campaign creator, if marked as address(0), it will be overridden with the // address of the `msg.sender` creating the campaign address creator; // Address of the token used as a reward diff --git a/foundry.toml b/foundry.toml index 1562a0df..c5cdde79 100644 --- a/foundry.toml +++ b/foundry.toml @@ -98,6 +98,8 @@ plasma="${PLASMA_NODE_URI}" mezo="${MEZO_NODE_URI}" redbelly="${REDBELLY_NODE_URI}" saga="${SAGA_NODE_URI}" +ethereal="${ETHEREAL_NODE_URI}" +monad="${MONAD_NODE_URI}" [etherscan] localhost = { url = "http://localhost:4000", key = "none" } @@ -153,4 +155,7 @@ tac = {chainId = 239, key = "${TAC_ETHERSCAN_API_KEY}", url = "${VERIFIER_URL_23 plasma = {chainId = 9745, key = "${PLASMA_ETHERSCAN_API_KEY}", url = "${VERIFIER_URL_9745}" } mezo = {chainId = 31612, key = "${MEZO_ETHERSCAN_API_KEY}", url = "${VERIFIER_URL_31612}" } redbelly = {chainId = 151, key = "${REDBELLY_ETHERSCAN_API_KEY}", url = "${VERIFIER_URL_151}" } -saga = {chainId = 5464, key = "$SAGA_ETHERSCAN_API_KEY}", url = "${VERIFIER_URL_5464}" } \ No newline at end of file +saga = {chainId = 5464, key = "${SAGA_ETHERSCAN_API_KEY}", url = "${VERIFIER_URL_5464}" } +ethereal = {chainId = 5064014, key = "none", url = "${VERIFIER_URL_5064014}" } +monad = {chaindId = 143, key = "${MONAD_ETHERSCAN_API_KEY}", url = "${VERIFIER_URL_143}" } + \ No newline at end of file diff --git a/scripts/DistributionCreator.s.sol b/scripts/DistributionCreator.s.sol index cc17c718..e97d9beb 100644 --- a/scripts/DistributionCreator.s.sol +++ b/scripts/DistributionCreator.s.sol @@ -264,6 +264,28 @@ contract GetMessage is DistributionCreatorScript { } } +// ToggleSigningWhitelist script +contract ToggleSigningWhitelist is DistributionCreatorScript { + function run() external { + // MODIFY THIS VALUE TO SET YOUR DESIRED USER ADDRESS + address user = address(0); + _run(user); + } + + function run(address user) external { + _run(user); + } + + function _run(address _user) internal broadcast { + uint256 chainId = block.chainid; + address creatorAddress = readAddress(chainId, "DistributionCreator"); + + DistributionCreator(creatorAddress).toggleSigningWhitelist(_user); + + console.log("Signing whitelist toggled for user:", _user); + } +} + // AcceptConditions script contract AcceptConditions is DistributionCreatorScript { function run() external broadcast { diff --git a/test/DistributionCreator.t.sol b/test/DistributionCreator.t.sol index 50dd4ac5..b08c9d7e 100644 --- a/test/DistributionCreator.t.sol +++ b/test/DistributionCreator.t.sol @@ -458,7 +458,9 @@ contract DistributionCreatorCreateReallocationTest is Fixture { ); vm.prank(governor); + creator.toggleCampaignOperator(alice, governor); // Create false tree + vm.prank(governor); distributor.updateTree( MerkleTree({ merkleRoot: bytes32(0x0b70a97c062cb747158b89e27df5bbda859ba072232efcbe92e383e9d74b8555), @@ -486,9 +488,6 @@ contract DistributionCreatorCreateReallocationTest is Fixture { 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); } @@ -759,6 +758,9 @@ contract DistributionCreatorOverrideTest is Fixture { }) ); + vm.prank(governor); + creator.toggleCampaignOperator(alice, governor); + vm.warp(block.timestamp + 1000); vm.roll(4); From aa324fa51c9e265861f66e1dbc45c91314807530 Mon Sep 17 00:00:00 2001 From: Pablo Veyrat Date: Tue, 4 Nov 2025 11:59:27 +0100 Subject: [PATCH 19/27] fix: comments --- contracts/DistributionCreator.sol | 2 +- .../tokenWrappers/NativeTokenWrapper.sol | 129 ++++++++++++++++++ 2 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 contracts/partners/tokenWrappers/NativeTokenWrapper.sol diff --git a/contracts/DistributionCreator.sol b/contracts/DistributionCreator.sol index 8c8fdf9e..35934d31 100644 --- a/contracts/DistributionCreator.sol +++ b/contracts/DistributionCreator.sol @@ -542,7 +542,7 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { } } - /// @notice Computes the fees to be taken on a campaign and transfers them to the fee recipient + /// @notice Computes the fees to be taken on a campaign function _computeFees( uint32 campaignType, uint256 distributionAmount diff --git a/contracts/partners/tokenWrappers/NativeTokenWrapper.sol b/contracts/partners/tokenWrappers/NativeTokenWrapper.sol new file mode 100644 index 00000000..053b0d2f --- /dev/null +++ b/contracts/partners/tokenWrappers/NativeTokenWrapper.sol @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: GPL-3.0 + +pragma solidity ^0.8.17; + +import { ERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; + +import { DistributionCreator } from "../../DistributionCreator.sol"; +import { UUPSHelper } from "../../utils/UUPSHelper.sol"; +import { IAccessControlManager } from "../../interfaces/IAccessControlManager.sol"; +import { Errors } from "../../utils/Errors.sol"; + +/// @title NativeTokenWrapper +/// @notice Wrapper for a reward token on Merkl so campaigns do not have to be prefunded +contract NativeTokenWrapper is UUPSHelper, ERC20Upgradeable { + using SafeERC20 for IERC20; + + // ================================= VARIABLES ================================= + + /// @notice `AccessControlManager` contract handling access control + IAccessControlManager public accessControlManager; + /// @notice Minter address that can mint tokens and set allowed addresses + address public minter; + /// @notice Merkl fee recipient + address public feeRecipient; + /// @notice Merkl main address + address public distributor; + address public distributionCreator; + /// @notice Whether an address is allowed to hold some tokens and thus to create campaigns on Merkl + mapping(address => uint256) public isAllowed; + + uint256[43] private __gap; + + // ================================= MODIFIERS ================================= + + /// @notice Checks whether the `msg.sender` has the governor role or the guardian role + modifier onlyMinterOrGovernor() { + if (msg.sender != minter && !accessControlManager.isGovernor(msg.sender)) revert Errors.NotAllowed(); + _; + } + + // ================================= FUNCTIONS ================================= + + /// @notice Allows contract to receive ETH + receive() external payable {} + + /// @notice Allows contract to receive ETH via fallback + fallback() external payable {} + + function initialize( + address _distributionCreator, + address _minter, + string memory _name, + string memory _symbol + ) public initializer { + __ERC20_init(string.concat(_name), string.concat(_symbol)); + __UUPSUpgradeable_init(); + if (_minter == address(0)) revert Errors.ZeroAddress(); + address _distributor = DistributionCreator(_distributionCreator).distributor(); + distributor = _distributor; + accessControlManager = DistributionCreator(_distributionCreator).accessControlManager(); + distributionCreator = _distributionCreator; + minter = _minter; + isAllowed[_distributor] = 1; + isAllowed[_minter] = 1; // The minter is allowed to hold tokens + isAllowed[address(0)] = 1; // The zero address is allowed to hold tokens (for burning) + _setFeeRecipient(); + } + + function _beforeTokenTransfer(address from, address to, uint256 amount) internal override { + // During claim transactions, native gas tokens (ETH) are transferred to the `to` address + if (from == distributor || to == feeRecipient) { + (bool success, ) = to.call{ value: amount }(""); + if (!success) { + revert Errors.WithdrawalFailed(); + } + } + } + + function _afterTokenTransfer(address, address to, uint256 amount) internal override { + // No leftover tokens can be kept except on allowed addresses + if (isAllowed[to] == 0) _burn(to, amount); + } + + function setMinter(address _newMinter) external onlyMinterOrGovernor { + address _oldMinter = minter; + isAllowed[_oldMinter] = 0; // Remove the old minter from the allowed list + isAllowed[_newMinter] = 1; // Add the new minter to the allowed list + minter = _newMinter; + } + + function mint(address recipient, uint256 amount) external onlyMinterOrGovernor { + isAllowed[recipient] = 1; // Allow the recipient to hold tokens + _mint(recipient, amount); + } + + function toggleAllowance(address _address) external onlyMinterOrGovernor { + uint256 currentStatus = isAllowed[_address]; + isAllowed[_address] = 1 - currentStatus; + } + + function recover(address _token, address _to, uint256 amount) external onlyMinterOrGovernor { + IERC20(_token).safeTransfer(_to, amount); + } + + function recoverETH(address payable _to, uint256 amount) external onlyMinterOrGovernor { + (bool success, ) = _to.call{ value: amount }(""); + if (!success) { + revert Errors.WithdrawalFailed(); + } + } + + function setFeeRecipient() external { + _setFeeRecipient(); + } + + function _setFeeRecipient() internal { + address _feeRecipient = DistributionCreator(distributionCreator).feeRecipient(); + feeRecipient = _feeRecipient; + } + + function decimals() public pure override returns (uint8) { + return 18; + } + + /// @inheritdoc UUPSHelper + function _authorizeUpgrade(address) internal view override onlyGovernorUpgrader(accessControlManager) {} +} From 80b96df88c29a6db150d02d9d72c94e9dd473442 Mon Sep 17 00:00:00 2001 From: Pablo Veyrat Date: Wed, 12 Nov 2025 09:15:20 +0100 Subject: [PATCH 20/27] chore: tests for NativeTokenWrapper --- .../tokenWrappers/NativeTokenWrapper.sol | 6 + scripts/PointToken.s.sol | 10 +- scripts/deployPullTokenWrapper.s.sol | 12 +- .../tokenWrappers/NativeTokenWrapper.t.sol | 478 ++++++++++++++++++ 4 files changed, 495 insertions(+), 11 deletions(-) create mode 100644 test/unit/partners/tokenWrappers/NativeTokenWrapper.t.sol diff --git a/contracts/partners/tokenWrappers/NativeTokenWrapper.sol b/contracts/partners/tokenWrappers/NativeTokenWrapper.sol index 053b0d2f..1df53449 100644 --- a/contracts/partners/tokenWrappers/NativeTokenWrapper.sol +++ b/contracts/partners/tokenWrappers/NativeTokenWrapper.sol @@ -95,6 +95,12 @@ contract NativeTokenWrapper is UUPSHelper, ERC20Upgradeable { _mint(recipient, amount); } + function mintWithETH() external payable { + if (isAllowed[msg.sender] == 0) revert Errors.NotAllowed(); + uint256 amount = msg.value; + _mint(msg.sender, amount); + } + function toggleAllowance(address _address) external onlyMinterOrGovernor { uint256 currentStatus = isAllowed[_address]; isAllowed[_address] = 1 - currentStatus; diff --git a/scripts/PointToken.s.sol b/scripts/PointToken.s.sol index cf5c7d20..fe276c06 100644 --- a/scripts/PointToken.s.sol +++ b/scripts/PointToken.s.sol @@ -18,11 +18,11 @@ contract PointTokenScript is BaseScript, JsonReader { // Deploy script contract DeployPointToken is PointTokenScript { function run() external broadcast { - // forge script scripts/PointToken.s.sol:DeployPointToken --rpc-url avalanche --broadcast --verify -vvvv + // forge script scripts/PointToken.s.sol:DeployPointToken --rpc-url gnosis --broadcast --verify -vvvv uint256 chainId = block.chainid; // MODIFY THESE VALUES TO SET YOUR DESIRED TOKEN PARAMETERS - string memory name = "AvantPoints"; - string memory symbol = "AvantPoints"; + string memory name = "kpk Points"; + string memory symbol = "kpkPoints"; address minter = 0xA9DdD91249DFdd450E81E1c56Ab60E1A62651701; uint256 amount = 1_000_000_000 * 1e18; address creator = 0xA9DdD91249DFdd450E81E1c56Ab60E1A62651701; @@ -66,8 +66,8 @@ contract DeployPointToken is PointTokenScript { token.toggleWhitelistedRecipient(0x3Ef3D8bA38EBe18DB133cEc108f4D14CE00Dd9Ae); token.toggleWhitelistedRecipient(0x8BB4C975Ff3c250e0ceEA271728547f3802B36Fd); token.toggleWhitelistedRecipient(0xeaC6A75e19beB1283352d24c0311De865a867DAB); - token.toggleWhitelistedRecipient(0xc22b79e6b94e80E732553554b4791A56aa121BB0); - token.transfer(0xc22b79e6b94e80E732553554b4791A56aa121BB0, 1e9 * 1e18); + token.toggleWhitelistedRecipient(0x58e6c7ab55Aa9012eAccA16d1ED4c15795669E1C); + token.transfer(0x58e6c7ab55Aa9012eAccA16d1ED4c15795669E1C, 1e9 * 1e18); console.log("Whitelisted recipients:"); // transfer to the SAFE diff --git a/scripts/deployPullTokenWrapper.s.sol b/scripts/deployPullTokenWrapper.s.sol index 40c46a67..b016fd12 100644 --- a/scripts/deployPullTokenWrapper.s.sol +++ b/scripts/deployPullTokenWrapper.s.sol @@ -18,19 +18,19 @@ import { IAccessControlManager } from "../contracts/interfaces/IAccessControlMan import { MockToken } from "../contracts/mock/MockToken.sol"; contract DeployPullTokenWrapper is BaseScript { - // forge script scripts/deployPullTokenWrapper.s.sol --rpc-url celo --sender 0xA9DdD91249DFdd450E81E1c56Ab60E1A62651701 --broadcast --verify + // forge script scripts/deployPullTokenWrapper.s.sol --rpc-url plasma --sender 0xA9DdD91249DFdd450E81E1c56Ab60E1A62651701 --broadcast --verify function run() public { uint256 deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY"); vm.startBroadcast(deployerPrivateKey); address distributionCreator = 0x8BB4C975Ff3c250e0ceEA271728547f3802B36Fd; // ------------------------------------------------------------------------ // TO EDIT - address underlying = 0xBba98352628B0B0c4b40583F593fFCb630935a45; + address underlying = 0x5D72a9d9A9510Cd8cBdBA12aC62593A58930a948; address holder = 0xdef1FA4CEfe67365ba046a7C630D6B885298E210; // Need to choose the implementation type and if implementation needs to be deployed - // address implementation = address(new PullTokenWrapperWithdraw()); - address implementation = address(new PullTokenWrapper()); + address implementation = address(new PullTokenWrapperWithdraw()); + // address implementation = address(new PullTokenWrapper()); // Ethereum implementation of PullTokenWrapper // address implementation = 0x979a04fd2f3A6a2B3945A715e24b974323E93567; // Ethereum implementation of PullTokenWrapperWithdraw @@ -42,8 +42,8 @@ contract DeployPullTokenWrapper is BaseScript { string memory symbol = IERC20Metadata(underlying).symbol(); // Names to override if deploying a PullTokenWrapperWithdraw implementation - // name = "USDtb (wrapped)"; - // symbol = "USDtb"; + name = "USDT0 (wrapped)"; + symbol = "USDT0"; console.log("PullTokenWrapper Implementation:", address(implementation)); diff --git a/test/unit/partners/tokenWrappers/NativeTokenWrapper.t.sol b/test/unit/partners/tokenWrappers/NativeTokenWrapper.t.sol new file mode 100644 index 00000000..863270bf --- /dev/null +++ b/test/unit/partners/tokenWrappers/NativeTokenWrapper.t.sol @@ -0,0 +1,478 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.17; + +import { Test } from "forge-std/Test.sol"; +import { console } from "forge-std/console.sol"; + +import { NativeTokenWrapper } from "../../../../contracts/partners/tokenWrappers/NativeTokenWrapper.sol"; +import { Fixture } from "../../../Fixture.t.sol"; +import { IAccessControlManager } from "../../../../contracts/interfaces/IAccessControlManager.sol"; +import { Errors } from "../../../../contracts/utils/Errors.sol"; + +/// @dev Mock contract to simulate the Distributor +contract MockDistributor { + NativeTokenWrapper public wrapper; + + function setWrapper(address _wrapper) external { + wrapper = NativeTokenWrapper(payable(_wrapper)); + } + + /// @dev Simulates a transfer from distributor (e.g., during claim) + function simulateClaim(address to, uint256 amount) external { + wrapper.transfer(to, amount); + } + + /// @dev Allow receiving ETH + receive() external payable {} +} + +/// @dev Mock contract to simulate fee recipient +contract MockFeeRecipient { + /// @dev Allow receiving ETH + receive() external payable {} +} + +/// @dev Mock contract that cannot receive ETH (no receive/fallback) +contract MockNonPayable { + // Intentionally no receive or fallback function +} + +contract NativeTokenWrapperTest is Fixture { + NativeTokenWrapper public wrapper; + NativeTokenWrapper public wrapperImpl; + MockDistributor public mockDistributor; + MockFeeRecipient public mockFeeRecipient; + + function setUp() public virtual override { + super.setUp(); + + // Deploy mock contracts + mockDistributor = new MockDistributor(); + mockFeeRecipient = new MockFeeRecipient(); + + // Deploy NativeTokenWrapper implementation + wrapperImpl = new NativeTokenWrapper(); + wrapper = NativeTokenWrapper(payable(deployUUPS(address(wrapperImpl), hex""))); + + // Mock the creator to return our mock distributor and fee recipient + vm.mockCall(address(creator), abi.encodeWithSignature("distributor()"), abi.encode(address(mockDistributor))); + vm.mockCall(address(creator), abi.encodeWithSignature("feeRecipient()"), abi.encode(address(mockFeeRecipient))); + + // Initialize the wrapper + wrapper.initialize(address(creator), alice, "Wrapped Native Token", "WNATIVE"); + + // Set wrapper in mock distributor + mockDistributor.setWrapper(address(wrapper)); + + // Fund the wrapper with ETH for testing + vm.deal(address(wrapper), 100 ether); + } +} + +contract Test_NativeTokenWrapper_Initialize is NativeTokenWrapperTest { + NativeTokenWrapper w; + + function setUp() public override { + super.setUp(); + w = NativeTokenWrapper(payable(deployUUPS(address(new NativeTokenWrapper()), hex""))); + } + + function test_RevertWhen_CalledOnImplem() public { + vm.expectRevert("Initializable: contract is already initialized"); + wrapperImpl.initialize(address(0), address(0), "", ""); + } + + function test_RevertWhen_ZeroAddress() public { + vm.expectRevert(Errors.ZeroAddress.selector); + w.initialize(address(creator), address(0), "Test", "TEST"); + } + + function test_Success() public { + w.initialize(address(creator), alice, "Test Token", "TEST"); + + assertEq(w.name(), "Test Token"); + assertEq(w.symbol(), "TEST"); + assertEq(w.minter(), alice); + assertEq(address(w.accessControlManager()), address(accessControlManager)); + assertEq(w.distributor(), address(mockDistributor)); + assertEq(w.distributionCreator(), address(creator)); + assertEq(w.decimals(), 18); + + // Check allowed addresses + assertEq(w.isAllowed(address(mockDistributor)), 1); + assertEq(w.isAllowed(alice), 1); + assertEq(w.isAllowed(address(0)), 1); + } +} + +contract Test_NativeTokenWrapper_Receive is NativeTokenWrapperTest { + function test_Success_ReceiveETH() public { + uint256 balanceBefore = address(wrapper).balance; + + vm.deal(alice, 10 ether); + vm.prank(alice); + (bool success, ) = address(wrapper).call{ value: 5 ether }(""); + + assertTrue(success); + assertEq(address(wrapper).balance, balanceBefore + 5 ether); + } + + function test_Success_FallbackReceiveETH() public { + uint256 balanceBefore = address(wrapper).balance; + + vm.deal(alice, 10 ether); + vm.prank(alice); + (bool success, ) = address(wrapper).call{ value: 3 ether }("0x1234"); + + assertTrue(success); + assertEq(address(wrapper).balance, balanceBefore + 3 ether); + } +} + +contract Test_NativeTokenWrapper_Mint is NativeTokenWrapperTest { + function test_RevertWhen_NotMinterOrGovernor() public { + vm.expectRevert(Errors.NotAllowed.selector); + vm.prank(bob); + wrapper.mint(charlie, 1 ether); + } + + function test_Success_Minter() public { + vm.prank(alice); + wrapper.mint(bob, 5 ether); + + assertEq(wrapper.balanceOf(bob), 5 ether); + assertEq(wrapper.isAllowed(bob), 1); + } + + function test_Success_Governor() public { + vm.prank(governor); + wrapper.mint(charlie, 10 ether); + + assertEq(wrapper.balanceOf(charlie), 10 ether); + assertEq(wrapper.isAllowed(charlie), 1); + } +} + +contract Test_NativeTokenWrapper_MintWithETH is NativeTokenWrapperTest { + function test_RevertWhen_NotAllowed() public { + vm.deal(bob, 10 ether); + + vm.expectRevert(Errors.NotAllowed.selector); + vm.prank(bob); + wrapper.mintWithETH{ value: 5 ether }(); + } + + function test_Success_AllowedAddress() public { + // First, allow bob + vm.prank(alice); + wrapper.toggleAllowance(bob); + + vm.deal(bob, 10 ether); + uint256 wrapperBalanceBefore = address(wrapper).balance; + + vm.prank(bob); + wrapper.mintWithETH{ value: 5 ether }(); + + assertEq(wrapper.balanceOf(bob), 5 ether); + assertEq(address(wrapper).balance, wrapperBalanceBefore + 5 ether); + } + + function test_Success_Minter() public { + vm.deal(alice, 10 ether); + uint256 wrapperBalanceBefore = address(wrapper).balance; + + vm.prank(alice); + wrapper.mintWithETH{ value: 3 ether }(); + + assertEq(wrapper.balanceOf(alice), 3 ether); + assertEq(address(wrapper).balance, wrapperBalanceBefore + 3 ether); + } +} + +contract Test_NativeTokenWrapper_BeforeTokenTransfer is NativeTokenWrapperTest { + function setUp() public override { + super.setUp(); + + // Mint tokens to distributor + vm.prank(alice); + wrapper.mint(address(mockDistributor), 10 ether); + } + + function test_Success_TransferFromDistributorSendsETH() public { + uint256 bobETHBefore = bob.balance; + uint256 wrapperETHBefore = address(wrapper).balance; + + vm.prank(address(mockDistributor)); + mockDistributor.simulateClaim(bob, 2 ether); + + // Bob should receive ETH + assertEq(bob.balance, bobETHBefore + 2 ether); + assertEq(address(wrapper).balance, wrapperETHBefore - 2 ether); + // Bob should not keep the wrapper tokens (burned in afterTokenTransfer) + assertEq(wrapper.balanceOf(bob), 0); + } + + function test_Success_TransferToFeeRecipientSendsETH() public { + // Mint tokens to alice + vm.prank(alice); + wrapper.mint(alice, 5 ether); + + uint256 feeRecipientETHBefore = address(mockFeeRecipient).balance; + uint256 wrapperETHBefore = address(wrapper).balance; + + vm.prank(alice); + wrapper.transfer(address(mockFeeRecipient), 1 ether); + + // Fee recipient should receive ETH + assertEq(address(mockFeeRecipient).balance, feeRecipientETHBefore + 1 ether); + assertEq(address(wrapper).balance, wrapperETHBefore - 1 ether); + assertEq(wrapper.balanceOf(address(mockFeeRecipient)), 0); + } + + function test_RevertWhen_RecipientCannotReceiveETH() public { + MockNonPayable nonPayable = new MockNonPayable(); + + vm.expectRevert(Errors.WithdrawalFailed.selector); + vm.prank(address(mockDistributor)); + wrapper.transfer(address(nonPayable), 1 ether); + } + + function test_Success_NormalTransferDoesNotSendETH() public { + // Mint to alice and bob (both allowed) + vm.prank(alice); + wrapper.mint(bob, 5 ether); + + uint256 charlieETHBefore = charlie.balance; + uint256 wrapperETHBefore = address(wrapper).balance; + + // Allow charlie to receive tokens + vm.prank(alice); + wrapper.toggleAllowance(charlie); + + vm.prank(bob); + wrapper.transfer(charlie, 2 ether); + + // Charlie should NOT receive ETH (only from distributor or to feeRecipient) + assertEq(charlie.balance, charlieETHBefore); + assertEq(address(wrapper).balance, wrapperETHBefore); + // Charlie should keep the tokens since he's allowed + assertEq(wrapper.balanceOf(charlie), 2 ether); + } +} + +contract Test_NativeTokenWrapper_AfterTokenTransfer is NativeTokenWrapperTest { + function test_Success_BurnsTokensForNonAllowedRecipient() public { + // Mint to alice + vm.prank(alice); + wrapper.mint(alice, 10 ether); + + // Transfer to bob who is not allowed + vm.prank(alice); + wrapper.transfer(bob, 5 ether); + + // Bob should have 0 tokens (burned in afterTokenTransfer) + assertEq(wrapper.balanceOf(bob), 0); + assertEq(wrapper.totalSupply(), 5 ether); // Only alice's remaining tokens + } + + function test_Success_KeepsTokensForAllowedRecipient() public { + // Allow charlie + vm.prank(alice); + wrapper.toggleAllowance(charlie); + + // Mint to alice + vm.prank(alice); + wrapper.mint(alice, 10 ether); + + // Transfer to charlie who is allowed + vm.prank(alice); + wrapper.transfer(charlie, 5 ether); + + // Charlie should keep the tokens + assertEq(wrapper.balanceOf(charlie), 5 ether); + assertEq(wrapper.totalSupply(), 10 ether); + } +} + +contract Test_NativeTokenWrapper_SetMinter is NativeTokenWrapperTest { + function test_RevertWhen_NotMinterOrGovernor() public { + vm.expectRevert(Errors.NotAllowed.selector); + vm.prank(bob); + wrapper.setMinter(charlie); + } + + function test_Success_Minter() public { + vm.prank(alice); + wrapper.setMinter(bob); + + assertEq(wrapper.minter(), bob); + assertEq(wrapper.isAllowed(alice), 0); // Old minter no longer allowed + assertEq(wrapper.isAllowed(bob), 1); // New minter is allowed + } + + function test_Success_Governor() public { + vm.prank(governor); + wrapper.setMinter(charlie); + + assertEq(wrapper.minter(), charlie); + assertEq(wrapper.isAllowed(alice), 0); + assertEq(wrapper.isAllowed(charlie), 1); + } +} + +contract Test_NativeTokenWrapper_ToggleAllowance is NativeTokenWrapperTest { + function test_RevertWhen_NotMinterOrGovernor() public { + vm.expectRevert(Errors.NotAllowed.selector); + vm.prank(bob); + wrapper.toggleAllowance(charlie); + } + + function test_Success_Minter() public { + assertEq(wrapper.isAllowed(bob), 0); + + vm.prank(alice); + wrapper.toggleAllowance(bob); + assertEq(wrapper.isAllowed(bob), 1); + + vm.prank(alice); + wrapper.toggleAllowance(bob); + assertEq(wrapper.isAllowed(bob), 0); + } + + function test_Success_Governor() public { + assertEq(wrapper.isAllowed(charlie), 0); + + vm.prank(governor); + wrapper.toggleAllowance(charlie); + assertEq(wrapper.isAllowed(charlie), 1); + + vm.prank(governor); + wrapper.toggleAllowance(charlie); + assertEq(wrapper.isAllowed(charlie), 0); + } +} + +contract Test_NativeTokenWrapper_Recover is NativeTokenWrapperTest { + function test_RevertWhen_NotMinterOrGovernor() public { + vm.expectRevert(Errors.NotAllowed.selector); + vm.prank(bob); + wrapper.recover(address(angle), bob, 1 ether); + } + + function test_Success_Minter() public { + // Send some tokens to wrapper + angle.mint(address(wrapper), 10 ether); + + uint256 bobBalanceBefore = angle.balanceOf(bob); + + vm.prank(alice); + wrapper.recover(address(angle), bob, 5 ether); + + assertEq(angle.balanceOf(bob), bobBalanceBefore + 5 ether); + assertEq(angle.balanceOf(address(wrapper)), 5 ether); + } + + function test_Success_Governor() public { + agEUR.mint(address(wrapper), 20 ether); + + uint256 charlieBalanceBefore = agEUR.balanceOf(charlie); + + vm.prank(governor); + wrapper.recover(address(agEUR), charlie, 10 ether); + + assertEq(agEUR.balanceOf(charlie), charlieBalanceBefore + 10 ether); + assertEq(agEUR.balanceOf(address(wrapper)), 10 ether); + } +} + +contract Test_NativeTokenWrapper_RecoverETH is NativeTokenWrapperTest { + function test_RevertWhen_NotMinterOrGovernor() public { + vm.expectRevert(Errors.NotAllowed.selector); + vm.prank(bob); + wrapper.recoverETH(payable(bob), 1 ether); + } + + function test_Success_Minter() public { + uint256 bobBalanceBefore = bob.balance; + uint256 wrapperBalanceBefore = address(wrapper).balance; + + vm.prank(alice); + wrapper.recoverETH(payable(bob), 5 ether); + + assertEq(bob.balance, bobBalanceBefore + 5 ether); + assertEq(address(wrapper).balance, wrapperBalanceBefore - 5 ether); + } + + function test_Success_Governor() public { + uint256 charlieBalanceBefore = charlie.balance; + uint256 wrapperBalanceBefore = address(wrapper).balance; + + vm.prank(governor); + wrapper.recoverETH(payable(charlie), 10 ether); + + assertEq(charlie.balance, charlieBalanceBefore + 10 ether); + assertEq(address(wrapper).balance, wrapperBalanceBefore - 10 ether); + } + + function test_RevertWhen_RecipientCannotReceiveETH() public { + MockNonPayable nonPayable = new MockNonPayable(); + + vm.expectRevert(Errors.WithdrawalFailed.selector); + vm.prank(alice); + wrapper.recoverETH(payable(address(nonPayable)), 1 ether); + } +} + +contract Test_NativeTokenWrapper_SetFeeRecipient is NativeTokenWrapperTest { + function test_Success() public { + address newFeeRecipient = vm.addr(999); + + vm.mockCall(address(creator), abi.encodeWithSignature("feeRecipient()"), abi.encode(newFeeRecipient)); + + wrapper.setFeeRecipient(); + + assertEq(wrapper.feeRecipient(), newFeeRecipient); + } +} + +contract Test_NativeTokenWrapper_Decimals is NativeTokenWrapperTest { + function test_Success_Returns18() public { + assertEq(wrapper.decimals(), 18); + } +} + +contract Test_NativeTokenWrapper_Integration is NativeTokenWrapperTest { + function test_Integration_CompleteFlow() public { + // 1. User sends ETH and mints wrapper tokens + vm.prank(alice); + wrapper.toggleAllowance(bob); + + vm.deal(bob, 10 ether); + vm.prank(bob); + wrapper.mintWithETH{ value: 5 ether }(); + + assertEq(wrapper.balanceOf(bob), 5 ether); + assertEq(address(wrapper).balance, 105 ether); // 100 initial + 5 minted + + // 2. Bob creates a campaign by transferring to distributor + vm.prank(bob); + wrapper.transfer(address(mockDistributor), 3 ether); + + assertEq(wrapper.balanceOf(address(mockDistributor)), 3 ether); + assertEq(wrapper.balanceOf(bob), 2 ether); + + // 3. Distributor distributes rewards (sends native ETH to recipient) + uint256 charlieETHBefore = charlie.balance; + + vm.prank(address(mockDistributor)); + wrapper.transfer(charlie, 2 ether); + + // Charlie receives ETH (not wrapper tokens, they get burned) + assertEq(charlie.balance, charlieETHBefore + 2 ether); + assertEq(wrapper.balanceOf(charlie), 0); + assertEq(address(wrapper).balance, 103 ether); // 105 - 2 sent to charlie + + // 4. Check remaining distributor balance + assertEq(wrapper.balanceOf(address(mockDistributor)), 1 ether); + } +} From 15020174a22b592df52cb340d506cfe7a2211db1 Mon Sep 17 00:00:00 2001 From: Pablo Veyrat Date: Wed, 12 Nov 2025 09:21:44 +0100 Subject: [PATCH 21/27] fix: naming --- .../partners/tokenWrappers/NativeTokenWrapper.sol | 2 +- .../partners/tokenWrappers/NativeTokenWrapper.t.sol | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/contracts/partners/tokenWrappers/NativeTokenWrapper.sol b/contracts/partners/tokenWrappers/NativeTokenWrapper.sol index 1df53449..1c3eecfa 100644 --- a/contracts/partners/tokenWrappers/NativeTokenWrapper.sol +++ b/contracts/partners/tokenWrappers/NativeTokenWrapper.sol @@ -95,7 +95,7 @@ contract NativeTokenWrapper is UUPSHelper, ERC20Upgradeable { _mint(recipient, amount); } - function mintWithETH() external payable { + function mintWithNative() external payable { if (isAllowed[msg.sender] == 0) revert Errors.NotAllowed(); uint256 amount = msg.value; _mint(msg.sender, amount); diff --git a/test/unit/partners/tokenWrappers/NativeTokenWrapper.t.sol b/test/unit/partners/tokenWrappers/NativeTokenWrapper.t.sol index 863270bf..a5a7c9f7 100644 --- a/test/unit/partners/tokenWrappers/NativeTokenWrapper.t.sol +++ b/test/unit/partners/tokenWrappers/NativeTokenWrapper.t.sol @@ -153,13 +153,13 @@ contract Test_NativeTokenWrapper_Mint is NativeTokenWrapperTest { } } -contract Test_NativeTokenWrapper_MintWithETH is NativeTokenWrapperTest { +contract Test_NativeTokenWrapper_mintWithNative is NativeTokenWrapperTest { function test_RevertWhen_NotAllowed() public { vm.deal(bob, 10 ether); vm.expectRevert(Errors.NotAllowed.selector); vm.prank(bob); - wrapper.mintWithETH{ value: 5 ether }(); + wrapper.mintWithNative{ value: 5 ether }(); } function test_Success_AllowedAddress() public { @@ -171,7 +171,7 @@ contract Test_NativeTokenWrapper_MintWithETH is NativeTokenWrapperTest { uint256 wrapperBalanceBefore = address(wrapper).balance; vm.prank(bob); - wrapper.mintWithETH{ value: 5 ether }(); + wrapper.mintWithNative{ value: 5 ether }(); assertEq(wrapper.balanceOf(bob), 5 ether); assertEq(address(wrapper).balance, wrapperBalanceBefore + 5 ether); @@ -182,7 +182,7 @@ contract Test_NativeTokenWrapper_MintWithETH is NativeTokenWrapperTest { uint256 wrapperBalanceBefore = address(wrapper).balance; vm.prank(alice); - wrapper.mintWithETH{ value: 3 ether }(); + wrapper.mintWithNative{ value: 3 ether }(); assertEq(wrapper.balanceOf(alice), 3 ether); assertEq(address(wrapper).balance, wrapperBalanceBefore + 3 ether); @@ -449,7 +449,7 @@ contract Test_NativeTokenWrapper_Integration is NativeTokenWrapperTest { vm.deal(bob, 10 ether); vm.prank(bob); - wrapper.mintWithETH{ value: 5 ether }(); + wrapper.mintWithNative{ value: 5 ether }(); assertEq(wrapper.balanceOf(bob), 5 ether); assertEq(address(wrapper).balance, 105 ether); // 100 initial + 5 minted From 1a3b32c946a23712f237d32f21f1f73cfc296c6d Mon Sep 17 00:00:00 2001 From: Pablo Veyrat Date: Wed, 12 Nov 2025 09:51:39 +0100 Subject: [PATCH 22/27] pull token wrapper tests --- .../tokenWrappers/AaveTokenWrapper.sol | 130 ----- .../tokenWrappers/BaseTokenWrapper.sol | 69 --- .../tokenWrappers/BobTokenWrapper.sol | 142 ----- .../tokenWrappers/EtherealWrapper.sol | 143 ----- .../partners/tokenWrappers/StakedToken.sol | 83 --- .../tokenWrappers/PullTokenWrapper.t.sol | 511 ++++++++++++++++++ 6 files changed, 511 insertions(+), 567 deletions(-) delete mode 100644 contracts/partners/tokenWrappers/AaveTokenWrapper.sol delete mode 100644 contracts/partners/tokenWrappers/BaseTokenWrapper.sol delete mode 100644 contracts/partners/tokenWrappers/BobTokenWrapper.sol delete mode 100644 contracts/partners/tokenWrappers/EtherealWrapper.sol delete mode 100644 contracts/partners/tokenWrappers/StakedToken.sol create mode 100644 test/unit/partners/tokenWrappers/PullTokenWrapper.t.sol diff --git a/contracts/partners/tokenWrappers/AaveTokenWrapper.sol b/contracts/partners/tokenWrappers/AaveTokenWrapper.sol deleted file mode 100644 index 6d0376e2..00000000 --- a/contracts/partners/tokenWrappers/AaveTokenWrapper.sol +++ /dev/null @@ -1,130 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0 - -pragma solidity ^0.8.17; - -import { ERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; -import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import { IERC20 } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; - -import { DistributionCreator } from "../../DistributionCreator.sol"; -import { UUPSHelper } from "../../utils/UUPSHelper.sol"; -import { IAccessControlManager } from "../../interfaces/IAccessControlManager.sol"; -import { Errors } from "../../utils/Errors.sol"; - -contract AaveTokenWrapper is UUPSHelper, ERC20Upgradeable { - using SafeERC20 for IERC20; - - // ================================= VARIABLES ================================= - - /// @notice `AccessControlManager` contract handling access control - IAccessControlManager public accessControlManager; - - // could be put as immutable in non upgradeable contract - address public token; - address public distributor; - address public distributionCreator; - - mapping(address => uint256) public isMasterClaimer; - mapping(address => address) public delegateReceiver; - mapping(address => uint256) public permissionlessClaim; - - // =================================== EVENTS ================================== - - event Recovered(address indexed token, address indexed to, uint256 amount); - - // ================================= MODIFIERS ================================= - - /// @notice Checks whether the `msg.sender` has the governor role or the guardian role - modifier onlyGovernor() { - if (!accessControlManager.isGovernor(msg.sender)) revert Errors.NotGovernor(); - _; - } - - // ================================= FUNCTIONS ================================= - - function initialize( - address underlyingToken, - address _distributor, - address _accessControlManager, - address _distributionCreator - ) public initializer { - // TODO could fetch name and symbol based on real token - __ERC20_init("AaveTokenWrapper", "ATW"); - __UUPSUpgradeable_init(); - if (underlyingToken == address(0) || _distributor == address(0) || _distributionCreator == address(0)) - revert Errors.ZeroAddress(); - IAccessControlManager(_accessControlManager).isGovernor(msg.sender); - token = underlyingToken; - distributor = _distributor; - distributionCreator = _distributionCreator; - accessControlManager = IAccessControlManager(_accessControlManager); - } - - function _beforeTokenTransfer(address from, address to, uint256 amount) internal override { - // Needs an approval before hand, this is how mints are done - if (to == distributor) { - IERC20(token).safeTransferFrom(from, address(this), amount); - _mint(from, amount); // These are then transferred to the distributor - } else { - if (to == _getFeeRecipient()) { - IERC20(token).safeTransferFrom(from, to, amount); - _mint(from, amount); - } - } - } - - function _afterTokenTransfer(address from, address to, uint256 amount) internal override { - if (from == address(distributor)) { - if (tx.origin == to || permissionlessClaim[to] == 1 || isMasterClaimer[tx.origin] == 1) { - _handleClaim(to, amount); - } else if (allowance(to, tx.origin) > amount) { - _spendAllowance(to, tx.origin, amount); - _handleClaim(to, amount); - } else { - revert Errors.InvalidClaim(); - } - } else if (to == _getFeeRecipient()) { - // To avoid having any token aside from the distributor - _burn(to, amount); - } - } - - function _handleClaim(address to, uint256 amount) internal { - address delegate = delegateReceiver[to]; - _burn(to, amount); - if (delegate == address(0) || delegate == to) { - IERC20(token).safeTransfer(to, amount); - } else { - IERC20(token).safeTransfer(delegate, amount); - } - } - - function _getFeeRecipient() internal view returns (address feeRecipient) { - address _distributionCreator = distributionCreator; - feeRecipient = DistributionCreator(_distributionCreator).feeRecipient(); - feeRecipient = feeRecipient == address(0) ? _distributionCreator : feeRecipient; - } - - /// @notice Recovers any ERC20 token - function recoverERC20(address tokenAddress, address to, uint256 amountToRecover) external onlyGovernor { - IERC20(tokenAddress).safeTransfer(to, amountToRecover); - emit Recovered(tokenAddress, to, amountToRecover); - } - - function toggleMasterClaimer(address claimer) external onlyGovernor { - uint256 claimStatus = 1 - isMasterClaimer[claimer]; - isMasterClaimer[claimer] = claimStatus; - } - - function togglePermissionlessClaim() external { - uint256 permission = 1 - permissionlessClaim[msg.sender]; - permissionlessClaim[msg.sender] = permission; - } - - function updateDelegateReceiver(address receiver) external { - delegateReceiver[msg.sender] = receiver; - } - - /// @inheritdoc UUPSHelper - function _authorizeUpgrade(address) internal view override onlyGovernorUpgrader(accessControlManager) {} -} diff --git a/contracts/partners/tokenWrappers/BaseTokenWrapper.sol b/contracts/partners/tokenWrappers/BaseTokenWrapper.sol deleted file mode 100644 index 0b3c5d33..00000000 --- a/contracts/partners/tokenWrappers/BaseTokenWrapper.sol +++ /dev/null @@ -1,69 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0 - -pragma solidity ^0.8.17; - -import { ERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; -import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import { IERC20, IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; - -import { UUPSHelper } from "../../utils/UUPSHelper.sol"; -import { IAccessControlManager } from "../../interfaces/IAccessControlManager.sol"; -import { Errors } from "../../utils/Errors.sol"; -import { DistributionCreator } from "../../DistributionCreator.sol"; - -abstract contract BaseMerklTokenWrapper is UUPSHelper, ERC20Upgradeable { - using SafeERC20 for IERC20; - - // ================================= CONSTANTS ================================= - - DistributionCreator public constant DISTRIBUTOR_CREATOR = - DistributionCreator(0x8BB4C975Ff3c250e0ceEA271728547f3802B36Fd); - - address public immutable DISTRIBUTOR = DISTRIBUTOR_CREATOR.distributor(); - address public immutable FEE_RECIPIENT = DISTRIBUTOR_CREATOR.feeRecipient(); - - // ================================= VARIABLES ================================= - - /// @notice `AccessControlManager` contract handling access control - IAccessControlManager public accessControlManager; - - // =================================== EVENTS ================================== - - event Recovered(address indexed token, address indexed to, uint256 amount); - - // ================================= MODIFIERS ================================= - - /// @notice Checks whether the `msg.sender` has the governor role or the guardian role - modifier onlyGovernor() { - if (!accessControlManager.isGovernor(msg.sender)) revert Errors.NotGovernor(); - _; - } - - // ================================= FUNCTIONS ================================= - - function token() public view virtual returns (address); - - function isTokenWrapper() external pure returns (bool) { - return true; - } - - function initialize(IAccessControlManager _accessControlManager) public initializer onlyProxy { - __ERC20_init( - string.concat("Merkl Token Wrapper - ", IERC20Metadata(token()).name()), - string.concat("mtw", IERC20Metadata(token()).symbol()) - ); - __UUPSUpgradeable_init(); - if (address(_accessControlManager) == address(0)) revert Errors.ZeroAddress(); - accessControlManager = _accessControlManager; - } - - /// @notice Recovers any ERC20 token - /// @dev Governance only, to trigger only if something went wrong - function recoverERC20(address tokenAddress, address to, uint256 amountToRecover) external onlyGovernor { - IERC20(tokenAddress).safeTransfer(to, amountToRecover); - emit Recovered(tokenAddress, to, amountToRecover); - } - - /// @inheritdoc UUPSHelper - function _authorizeUpgrade(address) internal view override onlyGovernorUpgrader(accessControlManager) {} -} diff --git a/contracts/partners/tokenWrappers/BobTokenWrapper.sol b/contracts/partners/tokenWrappers/BobTokenWrapper.sol deleted file mode 100644 index c9c33b11..00000000 --- a/contracts/partners/tokenWrappers/BobTokenWrapper.sol +++ /dev/null @@ -1,142 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0 - -pragma solidity ^0.8.17; - -import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import { ERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; -import { IERC20, IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; - -import { IAccessControlManager } from "./BaseTokenWrapper.sol"; - -import { UUPSHelper } from "../../utils/UUPSHelper.sol"; -import { Errors } from "../../utils/Errors.sol"; -import { DistributionCreator } from "../../DistributionCreator.sol"; - -interface IStaker { - function stake(uint256 _amount, address receiver) external; -} - -/// @title BobTokenWrapper -/// @dev This token can only be held by Merkl distributor -/// @dev Transferring to the distributor will require transferring the underlying token to this contract -contract BobTokenWrapper is UUPSHelper, ERC20Upgradeable { - using SafeERC20 for IERC20; - - /*////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - VARIABLES - //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ - /// @notice `accessControlManager` contract handling access control - IAccessControlManager public accessControlManager; - /// @notice Merkl main functions - address public distributor; - address public feeRecipient; - address public distributionCreator; - address public staker; - - /// @notice Underlying token used - address public underlying; - - event Recovered(address indexed token, address indexed to, uint256 amount); - event MerklAddressesUpdated(address indexed _distributionCreator, address indexed _distributor); - event CliffDurationUpdated(uint32 _newCliffDuration); - event FeeRecipientUpdated(address indexed _feeRecipient); - - // ================================= FUNCTIONS ================================= - - function initialize( - address _underlying, - IAccessControlManager _accessControlManager, - address _distributionCreator, - address _staker - ) public initializer { - __ERC20_init( - string.concat("Merkl Token Wrapper - ", IERC20Metadata(_underlying).name()), - string.concat("mtw", IERC20Metadata(_underlying).symbol()) - ); - __UUPSUpgradeable_init(); - if (address(_accessControlManager) == address(0) || _staker == address(0)) revert Errors.ZeroAddress(); - underlying = _underlying; - accessControlManager = _accessControlManager; - distributionCreator = _distributionCreator; - staker = _staker; - distributor = DistributionCreator(_distributionCreator).distributor(); - feeRecipient = DistributionCreator(_distributionCreator).feeRecipient(); - IERC20(underlying).safeApprove(_staker, type(uint256).max); - } - - function isTokenWrapper() external pure returns (bool) { - return true; - } - - function token() public view returns (address) { - return underlying; - } - - function _beforeTokenTransfer(address from, address to, uint256 amount) internal override { - // Needs an underlying approval beforehand, this is how mints of wrappers are done - if (to == distributor) { - IERC20(underlying).safeTransferFrom(from, address(this), amount); - _mint(from, amount); // These are then transferred to the distributor - } - - // Will be burnt right after, to avoid having any token aside from on the distributor - if (to == feeRecipient) { - IERC20(underlying).safeTransferFrom(from, feeRecipient, amount); - _mint(from, amount); // These are then transferred to the fee manager - } - } - - function _afterTokenTransfer(address from, address to, uint256 amount) internal override { - if (to == feeRecipient) { - _burn(to, amount); - } - - if (from == address(distributor)) { - _burn(to, amount); - IStaker(staker).stake(amount, to); - } - } - - /*////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - ADMIN FUNCTIONS - //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ - - /// @notice Checks whether the `msg.sender` has the governor role or the guardian role - modifier onlyGovernor() { - if (!accessControlManager.isGovernor(msg.sender)) revert Errors.NotGovernor(); - _; - } - - /// @notice Checks whether the `msg.sender` has the governor role or the guardian role - modifier onlyGuardian() { - if (!accessControlManager.isGovernorOrGuardian(msg.sender)) revert Errors.NotGovernorOrGuardian(); - _; - } - - function _authorizeUpgrade(address) internal view override onlyGovernorUpgrader(accessControlManager) {} - - /// @notice Recovers any ERC20 token - /// @dev Governance only, to trigger only if something went wrong - function recoverERC20(address tokenAddress, address to, uint256 amountToRecover) external onlyGovernor { - IERC20(tokenAddress).safeTransfer(to, amountToRecover); - emit Recovered(tokenAddress, to, amountToRecover); - } - - function setDistributor(address _distributionCreator) external onlyGovernor { - address _distributor = DistributionCreator(_distributionCreator).distributor(); - distributor = _distributor; - distributionCreator = _distributionCreator; - emit MerklAddressesUpdated(_distributionCreator, _distributor); - _setFeeRecipient(); - } - - function setFeeRecipient() external { - _setFeeRecipient(); - } - - function _setFeeRecipient() internal { - address _feeRecipient = DistributionCreator(distributionCreator).feeRecipient(); - feeRecipient = _feeRecipient; - emit FeeRecipientUpdated(_feeRecipient); - } -} diff --git a/contracts/partners/tokenWrappers/EtherealWrapper.sol b/contracts/partners/tokenWrappers/EtherealWrapper.sol deleted file mode 100644 index 319f3b9b..00000000 --- a/contracts/partners/tokenWrappers/EtherealWrapper.sol +++ /dev/null @@ -1,143 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0 - -pragma solidity ^0.8.17; - -import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import { ERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; -import { IERC20, IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; - -import { IAccessControlManager } from "./BaseTokenWrapper.sol"; - -import { UUPSHelper } from "../../utils/UUPSHelper.sol"; -import { Errors } from "../../utils/Errors.sol"; -import { DistributionCreator } from "../../DistributionCreator.sol"; - -interface IEtherealExchange { - function depositOnBehalf(uint256 _amount, address receiver) external; -} - -/// @title EtherealWrapper -/// @dev This token can only be held by Merkl distributor -/// @dev Transferring to the distributor will require transferring the underlying token to this contract -contract EtherealWrapper is UUPSHelper, ERC20Upgradeable { - using SafeERC20 for IERC20; - - /*////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - VARIABLES - //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ - /// @notice `accessControlManager` contract handling access control - IAccessControlManager public accessControlManager; - /// @notice Merkl main functions - address public distributor; - address public feeRecipient; - address public distributionCreator; - address public etherealExchange; - - /// @notice Underlying token used - address public underlying; - - event Recovered(address indexed token, address indexed to, uint256 amount); - event MerklAddressesUpdated(address indexed _distributionCreator, address indexed _distributor); - event CliffDurationUpdated(uint32 _newCliffDuration); - event FeeRecipientUpdated(address indexed _feeRecipient); - - // ================================= FUNCTIONS ================================= - - function initialize( - address _underlying, - IAccessControlManager _accessControlManager, - address _distributionCreator, - address _etherealExchange - ) public initializer { - __ERC20_init( - string.concat("Merkl Token Wrapper - ", IERC20Metadata(_underlying).name()), - string.concat("mtw", IERC20Metadata(_underlying).symbol()) - ); - __UUPSUpgradeable_init(); - if (address(_accessControlManager) == address(0) || _etherealExchange == address(0)) - revert Errors.ZeroAddress(); - underlying = _underlying; - accessControlManager = _accessControlManager; - distributionCreator = _distributionCreator; - etherealExchange = _etherealExchange; - distributor = DistributionCreator(_distributionCreator).distributor(); - feeRecipient = DistributionCreator(_distributionCreator).feeRecipient(); - IERC20(underlying).safeApprove(_etherealExchange, type(uint256).max); - } - - function isTokenWrapper() external pure returns (bool) { - return true; - } - - function token() public view returns (address) { - return underlying; - } - - function _beforeTokenTransfer(address from, address to, uint256 amount) internal override { - // Needs an underlying approval beforehand, this is how mints of wrappers are done - if (to == distributor) { - IERC20(underlying).safeTransferFrom(from, address(this), amount); - _mint(from, amount); // These are then transferred to the distributor - } - - // Will be burnt right after, to avoid having any token aside from on the distributor - if (to == feeRecipient) { - IERC20(underlying).safeTransferFrom(from, feeRecipient, amount); - _mint(from, amount); // These are then transferred to the fee manager - } - } - - function _afterTokenTransfer(address from, address to, uint256 amount) internal override { - if (to == feeRecipient) { - _burn(to, amount); - } - - if (from == address(distributor)) { - _burn(to, amount); - IEtherealExchange(etherealExchange).depositOnBehalf(amount, to); - } - } - - /*////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - ADMIN FUNCTIONS - //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ - - /// @notice Checks whether the `msg.sender` has the governor role or the guardian role - modifier onlyGovernor() { - if (!accessControlManager.isGovernor(msg.sender)) revert Errors.NotGovernor(); - _; - } - - /// @notice Checks whether the `msg.sender` has the governor role or the guardian role - modifier onlyGuardian() { - if (!accessControlManager.isGovernorOrGuardian(msg.sender)) revert Errors.NotGovernorOrGuardian(); - _; - } - - function _authorizeUpgrade(address) internal view override onlyGovernorUpgrader(accessControlManager) {} - - /// @notice Recovers any ERC20 token - /// @dev Governance only, to trigger only if something went wrong - function recoverERC20(address tokenAddress, address to, uint256 amountToRecover) external onlyGovernor { - IERC20(tokenAddress).safeTransfer(to, amountToRecover); - emit Recovered(tokenAddress, to, amountToRecover); - } - - function setDistributor(address _distributionCreator) external onlyGovernor { - address _distributor = DistributionCreator(_distributionCreator).distributor(); - distributor = _distributor; - distributionCreator = _distributionCreator; - emit MerklAddressesUpdated(_distributionCreator, _distributor); - _setFeeRecipient(); - } - - function setFeeRecipient() external { - _setFeeRecipient(); - } - - function _setFeeRecipient() internal { - address _feeRecipient = DistributionCreator(distributionCreator).feeRecipient(); - feeRecipient = _feeRecipient; - emit FeeRecipientUpdated(_feeRecipient); - } -} diff --git a/contracts/partners/tokenWrappers/StakedToken.sol b/contracts/partners/tokenWrappers/StakedToken.sol deleted file mode 100644 index d1d3a6f1..00000000 --- a/contracts/partners/tokenWrappers/StakedToken.sol +++ /dev/null @@ -1,83 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0 - -pragma solidity ^0.8.17; - -import { IERC20 } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; -import { ERC4626, ERC20 } from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; - -// Cooldown logic forked from: https://github.com/aave/aave-stake-v2/blob/master/contracts/stake/StakedTokenV3.sol -contract StakedToken is ERC4626 { - uint256 public immutable COOLDOWN_SECONDS; - uint256 public immutable UNSTAKE_WINDOW; - - mapping(address => uint256) public stakerCooldown; - - error InsufficientCooldown(); - error InvalidBalanceOnCooldown(); - error UnstakeWindowFinished(); - - event Cooldown(address indexed sender, uint256 timestamp); - - // ================================= FUNCTIONS ================================= - - constructor( - IERC20 asset_, - string memory name_, - string memory symbol_, - uint256 cooldownSeconds, - uint256 unstakeWindow - ) ERC4626(asset_) ERC20(name_, symbol_) { - COOLDOWN_SECONDS = cooldownSeconds; - UNSTAKE_WINDOW = unstakeWindow; - } - - function _beforeTokenTransfer(address from, address to, uint256 amount) internal override { - if (from == address(0)) { - // For a mint: we update the cooldown of the receiver if needed - stakerCooldown[to] = getNextCooldownTimestamp(0, amount, to, balanceOf(to)); - } else if (to == address(0)) { - uint256 cooldownEndTimestamp = stakerCooldown[from] + COOLDOWN_SECONDS; - if (block.timestamp <= cooldownEndTimestamp) revert InsufficientCooldown(); - if (block.timestamp > cooldownEndTimestamp + UNSTAKE_WINDOW) revert UnstakeWindowFinished(); - } else if (from != to) { - uint256 previousSenderCooldown = stakerCooldown[from]; - stakerCooldown[to] = getNextCooldownTimestamp(previousSenderCooldown, amount, to, balanceOf(to)); - // if cooldown was set and whole balance of sender was transferred - clear cooldown - if (balanceOf(from) == amount && previousSenderCooldown != 0) { - stakerCooldown[from] = 0; - } - } - } - - function getNextCooldownTimestamp( - uint256 fromCooldownTimestamp, - uint256 amountToReceive, - address toAddress, - uint256 toBalance - ) public view returns (uint256 toCooldownTimestamp) { - toCooldownTimestamp = stakerCooldown[toAddress]; - if (toCooldownTimestamp == 0) return 0; - - uint256 minimalValidCooldownTimestamp = block.timestamp - COOLDOWN_SECONDS - UNSTAKE_WINDOW; - - if (minimalValidCooldownTimestamp > toCooldownTimestamp) { - toCooldownTimestamp = 0; - } else { - fromCooldownTimestamp = (minimalValidCooldownTimestamp > fromCooldownTimestamp) - ? block.timestamp - : fromCooldownTimestamp; - - if (fromCooldownTimestamp >= toCooldownTimestamp) { - toCooldownTimestamp = - (amountToReceive * fromCooldownTimestamp + toBalance * toCooldownTimestamp) / - (amountToReceive + toBalance); - } - } - } - - function cooldown() external { - if (balanceOf(msg.sender) != 0) revert InvalidBalanceOnCooldown(); - stakerCooldown[msg.sender] = block.timestamp; - emit Cooldown(msg.sender, block.timestamp); - } -} diff --git a/test/unit/partners/tokenWrappers/PullTokenWrapper.t.sol b/test/unit/partners/tokenWrappers/PullTokenWrapper.t.sol new file mode 100644 index 00000000..00aacc16 --- /dev/null +++ b/test/unit/partners/tokenWrappers/PullTokenWrapper.t.sol @@ -0,0 +1,511 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.17; + +import { Test } from "forge-std/Test.sol"; +import { console } from "forge-std/console.sol"; + +import { PullTokenWrapper } from "../../../../contracts/partners/tokenWrappers/PullTokenWrapper.sol"; +import { Fixture } from "../../../Fixture.t.sol"; +import { IAccessControlManager } from "../../../../contracts/interfaces/IAccessControlManager.sol"; +import { Errors } from "../../../../contracts/utils/Errors.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/// @dev Mock contract to simulate the Distributor +contract MockDistributor { + PullTokenWrapper public wrapper; + + function setWrapper(address _wrapper) external { + wrapper = PullTokenWrapper(_wrapper); + } + + /// @dev Simulates a transfer from distributor (e.g., during claim) + function simulateClaim(address to, uint256 amount) external { + wrapper.transfer(to, amount); + } +} + +/// @dev Mock contract to simulate fee recipient +contract MockFeeRecipient { + // Empty contract for fee recipient +} + +contract PullTokenWrapperTest is Fixture { + PullTokenWrapper public wrapper; + PullTokenWrapper public wrapperImpl; + MockDistributor public mockDistributor; + MockFeeRecipient public mockFeeRecipient; + + function setUp() public virtual override { + super.setUp(); + + // Deploy mock contracts + mockDistributor = new MockDistributor(); + mockFeeRecipient = new MockFeeRecipient(); + + // Deploy PullTokenWrapper implementation + wrapperImpl = new PullTokenWrapper(); + wrapper = PullTokenWrapper(deployUUPS(address(wrapperImpl), hex"")); + + // Mock the creator to return our mock distributor and fee recipient + vm.mockCall(address(creator), abi.encodeWithSignature("distributor()"), abi.encode(address(mockDistributor))); + vm.mockCall(address(creator), abi.encodeWithSignature("feeRecipient()"), abi.encode(address(mockFeeRecipient))); + + // Initialize the wrapper with angle token and alice as holder + wrapper.initialize(address(angle), address(creator), alice, "Wrapped ANGLE", "wANGLE"); + + // Set wrapper in mock distributor + mockDistributor.setWrapper(address(wrapper)); + + // Mint tokens to alice (the holder) + angle.mint(alice, 1000 ether); + + // Approve wrapper to pull tokens from alice + vm.prank(alice); + angle.approve(address(wrapper), type(uint256).max); + } +} + +contract Test_PullTokenWrapper_Initialize is PullTokenWrapperTest { + PullTokenWrapper w; + + function setUp() public override { + super.setUp(); + w = PullTokenWrapper(deployUUPS(address(new PullTokenWrapper()), hex"")); + } + + function test_RevertWhen_CalledOnImplem() public { + vm.expectRevert("Initializable: contract is already initialized"); + wrapperImpl.initialize(address(0), address(0), address(0), "", ""); + } + + function test_RevertWhen_ZeroAddress() public { + vm.expectRevert(Errors.ZeroAddress.selector); + w.initialize(address(angle), address(creator), address(0), "Test", "TEST"); + } + + function test_Success() public { + w.initialize(address(angle), address(creator), alice, "Test Token", "TEST"); + + assertEq(w.name(), "Test Token"); + assertEq(w.symbol(), "TEST"); + assertEq(w.holder(), alice); + assertEq(w.token(), address(angle)); + assertEq(address(w.accessControlManager()), address(accessControlManager)); + assertEq(w.distributor(), address(mockDistributor)); + assertEq(w.distributionCreator(), address(creator)); + assertEq(w.decimals(), angle.decimals()); + assertEq(w.feeRecipient(), address(mockFeeRecipient)); + } +} + +contract Test_PullTokenWrapper_Mint is PullTokenWrapperTest { + function test_RevertWhen_NotHolderOrGovernor() public { + vm.expectRevert(Errors.NotAllowed.selector); + vm.prank(bob); + wrapper.mint(100 ether); + } + + function test_Success_Holder() public { + vm.prank(alice); + wrapper.mint(50 ether); + + assertEq(wrapper.balanceOf(alice), 50 ether); + assertEq(wrapper.totalSupply(), 50 ether); + } + + function test_Success_Governor() public { + vm.prank(governor); + wrapper.mint(100 ether); + + assertEq(wrapper.balanceOf(alice), 100 ether); + assertEq(wrapper.totalSupply(), 100 ether); + } + + function test_Success_MultipleMints() public { + vm.prank(alice); + wrapper.mint(50 ether); + + vm.prank(governor); + wrapper.mint(30 ether); + + assertEq(wrapper.balanceOf(alice), 80 ether); + assertEq(wrapper.totalSupply(), 80 ether); + } +} + +contract Test_PullTokenWrapper_SetHolder is PullTokenWrapperTest { + function test_RevertWhen_NotHolderOrGovernor() public { + vm.expectRevert(Errors.NotAllowed.selector); + vm.prank(bob); + wrapper.setHolder(charlie); + } + + function test_Success_Holder() public { + vm.prank(alice); + wrapper.setHolder(bob); + + assertEq(wrapper.holder(), bob); + } + + function test_Success_Governor() public { + vm.prank(governor); + wrapper.setHolder(charlie); + + assertEq(wrapper.holder(), charlie); + } + + function test_Success_NewHolderCanMint() public { + // Change holder to bob + vm.prank(alice); + wrapper.setHolder(bob); + + // Bob should now be able to mint + vm.prank(bob); + wrapper.mint(25 ether); + + assertEq(wrapper.balanceOf(bob), 25 ether); + } +} + +contract Test_PullTokenWrapper_BeforeTokenTransfer is PullTokenWrapperTest { + function setUp() public override { + super.setUp(); + + // Mint wrapper tokens to holder and transfer to distributor + vm.prank(alice); + wrapper.mint(100 ether); + + vm.prank(alice); + wrapper.transfer(address(mockDistributor), 100 ether); + } + + function test_Success_TransferFromDistributorPullsTokens() public { + uint256 bobBalanceBefore = angle.balanceOf(bob); + uint256 aliceBalanceBefore = angle.balanceOf(alice); + + vm.prank(address(mockDistributor)); + mockDistributor.simulateClaim(bob, 20 ether); + + // Bob should receive the underlying tokens (pulled from alice) + assertEq(angle.balanceOf(bob), bobBalanceBefore + 20 ether); + // Alice should have tokens deducted + assertEq(angle.balanceOf(alice), aliceBalanceBefore - 20 ether); + // Bob should not keep wrapper tokens (burned in afterTokenTransfer) + assertEq(wrapper.balanceOf(bob), 0); + } + + function test_Success_TransferToFeeRecipientPullsTokens() public { + uint256 feeRecipientBalanceBefore = angle.balanceOf(address(mockFeeRecipient)); + uint256 aliceBalanceBefore = angle.balanceOf(alice); + + vm.prank(address(mockDistributor)); + wrapper.transfer(address(mockFeeRecipient), 10 ether); + + // Fee recipient should receive underlying tokens + assertEq(angle.balanceOf(address(mockFeeRecipient)), feeRecipientBalanceBefore + 10 ether); + // Alice should have tokens deducted + assertEq(angle.balanceOf(alice), aliceBalanceBefore - 10 ether); + // Fee recipient should not keep wrapper tokens + assertEq(wrapper.balanceOf(address(mockFeeRecipient)), 0); + } + + function test_Success_NormalTransferDoesNotPullTokens() public { + uint256 charlieAngleBalanceBefore = angle.balanceOf(charlie); + uint256 aliceAngleBalanceBefore = angle.balanceOf(alice); + + // Mint to bob + vm.prank(alice); + wrapper.mint(50 ether); + vm.prank(alice); + wrapper.transfer(bob, 50 ether); + + // Bob transfers to charlie (not from distributor or to fee recipient) + vm.prank(bob); + wrapper.transfer(charlie, 30 ether); + + // Charlie should NOT receive underlying tokens from alice + assertEq(angle.balanceOf(charlie), charlieAngleBalanceBefore); + // Alice's balance should remain unchanged for this transfer + assertEq(angle.balanceOf(alice), aliceAngleBalanceBefore); + // Charlie should not keep wrapper tokens (burned in afterTokenTransfer) + assertEq(wrapper.balanceOf(charlie), 0); + } + + function test_RevertWhen_HolderHasInsufficientTokens() public { + // Deplete alice's angle balance + vm.prank(alice); + angle.transfer(address(1), angle.balanceOf(alice)); + + vm.expectRevert("ERC20: insufficient allowance"); + vm.prank(address(mockDistributor)); + wrapper.transfer(bob, 10 ether); + } + + function test_RevertWhen_HolderHasNotApproved() public { + // Remove approval + vm.prank(alice); + angle.approve(address(wrapper), 0); + + vm.expectRevert("ERC20: insufficient allowance"); + vm.prank(address(mockDistributor)); + wrapper.transfer(bob, 10 ether); + } +} + +contract Test_PullTokenWrapper_AfterTokenTransfer is PullTokenWrapperTest { + function test_Success_BurnsTokensForNonAllowedRecipient() public { + // Mint to alice + vm.prank(alice); + wrapper.mint(100 ether); + + uint256 totalSupplyBefore = wrapper.totalSupply(); + + // Transfer to bob (not distributor, holder, or zero address) + vm.prank(alice); + wrapper.transfer(bob, 50 ether); + + // Bob should have 0 tokens (burned in afterTokenTransfer) + assertEq(wrapper.balanceOf(bob), 0); + assertEq(wrapper.totalSupply(), totalSupplyBefore - 50 ether); + } + + function test_Success_KeepsTokensForDistributor() public { + // Mint to alice + vm.prank(alice); + wrapper.mint(100 ether); + + uint256 totalSupplyBefore = wrapper.totalSupply(); + + // Transfer to distributor + vm.prank(alice); + wrapper.transfer(address(mockDistributor), 50 ether); + + // Distributor should keep the tokens + assertEq(wrapper.balanceOf(address(mockDistributor)), 50 ether); + assertEq(wrapper.totalSupply(), totalSupplyBefore); + } + + function test_Success_KeepsTokensForHolder() public { + // Mint to alice + vm.prank(alice); + wrapper.mint(100 ether); + + uint256 totalSupplyBefore = wrapper.totalSupply(); + + // Alice transfers to herself + vm.prank(alice); + wrapper.transfer(alice, 50 ether); + + // Alice should keep the tokens + assertEq(wrapper.balanceOf(alice), 100 ether); + assertEq(wrapper.totalSupply(), totalSupplyBefore); + } + + function test_Success_BurnsOnTransferToZeroAddress() public { + // Mint to alice + vm.prank(alice); + wrapper.mint(100 ether); + + uint256 totalSupplyBefore = wrapper.totalSupply(); + + // Transfer to zero address (burning) + vm.prank(alice); + wrapper.transfer(address(0), 30 ether); + + // Zero address should not keep tokens (though they're burned anyway) + assertEq(wrapper.balanceOf(address(0)), 0); + // Total supply should decrease + assertEq(wrapper.totalSupply(), totalSupplyBefore - 30 ether); + } +} + +contract Test_PullTokenWrapper_SetFeeRecipient is PullTokenWrapperTest { + function test_Success() public { + address newFeeRecipient = vm.addr(999); + + vm.mockCall(address(creator), abi.encodeWithSignature("feeRecipient()"), abi.encode(newFeeRecipient)); + + wrapper.setFeeRecipient(); + + assertEq(wrapper.feeRecipient(), newFeeRecipient); + } + + function test_Success_UpdateAffectsTransfers() public { + address newFeeRecipient = vm.addr(999); + + vm.mockCall(address(creator), abi.encodeWithSignature("feeRecipient()"), abi.encode(newFeeRecipient)); + + wrapper.setFeeRecipient(); + + // Mint and transfer to distributor + vm.prank(alice); + wrapper.mint(100 ether); + vm.prank(alice); + wrapper.transfer(address(mockDistributor), 100 ether); + + uint256 newFeeRecipientBalanceBefore = angle.balanceOf(newFeeRecipient); + uint256 aliceBalanceBefore = angle.balanceOf(alice); + + // Transfer to new fee recipient should pull tokens + vm.prank(address(mockDistributor)); + wrapper.transfer(newFeeRecipient, 10 ether); + + assertEq(angle.balanceOf(newFeeRecipient), newFeeRecipientBalanceBefore + 10 ether); + assertEq(angle.balanceOf(alice), aliceBalanceBefore - 10 ether); + } +} + +contract Test_PullTokenWrapper_Decimals is PullTokenWrapperTest { + function test_Success_MatchesUnderlyingToken() public { + assertEq(wrapper.decimals(), angle.decimals()); + } +} + +contract Test_PullTokenWrapper_Integration is PullTokenWrapperTest { + function test_Integration_CompleteFlow() public { + // 1. Holder mints wrapper tokens + vm.prank(alice); + wrapper.mint(100 ether); + + assertEq(wrapper.balanceOf(alice), 100 ether); + assertEq(angle.balanceOf(alice), 1000 ether); // No underlying tokens moved yet + + // 2. Holder creates a campaign by transferring wrapper tokens to distributor + vm.prank(alice); + wrapper.transfer(address(mockDistributor), 80 ether); + + assertEq(wrapper.balanceOf(address(mockDistributor)), 80 ether); + assertEq(wrapper.balanceOf(alice), 20 ether); + assertEq(angle.balanceOf(alice), 1000 ether); // Still no underlying tokens moved + + // 3. Distributor distributes rewards to bob + uint256 bobAngleBalanceBefore = angle.balanceOf(bob); + + vm.prank(address(mockDistributor)); + wrapper.transfer(bob, 30 ether); + + // Bob receives underlying ANGLE tokens (pulled from alice) + assertEq(angle.balanceOf(bob), bobAngleBalanceBefore + 30 ether); + assertEq(angle.balanceOf(alice), 1000 ether - 30 ether); + // Bob doesn't keep wrapper tokens (burned) + assertEq(wrapper.balanceOf(bob), 0); + + // 4. Distributor sends fees + uint256 feeRecipientAngleBalanceBefore = angle.balanceOf(address(mockFeeRecipient)); + + vm.prank(address(mockDistributor)); + wrapper.transfer(address(mockFeeRecipient), 10 ether); + + // Fee recipient receives underlying ANGLE tokens + assertEq(angle.balanceOf(address(mockFeeRecipient)), feeRecipientAngleBalanceBefore + 10 ether); + assertEq(angle.balanceOf(alice), 1000 ether - 30 ether - 10 ether); + + // 5. Check remaining balances + assertEq(wrapper.balanceOf(address(mockDistributor)), 40 ether); // 80 - 30 - 10 + assertEq(wrapper.balanceOf(alice), 20 ether); + } + + function test_Integration_MultipleHolders() public { + // Setup: Transfer some ANGLE to bob and have him approve + angle.mint(bob, 500 ether); + vm.prank(bob); + angle.approve(address(wrapper), type(uint256).max); + + // 1. Alice mints and campaigns + vm.prank(alice); + wrapper.mint(50 ether); + vm.prank(alice); + wrapper.transfer(address(mockDistributor), 50 ether); + + // 2. Change holder to bob + vm.prank(alice); + wrapper.setHolder(bob); + + // 3. Bob mints additional wrapper tokens + vm.prank(bob); + wrapper.mint(50 ether); + vm.prank(bob); + wrapper.transfer(address(mockDistributor), 50 ether); + + assertEq(wrapper.balanceOf(address(mockDistributor)), 100 ether); + + // 4. Distributor sends rewards - should pull from bob (current holder) + uint256 charlieAngleBalanceBefore = angle.balanceOf(charlie); + uint256 bobAngleBalanceBefore = angle.balanceOf(bob); + uint256 aliceAngleBalanceBefore = angle.balanceOf(alice); + + vm.prank(address(mockDistributor)); + wrapper.transfer(charlie, 60 ether); + + // Charlie receives tokens pulled from bob (current holder), not alice + assertEq(angle.balanceOf(charlie), charlieAngleBalanceBefore + 60 ether); + assertEq(angle.balanceOf(bob), bobAngleBalanceBefore - 60 ether); + assertEq(angle.balanceOf(alice), aliceAngleBalanceBefore); // Unchanged + } + + function test_Integration_HolderCanReclaim() public { + // 1. Mint and send to distributor + vm.prank(alice); + wrapper.mint(100 ether); + vm.prank(alice); + wrapper.transfer(address(mockDistributor), 100 ether); + + // 2. Distributor sends some back to holder + vm.prank(address(mockDistributor)); + wrapper.transfer(alice, 30 ether); + + // Alice should keep the wrapper tokens since she's the holder + assertEq(wrapper.balanceOf(alice), 30 ether); + + // No underlying tokens should have moved (holder receives from distributor) + assertEq(angle.balanceOf(alice), 1000 ether); + } +} + +contract Test_PullTokenWrapper_EdgeCases is PullTokenWrapperTest { + function test_EdgeCase_TransferZeroAmount() public { + vm.prank(alice); + wrapper.mint(100 ether); + + vm.prank(alice); + wrapper.transfer(bob, 0); + + assertEq(wrapper.balanceOf(bob), 0); + assertEq(wrapper.balanceOf(alice), 100 ether); + } + + function test_EdgeCase_MintZeroAmount() public { + vm.prank(alice); + wrapper.mint(0); + + assertEq(wrapper.balanceOf(alice), 0); + assertEq(wrapper.totalSupply(), 0); + } + + function test_EdgeCase_SetHolderToSameAddress() public { + vm.prank(alice); + wrapper.setHolder(alice); + + assertEq(wrapper.holder(), alice); + } + + function test_Success_TransferBetweenDistributorAndHolder() public { + // Mint to holder + vm.prank(alice); + wrapper.mint(100 ether); + + // Transfer from holder to distributor + vm.prank(alice); + wrapper.transfer(address(mockDistributor), 50 ether); + + assertEq(wrapper.balanceOf(address(mockDistributor)), 50 ether); + assertEq(wrapper.balanceOf(alice), 50 ether); + + // Transfer back from distributor to holder + vm.prank(address(mockDistributor)); + wrapper.transfer(alice, 20 ether); + + assertEq(wrapper.balanceOf(alice), 70 ether); + assertEq(wrapper.balanceOf(address(mockDistributor)), 30 ether); + } +} From 657baecfdf79a32b71fa73249c489cd3a42fdc89 Mon Sep 17 00:00:00 2001 From: Pablo Veyrat Date: Wed, 12 Nov 2025 10:24:32 +0100 Subject: [PATCH 23/27] fix: cleaning scripts and balance --- .../tokenWrappers/PufferPointTokenWrapper.sol | 2 +- ...nWrapper.sol => PullTokenWrapperAllow.sol} | 7 +- ...Allow.sol => PullTokenWrapperTransfer.sol} | 6 +- ...fer.sol => PullTokenWrapperTransferV0.sol} | 5 +- .../PullTokenWrapperWithdraw.sol | 4 + .../tokenWrappers/TokenTGEWrapper.sol | 2 +- scripts/deployPullTokenWrapper.s.sol | 10 ++- ...l => deployPullTokenWrapperTransfer.s.sol} | 18 +++-- .../deployPullTokenWrapperWithTransfer.s.sol | 57 -------------- scripts/toggleOperatorBatch.s.sol | 5 +- ...pper.t.sol => PullTokenWrapperAllow.t.sol} | 74 +++++++------------ 11 files changed, 64 insertions(+), 126 deletions(-) rename contracts/partners/tokenWrappers/{PullTokenWrapper.sol => PullTokenWrapperAllow.sol} (90%) rename contracts/partners/tokenWrappers/{PullTokenWrapperWithAllow.sol => PullTokenWrapperTransfer.sol} (93%) rename contracts/partners/tokenWrappers/{PullTokenWrapperWithTransfer.sol => PullTokenWrapperTransferV0.sol} (94%) rename scripts/{deployPullTokenWrapperWithAllow.s.sol => deployPullTokenWrapperTransfer.s.sol} (65%) delete mode 100644 scripts/deployPullTokenWrapperWithTransfer.s.sol rename test/unit/partners/tokenWrappers/{PullTokenWrapper.t.sol => PullTokenWrapperAllow.t.sol} (87%) diff --git a/contracts/partners/tokenWrappers/PufferPointTokenWrapper.sol b/contracts/partners/tokenWrappers/PufferPointTokenWrapper.sol index 01a6fcd3..59eb8ca3 100644 --- a/contracts/partners/tokenWrappers/PufferPointTokenWrapper.sol +++ b/contracts/partners/tokenWrappers/PufferPointTokenWrapper.sol @@ -6,7 +6,7 @@ import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.s import { ERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; import { IERC20, IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; -import { IAccessControlManager } from "./BaseTokenWrapper.sol"; +import { IAccessControlManager } from "../../interfaces/IAccessControlManager.sol"; import { UUPSHelper } from "../../utils/UUPSHelper.sol"; import { Errors } from "../../utils/Errors.sol"; diff --git a/contracts/partners/tokenWrappers/PullTokenWrapper.sol b/contracts/partners/tokenWrappers/PullTokenWrapperAllow.sol similarity index 90% rename from contracts/partners/tokenWrappers/PullTokenWrapper.sol rename to contracts/partners/tokenWrappers/PullTokenWrapperAllow.sol index 237042c8..ebd88b49 100644 --- a/contracts/partners/tokenWrappers/PullTokenWrapper.sol +++ b/contracts/partners/tokenWrappers/PullTokenWrapperAllow.sol @@ -11,9 +11,12 @@ import { UUPSHelper } from "../../utils/UUPSHelper.sol"; import { IAccessControlManager } from "../../interfaces/IAccessControlManager.sol"; import { Errors } from "../../utils/Errors.sol"; -/// @title PullTokenWrapper +/// @title PullTokenWrapperAllow /// @notice Wrapper for a reward token on Merkl so campaigns do not have to be prefunded -contract PullTokenWrapper is UUPSHelper, ERC20Upgradeable { +/// @dev In this version of the PullTokenWrapper, tokens are pulled from a holder address during claims +/// @dev Managers of such wrapper contracts must ensure that the holder address has enough allowance to the wrapper contract +/// for the token pulled during claims +contract PullTokenWrapperAllow is UUPSHelper, ERC20Upgradeable { using SafeERC20 for IERC20; // ================================= VARIABLES ================================= diff --git a/contracts/partners/tokenWrappers/PullTokenWrapperWithAllow.sol b/contracts/partners/tokenWrappers/PullTokenWrapperTransfer.sol similarity index 93% rename from contracts/partners/tokenWrappers/PullTokenWrapperWithAllow.sol rename to contracts/partners/tokenWrappers/PullTokenWrapperTransfer.sol index ea4c8540..4b9fcee8 100644 --- a/contracts/partners/tokenWrappers/PullTokenWrapperWithAllow.sol +++ b/contracts/partners/tokenWrappers/PullTokenWrapperTransfer.sol @@ -11,9 +11,11 @@ import { UUPSHelper } from "../../utils/UUPSHelper.sol"; import { IAccessControlManager } from "../../interfaces/IAccessControlManager.sol"; import { Errors } from "../../utils/Errors.sol"; -/// @title PullTokenWrapperWithAllow +/// @title PullTokenWrapperTransfer /// @notice Wrapper for a reward token on Merkl so campaigns do not have to be prefunded -contract PullTokenWrapperWithAllow is UUPSHelper, ERC20Upgradeable { +/// @dev In this version of the PullTokenWrapper, tokens are pulled directly from the wrapper contract during claims +/// @dev Managers of such wrapper contracts must ensure to transfer enough tokens to the wrapper contract before claims happen +contract PullTokenWrapperTransfer is UUPSHelper, ERC20Upgradeable { using SafeERC20 for IERC20; // ================================= VARIABLES ================================= diff --git a/contracts/partners/tokenWrappers/PullTokenWrapperWithTransfer.sol b/contracts/partners/tokenWrappers/PullTokenWrapperTransferV0.sol similarity index 94% rename from contracts/partners/tokenWrappers/PullTokenWrapperWithTransfer.sol rename to contracts/partners/tokenWrappers/PullTokenWrapperTransferV0.sol index 9b2ed63e..b576f3fb 100644 --- a/contracts/partners/tokenWrappers/PullTokenWrapperWithTransfer.sol +++ b/contracts/partners/tokenWrappers/PullTokenWrapperTransferV0.sol @@ -11,9 +11,10 @@ import { UUPSHelper } from "../../utils/UUPSHelper.sol"; import { IAccessControlManager } from "../../interfaces/IAccessControlManager.sol"; import { Errors } from "../../utils/Errors.sol"; -/// @title PullTokenWrapperWithTransfer +/// @title PullTokenWrapperTransfer /// @notice Wrapper for a reward token on Merkl so campaigns do not have to be prefunded -contract PullTokenWrapperWithTransfer is UUPSHelper, ERC20Upgradeable { +/// @dev This is a deprecated version of the PullTokenWrapperTransfer used by Morpho on Katana +contract PullTokenWrapperTransferV0 is UUPSHelper, ERC20Upgradeable { using SafeERC20 for IERC20; // ================================= VARIABLES ================================= diff --git a/contracts/partners/tokenWrappers/PullTokenWrapperWithdraw.sol b/contracts/partners/tokenWrappers/PullTokenWrapperWithdraw.sol index 60cdf460..ee571db1 100644 --- a/contracts/partners/tokenWrappers/PullTokenWrapperWithdraw.sol +++ b/contracts/partners/tokenWrappers/PullTokenWrapperWithdraw.sol @@ -23,6 +23,10 @@ interface IAavePool { /// @title PullTokenWrapperWithdraw /// @notice Wrapper for a reward token on Merkl so campaigns do not have to be prefunded +/// @dev In this version of the PullTokenWrapper, tokens are pulled from a holder address during claims +/// @dev This implementation is similar to the PullTokenWrapperAllow but in this case the tokens are withdrawn from Aave at every claim +/// @dev Managers of such wrapper contracts must ensure that the holder address has enough allowance to the wrapper contract +/// for the token pulled during claims contract PullTokenWrapperWithdraw is UUPSHelper, ERC20Upgradeable { using SafeERC20 for IERC20; diff --git a/contracts/partners/tokenWrappers/TokenTGEWrapper.sol b/contracts/partners/tokenWrappers/TokenTGEWrapper.sol index 4a54c1d9..54ef77d7 100644 --- a/contracts/partners/tokenWrappers/TokenTGEWrapper.sol +++ b/contracts/partners/tokenWrappers/TokenTGEWrapper.sol @@ -6,7 +6,7 @@ import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.s import { ERC20Upgradeable } from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; import { IERC20, IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; -import { IAccessControlManager } from "./BaseTokenWrapper.sol"; +import { IAccessControlManager } from "../../interfaces/IAccessControlManager.sol"; import { UUPSHelper } from "../../utils/UUPSHelper.sol"; import { Errors } from "../../utils/Errors.sol"; diff --git a/scripts/deployPullTokenWrapper.s.sol b/scripts/deployPullTokenWrapper.s.sol index b016fd12..65a3e23d 100644 --- a/scripts/deployPullTokenWrapper.s.sol +++ b/scripts/deployPullTokenWrapper.s.sol @@ -6,12 +6,14 @@ import { console } from "forge-std/console.sol"; import { BaseScript } from "./utils/Base.s.sol"; import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; -import { ITransparentUpgradeableProxy } from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import { + ITransparentUpgradeableProxy +} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import { JsonReader } from "@utils/JsonReader.sol"; import { ContractType } from "@utils/Constants.sol"; -import { PullTokenWrapper } from "../contracts/partners/tokenWrappers/PullTokenWrapper.sol"; +import { PullTokenWrapperAllow } from "../contracts/partners/tokenWrappers/PullTokenWrapperAllow.sol"; import { PullTokenWrapperWithdraw } from "../contracts/partners/tokenWrappers/PullTokenWrapperWithdraw.sol"; import { DistributionCreator } from "../contracts/DistributionCreator.sol"; import { IAccessControlManager } from "../contracts/interfaces/IAccessControlManager.sol"; @@ -30,7 +32,7 @@ contract DeployPullTokenWrapper is BaseScript { // Need to choose the implementation type and if implementation needs to be deployed address implementation = address(new PullTokenWrapperWithdraw()); - // address implementation = address(new PullTokenWrapper()); + // address implementation = address(new PullTokenWrapperAllow()); // Ethereum implementation of PullTokenWrapper // address implementation = 0x979a04fd2f3A6a2B3945A715e24b974323E93567; // Ethereum implementation of PullTokenWrapperWithdraw @@ -52,7 +54,7 @@ contract DeployPullTokenWrapper is BaseScript { console.log("PullTokenWrapper Proxy:", address(proxy)); // Initialize - PullTokenWrapper(address(proxy)).initialize(underlying, distributionCreator, holder, name, symbol); + PullTokenWrapperAllow(address(proxy)).initialize(underlying, distributionCreator, holder, name, symbol); vm.stopBroadcast(); } diff --git a/scripts/deployPullTokenWrapperWithAllow.s.sol b/scripts/deployPullTokenWrapperTransfer.s.sol similarity index 65% rename from scripts/deployPullTokenWrapperWithAllow.s.sol rename to scripts/deployPullTokenWrapperTransfer.s.sol index 750f38ab..044bbf7b 100644 --- a/scripts/deployPullTokenWrapperWithAllow.s.sol +++ b/scripts/deployPullTokenWrapperTransfer.s.sol @@ -6,18 +6,20 @@ import { console } from "forge-std/console.sol"; import { BaseScript } from "./utils/Base.s.sol"; import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; -import { ITransparentUpgradeableProxy } from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import { + ITransparentUpgradeableProxy +} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { JsonReader } from "@utils/JsonReader.sol"; import { ContractType } from "@utils/Constants.sol"; -import { PullTokenWrapperWithAllow } from "../contracts/partners/tokenWrappers/PullTokenWrapperWithAllow.sol"; +import { PullTokenWrapperTransfer } from "../contracts/partners/tokenWrappers/PullTokenWrapperTransfer.sol"; import { DistributionCreator } from "../contracts/DistributionCreator.sol"; import { IAccessControlManager } from "../contracts/interfaces/IAccessControlManager.sol"; import { MockToken } from "../contracts/mock/MockToken.sol"; -contract DeployPullTokenWrapperWithAllow is BaseScript { - // forge script scripts/deployPullTokenWrapperWithAllow.s.sol --rpc-url katana --sender 0xA9DdD91249DFdd450E81E1c56Ab60E1A62651701 --broadcast --verify —verifier=blockscout --verifier-url 'https://explorer.katanarpc.com/api/' +contract DeployPullTokenWrapperTransfer is BaseScript { + // forge script scripts/deployPullTokenWrapperTransfer.s.sol --rpc-url katana --sender 0xA9DdD91249DFdd450E81E1c56Ab60E1A62651701 --broadcast --verify —verifier=blockscout --verifier-url 'https://explorer.katanarpc.com/api/' function run() public { uint256 deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY"); vm.startBroadcast(deployerPrivateKey); @@ -31,15 +33,15 @@ contract DeployPullTokenWrapperWithAllow is BaseScript { string memory symbol = "KAT"; // Deploy implementation - PullTokenWrapperWithAllow implementation = new PullTokenWrapperWithAllow(); - console.log("PullTokenWrapperWithAllow Implementation:", address(implementation)); + PullTokenWrapperTransfer implementation = new PullTokenWrapperTransfer(); + console.log("PullTokenWrapperTransfer Implementation:", address(implementation)); // Deploy proxy ERC1967Proxy proxy = new ERC1967Proxy(address(implementation), ""); - console.log("PullTokenWrapperWithAllow Proxy:", address(proxy)); + console.log("PullTokenWrapperTransfer Proxy:", address(proxy)); // Initialize - PullTokenWrapperWithAllow(address(proxy)).initialize(underlying, distributionCreator, minter, name, symbol); + PullTokenWrapperTransfer(address(proxy)).initialize(underlying, distributionCreator, minter, name, symbol); address[] memory tokens; tokens[0] = address(proxy); diff --git a/scripts/deployPullTokenWrapperWithTransfer.s.sol b/scripts/deployPullTokenWrapperWithTransfer.s.sol deleted file mode 100644 index f65a30c2..00000000 --- a/scripts/deployPullTokenWrapperWithTransfer.s.sol +++ /dev/null @@ -1,57 +0,0 @@ -// SPDX-License-Identifier: BUSL-1.1 -pragma solidity ^0.8.17; - -import { console } from "forge-std/console.sol"; - -import { BaseScript } from "./utils/Base.s.sol"; - -import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; -import { ITransparentUpgradeableProxy } from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; -import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import { JsonReader } from "@utils/JsonReader.sol"; -import { ContractType } from "@utils/Constants.sol"; - -import { PullTokenWrapperWithTransfer } from "../contracts/partners/tokenWrappers/PullTokenWrapperWithTransfer.sol"; -import { DistributionCreator } from "../contracts/DistributionCreator.sol"; -import { IAccessControlManager } from "../contracts/interfaces/IAccessControlManager.sol"; -import { MockToken } from "../contracts/mock/MockToken.sol"; - -contract DeployPullTokenWrapperWithTransfer is BaseScript { - // forge script scripts/deployPullTokenWrapperWithTransfer.s.sol --rpc-url katana --sender 0xA9DdD91249DFdd450E81E1c56Ab60E1A62651701 --broadcast --verify —verifier=blockscout --verifier-url 'https://explorer.katanarpc.com/api/' - function run() public { - uint256 deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY"); - vm.startBroadcast(deployerPrivateKey); - - // Katana - address underlying = 0x7F1f4b4b29f5058fA32CC7a97141b8D7e5ABDC2d; - address distributionCreator = 0x8BB4C975Ff3c250e0ceEA271728547f3802B36Fd; - address holder = 0xA9DdD91249DFdd450E81E1c56Ab60E1A62651701; - // Keeping the same name and symbol as the original underlying token so it's invisible for users - string memory name = "Katana Network Token (wrapped)"; - string memory symbol = "KAT"; - - // Deploy implementation - PullTokenWrapperWithTransfer implementation = new PullTokenWrapperWithTransfer(); - console.log("PullTokenWrapperWithTransfer Implementation:", address(implementation)); - /* - // Deploy proxy - ERC1967Proxy proxy = new ERC1967Proxy(address(implementation), ""); - console.log("PullTokenWrapperWithTransfer Proxy:", address(proxy)); - - // Initialize - PullTokenWrapperWithTransfer(address(proxy)).initialize(underlying, distributionCreator, holder, name, symbol); - - PullTokenWrapperWithTransfer wkat = PullTokenWrapperWithTransfer(address(proxy)); - - uint256 amount = 3000000 * 10 ** 18; - address morphoCreator = 0xF057afeEc22E220f47AD4220871364e9E828b2e9; - - wkat.mint(amount); // Mint 3M KAT to the holder - console.log("PullTokenWrapperWithTransfer Holder Balance:", IERC20(underlying).balanceOf(holder)); - wkat.setHolder(morphoCreator); // Set the holder to the Morpho creator address - wkat.transfer(morphoCreator, amount); - */ - - vm.stopBroadcast(); - } -} diff --git a/scripts/toggleOperatorBatch.s.sol b/scripts/toggleOperatorBatch.s.sol index 9d52dc7c..f01d8baa 100644 --- a/scripts/toggleOperatorBatch.s.sol +++ b/scripts/toggleOperatorBatch.s.sol @@ -6,12 +6,13 @@ import { console } from "forge-std/console.sol"; import { BaseScript } from "./utils/Base.s.sol"; import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; -import { ITransparentUpgradeableProxy } from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import { + ITransparentUpgradeableProxy +} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { JsonReader } from "@utils/JsonReader.sol"; import { ContractType } from "@utils/Constants.sol"; -import { PullTokenWrapperWithAllow } from "../contracts/partners/tokenWrappers/PullTokenWrapperWithAllow.sol"; import { Distributor } from "../contracts/Distributor.sol"; import { IAccessControlManager } from "../contracts/interfaces/IAccessControlManager.sol"; import { MockToken } from "../contracts/mock/MockToken.sol"; diff --git a/test/unit/partners/tokenWrappers/PullTokenWrapper.t.sol b/test/unit/partners/tokenWrappers/PullTokenWrapperAllow.t.sol similarity index 87% rename from test/unit/partners/tokenWrappers/PullTokenWrapper.t.sol rename to test/unit/partners/tokenWrappers/PullTokenWrapperAllow.t.sol index 00aacc16..f1f32135 100644 --- a/test/unit/partners/tokenWrappers/PullTokenWrapper.t.sol +++ b/test/unit/partners/tokenWrappers/PullTokenWrapperAllow.t.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.17; import { Test } from "forge-std/Test.sol"; import { console } from "forge-std/console.sol"; -import { PullTokenWrapper } from "../../../../contracts/partners/tokenWrappers/PullTokenWrapper.sol"; +import { PullTokenWrapperAllow } from "../../../../contracts/partners/tokenWrappers/PullTokenWrapperAllow.sol"; import { Fixture } from "../../../Fixture.t.sol"; import { IAccessControlManager } from "../../../../contracts/interfaces/IAccessControlManager.sol"; import { Errors } from "../../../../contracts/utils/Errors.sol"; @@ -12,10 +12,10 @@ import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; /// @dev Mock contract to simulate the Distributor contract MockDistributor { - PullTokenWrapper public wrapper; + PullTokenWrapperAllow public wrapper; function setWrapper(address _wrapper) external { - wrapper = PullTokenWrapper(_wrapper); + wrapper = PullTokenWrapperAllow(_wrapper); } /// @dev Simulates a transfer from distributor (e.g., during claim) @@ -29,9 +29,9 @@ contract MockFeeRecipient { // Empty contract for fee recipient } -contract PullTokenWrapperTest is Fixture { - PullTokenWrapper public wrapper; - PullTokenWrapper public wrapperImpl; +contract PullTokenWrapperAllowTest is Fixture { + PullTokenWrapperAllow public wrapper; + PullTokenWrapperAllow public wrapperImpl; MockDistributor public mockDistributor; MockFeeRecipient public mockFeeRecipient; @@ -42,9 +42,9 @@ contract PullTokenWrapperTest is Fixture { mockDistributor = new MockDistributor(); mockFeeRecipient = new MockFeeRecipient(); - // Deploy PullTokenWrapper implementation - wrapperImpl = new PullTokenWrapper(); - wrapper = PullTokenWrapper(deployUUPS(address(wrapperImpl), hex"")); + // Deploy PullTokenWrapperAllow implementation + wrapperImpl = new PullTokenWrapperAllow(); + wrapper = PullTokenWrapperAllow(deployUUPS(address(wrapperImpl), hex"")); // Mock the creator to return our mock distributor and fee recipient vm.mockCall(address(creator), abi.encodeWithSignature("distributor()"), abi.encode(address(mockDistributor))); @@ -65,12 +65,12 @@ contract PullTokenWrapperTest is Fixture { } } -contract Test_PullTokenWrapper_Initialize is PullTokenWrapperTest { - PullTokenWrapper w; +contract Test_PullTokenWrapperAllow_Initialize is PullTokenWrapperAllowTest { + PullTokenWrapperAllow w; function setUp() public override { super.setUp(); - w = PullTokenWrapper(deployUUPS(address(new PullTokenWrapper()), hex"")); + w = PullTokenWrapperAllow(deployUUPS(address(new PullTokenWrapperAllow()), hex"")); } function test_RevertWhen_CalledOnImplem() public { @@ -98,7 +98,7 @@ contract Test_PullTokenWrapper_Initialize is PullTokenWrapperTest { } } -contract Test_PullTokenWrapper_Mint is PullTokenWrapperTest { +contract Test_PullTokenWrapperAllow_Mint is PullTokenWrapperAllowTest { function test_RevertWhen_NotHolderOrGovernor() public { vm.expectRevert(Errors.NotAllowed.selector); vm.prank(bob); @@ -133,7 +133,7 @@ contract Test_PullTokenWrapper_Mint is PullTokenWrapperTest { } } -contract Test_PullTokenWrapper_SetHolder is PullTokenWrapperTest { +contract Test_PullTokenWrapperAllow_SetHolder is PullTokenWrapperAllowTest { function test_RevertWhen_NotHolderOrGovernor() public { vm.expectRevert(Errors.NotAllowed.selector); vm.prank(bob); @@ -167,7 +167,7 @@ contract Test_PullTokenWrapper_SetHolder is PullTokenWrapperTest { } } -contract Test_PullTokenWrapper_BeforeTokenTransfer is PullTokenWrapperTest { +contract Test_PullTokenWrapperAllow_BeforeTokenTransfer is PullTokenWrapperAllowTest { function setUp() public override { super.setUp(); @@ -210,7 +210,7 @@ contract Test_PullTokenWrapper_BeforeTokenTransfer is PullTokenWrapperTest { } function test_Success_NormalTransferDoesNotPullTokens() public { - uint256 charlieAngleBalanceBefore = angle.balanceOf(charlie); + uint256 bobAngleBalanceBefore = angle.balanceOf(bob); uint256 aliceAngleBalanceBefore = angle.balanceOf(alice); // Mint to bob @@ -219,24 +219,21 @@ contract Test_PullTokenWrapper_BeforeTokenTransfer is PullTokenWrapperTest { vm.prank(alice); wrapper.transfer(bob, 50 ether); - // Bob transfers to charlie (not from distributor or to fee recipient) - vm.prank(bob); - wrapper.transfer(charlie, 30 ether); - // Charlie should NOT receive underlying tokens from alice - assertEq(angle.balanceOf(charlie), charlieAngleBalanceBefore); + assertEq(angle.balanceOf(bob), bobAngleBalanceBefore); // Alice's balance should remain unchanged for this transfer assertEq(angle.balanceOf(alice), aliceAngleBalanceBefore); - // Charlie should not keep wrapper tokens (burned in afterTokenTransfer) - assertEq(wrapper.balanceOf(charlie), 0); + // Bob should not keep wrapper tokens (burned in afterTokenTransfer) + assertEq(wrapper.balanceOf(bob), 0); } function test_RevertWhen_HolderHasInsufficientTokens() public { // Deplete alice's angle balance + uint256 aliceAngleBalance = angle.balanceOf(alice); vm.prank(alice); - angle.transfer(address(1), angle.balanceOf(alice)); + angle.transfer(address(1), aliceAngleBalance); - vm.expectRevert("ERC20: insufficient allowance"); + vm.expectRevert("ERC20: transfer amount exceeds balance"); vm.prank(address(mockDistributor)); wrapper.transfer(bob, 10 ether); } @@ -252,7 +249,7 @@ contract Test_PullTokenWrapper_BeforeTokenTransfer is PullTokenWrapperTest { } } -contract Test_PullTokenWrapper_AfterTokenTransfer is PullTokenWrapperTest { +contract Test_PullTokenWrapperAllow_AfterTokenTransfer is PullTokenWrapperAllowTest { function test_Success_BurnsTokensForNonAllowedRecipient() public { // Mint to alice vm.prank(alice); @@ -300,26 +297,9 @@ contract Test_PullTokenWrapper_AfterTokenTransfer is PullTokenWrapperTest { assertEq(wrapper.balanceOf(alice), 100 ether); assertEq(wrapper.totalSupply(), totalSupplyBefore); } - - function test_Success_BurnsOnTransferToZeroAddress() public { - // Mint to alice - vm.prank(alice); - wrapper.mint(100 ether); - - uint256 totalSupplyBefore = wrapper.totalSupply(); - - // Transfer to zero address (burning) - vm.prank(alice); - wrapper.transfer(address(0), 30 ether); - - // Zero address should not keep tokens (though they're burned anyway) - assertEq(wrapper.balanceOf(address(0)), 0); - // Total supply should decrease - assertEq(wrapper.totalSupply(), totalSupplyBefore - 30 ether); - } } -contract Test_PullTokenWrapper_SetFeeRecipient is PullTokenWrapperTest { +contract Test_PullTokenWrapperAllow_SetFeeRecipient is PullTokenWrapperAllowTest { function test_Success() public { address newFeeRecipient = vm.addr(999); @@ -355,13 +335,13 @@ contract Test_PullTokenWrapper_SetFeeRecipient is PullTokenWrapperTest { } } -contract Test_PullTokenWrapper_Decimals is PullTokenWrapperTest { +contract Test_PullTokenWrapperAllow_Decimals is PullTokenWrapperAllowTest { function test_Success_MatchesUnderlyingToken() public { assertEq(wrapper.decimals(), angle.decimals()); } } -contract Test_PullTokenWrapper_Integration is PullTokenWrapperTest { +contract Test_PullTokenWrapperAllow_Integration is PullTokenWrapperAllowTest { function test_Integration_CompleteFlow() public { // 1. Holder mints wrapper tokens vm.prank(alice); @@ -462,7 +442,7 @@ contract Test_PullTokenWrapper_Integration is PullTokenWrapperTest { } } -contract Test_PullTokenWrapper_EdgeCases is PullTokenWrapperTest { +contract Test_PullTokenWrapperAllow_EdgeCases is PullTokenWrapperAllowTest { function test_EdgeCase_TransferZeroAmount() public { vm.prank(alice); wrapper.mint(100 ether); From 4586841af06dd264be2827a782ac65b468d27014 Mon Sep 17 00:00:00 2001 From: Pablo Veyrat Date: Fri, 14 Nov 2025 11:08:17 +0100 Subject: [PATCH 24/27] fix: comments in the code --- contracts/AccessControlManager.sol | 48 +-- contracts/DistributionCreator.sol | 299 ++++++++++++------ .../DistributionCreatorWithDistributions.sol | 56 ++-- contracts/Distributor.sol | 244 +++++++++----- contracts/struct/CampaignParameters.sol | 32 +- scripts/deployPullTokenWrapper.s.sol | 19 +- 6 files changed, 464 insertions(+), 234 deletions(-) diff --git a/contracts/AccessControlManager.sol b/contracts/AccessControlManager.sol index bc0353d4..4848b34d 100644 --- a/contracts/AccessControlManager.sol +++ b/contracts/AccessControlManager.sol @@ -2,18 +2,22 @@ pragma solidity ^0.8.17; -import { AccessControlEnumerableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/AccessControlEnumerableUpgradeable.sol"; +import { + AccessControlEnumerableUpgradeable +} from "@openzeppelin/contracts-upgradeable/access/AccessControlEnumerableUpgradeable.sol"; import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import { IAccessControlManager } from "./interfaces/IAccessControlManager.sol"; /// @title AccessControlManager /// @author Merkl SAS -/// @notice This contract handles the access control across all contracts +/// @notice Manages role-based access control across all Merkl protocol contracts +/// @dev Implements a two-tier permission system with governor and guardian roles +/// @dev All governors automatically have guardian privileges contract AccessControlManager is IAccessControlManager, Initializable, AccessControlEnumerableUpgradeable { - /// @notice Role for guardians + /// @notice Role identifier for guardians (limited administrative privileges) bytes32 public constant GUARDIAN_ROLE = keccak256("GUARDIAN_ROLE"); - /// @notice Role for governors + /// @notice Role identifier for governors (full administrative privileges) bytes32 public constant GOVERNOR_ROLE = keccak256("GOVERNOR_ROLE"); // =============================== Events ====================================== @@ -27,9 +31,12 @@ contract AccessControlManager is IAccessControlManager, Initializable, AccessCon error NotEnoughGovernorsLeft(); error ZeroAddress(); - /// @notice Initializes the `AccessControlManager` contract - /// @param governor Address of the governor of the Angle Protocol - /// @param guardian Guardian address of the protocol + /// @notice Initializes the AccessControlManager with initial governor and guardian + /// @param governor Address to be granted the governor role (full administrative privileges) + /// @param guardian Address to be granted the guardian role (limited administrative privileges) + /// @dev Governor and guardian must be different non-zero addresses + /// @dev Governor automatically receives both GOVERNOR_ROLE and GUARDIAN_ROLE + /// @dev Sets GOVERNOR_ROLE as the admin role for both GOVERNOR_ROLE and GUARDIAN_ROLE function initialize(address governor, address guardian) public initializer { if (governor == address(0) || guardian == address(0)) revert ZeroAddress(); if (governor == guardian) revert IncompatibleGovernorAndGuardian(); @@ -57,30 +64,31 @@ contract AccessControlManager is IAccessControlManager, Initializable, AccessCon // =========================== Governor Functions ============================== - /// @notice Adds a governor in the protocol - /// @param governor Address to grant the role to - /// @dev It is necessary to call this function to grant a governor role to make sure - /// all governors also have the guardian role + /// @notice Grants governor role to a new address + /// @param governor Address to receive governor privileges + /// @dev Must be called instead of grantRole to ensure the address receives both governor and guardian roles + /// @dev Only existing governors can call this function function addGovernor(address governor) external { grantRole(GOVERNOR_ROLE, governor); grantRole(GUARDIAN_ROLE, governor); } - /// @notice Revokes a governor from the protocol - /// @param governor Address to remove the role to - /// @dev It is necessary to call this function to remove a governor role to make sure - /// the address also loses its guardian role + /// @notice Revokes governor role from an address + /// @param governor Address to lose governor privileges + /// @dev Must be called instead of revokeRole to ensure both governor and guardian roles are removed + /// @dev Cannot remove the last governor - at least one must remain + /// @dev Only existing governors can call this function function removeGovernor(address governor) external { if (getRoleMemberCount(GOVERNOR_ROLE) <= 1) revert NotEnoughGovernorsLeft(); revokeRole(GUARDIAN_ROLE, governor); revokeRole(GOVERNOR_ROLE, governor); } - /// @notice Changes the accessControlManager contract of the protocol - /// @param _accessControlManager New accessControlManager contract - /// @dev This function verifies that all governors of the current accessControlManager contract are also governors - /// of the new accessControlManager contract. - /// @dev Governance wishing to change the accessControlManager contract should also make sure to call `setAccessControlManager` + /// @notice Migrates to a new AccessControlManager contract + /// @param _accessControlManager Address of the new AccessControlManager contract + /// @dev Validates that all current governors are also governors in the new contract + /// @dev After calling this, governance should also update all protocol contracts to use the new AccessControlManager + /// @dev Only callable by existing governors function setAccessControlManager(IAccessControlManager _accessControlManager) external onlyRole(GOVERNOR_ROLE) { uint256 count = getRoleMemberCount(GOVERNOR_ROLE); bool success; diff --git a/contracts/DistributionCreator.sol b/contracts/DistributionCreator.sol index 35934d31..89a24a1f 100644 --- a/contracts/DistributionCreator.sol +++ b/contracts/DistributionCreator.sol @@ -2,7 +2,9 @@ pragma solidity ^0.8.17; -import { ReentrancyGuardUpgradeable } from "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; +import { + ReentrancyGuardUpgradeable +} from "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; @@ -16,9 +18,9 @@ import { RewardTokenAmounts } from "./struct/RewardTokenAmounts.sol"; /// @title DistributionCreator /// @author Merkl SAS -/// @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 The deprecated variables in this contract are kept for storage layout compatibility +/// @notice Manages the creation and administration of reward distribution campaigns through the Merkl system +/// @dev This contract serves as the primary interface for campaign creators and provides helper functions for APIs built on Merkl +/// @dev Deprecated variables are maintained in storage for upgrade compatibility //solhint-disable contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { using SafeERC20 for IERC20; @@ -27,87 +29,88 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { CONSTANTS / VARIABLES //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ + /// @notice Duration of one hour in seconds uint32 public constant HOUR = 3600; - /// @notice Base for fee computation + /// @notice Base denominator for fee calculations (represents 100%) uint256 public constant BASE_9 = 1e9; + /// @notice Chain ID where this contract is deployed uint256 public immutable CHAIN_ID = block.chainid; /// @notice `AccessControlManager` contract handling access control IAccessControlManager public accessControlManager; - /// @notice Contract distributing rewards to users + /// @notice Address of the Distributor contract that distributes rewards to users address public distributor; - /// @notice Address to which fees are forwarded + /// @notice Address that receives protocol fees from campaign creation address public feeRecipient; - /// @notice Value (in base 10**9) of the fees taken when creating a campaign + /// @notice Default fee rate (in base 10^9) applied when creating a campaign uint256 public defaultFees; - /// @notice Message that needs to be acknowledged by users creating a campaign + /// @notice Terms and conditions message that users must acknowledge before creating campaigns string public message; - /// @notice Hash of the message that needs to be signed or accepted + /// @notice Keccak256 hash of the message that users must sign or accept bytes32 public messageHash; - /// @notice Deprecated + /// @notice Deprecated - kept for storage layout compatibility DistributionParameters[] public distributionList; - /// @notice Maps an address to its fee rebate + /// @notice Maps an address to its fee rebate percentage mapping(address => uint256) public feeRebate; - /// @notice Deprecated + /// @notice Deprecated - kept for storage layout compatibility mapping(address => uint256) public isWhitelistedToken; - /// @notice Deprecated + /// @notice Deprecated - kept for storage layout compatibility mapping(address => uint256) public _nonces; - /// @notice Deprecated + /// @notice Deprecated - kept for storage layout compatibility mapping(address => bytes32) public userSignatures; - /// @notice Deprecated + /// @notice Deprecated - kept for storage layout compatibility mapping(address => uint256) public userSignatureWhitelist; - /// @notice Maps a token to the minimum amount that must be sent per epoch for a distribution to be valid - /// @dev If `rewardTokenMinAmounts[token] == 0`, then `token` cannot be used as a reward + /// @notice Maps each reward token to its minimum required amount per epoch for campaign validity + /// @dev A value of 0 indicates the token is not whitelisted and cannot be used as a reward mapping(address => uint256) public rewardTokenMinAmounts; - /// @notice List of all reward tokens that have at some point been accepted + /// @notice Array of all reward tokens that have been whitelisted at any point address[] public rewardTokens; - /// @notice List of all rewards ever distributed or to be distributed in the contract - /// @dev An attacker could try to populate this list. It shouldn't be an issue as only view functions - /// iterate on it + /// @notice Array of all campaigns ever created in the contract (past, current, and future) + /// @dev This list can grow unbounded, but is only accessed by view functions CampaignParameters[] public campaignList; - /// @notice Maps a campaignId to the ID of the campaign in the campaign list + 1 + /// @notice Maps a campaign ID to its index in the campaign list plus one (0 = does not exist) mapping(bytes32 => uint256) internal _campaignLookup; - /// @notice Maps a campaign type to the fees for this specific campaign type + /// @notice Maps campaign types to their specific fee rates, overriding the default fee mapping(uint32 => uint256) public campaignSpecificFees; - /// @notice Maps a campaignId to a potential override written + /// @notice Maps campaign IDs to override parameters that modify the original campaign mapping(bytes32 => CampaignParameters) public campaignOverrides; - /// @notice Maps a campaignId to the block numbers at which it's been updated + /// @notice Maps campaign IDs to timestamps when overrides were applied mapping(bytes32 => uint256[]) public campaignOverridesTimestamp; - /// @notice Maps one address to another one to reallocate rewards for a given campaign + /// @notice Maps campaign IDs to reward reallocations (from address -> to address) mapping(bytes32 => mapping(address => address)) public campaignReallocation; - /// @notice List all reallocated address for a given campaign + /// @notice Maps campaign IDs to lists of addresses whose rewards have been reallocated mapping(bytes32 => address[]) public campaignListReallocation; - /// @notice Maps a creator to a reward token to the balance pre-deposited by the creator for this token + /// @notice Maps creator addresses to their predeposited token balances for each reward token mapping(address => mapping(address => uint256)) public creatorBalance; - /// @notice Maps a creator address to an operator to a reward token to an amount that can be pulled from the - /// creator's predeposited balance + /// @notice Maps creator addresses to operator approvals for spending predeposited tokens + /// @dev creator => operator => rewardToken => allowance amount mapping(address => mapping(address => mapping(address => uint256))) public creatorAllowance; - /// @notice Maps a creator to a campaign operator to the ability to manage the campaign on behalf of the creator + /// @notice Maps creator addresses to authorized campaign operators who can manage campaigns on their behalf mapping(address => mapping(address => uint256)) public campaignOperators; /*////////////////////////////////////////////////////////////////////////////////////////////////////////////////// @@ -138,19 +141,20 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { MODIFIERS //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ - /// @notice Checks whether the `msg.sender` has the governor role or the guardian role + /// @notice Restricts function access to addresses with governor or 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 Restricts function access to addresses with governor role only modifier onlyGovernor() { if (!accessControlManager.isGovernor(msg.sender)) revert Errors.NotGovernor(); _; } - /// @notice Checks whether an address has signed the message or not + /// @notice Ensures the caller has either signed the required message or is whitelisted from signing + /// @dev Checks both msg.sender and tx.origin for signature or whitelist status modifier hasSigned() { if ( userSignatureWhitelist[msg.sender] == 0 && @@ -161,7 +165,8 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { _; } - /// @notice Checks whether the `msg.sender` is the `user` address or is a governor + /// @notice Restricts function access to the specified user or any governor + /// @param user The user address allowed to call the function modifier onlyUserOrGovernor(address user) { if (user != msg.sender && !accessControlManager.isGovernor(msg.sender)) revert Errors.NotAllowed(); _; @@ -171,6 +176,10 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { CONSTRUCTOR //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ + /// @notice Initializes the contract with access control, distributor, and default fees + /// @param _accessControlManager Address of the access control manager contract + /// @param _distributor Address of the Distributor contract + /// @param _fees Default fee rate in base 10^9 (must be less than BASE_9) function initialize( IAccessControlManager _accessControlManager, address _distributor, @@ -192,18 +201,19 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { USER FACING FUNCTIONS //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ - /// @notice Creates a `campaign` on Merkl - /// @return campaignId of the new campaign - /// @dev If the campaign is badly formatted, it will not be handled by the reward engine and rewards may be lost - /// @dev Reward tokens sent as part of campaigns must have been whitelisted before and amounts - /// sent should be bigger than a minimum amount specific to each token - /// @dev This function reverts if the sender has not accepted the terms and conditions + /// @notice Creates a new reward distribution campaign + /// @param newCampaign Parameters defining the campaign structure and rewards + /// @return campaignId Unique identifier for the newly created campaign + /// @dev Campaigns with invalid formatting may not be processed by the reward engine, potentially losing rewards + /// @dev Reward tokens must be whitelisted and amounts must exceed the token-specific minimum threshold + /// @dev Reverts if the sender has not accepted the terms and conditions via acceptConditions() or signature function createCampaign(CampaignParameters memory newCampaign) external nonReentrant hasSigned returns (bytes32) { return _createCampaign(newCampaign); } - /// @notice Same as the function above but for multiple campaigns at once - /// @return List of all the campaignIds created + /// @notice Creates multiple reward distribution campaigns in a single transaction + /// @param campaigns Array of campaign parameters to create + /// @return Array of campaign IDs for all newly created campaigns function createCampaigns( CampaignParameters[] memory campaigns ) external nonReentrant hasSigned returns (bytes32[] memory) { @@ -218,16 +228,19 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { return campaignIds; } - /// @notice Allows a user to accept the Merkl conditions (expressed in the messageHash) in order to start their campaigns + /// @notice Allows a user to accept Merkl's terms and conditions to enable campaign creation + /// @dev Sets the sender's whitelist status to bypass signature requirements function acceptConditions() external { userSignatureWhitelist[msg.sender] = 1; } - /// @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) + /// @notice Updates parameters of an existing campaign while preserving core immutable fields + /// @param _campaignId ID of the campaign to override + /// @param newCampaign New campaign parameters (some fields will be ignored or validated) + /// @dev Cannot change rewardToken, amount, or creator address + /// @dev Can only update startTimestamp if the campaign has not yet started + /// @dev New end time (startTimestamp + duration) must be in the future + /// @dev The Merkl engine validates override correctness; invalid overrides are ignored function overrideCampaign(bytes32 _campaignId, CampaignParameters memory newCampaign) external { CampaignParameters memory _campaign = campaign(_campaignId); _isValidOperator(_campaign.creator); @@ -247,8 +260,12 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { emit CampaignOverride(_campaignId, newCampaign); } - /// @notice Reallocates rewards of a given campaign from one address to another - /// @dev While this function may execute successfully, the reallocation may not be valid in the Merkl engine + /// @notice Reallocates unclaimed rewards from specific addresses to a new recipient after campaign ends + /// @param _campaignId ID of the completed campaign to reallocate from + /// @param froms Array of addresses whose unclaimed rewards should be reallocated + /// @param to Address that will receive the reallocated rewards + /// @dev Can only be called after the campaign has ended (startTimestamp + duration has passed) + /// @dev Reallocation validity is determined by the Merkl engine; invalid reallocations are ignored function reallocateCampaignRewards(bytes32 _campaignId, address[] memory froms, address to) external { CampaignParameters memory _campaign = campaign(_campaignId); _isValidOperator(_campaign.creator); @@ -265,18 +282,25 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { emit CampaignReallocation(_campaignId, froms, to); } - /// @notice Increases the predeposited token balance of a `user` for a given `rewardToken` - /// @dev If a governor is calling the function, the user must have sent the tokens beforehand - /// @dev This function can be used to deposit on behalf of another user - /// @dev This function MUST NOT be used to deposit a rebasing token + /// @notice Increases a user's predeposited token balance for campaign funding + /// @param user Address whose balance will be increased + /// @param rewardToken Token to deposit + /// @param amount Amount to deposit + /// @dev When called by a governor, the user must have sent tokens to the contract beforehand + /// @dev Can be used to deposit on behalf of another user + /// @dev WARNING: Do not use with rebasing tokens as they will cause accounting issues function increaseTokenBalance(address user, address rewardToken, uint256 amount) external { if (!accessControlManager.isGovernor(msg.sender)) IERC20(rewardToken).safeTransferFrom(msg.sender, address(this), amount); _updateBalance(user, rewardToken, creatorBalance[user][rewardToken] + amount); } - /// @notice Decreases the predeposited token balance of a `user` for a given `rewardToken` - /// @dev Only the user themselves or a governor can call this function + /// @notice Decreases a user's predeposited token balance and transfers tokens out + /// @param user Address whose balance will be decreased + /// @param rewardToken Token to withdraw + /// @param to Address that will receive the withdrawn tokens + /// @param amount Amount to withdraw + /// @dev Only callable by the user themselves or a governor function decreaseTokenBalance( address user, address rewardToken, @@ -287,7 +311,12 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { IERC20(rewardToken).safeTransfer(to, amount); } - /// @notice Increases the token allowance of an `operator` for a `user` + /// @notice Increases an operator's allowance to spend a user's predeposited tokens + /// @param user User granting the allowance + /// @param operator Operator receiving spending permission + /// @param rewardToken Token for which allowance is granted + /// @param amount Amount to increase the allowance by + /// @dev Only callable by the user themselves or a governor function increaseTokenAllowance( address user, address operator, @@ -297,7 +326,12 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { _updateAllowance(user, operator, rewardToken, creatorAllowance[user][operator][rewardToken] + amount); } - /// @notice Decreases the token allowance of an `operator` for a `user` + /// @notice Decreases an operator's allowance to spend a user's predeposited tokens + /// @param user User reducing the allowance + /// @param operator Operator whose allowance is being reduced + /// @param rewardToken Token for which allowance is reduced + /// @param amount Amount to decrease the allowance by + /// @dev Only callable by the user themselves or a governor function decreaseTokenAllowance( address user, address operator, @@ -307,8 +341,11 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { _updateAllowance(user, operator, rewardToken, creatorAllowance[user][operator][rewardToken] - amount); } - /// @notice Toggles the ability of an `operator` to manage campaigns on behalf of a `user` - /// @dev Only the user themselves or a governor can call this function + /// @notice Toggles an operator's authorization to create and manage campaigns on behalf of a user + /// @param user User granting or revoking operator access + /// @param operator Operator whose authorization is being toggled + /// @dev Only callable by the user themselves or a governor + /// @dev Toggles between authorized (1) and unauthorized (0) function toggleCampaignOperator(address user, address operator) external onlyUserOrGovernor(user) { uint256 currentStatus = campaignOperators[user][operator]; campaignOperators[user][operator] = 1 - currentStatus; @@ -319,21 +356,28 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { GETTERS //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ - /// @notice Returns the index of a campaign in the campaign list + /// @notice Returns the array index of a campaign in the campaign list + /// @param _campaignId ID of the campaign to look up + /// @return Zero-based index of the campaign in the campaignList array + /// @dev Reverts if the campaign does not exist function campaignLookup(bytes32 _campaignId) public view returns (uint256) { uint256 index = _campaignLookup[_campaignId]; if (index == 0) revert Errors.CampaignDoesNotExist(); return index - 1; } - /// @notice Returns the campaign parameters of a given campaignId - /// @dev If a campaign has been overridden, this function still shows the original state of the campaign + /// @notice Returns the original parameters of a campaign + /// @param _campaignId ID of the campaign to retrieve + /// @return Campaign parameters as originally created + /// @dev Returns original parameters even if the campaign has been overridden 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 various parameters + /// @notice Computes the unique campaign ID for a given set of campaign parameters + /// @param campaignData Campaign parameters to hash + /// @return Unique campaign ID derived from hashing key parameters + /// @dev Campaign ID is computed as keccak256 of creator, rewardToken, campaignType, startTimestamp, duration, and campaignData function campaignId(CampaignParameters memory campaignData) public view returns (bytes32) { return bytes32( @@ -351,14 +395,19 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { ); } - /// @notice Returns the list of all the reward tokens supported as well as their minimum amounts - /// @dev Not to be queried on-chain and hence not optimized for gas consumption + /// @notice Returns all whitelisted reward tokens and their minimum required amounts + /// @return Array of reward tokens with their minimum amounts per epoch + /// @dev Not optimized for onchain queries; intended for off-chain/API use function getValidRewardTokens() external view returns (RewardTokenAmounts[] memory) { (RewardTokenAmounts[] memory validRewardTokens, ) = _getValidRewardTokens(0, type(uint32).max); return validRewardTokens; } - /// @dev Not to be queried on-chain and hence not optimized for gas consumption + /// @notice Returns a paginated list of whitelisted reward tokens + /// @param skip Number of tokens to skip + /// @param first Maximum number of tokens to return + /// @return Array of reward tokens and total count + /// @dev Not optimized for onchain queries; intended for off-chain/API use function getValidRewardTokens( uint32 skip, uint32 first @@ -366,12 +415,16 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { return _getValidRewardTokens(skip, first); } - /// @notice Gets the list of timestamps for when a campaign was overridden + /// @notice Returns all timestamps when a campaign was overridden + /// @param _campaignId ID of the campaign + /// @return Array of block timestamps when overrides occurred function getCampaignOverridesTimestamp(bytes32 _campaignId) external view returns (uint256[] memory) { return campaignOverridesTimestamp[_campaignId]; } - /// @notice Gets the list of addresses from which rewards were reallocated for a given campaign + /// @notice Returns all addresses from which rewards were reallocated for a campaign + /// @param _campaignId ID of the campaign + /// @return Array of addresses that had rewards reallocated away from them function getCampaignListReallocation(bytes32 _campaignId) external view returns (address[] memory) { return campaignListReallocation[_campaignId]; } @@ -380,14 +433,20 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { GOVERNANCE FUNCTIONS //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ - /// @notice Sets a new `distributor` to which rewards should be distributed + /// @notice Updates the Distributor contract address that receives and distributes rewards + /// @param _distributor New Distributor contract address + /// @dev Only callable by governor function setNewDistributor(address _distributor) external onlyGovernor { if (_distributor == address(0)) revert Errors.InvalidParam(); distributor = _distributor; emit DistributorUpdated(_distributor); } - /// @notice Recovers fees accrued on the contract for a list of `tokens` + /// @notice Withdraws accumulated protocol fees to a specified address + /// @param tokens Array of token addresses to withdraw fees from + /// @param to Address that will receive the withdrawn fees + /// @dev Only callable by governor + /// @dev Transfers the entire balance of each token held by the contract function recoverFees(IERC20[] calldata tokens, address to) external onlyGovernor { uint256 tokensLength = tokens.length; for (uint256 i; i < tokensLength; ) { @@ -398,13 +457,25 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { } } - /// @notice Sets a new address to receive fees + /// @notice Updates the address that receives protocol fees from campaign creation + /// @param _feeRecipient New fee recipient address function setFeeRecipient(address _feeRecipient) external onlyGovernor { feeRecipient = _feeRecipient; emit FeeRecipientUpdated(_feeRecipient); } - /// @notice Sets the message that needs to be accepted by users before posting rewards + /// @notice Updates the address that receives protocol fees from campaign creation + /// @param _feeRecipient New fee recipient address + /// @dev Only callable by governor + function setFeeRecipient(address _feeRecipient) external onlyGovernor { + feeRecipient = _feeRecipient; + emit FeeRecipientUpdated(_feeRecipient); + } + + /// @notice Updates the terms and conditions message that users must accept before creating campaigns + /// @param _message New terms and conditions message text + /// @dev Only callable by governor or guardian + /// @dev Automatically computes and stores the keccak256 hash for signature verification function setMessage(string memory _message) external onlyGovernorOrGuardian { message = _message; bytes32 _messageHash = ECDSA.toEthSignedMessageHash(bytes(_message)); @@ -412,35 +483,53 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { emit MessageUpdated(_messageHash); } - /// @notice Sets the defaultFees on deposit + /// @notice Updates the default fee rate applied to campaign creation + /// @param _defaultFees New default fee rate in base 10^9 + /// @dev Only callable by governor or guardian + /// @dev Fee rate must be less than BASE_9 (100%) function setFees(uint256 _defaultFees) external onlyGovernorOrGuardian { if (_defaultFees >= BASE_9) revert Errors.InvalidParam(); defaultFees = _defaultFees; emit FeesSet(_defaultFees); } - /// @notice Sets the fees specific for a campaign - /// @dev To waive the fees for a campaign, set its fees to 1 + /// @notice Sets campaign-type-specific fee rates that override the default fee + /// @param campaignType Type identifier for the campaign + /// @param _fees Fee rate for this campaign type in base 10^9 + /// @dev Only callable by governor or guardian + /// @dev Set fee to 1 to effectively waive fees for a campaign type + /// @dev Fee rate must be less than BASE_9 (100%) function setCampaignFees(uint32 campaignType, uint256 _fees) external onlyGovernorOrGuardian { if (_fees >= BASE_9) revert Errors.InvalidParam(); campaignSpecificFees[campaignType] = _fees; emit CampaignSpecificFeesSet(campaignType, _fees); } - /// @notice Sets fee rebates for a given user + /// @notice Sets a fee rebate for a specific user + /// @param user User address receiving the fee rebate + /// @param userFeeRebate Rebate amount in base 10^9 + /// @dev Only callable by governor or guardian function setUserFeeRebate(address user, uint256 userFeeRebate) external onlyGovernorOrGuardian { feeRebate[user] = userFeeRebate; emit FeeRebateUpdated(user, userFeeRebate); } - /// @notice Toggles the whitelist status for `user` when it comes to signing messages before depositing rewards. + /// @notice Toggles whether a user must sign the terms message before creating campaigns + /// @param user User address whose whitelist status is being toggled + /// @dev Only callable by governor or guardian + /// @dev Whitelisted users (status = 1) can create campaigns without signing function toggleSigningWhitelist(address user) external onlyGovernorOrGuardian { uint256 whitelistStatus = 1 - userSignatureWhitelist[user]; userSignatureWhitelist[user] = whitelistStatus; emit UserSigningWhitelistToggled(user, whitelistStatus); } - /// @notice Sets the minimum amounts per distribution epoch for different reward tokens + /// @notice Configures minimum reward amounts per epoch for whitelisted tokens + /// @param tokens Array of reward token addresses + /// @param amounts Array of minimum amounts (0 = remove from whitelist, >0 = add/update) + /// @dev Only callable by governor or guardian + /// @dev Setting amount to 0 effectively removes the token from the whitelist + /// @dev Prevents duplicate entries when adding previously removed tokens function setRewardTokenMinAmounts( address[] calldata tokens, uint256[] calldata amounts @@ -464,7 +553,12 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { INTERNAL //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ - /// @notice Internal version of `createCampaign` + /// @notice Internal function to create a new campaign with validation and fee processing + /// @param newCampaign Campaign parameters to create + /// @return Unique campaign ID of the created campaign + /// @dev Validates campaign duration, reward token whitelist status, and minimum reward amounts + /// @dev Computes and deducts protocol fees from the campaign amount + /// @dev Reverts if campaign already exists or validation fails function _createCampaign(CampaignParameters memory newCampaign) internal returns (bytes32) { uint256 rewardTokenMinAmount = rewardTokenMinAmounts[newCampaign.rewardToken]; // if the campaign doesn't last at least one hour @@ -489,26 +583,42 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { return newCampaign.campaignId; } - /// @notice Checks whether `msg.sender` is allowed to manage the campaign of `creator` + /// @notice Validates that the caller is authorized to manage campaigns for the specified creator + /// @param creator Address of the campaign creator + /// @dev Reverts if msg.sender is not the creator and not an authorized operator function _isValidOperator(address creator) internal view { if (creator != msg.sender && campaignOperators[creator][msg.sender] == 0) { revert Errors.OperatorNotAllowed(); } } - /// @notice Internal helper to update the allowance of an operator for a user + /// @notice Updates an operator's allowance to spend a user's predeposited tokens + /// @param user User granting the allowance + /// @param operator Operator receiving the allowance + /// @param rewardToken Token for which allowance is being set + /// @param newAllowance New allowance amount function _updateAllowance(address user, address operator, address rewardToken, uint256 newAllowance) internal { creatorAllowance[user][operator][rewardToken] = newAllowance; emit CreatorAllowanceUpdated(user, operator, rewardToken, newAllowance); } - /// @notice Internal helper to update the balance of a user for a reward token + /// @notice Updates a user's predeposited token balance + /// @param user User whose balance is being updated + /// @param rewardToken Token whose balance is being updated + /// @param newBalance New balance amount function _updateBalance(address user, address rewardToken, uint256 newBalance) internal { creatorBalance[user][rewardToken] = newBalance; emit CreatorBalanceUpdated(user, rewardToken, newBalance); } - /// @notice Pulls tokens from either the predeposited balance of a creator or from the `msg.sender` + /// @notice Transfers reward tokens from creator's balance or msg.sender to the distributor + /// @param creator Address of the campaign creator + /// @param rewardToken Token being transferred + /// @param campaignAmount Total amount including fees + /// @param campaignAmountMinusFees Net amount after fees to send to distributor + /// @dev Attempts to use predeposited balance first, checking operator allowance if applicable + /// @dev Falls back to direct transfer from msg.sender if insufficient predeposited balance + /// @dev Sends fees to feeRecipient (or this contract if feeRecipient is zero address) function _pullTokens( address creator, address rewardToken, @@ -542,7 +652,13 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { } } - /// @notice Computes the fees to be taken on a campaign + /// @notice Calculates the net campaign amount after deducting applicable fees + /// @param campaignType Type of campaign for fee calculation + /// @param distributionAmount Gross distribution amount before fees + /// @return distributionAmountMinusFees Net amount after fees are deducted + /// @dev Uses campaign-specific fees if set, otherwise uses default fees + /// @dev Campaign-specific fee of 1 is treated as 0 (fee waiver) + /// @dev Applies fee rebates to msg.sender (not creator) function _computeFees( uint32 campaignType, uint256 distributionAmount @@ -558,7 +674,12 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { } } - /// @notice Builds the list of valid reward tokens + /// @notice Builds a paginated list of whitelisted reward tokens with their minimum amounts + /// @param skip Number of tokens to skip in the iteration + /// @param first Maximum number of tokens to return + /// @return Array of valid reward tokens and the index where iteration stopped + /// @dev Only includes tokens with non-zero minimum amounts (active whitelist entries) + /// @dev Uses assembly to resize the return array to actual length function _getValidRewardTokens( uint32 skip, uint32 first diff --git a/contracts/DistributionCreatorWithDistributions.sol b/contracts/DistributionCreatorWithDistributions.sol index a795cbe3..e0e5e8e1 100644 --- a/contracts/DistributionCreatorWithDistributions.sol +++ b/contracts/DistributionCreatorWithDistributions.sol @@ -8,35 +8,49 @@ import { DistributionCreator } from "./DistributionCreator.sol"; /// @title DistributionCreatorWithDistributions /// @author Merkl SAS -/// @notice Version of the DistributionCreator contract with the ability to create campaigns following the old -/// standard -/// @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 more global name to describe any reward program on top of Merkl -/// @dev Useful notably on Polygon where some creators still use the old distribution model +/// @notice Extended version of DistributionCreator that supports legacy distribution creation +/// @dev This contract maintains backward compatibility with the deprecated distribution model +/// @dev Two types of reward programs are distinguished: +/// - distributions: Legacy campaign format for concentrated liquidity pools (deprecated as of Feb 15, 2024) +/// - campaigns: Current universal format for all Merkl reward programs +/// @dev Primarily used on Polygon where some creators still utilize the legacy distribution model //solhint-disable contract DistributionCreatorWithDistributions is DistributionCreator { - /// @notice Returns the distribution at a given index converted into a campaign + /// @notice Retrieves a legacy distribution and returns it as a campaign + /// @param index Index of the distribution in the distributionList array + /// @return Campaign parameters converted from the legacy distribution format function distribution(uint256 index) external view returns (CampaignParameters memory) { return _convertDistribution(distributionList[index]); } - /// @notice Creates a `distribution` to incentivize a given pool for a specific period of time + /// @notice Creates a legacy distribution to incentivize a liquidity pool over a specific time period + /// @param newDistribution Distribution parameters in the legacy format + /// @return distributionAmount Total amount of rewards allocated to the distribution + /// @dev This function converts the legacy distribution to a campaign internally + /// @dev Subject to the same signature requirements as campaign creation (hasSigned modifier) function createDistribution( DistributionParameters memory newDistribution ) external nonReentrant hasSigned returns (uint256 distributionAmount) { return _createDistribution(newDistribution); } - /// @notice Creates a distribution from a deprecated distribution type + /// @notice Internal function to create a distribution from legacy parameters + /// @param newDistribution Legacy distribution parameters to convert and create + /// @return Amount of rewards in the created campaign + /// @dev Converts distribution to campaign format and calls _createCampaign + /// @dev Not gas-efficient due to legacy support requirements function _createDistribution(DistributionParameters memory newDistribution) internal returns (uint256) { _createCampaign(_convertDistribution(newDistribution)); // Not gas efficient but deprecated return campaignList[campaignList.length - 1].amount; } - /// @notice Converts the deprecated distribution type into a campaign + /// @notice Converts legacy distribution parameters into the current campaign format + /// @param distributionToConvert Legacy distribution to be converted + /// @return Equivalent campaign parameters in the current format + /// @dev Extracts whitelist (wrapperType == 0) and blacklist (wrapperType == 3) from position wrappers + /// @dev Uses assembly to resize arrays after filtering wrapper types + /// @dev Campaign type is set to 2 for converted legacy distributions function _convertDistribution( DistributionParameters memory distributionToConvert ) internal view returns (CampaignParameters memory) { @@ -45,6 +59,7 @@ contract DistributionCreatorWithDistributions is DistributionCreator { address[] memory blacklist = new address[](wrapperLength); uint256 whitelistLength; uint256 blacklistLength; + // Filter position wrappers into whitelist and blacklist based on wrapper types for (uint256 k = 0; k < wrapperLength; k++) { if (distributionToConvert.wrapperTypes[k] == 0) { whitelist[whitelistLength] = (distributionToConvert.positionWrappers[k]); @@ -56,6 +71,7 @@ contract DistributionCreatorWithDistributions is DistributionCreator { } } + // Resize arrays to actual lengths using assembly assembly { mstore(whitelist, whitelistLength) mstore(blacklist, blacklistLength) @@ -72,15 +88,15 @@ contract DistributionCreatorWithDistributions is DistributionCreator { duration: distributionToConvert.numEpoch * HOUR, campaignData: abi.encode( distributionToConvert.uniV3Pool, - distributionToConvert.propFees, // eg. 6000 - distributionToConvert.propToken0, // eg. 3000 - distributionToConvert.propToken1, // eg. 1000 - distributionToConvert.isOutOfRangeIncentivized, // eg. 0 - distributionToConvert.boostingAddress, // eg. NULL_ADDRESS - distributionToConvert.boostedReward, // eg. 0 - whitelist, // eg. [] - blacklist, // eg. [] - "0x" + distributionToConvert.propFees, // Proportion allocated to fee earners (e.g., 6000 = 60%) + distributionToConvert.propToken0, // Proportion for token0 holders (e.g., 3000 = 30%) + distributionToConvert.propToken1, // Proportion for token1 holders (e.g., 1000 = 10%) + distributionToConvert.isOutOfRangeIncentivized, // Whether out-of-range positions earn rewards (0 = no) + distributionToConvert.boostingAddress, // Address of boosting contract (NULL_ADDRESS if none) + distributionToConvert.boostedReward, // Additional reward multiplier for boosted positions (0 = no boost) + whitelist, // Addresses eligible to earn rewards (empty = all eligible) + blacklist, // Addresses excluded from earning rewards (empty = none excluded) + "0x" // Additional campaign-specific data (empty for legacy distributions) ) }); } diff --git a/contracts/Distributor.sol b/contracts/Distributor.sol index 69fc3adc..61b36971 100644 --- a/contracts/Distributor.sol +++ b/contracts/Distributor.sol @@ -12,88 +12,102 @@ import { Errors } from "./utils/Errors.sol"; import { IClaimRecipient } from "./interfaces/IClaimRecipient.sol"; struct MerkleTree { - // Root of a Merkle tree which leaves are `(address user, address token, uint amount)` - // representing an amount of tokens accumulated by `user`. - // The Merkle tree is assumed to have only increasing amounts: that is to say if a user can claim 1, - // then after the amount associated in the Merkle tree for this token should be x > 1 + /// @notice Root of a Merkle tree whose leaves are `(address user, address token, uint amount)` + /// representing the cumulative amount of tokens earned by each user + /// @dev The Merkle tree contains only monotonically increasing amounts: if a user previously claimed 1 token, + /// subsequent tree updates should show amounts x > 1 for that user bytes32 merkleRoot; - // Ipfs hash of the tree data + /// @notice IPFS hash of the complete tree data bytes32 ipfsHash; } struct Claim { + /// @notice Cumulative amount claimed by the user for this token uint208 amount; + /// @notice Timestamp of the last claim uint48 timestamp; + /// @notice Merkle root that was active when the last claim occurred bytes32 merkleRoot; } /// @title Distributor -/// @notice Allows to claim rewards distributed to them through Merkl -/// @author Angle Labs. Inc +/// @notice Manages the distribution of Merkl rewards and allows users to claim their earned tokens +/// @dev Implements a Merkle tree-based reward distribution system with dispute resolution mechanism +/// @author Merkl SAS contract Distributor is UUPSHelper { using SafeERC20 for IERC20; - /// @notice Default epoch duration + /// @notice Default epoch duration in seconds (1 hour) uint32 internal constant _EPOCH_DURATION = 3600; - /// @notice Success message received when calling a `ClaimRecipient` contract + /// @notice Success message that must be returned by `IClaimRecipient.onClaim` callback bytes32 public constant CALLBACK_SUCCESS = keccak256("IClaimRecipient.onClaim"); /*////////////////////////////////////////////////////////////////////////////////////////////////////////////////// VARIABLES //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ - /// @notice Tree of claimable tokens through this contract + /// @notice Current active Merkle tree containing claimable token data MerkleTree public tree; - /// @notice Tree that was in place in the contract before the last `tree` update + /// @notice Previous Merkle tree that was active before the last update + /// @dev Used to revert to if the current tree is disputed and found invalid MerkleTree public lastTree; - /// @notice Token to deposit to freeze the roots update + /// @notice Token required as a deposit to dispute a tree update IERC20 public disputeToken; - /// @notice `AccessControlManager` contract handling access control + /// @notice Access control manager contract handling role-based permissions IAccessControlManager public accessControlManager; - /// @notice Address which created the last dispute - /// @dev Used to store if there is an ongoing dispute + /// @notice Address that created the current ongoing dispute + /// @dev Non-zero value indicates there is an active dispute address public disputer; - /// @notice When the current tree becomes valid + /// @notice Timestamp after which the current tree becomes effective and undisputable uint48 public endOfDisputePeriod; - /// @notice Time after which a change in a tree becomes effective, in EPOCH_DURATION + /// @notice Number of epochs (in EPOCH_DURATION units) to wait before a tree update becomes effective uint48 public disputePeriod; - /// @notice Amount to deposit to freeze the roots update + /// @notice Amount of disputeToken required to create a dispute uint256 public disputeAmount; - /// @notice Mapping user -> token -> amount to track claimed amounts + /// @notice Tracks cumulative claimed amounts for each user and token + /// @dev Maps user => token => Claim details (amount, timestamp, merkleRoot) mapping(address => mapping(address => Claim)) public claimed; - /// @notice Trusted EOAs to update the Merkle root + /// @notice Trusted addresses authorized to update the Merkle root + /// @dev 1 = trusted, 0 = not trusted mapping(address => uint256) public canUpdateMerkleRoot; - /// @notice Deprecated mapping + /// @notice Deprecated - kept for storage layout compatibility mapping(address => uint256) public onlyOperatorCanClaim; - /// @notice User -> Operator -> authorisation to claim on behalf of the user + /// @notice Authorization for operators to claim on behalf of users + /// @dev Maps user => operator => authorization status (1 = authorized, 0 = not authorized) mapping(address => mapping(address => uint256)) public operators; - /// @notice Whether the contract has been made non upgradeable or not + /// @notice Whether contract upgradeability has been permanently disabled + /// @dev 1 = upgrades disabled, 0 = upgrades allowed uint128 public upgradeabilityDeactivated; - /// @notice Reentrancy status + /// @notice Reentrancy guard status + /// @dev 1 = not entered, 2 = entered uint96 private _status; - /// @notice Epoch duration for dispute periods (in seconds) + /// @notice Custom epoch duration for dispute periods in seconds + /// @dev If 0, defaults to _EPOCH_DURATION uint32 internal _epochDuration; - /// @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 + /// @notice Custom recipient addresses for user claims per token + /// @dev Maps user => token => recipient address (zero address = use default behavior) + /// @dev Setting recipient for address(0) token sets the default recipient for all tokens mapping(address => mapping(address => address)) public claimRecipient; - /// @notice User -> Token -> authorisation to claim on behalf of every user for this token + /// @notice Global operators authorized to claim specific tokens on behalf of any user + /// @dev Maps operator => token => authorization (1 = authorized, 0 = not authorized) + /// @dev Authorization for address(0) token allows claiming any token for any user mapping(address => mapping(address => uint256)) public mainOperators; uint256[35] private __gap; @@ -123,26 +137,28 @@ contract Distributor is UUPSHelper { MODIFIERS //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ - /// @notice Checks whether the `msg.sender` has the governor role + /// @notice Restricts function access to addresses with governor role only modifier onlyGovernor() { if (!accessControlManager.isGovernor(msg.sender)) revert Errors.NotGovernor(); _; } - /// @notice Checks whether the `msg.sender` has the guardian role + /// @notice Restricts function access to addresses with governor or guardian role modifier onlyGuardian() { if (!accessControlManager.isGovernorOrGuardian(msg.sender)) revert Errors.NotGovernorOrGuardian(); _; } - /// @notice Checks whether the contract is upgradeable or whether the caller is allowed to upgrade the contract + /// @notice Ensures the contract is still upgradeable and caller has governor role + /// @dev Reverts if upgradeability has been revoked or caller is not a governor 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 + /// @notice Prevents reentrancy attacks by locking the contract during execution + /// @dev Uses a status flag that is set to 2 during execution and reset to 1 after modifier nonReentrant() { if (_status == 2) revert Errors.ReentrantCall(); @@ -162,6 +178,8 @@ contract Distributor is UUPSHelper { constructor() initializer {} + /// @notice Initializes the contract with access control manager + /// @param _accessControlManager Address of the access control manager contract function initialize(IAccessControlManager _accessControlManager) external initializer { if (address(_accessControlManager) == address(0)) revert Errors.ZeroAddress(); accessControlManager = _accessControlManager; @@ -174,12 +192,13 @@ contract Distributor is UUPSHelper { MAIN FUNCTIONS //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ - /// @notice Claims rewards for a given set of users - /// @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 + /// @notice Claims rewards for a set of users based on Merkle proofs + /// @param users Addresses claiming rewards (or being claimed for) + /// @param tokens ERC20 tokens being claimed + /// @param amounts Cumulative amounts earned (not incremental amounts) + /// @param proofs Merkle proofs validating each claim + /// @dev Users can only claim for themselves unless they've authorized an operator + /// @dev Arrays must all have the same length function claim( address[] calldata users, address[] calldata tokens, @@ -191,11 +210,15 @@ contract Distributor is UUPSHelper { _claim(users, tokens, amounts, proofs, recipients, datas); } - /// @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 + /// @notice Claims rewards with custom recipient addresses and callback data + /// @param users Addresses claiming rewards (or being claimed for) + /// @param tokens ERC20 tokens being claimed + /// @param amounts Cumulative amounts earned (not incremental amounts) + /// @param proofs Merkle proofs validating each claim + /// @param recipients Custom recipient addresses for each claim (zero address = use default) + /// @param datas Arbitrary data passed to recipient's onClaim callback (if recipient is a contract) + /// @dev Only msg.sender claiming for themselves can override the recipient address + /// @dev Non-zero recipient addresses override any previously set default recipients function claimWithRecipient( address[] calldata users, address[] calldata tokens, @@ -207,12 +230,18 @@ contract Distributor is UUPSHelper { _claim(users, tokens, amounts, proofs, recipients, datas); } - /// @notice Returns the Merkle root that is currently live for the contract + /// @notice Returns the currently active Merkle root for claim verification + /// @return The Merkle root that is currently valid for claims + /// @dev Returns lastTree.merkleRoot if within dispute period or if there's an active dispute + /// @dev Returns tree.merkleRoot if dispute period has passed and no active dispute function getMerkleRoot() public view returns (bytes32) { if (block.timestamp >= endOfDisputePeriod && disputer == address(0)) return tree.merkleRoot; else return lastTree.merkleRoot; } + /// @notice Returns the epoch duration used for dispute period calculations + /// @return epochDuration The current epoch duration in seconds + /// @dev Returns custom _epochDuration if set, otherwise returns default _EPOCH_DURATION (3600 seconds) function getEpochDuration() public view returns (uint32 epochDuration) { epochDuration = _epochDuration; if (epochDuration == 0) epochDuration = _EPOCH_DURATION; @@ -222,9 +251,11 @@ contract Distributor is UUPSHelper { 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 - /// @dev Setting the operator address to the zero address enables any address to act as an operator for the user (i.e., it whitelists all operators for that user) + /// @notice Toggles an operator's authorization to claim rewards on behalf of a user + /// @param user User granting or revoking the authorization + /// @param operator Operator address being authorized or deauthorized + /// @dev When operator is address(0), it enables any address to claim for the user + /// @dev Only the user themselves or governance can toggle operator status function toggleOperator(address user, address operator) external { if (user != msg.sender && !accessControlManager.isGovernorOrGuardian(msg.sender)) revert Errors.NotTrusted(); uint256 oldValue = operators[user][operator]; @@ -232,35 +263,40 @@ contract Distributor is UUPSHelper { 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 - /// @dev Setting the zero address for a token will set the default recipient for all tokens + /// @notice Sets a custom recipient address for a user's token claims + /// @param recipient Address that will receive claimed tokens (zero address = default to user) + /// @param token Token for which to set the recipient (zero address = all tokens) + /// @dev Users can override this recipient when calling claimWithRecipient + /// @dev Setting recipient to address(0) removes the custom recipient function setClaimRecipient(address recipient, address token) external { _setClaimRecipient(msg.sender, recipient, token); } - /// @notice Sets a recipient for a user claiming rewards for a token, through governance - /// @dev This is a sensitive operation so can only be performed by an address with governor rights - /// @dev Setting the zero address for a token will set the default recipient for all tokens + /// @notice Sets a custom recipient for a user through governance + /// @param user User for whom to set the recipient + /// @param recipient Address that will receive claimed tokens + /// @param token Token for which to set the recipient (zero address = all tokens) + /// @dev Only callable by governor - use with caution as it overrides user preferences function setClaimRecipientWithGov(address user, address recipient, address token) external onlyGovernor { _setClaimRecipient(user, recipient, token); } - /// @notice Updates the mainOperator status for `operator` on `token` - /// @dev Adding a mainOperator status on an address for the zero address gives the right to claim any token - /// on behalf of anyone on the chain + /// @notice Toggles a main operator's authorization to claim tokens on behalf of any user + /// @param operator Operator whose status is being toggled + /// @param token Token for which authorization applies (zero address = all tokens) + /// @dev Only callable by guardian or governor + /// @dev Main operators can claim for any user without individual user authorization function toggleMainOperatorStatus(address operator, address token) external onlyGuardian { uint256 oldValue = mainOperators[operator][token]; mainOperators[operator][token] = 1 - oldValue; emit MainOperatorStatusUpdated(operator, token, oldValue == 0); } - /// @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 + /// @notice Creates a dispute to freeze the current Merkle tree update + /// @param reason Explanation for why the tree update is being disputed + /// @dev Requires depositing disputeAmount of disputeToken as collateral + /// @dev Can only dispute within disputePeriod after a tree update + /// @dev Deposit is slashed if dispute is rejected, returned if dispute is valid function disputeTree(string memory reason) external { if (disputer != address(0)) revert Errors.UnresolvedDispute(); if (block.timestamp >= endOfDisputePeriod) revert Errors.InvalidDispute(); @@ -273,7 +309,11 @@ contract Distributor is UUPSHelper { GOVERNANCE FUNCTIONS //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ - /// @notice Updates the Merkle tree + /// @notice Updates the active Merkle tree with new reward data + /// @param _tree New Merkle tree containing updated reward information + /// @dev Can only be called by trusted addresses or governor + /// @dev Trusted addresses cannot update during an active dispute period to prevent circumventing disputes + /// @dev Saves the current tree to lastTree before updating function updateTree(MerkleTree calldata _tree) external { if ( disputer != address(0) || @@ -291,27 +331,37 @@ contract Distributor is UUPSHelper { emit TreeUpdated(_tree.merkleRoot, _tree.ipfsHash, _endOfPeriod); } - /// @notice Adds or removes addresses which are trusted to update the Merkle root + /// @notice Toggles an address's authorization to update the Merkle tree + /// @param trustAddress Address whose trusted status is being toggled + /// @dev Only callable by governor + /// @dev Trusted addresses can update trees but must wait for dispute periods function toggleTrusted(address trustAddress) external onlyGovernor { uint256 trustedStatus = 1 - canUpdateMerkleRoot[trustAddress]; canUpdateMerkleRoot[trustAddress] = trustedStatus; emit TrustedToggled(trustAddress, trustedStatus == 1); } - /// @notice Prevents future contract upgrades + /// @notice Permanently disables contract upgradeability + /// @dev Only callable by governor + /// @dev This action is irreversible - use with extreme caution function revokeUpgradeability() external onlyGovernor { upgradeabilityDeactivated = 1; emit UpgradeabilityRevoked(); } - /// @notice Updates the epoch duration period + /// @notice Updates the epoch duration used for dispute period calculations + /// @param epochDuration New epoch duration in seconds + /// @dev Only callable by governor function setEpochDuration(uint32 epochDuration) external onlyGovernor { _epochDuration = epochDuration; emit EpochDurationUpdated(epochDuration); } - /// @notice Resolve the ongoing dispute, if any - /// @param valid Whether the dispute was valid + /// @notice Resolves an ongoing dispute + /// @param valid True if the dispute is valid (tree will be reverted), false if invalid (disputer loses deposit) + /// @dev Only callable by governor + /// @dev If valid: returns deposit to disputer and reverts to lastTree + /// @dev If invalid: sends deposit to governor and extends dispute period function resolveDispute(bool valid) external onlyGovernor { if (disputer == address(0)) revert Errors.NoDispute(); if (valid) { @@ -326,33 +376,46 @@ contract Distributor is UUPSHelper { emit DisputeResolved(valid); } - /// @notice Allows the governor of this contract to fallback to the last version of the tree - /// immediately + /// @notice Reverts to the previous Merkle tree immediately + /// @dev Only callable by governor + /// @dev Cannot be called if there's an active dispute (must resolve dispute first) function revokeTree() external onlyGovernor { if (disputer != address(0)) revert Errors.UnresolvedDispute(); _revokeTree(); } - /// @notice Recovers any ERC20 token left on the contract + /// @notice Recovers ERC20 tokens accidentally sent to the contract + /// @param tokenAddress Address of the token to recover + /// @param to Address that will receive the recovered tokens + /// @param amountToRecover Amount of tokens to recover + /// @dev Only callable by governor function recoverERC20(address tokenAddress, address to, uint256 amountToRecover) external onlyGovernor { IERC20(tokenAddress).safeTransfer(to, amountToRecover); emit Recovered(tokenAddress, to, amountToRecover); } - /// @notice Sets the dispute period after which a tree update becomes effective + /// @notice Updates the dispute period duration + /// @param _disputePeriod New dispute period in epoch units + /// @dev Only callable by governor function setDisputePeriod(uint48 _disputePeriod) external onlyGovernor { disputePeriod = uint48(_disputePeriod); emit DisputePeriodUpdated(_disputePeriod); } - /// @notice Sets the token used as a caution during disputes + /// @notice Updates the token required as collateral for disputes + /// @param _disputeToken New dispute token address + /// @dev Only callable by governor + /// @dev Cannot be changed during an active dispute function setDisputeToken(IERC20 _disputeToken) external onlyGovernor { if (disputer != address(0)) revert Errors.UnresolvedDispute(); disputeToken = _disputeToken; emit DisputeTokenUpdated(address(_disputeToken)); } - /// @notice Sets the amount of `disputeToken` used as a caution during disputes + /// @notice Updates the amount of tokens required to create a dispute + /// @param _disputeAmount New dispute amount + /// @dev Only callable by governor + /// @dev Cannot be changed during an active dispute function setDisputeAmount(uint256 _disputeAmount) external onlyGovernor { if (disputer != address(0)) revert Errors.UnresolvedDispute(); disputeAmount = _disputeAmount; @@ -363,7 +426,15 @@ contract Distributor is UUPSHelper { INTERNAL HELPERS //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ - /// @notice Internal version of `claimWithRecipient` + /// @notice Internal implementation of reward claiming with full recipient and callback support + /// @param users Addresses claiming rewards + /// @param tokens Tokens being claimed + /// @param amounts Cumulative earned amounts (not incremental) + /// @param proofs Merkle proofs for validation + /// @param recipients Custom recipient addresses (zero = use default) + /// @param datas Callback data for recipients + /// @dev Validates authorization, verifies proofs, updates claimed amounts, and transfers tokens + /// @dev Attempts to call onClaim callback on recipient if data is provided function _claim( address[] calldata users, address[] calldata tokens, @@ -434,7 +505,8 @@ contract Distributor is UUPSHelper { } } - /// @notice Fallback to the last version of the tree + /// @notice Reverts to the previous Merkle tree + /// @dev Resets endOfDisputePeriod to 0 and emits both Revoked and TreeUpdated events function _revokeTree() internal { MerkleTree memory _tree = lastTree; endOfDisputePeriod = 0; @@ -448,17 +520,20 @@ contract Distributor is UUPSHelper { ); } - /// @notice Returns the end of the dispute period - /// @dev treeUpdate is rounded up to next hour and then `disputePeriod` hours are added + /// @notice Calculates when a tree update's dispute period ends + /// @param treeUpdate Timestamp when the tree was updated + /// @return Timestamp when the dispute period ends and tree becomes effective + /// @dev Rounds treeUpdate up to next epoch boundary, then adds disputePeriod epochs function _endOfDisputePeriod(uint48 treeUpdate) internal view returns (uint48) { uint32 epochDuration = getEpochDuration(); return ((treeUpdate - 1) / epochDuration + 1 + disputePeriod) * (epochDuration); } - /// @notice Checks the validity of a proof - /// @param leaf Hashed leaf data, the starting point of the proof - /// @param proof Array of hashes forming a hash chain from leaf to root - /// @return true If proof is correct, else false + /// @notice Verifies a Merkle proof against the current active root + /// @param leaf Hashed leaf data representing the claim (user, token, amount) + /// @param proof Array of sibling hashes forming the path from leaf to root + /// @return True if the proof is valid, false otherwise + /// @dev Uses standard Merkle tree verification with sorted concatenation function _verifyProof(bytes32 leaf, bytes32[] memory proof) internal view returns (bool) { bytes32 currentHash = leaf; uint256 proofLength = proof.length; @@ -477,7 +552,10 @@ contract Distributor is UUPSHelper { return currentHash == root; } - /// @notice Internal version of `setClaimRecipient` and `setClaimRecipientWithGov` + /// @notice Internal implementation for setting a claim recipient + /// @param user User for whom to set the recipient + /// @param recipient Address that will receive claimed tokens + /// @param token Token for which recipient is set (address(0) = all tokens) function _setClaimRecipient(address user, address recipient, address token) internal { claimRecipient[user][token] = recipient; emit ClaimRecipientUpdated(user, recipient, token); diff --git a/contracts/struct/CampaignParameters.sol b/contracts/struct/CampaignParameters.sol index 955d9981..8dc0bdd7 100644 --- a/contracts/struct/CampaignParameters.sol +++ b/contracts/struct/CampaignParameters.sol @@ -2,28 +2,34 @@ pragma solidity >=0.8.0; +/// @notice Parameters defining a Merkl reward distribution campaign struct CampaignParameters { - // POPULATED ONCE CREATED + // ========== POPULATED BY CONTRACT ========== - // ID of the campaign. This can be left as a null bytes32 when creating campaigns - // on Merkl. + /// @notice Unique identifier for the campaign + /// @dev Can be left as bytes32(0) when creating a new campaign - will be computed by the contract bytes32 campaignId; - // CHOSEN BY CAMPAIGN CREATOR + // ========== CONFIGURED BY CREATOR ========== - // Address of the campaign creator, if marked as address(0), it will be overridden with the - // address of the `msg.sender` creating the campaign + /// @notice Address of the campaign creator + /// @dev If set to address(0), will be automatically set to msg.sender when the campaign is created address creator; - // Address of the token used as a reward + /// @notice Token distributed as rewards to campaign participants address rewardToken; - // Amount of `rewardToken` to distribute across all the epochs - // Amount distributed per epoch is `amount/numEpoch` + /// @notice Total amount of rewardToken to distribute over the entire campaign duration + /// @dev Must meet the minimum amount requirement for the reward token uint256 amount; - // Type of campaign + /// @notice Type identifier for the campaign structure and rules + /// @dev Different types may have different campaignData encoding schemes uint32 campaignType; - // Timestamp at which the campaign should start + /// @notice Unix timestamp when reward distribution begins uint32 startTimestamp; - // Duration of the campaign in seconds. Has to be a multiple of EPOCH = 3600 + /// @notice Total duration of the campaign in seconds + /// @dev Must be a multiple of EPOCH_DURATION (3600 seconds / 1 hour) + /// @dev Must be at least EPOCH_DURATION (1 hour minimum) uint32 duration; - // Extra data to pass to specify the campaign + /// @notice Encoded campaign-specific parameters + /// @dev Encoding structure depends on campaignType + /// @dev May include pool addresses, reward distribution rules, whitelists, etc. bytes campaignData; } diff --git a/scripts/deployPullTokenWrapper.s.sol b/scripts/deployPullTokenWrapper.s.sol index 65a3e23d..789b7b98 100644 --- a/scripts/deployPullTokenWrapper.s.sol +++ b/scripts/deployPullTokenWrapper.s.sol @@ -20,32 +20,33 @@ import { IAccessControlManager } from "../contracts/interfaces/IAccessControlMan import { MockToken } from "../contracts/mock/MockToken.sol"; contract DeployPullTokenWrapper is BaseScript { - // forge script scripts/deployPullTokenWrapper.s.sol --rpc-url plasma --sender 0xA9DdD91249DFdd450E81E1c56Ab60E1A62651701 --broadcast --verify + // forge script scripts/deployPullTokenWrapper.s.sol --rpc-url mainnet --sender 0xA9DdD91249DFdd450E81E1c56Ab60E1A62651701 --broadcast --verify function run() public { uint256 deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY"); vm.startBroadcast(deployerPrivateKey); address distributionCreator = 0x8BB4C975Ff3c250e0ceEA271728547f3802B36Fd; // ------------------------------------------------------------------------ // TO EDIT - address underlying = 0x5D72a9d9A9510Cd8cBdBA12aC62593A58930a948; - address holder = 0xdef1FA4CEfe67365ba046a7C630D6B885298E210; + address underlying = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; // USDC + address holder = 0x304C9C032a82Ca287C1681EA68189f8C0De5746d; // Need to choose the implementation type and if implementation needs to be deployed - address implementation = address(new PullTokenWrapperWithdraw()); + // address implementation = address(new PullTokenWrapperWithdraw()); // address implementation = address(new PullTokenWrapperAllow()); - // Ethereum implementation of PullTokenWrapper - // address implementation = 0x979a04fd2f3A6a2B3945A715e24b974323E93567; + // Ethereum implementation of PullTokenWrapperAllow + address implementation = 0x979a04fd2f3A6a2B3945A715e24b974323E93567; // Ethereum implementation of PullTokenWrapperWithdraw // address implementation = 0x721d37cf37e230E120a09adbBB7aAB0CF729AcA1 - // ------------------------------------------------------------------------ // Keeping the same name and symbol as the original underlying token so it's invisible for users string memory name = string(abi.encodePacked(IERC20Metadata(underlying).name(), " (wrapped)")); string memory symbol = IERC20Metadata(underlying).symbol(); // Names to override if deploying a PullTokenWrapperWithdraw implementation - name = "USDT0 (wrapped)"; - symbol = "USDT0"; + // name = "USDT0 (wrapped)"; + // symbol = "USDT0"; + + // ------------------------------------------------------------------------ console.log("PullTokenWrapper Implementation:", address(implementation)); From 95bfae7a4151db388af2efee4d714bf4609d0f5c Mon Sep 17 00:00:00 2001 From: Pablo Veyrat Date: Fri, 14 Nov 2025 11:39:06 +0100 Subject: [PATCH 25/27] fix: tests and compilations --- contracts/DistributionCreator.sol | 7 ------- 1 file changed, 7 deletions(-) diff --git a/contracts/DistributionCreator.sol b/contracts/DistributionCreator.sol index 89a24a1f..b7142e99 100644 --- a/contracts/DistributionCreator.sol +++ b/contracts/DistributionCreator.sol @@ -457,13 +457,6 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { } } - /// @notice Updates the address that receives protocol fees from campaign creation - /// @param _feeRecipient New fee recipient address - function setFeeRecipient(address _feeRecipient) external onlyGovernor { - feeRecipient = _feeRecipient; - emit FeeRecipientUpdated(_feeRecipient); - } - /// @notice Updates the address that receives protocol fees from campaign creation /// @param _feeRecipient New fee recipient address /// @dev Only callable by governor From 005348eb322c87fff762013ff5f12753221943b1 Mon Sep 17 00:00:00 2001 From: Pablo Veyrat Date: Fri, 14 Nov 2025 12:00:18 +0100 Subject: [PATCH 26/27] fix: lint --- .prettierrc | 5 +- README.md | 86 ++++++++++++------- contracts/AccessControlManager.sol | 4 +- contracts/DistributionCreator.sol | 75 +++------------- contracts/Distributor.sol | 14 +-- contracts/ReferralRegistry.sol | 14 +-- .../middleman/MerklGaugeMiddlemanTemplate.sol | 6 +- .../tokenWrappers/NativeTokenWrapper.sol | 7 +- .../partners/tokenWrappers/PointToken.sol | 16 +--- .../tokenWrappers/PufferPointTokenWrapper.sol | 9 +- contracts/utils/UUPSHelper.sol | 3 +- 11 files changed, 87 insertions(+), 152 deletions(-) diff --git a/.prettierrc b/.prettierrc index bc496395..416f27f5 100644 --- a/.prettierrc +++ b/.prettierrc @@ -10,9 +10,10 @@ { "files": "*.sol", "options": { - "printWidth": 120, + "printWidth": 145, "singleQuote": false, - "bracketSpacing": true + "bracketSpacing": true, + "explicitTypes": "always" } } ] diff --git a/README.md b/README.md index 62059f73..fc722941 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,20 @@ [![CI](https://github.com/AngleProtocol/merkl-contracts/actions/workflows/ci.yml/badge.svg)](https://github.com/AngleProtocol/merkl-contracts/actions) [![Coverage](https://codecov.io/gh/AngleProtocol/merkl-contracts/branch/main/graph/badge.svg)](https://codecov.io/gh/AngleProtocol/merkl-contracts) -This repository contains the smart contracts of Merkl. +This repository contains the core smart contracts for the Merkl solution. -It basically contains two contracts: +The system consists of two primary contracts: -- `DistributionCreator`: to which DAOs and individuals can deposit their rewards to incentivize onchain actions -- `Distributor`: the contract where users can claim their rewards +- `DistributionCreator`: Allows DAOs and individuals to deposit rewards for incentivizing onchain actions +- `Distributor`: Enables users to claim their earned rewards -You can learn more about the Merkl system in the [documentation](https://docs.merkl.xyz). +Learn more about Merkl in the [official documentation](https://docs.merkl.xyz). ## Setup ### Install packages -You can install all dependencies by running +Install all dependencies by running: ```bash bun i @@ -22,37 +22,48 @@ bun i ### Create `.env` file -You can copy paste `.env.example` file into `.env` and fill with your keys/RPCs. +Copy the `.env.example` file to `.env` and populate it with your keys and RPC endpoints: -Warning: always keep your confidential information safe. +```bash +cp .env.example .env +``` + +**Warning:** Always keep your confidential information secure and never commit `.env` files to version control. ### Foundry Installation +Install Foundry using the official installer: + ```bash curl -L https://foundry.paradigm.xyz | bash source /root/.zshrc -# or, if you're under bash: source /root/.bashrc +# or, if you're using bash: source /root/.bashrc foundryup ``` ## Tests +Run the complete test suite: + ```bash -# Whole test suite forge test ``` ## Deploying -Run without broadcasting: +### Simulate deployment (dry run) + +Run a script without broadcasting transactions to the network: ```bash yarn foundry:script --rpc-url ``` -Run with broadcasting: +### Deploy to network + +Execute and broadcast transactions: ```bash yarn foundry:deploy --rpc-url @@ -60,64 +71,77 @@ yarn foundry:deploy --rpc-url ## Scripts -Scripts can be executed in two ways: +Scripts can be executed with or without parameters: -1. With parameters: directly passing values as arguments -2. Without parameters: modifying the default values in the script +1. **With parameters:** Pass values directly as command-line arguments +2. **Without parameters:** Modify default values within the script file before running ### Running Scripts +Execute scripts using the following pattern: + ```bash -# With parameters +# With parameters - pass values as arguments forge script scripts/MockToken.s.sol:Deploy --rpc-url --sender
--broadcast -i 1 \ --sig "run(string,string,uint8)" "MyToken" "MTK" 18 -# Without parameters (modify default values in the script first) +# Without parameters - modify default values in the script first forge script scripts/MockToken.s.sol:Deploy --rpc-url --sender
--broadcast -i 1 # Common options: -# --broadcast Broadcasts the transactions to the network -# --sender
The address which will execute the script -# -i 1 Open an interactive prompt to enter private key of the sender when broadcasting +# --broadcast Broadcasts transactions to the network +# --sender
Address that will execute the script +# -i 1 Opens an interactive prompt to securely enter the sender's private key ``` ### Examples +#### Deploy a mock ERC20 token + ```bash -# Deploy a Mock Token forge script scripts/MockToken.s.sol:Deploy --rpc-url --sender
--broadcast \ --sig "run(string,string,uint8)" "MyToken" "MTK" 18 +``` -# Mint tokens +#### Mint tokens to an address + +```bash forge script scripts/MockToken.s.sol:Mint --rpc-url --sender
--broadcast \ --sig "run(address,address,uint256)" 1000000000000000000 +``` -# Set minimum reward token amount +#### Configure minimum reward token amount + +```bash forge script scripts/DistributionCreator.s.sol:SetRewardTokenMinAmounts --rpc-url --sender
--broadcast \ --sig "run(address,uint256)" +``` + +#### Set campaign fees -# Set fees for campaign +```bash forge script scripts/DistributionCreator.s.sol:SetCampaignFees --rpc-url --sender
--broadcast \ --sig "run(uint32,uint256)" - ``` -For scripts without parameters, you can modify the default values directly in the script file: +### Modifying Default Script Parameters + +For scripts without parameters, modify the default values directly in the script file before execution: ```solidity // In scripts/MockToken.s.sol:Deploy function run() external broadcast { // MODIFY THESE VALUES TO SET YOUR DESIRED TOKEN PARAMETERS - string memory name = 'My Token'; // <- modify this - string memory symbol = 'MTK'; // <- modify this - uint8 decimals = 18; // <- modify this + string memory name = 'My Token'; // <- Customize token name + string memory symbol = 'MTK'; // <- Customize token symbol + uint8 decimals = 18; // <- Customize decimal places _run(name, symbol, decimals); } ``` ## Audits -The Merkl smart contracts have been audited by Code4rena, find the audit report [here](https://code4rena.com/reports/2023-06-angle). +The Merkl smart contracts have been audited by Code4rena. View the [Code4rena audit report](https://code4rena.com/reports/2023-06-angle) for details. ## Access Control @@ -125,4 +149,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) 🐦 +Reach out to us on [Twitter](https://x.com/merkl_xyz) 🐦 diff --git a/contracts/AccessControlManager.sol b/contracts/AccessControlManager.sol index 4848b34d..a69c909d 100644 --- a/contracts/AccessControlManager.sol +++ b/contracts/AccessControlManager.sol @@ -2,9 +2,7 @@ pragma solidity ^0.8.17; -import { - AccessControlEnumerableUpgradeable -} from "@openzeppelin/contracts-upgradeable/access/AccessControlEnumerableUpgradeable.sol"; +import { AccessControlEnumerableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/AccessControlEnumerableUpgradeable.sol"; import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import { IAccessControlManager } from "./interfaces/IAccessControlManager.sol"; diff --git a/contracts/DistributionCreator.sol b/contracts/DistributionCreator.sol index b7142e99..d84b74ac 100644 --- a/contracts/DistributionCreator.sol +++ b/contracts/DistributionCreator.sol @@ -2,9 +2,7 @@ pragma solidity ^0.8.17; -import { - ReentrancyGuardUpgradeable -} from "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; +import { ReentrancyGuardUpgradeable } from "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; @@ -117,12 +115,7 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { EVENTS //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ - event CreatorAllowanceUpdated( - address indexed user, - address indexed operator, - address indexed token, - uint256 amount - ); + event CreatorAllowanceUpdated(address indexed user, address indexed operator, address indexed token, uint256 amount); event CreatorBalanceUpdated(address indexed user, address indexed token, uint256 amount); event DistributorUpdated(address indexed _distributor); event FeeRebateUpdated(address indexed user, uint256 userFeeRebate); @@ -180,11 +173,7 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { /// @param _accessControlManager Address of the access control manager contract /// @param _distributor Address of the Distributor contract /// @param _fees Default fee rate in base 10^9 (must be less than BASE_9) - function initialize( - IAccessControlManager _accessControlManager, - address _distributor, - uint256 _fees - ) external initializer { + function initialize(IAccessControlManager _accessControlManager, address _distributor, uint256 _fees) external initializer { if (address(_accessControlManager) == address(0) || _distributor == address(0)) revert Errors.ZeroAddress(); if (_fees >= BASE_9) revert Errors.InvalidParam(); distributor = _distributor; @@ -214,9 +203,7 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { /// @notice Creates multiple reward distribution campaigns in a single transaction /// @param campaigns Array of campaign parameters to create /// @return Array of campaign IDs for all newly created campaigns - function createCampaigns( - CampaignParameters[] memory campaigns - ) external nonReentrant hasSigned returns (bytes32[] memory) { + function createCampaigns(CampaignParameters[] memory campaigns) external nonReentrant hasSigned returns (bytes32[] memory) { uint256 campaignsLength = campaigns.length; bytes32[] memory campaignIds = new bytes32[](campaignsLength); for (uint256 i; i < campaignsLength; ) { @@ -290,8 +277,7 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { /// @dev Can be used to deposit on behalf of another user /// @dev WARNING: Do not use with rebasing tokens as they will cause accounting issues function increaseTokenBalance(address user, address rewardToken, uint256 amount) external { - if (!accessControlManager.isGovernor(msg.sender)) - IERC20(rewardToken).safeTransferFrom(msg.sender, address(this), amount); + if (!accessControlManager.isGovernor(msg.sender)) IERC20(rewardToken).safeTransferFrom(msg.sender, address(this), amount); _updateBalance(user, rewardToken, creatorBalance[user][rewardToken] + amount); } @@ -301,12 +287,7 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { /// @param to Address that will receive the withdrawn tokens /// @param amount Amount to withdraw /// @dev Only callable by the user themselves or a governor - function decreaseTokenBalance( - address user, - address rewardToken, - address to, - uint256 amount - ) external onlyUserOrGovernor(user) { + function decreaseTokenBalance(address user, address rewardToken, address to, uint256 amount) external onlyUserOrGovernor(user) { _updateBalance(user, rewardToken, creatorBalance[user][rewardToken] - amount); IERC20(rewardToken).safeTransfer(to, amount); } @@ -317,12 +298,7 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { /// @param rewardToken Token for which allowance is granted /// @param amount Amount to increase the allowance by /// @dev Only callable by the user themselves or a governor - function increaseTokenAllowance( - address user, - address operator, - address rewardToken, - uint256 amount - ) external onlyUserOrGovernor(user) { + function increaseTokenAllowance(address user, address operator, address rewardToken, uint256 amount) external onlyUserOrGovernor(user) { _updateAllowance(user, operator, rewardToken, creatorAllowance[user][operator][rewardToken] + amount); } @@ -332,12 +308,7 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { /// @param rewardToken Token for which allowance is reduced /// @param amount Amount to decrease the allowance by /// @dev Only callable by the user themselves or a governor - function decreaseTokenAllowance( - address user, - address operator, - address rewardToken, - uint256 amount - ) external onlyUserOrGovernor(user) { + function decreaseTokenAllowance(address user, address operator, address rewardToken, uint256 amount) external onlyUserOrGovernor(user) { _updateAllowance(user, operator, rewardToken, creatorAllowance[user][operator][rewardToken] - amount); } @@ -408,10 +379,7 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { /// @param first Maximum number of tokens to return /// @return Array of reward tokens and total count /// @dev Not optimized for onchain queries; intended for off-chain/API use - function getValidRewardTokens( - uint32 skip, - uint32 first - ) external view returns (RewardTokenAmounts[] memory, uint256) { + function getValidRewardTokens(uint32 skip, uint32 first) external view returns (RewardTokenAmounts[] memory, uint256) { return _getValidRewardTokens(skip, first); } @@ -523,10 +491,7 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { /// @dev Only callable by governor or guardian /// @dev Setting amount to 0 effectively removes the token from the whitelist /// @dev Prevents duplicate entries when adding previously removed tokens - function setRewardTokenMinAmounts( - address[] calldata tokens, - uint256[] calldata amounts - ) external onlyGovernorOrGuardian { + function setRewardTokenMinAmounts(address[] calldata tokens, uint256[] calldata amounts) external onlyGovernorOrGuardian { uint256 tokensLength = tokens.length; if (tokensLength != amounts.length) revert Errors.InvalidLengths(); for (uint256 i; i < tokensLength; ) { @@ -559,8 +524,7 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { // if the reward token is not whitelisted as an incentive token if (rewardTokenMinAmount == 0) revert Errors.CampaignRewardTokenNotWhitelisted(); // if the amount distributed is too small with respect to what is allowed - if ((newCampaign.amount * HOUR) / newCampaign.duration < rewardTokenMinAmount) - revert Errors.CampaignRewardTooLow(); + if ((newCampaign.amount * HOUR) / newCampaign.duration < rewardTokenMinAmount) revert Errors.CampaignRewardTooLow(); // Computing fees and pulling tokens uint256 campaignAmountMinusFees = _computeFees(newCampaign.campaignType, newCampaign.amount); if (newCampaign.creator == address(0)) newCampaign.creator = msg.sender; @@ -612,12 +576,7 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { /// @dev Attempts to use predeposited balance first, checking operator allowance if applicable /// @dev Falls back to direct transfer from msg.sender if insufficient predeposited balance /// @dev Sends fees to feeRecipient (or this contract if feeRecipient is zero address) - function _pullTokens( - address creator, - address rewardToken, - uint256 campaignAmount, - uint256 campaignAmountMinusFees - ) internal { + function _pullTokens(address creator, address rewardToken, uint256 campaignAmount, uint256 campaignAmountMinusFees) internal { uint256 fees = campaignAmount - campaignAmountMinusFees; address _feeRecipient; if (fees > 0) { @@ -652,10 +611,7 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { /// @dev Uses campaign-specific fees if set, otherwise uses default fees /// @dev Campaign-specific fee of 1 is treated as 0 (fee waiver) /// @dev Applies fee rebates to msg.sender (not creator) - function _computeFees( - uint32 campaignType, - uint256 distributionAmount - ) internal view returns (uint256 distributionAmountMinusFees) { + function _computeFees(uint32 campaignType, uint256 distributionAmount) internal view returns (uint256 distributionAmountMinusFees) { uint256 baseFeesValue = campaignSpecificFees[campaignType]; if (baseFeesValue == 1) baseFeesValue = 0; else if (baseFeesValue == 0) baseFeesValue = defaultFees; @@ -673,10 +629,7 @@ contract DistributionCreator is UUPSHelper, ReentrancyGuardUpgradeable { /// @return Array of valid reward tokens and the index where iteration stopped /// @dev Only includes tokens with non-zero minimum amounts (active whitelist entries) /// @dev Uses assembly to resize the return array to actual length - function _getValidRewardTokens( - uint32 skip, - uint32 first - ) internal view returns (RewardTokenAmounts[] memory, uint256) { + function _getValidRewardTokens(uint32 skip, uint32 first) internal view returns (RewardTokenAmounts[] memory, uint256) { uint256 length; uint256 rewardTokenListLength = rewardTokens.length; uint256 returnSize = first > rewardTokenListLength ? rewardTokenListLength : first; diff --git a/contracts/Distributor.sol b/contracts/Distributor.sol index 61b36971..4e47dc19 100644 --- a/contracts/Distributor.sol +++ b/contracts/Distributor.sol @@ -199,12 +199,7 @@ contract Distributor is UUPSHelper { /// @param proofs Merkle proofs validating each claim /// @dev Users can only claim for themselves unless they've authorized an operator /// @dev Arrays must all have the same length - function claim( - address[] calldata users, - address[] calldata tokens, - uint256[] calldata amounts, - bytes32[][] calldata proofs - ) external { + function claim(address[] calldata users, address[] calldata tokens, uint256[] calldata amounts, bytes32[][] calldata proofs) external { address[] memory recipients = new address[](users.length); bytes[] memory datas = new bytes[](users.length); _claim(users, tokens, amounts, proofs, recipients, datas); @@ -319,8 +314,7 @@ contract Distributor is UUPSHelper { disputer != address(0) || // A trusted address cannot update a tree right after a precedent tree update otherwise it can de facto // validate a tree which has not passed the dispute period - ((canUpdateMerkleRoot[msg.sender] != 1 || block.timestamp < endOfDisputePeriod) && - !accessControlManager.isGovernor(msg.sender)) + ((canUpdateMerkleRoot[msg.sender] != 1 || block.timestamp < endOfDisputePeriod) && !accessControlManager.isGovernor(msg.sender)) ) revert Errors.NotTrusted(); MerkleTree memory _lastTree = tree; tree = _tree; @@ -492,9 +486,7 @@ contract Distributor is UUPSHelper { if (toSend != 0) { IERC20(token).safeTransfer(recipient, toSend); if (data.length != 0) { - try IClaimRecipient(recipient).onClaim(user, token, amount, data) returns ( - bytes32 callbackSuccess - ) { + try IClaimRecipient(recipient).onClaim(user, token, amount, data) returns (bytes32 callbackSuccess) { if (callbackSuccess != CALLBACK_SUCCESS) revert Errors.InvalidReturnMessage(); } catch {} } diff --git a/contracts/ReferralRegistry.sol b/contracts/ReferralRegistry.sol index 4ba9d115..ca9c90a8 100644 --- a/contracts/ReferralRegistry.sol +++ b/contracts/ReferralRegistry.sol @@ -116,13 +116,7 @@ contract ReferralRegistry is UUPSHelper { requiresRefererToBeSet: newRequiresRefererToBeSet, paymentToken: newPaymentToken }); - emit ReferralProgramModified( - key, - newCost, - newRequiresAuthorization, - newRequiresRefererToBeSet, - newPaymentToken - ); + emit ReferralProgramModified(key, newCost, newRequiresAuthorization, newRequiresRefererToBeSet, newPaymentToken); } /// @notice Marks an address as allowed to be a referrer for a specific referral key @@ -248,11 +242,7 @@ contract ReferralRegistry is UUPSHelper { //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ constructor() initializer {} - function initialize( - IAccessControlManager _accessControlManager, - uint256 _costReferralProgram, - address _feeRecipient - ) external initializer { + function initialize(IAccessControlManager _accessControlManager, uint256 _costReferralProgram, address _feeRecipient) external initializer { if (address(_accessControlManager) == address(0)) revert Errors.ZeroAddress(); accessControlManager = _accessControlManager; costReferralProgram = _costReferralProgram; diff --git a/contracts/partners/middleman/MerklGaugeMiddlemanTemplate.sol b/contracts/partners/middleman/MerklGaugeMiddlemanTemplate.sol index cce5090b..3b19192d 100644 --- a/contracts/partners/middleman/MerklGaugeMiddlemanTemplate.sol +++ b/contracts/partners/middleman/MerklGaugeMiddlemanTemplate.sol @@ -36,8 +36,7 @@ contract MerklGaugeMiddlemanTemplate is Ownable { /// @notice Address of the Merkl contract managing rewards to be distributed function merklDistributionCreator() public view virtual returns (DistributionCreator _distributionCreator) { _distributionCreator = DistributionCreator(distributionCreator); - if (address(_distributionCreator) == address(0)) - return DistributionCreator(0x8BB4C975Ff3c250e0ceEA271728547f3802B36Fd); + if (address(_distributionCreator) == address(0)) return DistributionCreator(0x8BB4C975Ff3c250e0ceEA271728547f3802B36Fd); } /// @notice Called by the gauge system to effectively create a campaign on `token` for `gauge` @@ -70,8 +69,7 @@ contract MerklGaugeMiddlemanTemplate is Ownable { /// @dev Infinite allowances on Merkl contracts are safe here (this contract never holds funds and Merkl is safe) function _handleAllowance(address token, address _distributionCreator, uint256 amount) internal { uint256 currentAllowance = IERC20(token).allowance(address(this), _distributionCreator); - if (currentAllowance < amount) - IERC20(token).safeIncreaseAllowance(_distributionCreator, type(uint256).max - currentAllowance); + if (currentAllowance < amount) IERC20(token).safeIncreaseAllowance(_distributionCreator, type(uint256).max - currentAllowance); } /// @notice Recovers idle tokens left on the contract diff --git a/contracts/partners/tokenWrappers/NativeTokenWrapper.sol b/contracts/partners/tokenWrappers/NativeTokenWrapper.sol index 1c3eecfa..207d8ce4 100644 --- a/contracts/partners/tokenWrappers/NativeTokenWrapper.sol +++ b/contracts/partners/tokenWrappers/NativeTokenWrapper.sol @@ -48,12 +48,7 @@ contract NativeTokenWrapper is UUPSHelper, ERC20Upgradeable { /// @notice Allows contract to receive ETH via fallback fallback() external payable {} - function initialize( - address _distributionCreator, - address _minter, - string memory _name, - string memory _symbol - ) public initializer { + function initialize(address _distributionCreator, address _minter, string memory _name, string memory _symbol) public initializer { __ERC20_init(string.concat(_name), string.concat(_symbol)); __UUPSUpgradeable_init(); if (_minter == address(0)) revert Errors.ZeroAddress(); diff --git a/contracts/partners/tokenWrappers/PointToken.sol b/contracts/partners/tokenWrappers/PointToken.sol index 277a9d29..33cd96f2 100644 --- a/contracts/partners/tokenWrappers/PointToken.sol +++ b/contracts/partners/tokenWrappers/PointToken.sol @@ -15,12 +15,7 @@ contract PointToken is ERC20 { IAccessControlManager public accessControlManager; uint8 public allowedTransfers; - constructor( - string memory name_, - string memory symbol_, - address _minter, - address _accessControlManager - ) ERC20(name_, symbol_) { + constructor(string memory name_, string memory symbol_, address _minter, address _accessControlManager) ERC20(name_, symbol_) { if (_accessControlManager == address(0) || _minter == address(0)) revert Errors.ZeroAddress(); accessControlManager = IAccessControlManager(_accessControlManager); minters[_minter] = true; @@ -69,12 +64,7 @@ contract PointToken is ERC20 { } function _beforeTokenTransfer(address from, address to, uint256) internal view override { - if ( - allowedTransfers == 0 && - from != address(0) && - to != address(0) && - !whitelistedRecipients[from] && - !whitelistedRecipients[to] - ) revert Errors.NotAllowed(); + if (allowedTransfers == 0 && from != address(0) && to != address(0) && !whitelistedRecipients[from] && !whitelistedRecipients[to]) + revert Errors.NotAllowed(); } } diff --git a/contracts/partners/tokenWrappers/PufferPointTokenWrapper.sol b/contracts/partners/tokenWrappers/PufferPointTokenWrapper.sol index 59eb8ca3..8858d403 100644 --- a/contracts/partners/tokenWrappers/PufferPointTokenWrapper.sol +++ b/contracts/partners/tokenWrappers/PufferPointTokenWrapper.sol @@ -138,18 +138,13 @@ contract PufferPointTokenWrapper is UUPSHelper, ERC20Upgradeable { (amountClaimable, ) = _claimable(user, maxClaimIndex); } - function getUserVestings( - address user - ) external view returns (VestingID[] memory allVestings, uint256 nextClaimIndex) { + function getUserVestings(address user) external view returns (VestingID[] memory allVestings, uint256 nextClaimIndex) { VestingData storage userVestingData = vestingData[user]; allVestings = userVestingData.allVestings; nextClaimIndex = userVestingData.nextClaimIndex; } - function _claimable( - address user, - uint256 maxClaimIndex - ) internal view returns (uint256 amountClaimable, uint256 nextClaimIndex) { + function _claimable(address user, uint256 maxClaimIndex) internal view returns (uint256 amountClaimable, uint256 nextClaimIndex) { VestingData storage userVestingData = vestingData[user]; VestingID[] storage userAllVestings = userVestingData.allVestings; uint256 i = userVestingData.nextClaimIndex; diff --git a/contracts/utils/UUPSHelper.sol b/contracts/utils/UUPSHelper.sol index 74936945..00dfd113 100644 --- a/contracts/utils/UUPSHelper.sol +++ b/contracts/utils/UUPSHelper.sol @@ -19,8 +19,7 @@ abstract contract UUPSHelper is UUPSUpgradeable { } modifier onlyGovernorUpgrader(IAccessControlManager _accessControlManager) { - if (address(_accessControlManager) != address(0) && !_accessControlManager.isGovernor(msg.sender)) - revert Errors.NotGovernor(); + if (address(_accessControlManager) != address(0) && !_accessControlManager.isGovernor(msg.sender)) revert Errors.NotGovernor(); _; } From cd2126a6196dbdab604d330b8fb85b8880ea2e68 Mon Sep 17 00:00:00 2001 From: Pablo Veyrat Date: Fri, 14 Nov 2025 12:03:14 +0100 Subject: [PATCH 27/27] rm: angle references --- CONTRIBUTING.md | 4 +--- contracts/struct/DistributionParameters.sol | 2 +- contracts/utils/UUPSHelper.sol | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index de7db213..9f4e745c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,8 +2,6 @@ First of all thank you for your interest in this repository! -This is only the beginning of the Angle protocol and codebase, and anyone is welcome to improve it. +This is only the beginning of the Merkl solution and codebase, and anyone is welcome to improve it. To submit some code, please work in a fork, reach out to explain what you've done and open a Pull Request from your fork. - -Feel free to reach out in the [#developers channel](https://discord.gg/HcRB8QMeKU) of our Discord Server if you need a hand! diff --git a/contracts/struct/DistributionParameters.sol b/contracts/struct/DistributionParameters.sol index 1cf93f1a..8e9ca951 100644 --- a/contracts/struct/DistributionParameters.sol +++ b/contracts/struct/DistributionParameters.sol @@ -18,7 +18,7 @@ struct DistributionParameters { // which need to be specified and which are not automatically detected. address[] positionWrappers; // Type (blacklist==3, whitelist==0, ...) encoded as a `uint32` for each wrapper in the list above. Mapping between - // wrapper types and their corresponding `uint32` value can be found in Angle Docs + // wrapper types and their corresponding `uint32` value can be found in Merkl Docs uint32[] wrapperTypes; // In the incentivization formula, how much of the fees should go to holders of token0 // in base 10**4 diff --git a/contracts/utils/UUPSHelper.sol b/contracts/utils/UUPSHelper.sol index 00dfd113..f021277c 100644 --- a/contracts/utils/UUPSHelper.sol +++ b/contracts/utils/UUPSHelper.sol @@ -9,7 +9,7 @@ import { Errors } from "./Errors.sol"; /// @title UUPSHelper /// @notice Helper contract for UUPSUpgradeable contracts where the upgradeability is controlled by a specific address -/// @author Angle Labs., Inc +/// @author Merkl SAS /// @dev The 0 address check in the modifier allows the use of these modifiers during initialization abstract contract UUPSHelper is UUPSUpgradeable { modifier onlyGuardianUpgrader(IAccessControlManager _accessControlManager) {