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/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/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/README.md b/README.md index 083ee925..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,67 +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)" - -# 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: +### 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 @@ -128,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/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/contracts/AccessControlManager.sol b/contracts/AccessControlManager.sol index 700873c3..a69c909d 100644 --- a/contracts/AccessControlManager.sol +++ b/contracts/AccessControlManager.sol @@ -8,12 +8,14 @@ import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/I import { IAccessControlManager } from "./interfaces/IAccessControlManager.sol"; /// @title AccessControlManager -/// @author Angle Labs, Inc. -/// @notice This contract handles the access control across all contracts +/// @author Merkl SAS +/// @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 +29,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 +62,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 a8615e1a..d84b74ac 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. -/// @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 +/// @author Merkl SAS +/// @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; @@ -32,136 +27,153 @@ 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 + /// @notice Keccak256 hash of the message that users must sign or accept 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 - 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 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 - kept for storage layout compatibility mapping(address => uint256) public isWhitelistedToken; - /// @notice Deprecated, kept for storage compatibility + /// @notice Deprecated - kept for storage layout compatibility mapping(address => uint256) public _nonces; - /// @notice Maps an address to the last valid hash signed + /// @notice Deprecated - kept for storage layout compatibility mapping(address => bytes32) public userSignatures; - /// @notice Maps a user to whether it is whitelisted for not signing + /// @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 + /// @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 creator addresses to their predeposited token balances for each reward token + mapping(address => mapping(address => uint256)) public creatorBalance; + + /// @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 creator addresses to authorized campaign operators who can manage campaigns on their behalf + mapping(address => mapping(address => uint256)) public campaignOperators; + /*////////////////////////////////////////////////////////////////////////////////////////////////////////////////// EVENTS //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ + 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); 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); 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 //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ - /// @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 && - userSignatures[msg.sender] != messageHash && userSignatureWhitelist[tx.origin] == 0 && + userSignatures[msg.sender] != messageHash && userSignatures[tx.origin] != messageHash ) revert Errors.NotSigned(); _; } + /// @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(); + _; + } + /*////////////////////////////////////////////////////////////////////////////////////////////////////////////////// CONSTRUCTOR //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ - function initialize( - IAccessControlManager _accessControlManager, - address _distributor, - uint256 _fees - ) external initializer { + /// @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, uint256 _fees) external initializer { if (address(_accessControlManager) == address(0) || _distributor == address(0)) revert Errors.ZeroAddress(); if (_fees >= BASE_9) revert Errors.InvalidParam(); distributor = _distributor; @@ -178,21 +190,20 @@ 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 - /// @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 campaign amounts actually deposited for each `campaign` in the `campaigns` list - function createCampaigns( - CampaignParameters[] memory campaigns - ) external nonReentrant hasSigned returns (bytes32[] memory) { + /// @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) { uint256 campaignsLength = campaigns.length; bytes32[] memory campaignIds = new bytes32[](campaignsLength); for (uint256 i; i < campaignsLength; ) { @@ -204,60 +215,23 @@ 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 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 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 - /// 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); 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 @@ -265,81 +239,116 @@ 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; + // The creator address cannot be changed + newCampaign.creator = _campaign.creator; campaignOverrides[_campaignId] = newCampaign; campaignOverridesTimestamp[_campaignId].push(block.timestamp); emit CampaignOverride(_campaignId, newCampaign); } - /// @notice Reallocates rewards of a given campaign from one address to another - /// @dev To prevent manipulations by campaign creators, this function can only be called by the - /// initial campaign creator if the `from` address has never claimed any reward on the chain - /// @dev Compute engine should also make sure when reallocating rewards that `from` claimed amount - /// is still 0 - otherwise double allocation can happen - /// @dev It is meant to be used for the case of addresses accruing rewards but unable to claim them + /// @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); - 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; - 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) - } - - if (count == 0) revert Errors.InvalidOverride(); - emit CampaignReallocation(_campaignId, successfullFrom, to); + emit CampaignReallocation(_campaignId, froms, to); + } + + /// @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 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, address to, uint256 amount) external onlyUserOrGovernor(user) { + _updateBalance(user, rewardToken, creatorBalance[user][rewardToken] - amount); + IERC20(rewardToken).safeTransfer(to, amount); + } + + /// @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, address rewardToken, uint256 amount) external onlyUserOrGovernor(user) { + _updateAllowance(user, operator, rewardToken, creatorAllowance[user][operator][rewardToken] + amount); + } + + /// @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, address rewardToken, uint256 amount) external onlyUserOrGovernor(user) { + _updateAllowance(user, operator, rewardToken, creatorAllowance[user][operator][rewardToken] - amount); + } + + /// @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; + emit CampaignOperatorToggled(user, operator, currentStatus == 0); } /*////////////////////////////////////////////////////////////////////////////////////////////////////////////////// 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 + /// @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 overriden, 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 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 + /// @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( @@ -357,41 +366,33 @@ 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 - function getValidRewardTokens( - uint32 skip, - uint32 first - ) external view returns (RewardTokenAmounts[] memory, uint256) { + /// @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) external view returns (RewardTokenAmounts[] memory, uint256) { 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 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 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]; } @@ -400,21 +401,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 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` + /// @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; ) { @@ -425,70 +425,98 @@ 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 + /// @dev Only callable by governor function setFeeRecipient(address _feeRecipient) external onlyGovernor { feeRecipient = _feeRecipient; emit FeeRecipientUpdated(_feeRecipient); } - /// @notice Sets the message that needs to be signed by users before posting rewards - function setMessage(string memory _message) external onlyGovernor { + /// @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)); messageHash = _messageHash; emit MessageUpdated(_messageHash); } - /// @notice Sets the fees specific for a campaign - /// @dev To waive the fees for a campaign, set its fees to 1 + /// @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 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 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 + /// @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 Sets the minimum amounts per distribution epoch for different reward tokens - function setRewardTokenMinAmounts( - address[] calldata tokens, - uint256[] calldata amounts - ) external onlyGovernorOrGuardian { + /// @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 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) 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 //////////////////////////////////////////////////////////////////////////////////////////////////////////////////*/ - /// @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 @@ -496,18 +524,11 @@ 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; - - // 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); + _pullTokens(newCampaign.creator, newCampaign.rewardToken, newCampaign.amount, campaignAmountMinusFees); newCampaign.amount = campaignAmountMinusFees; newCampaign.campaignId = campaignId(newCampaign); @@ -519,137 +540,96 @@ 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; - } + /// @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(); } + } - assembly { - mstore(whitelist, whitelistLength) - mstore(blacklist, blacklistLength) + /// @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 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 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, uint256 campaignAmount, uint256 campaignAmountMinusFees) internal { + uint256 fees = campaignAmount - campaignAmountMinusFees; + 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) { + uint256 senderAllowance = creatorAllowance[creator][msg.sender][rewardToken]; + if (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; + } + } + _updateBalance(creator, rewardToken, userBalance - campaignAmount); + 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); + 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) { + /// @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) internal view returns (uint256 distributionAmountMinusFees) { 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) { 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 - function _getValidRewardTokens( - uint32 skip, - uint32 first - ) internal view returns (RewardTokenAmounts[] memory, uint256) { + /// @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) internal view returns (RewardTokenAmounts[] memory, uint256) { uint256 length; uint256 rewardTokenListLength = rewardTokens.length; uint256 returnSize = first > rewardTokenListLength ? rewardTokenListLength : first; @@ -678,5 +658,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; } diff --git a/contracts/DistributionCreatorWithDistributions.sol b/contracts/DistributionCreatorWithDistributions.sol new file mode 100644 index 00000000..e0e5e8e1 --- /dev/null +++ b/contracts/DistributionCreatorWithDistributions.sol @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: BUSL-1.1 + +pragma solidity ^0.8.17; + +import { CampaignParameters } from "./struct/CampaignParameters.sol"; +import { DistributionParameters } from "./struct/DistributionParameters.sol"; +import { DistributionCreator } from "./DistributionCreator.sol"; + +/// @title DistributionCreatorWithDistributions +/// @author Merkl SAS +/// @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 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 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 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 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) { + uint256 wrapperLength = distributionToConvert.wrapperTypes.length; + address[] memory whitelist = new address[](wrapperLength); + 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]); + whitelistLength += 1; + } + if (distributionToConvert.wrapperTypes[k] == 3) { + blacklist[blacklistLength] = (distributionToConvert.positionWrappers[k]); + blacklistLength += 1; + } + } + + // Resize arrays to actual lengths using assembly + 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, // 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) + ) + }); + } + + /** + * @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[50] private __gap2; +} diff --git a/contracts/Distributor.sol b/contracts/Distributor.sol index 60c8d50b..4e47dc19 100644 --- a/contracts/Distributor.sol +++ b/contracts/Distributor.sol @@ -9,95 +9,108 @@ 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)` - // 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; } -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 +/// @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; - uint256[36] private __gap; + /// @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; /*////////////////////////////////////////////////////////////////////////////////////////////////////////////////// EVENTS @@ -111,6 +124,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); @@ -123,30 +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` 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(); + /// @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(); @@ -166,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; @@ -178,28 +192,28 @@ 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 - function claim( - address[] calldata users, - address[] calldata tokens, - uint256[] calldata amounts, - bytes32[][] calldata proofs - ) external { + /// @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, 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); } - /// @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, @@ -211,12 +225,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; @@ -226,27 +246,52 @@ 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 - function toggleOperator(address user, address operator) external onlyTrustedOrUser(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]; operators[user][operator] = 1 - oldValue; emit OperatorToggled(user, operator, oldValue == 0); } - /// @notice Sets a recipient for a user claiming rewards for a token - /// @dev This is an optional functionality and if the `recipient` is set to the zero address, then - /// the user will still accrue all rewards to its address - /// @dev Users may still specify a different recipient when they claim token rewards with the - /// `claimWithRecipient` function + /// @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 { - claimRecipient[msg.sender][token] = recipient; - emit ClaimRecipientUpdated(msg.sender, recipient, token); + _setClaimRecipient(msg.sender, recipient, token); + } + + /// @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 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 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 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(); @@ -259,14 +304,17 @@ 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) || // 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; @@ -277,27 +325,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) { @@ -312,33 +370,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; @@ -349,7 +420,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, @@ -374,9 +453,16 @@ 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 && + !accessControlManager.isGovernorOrGuardian(msg.sender) + ) revert Errors.NotWhitelisted(); // Verifying proof bytes32 leaf = keccak256(abi.encode(user, token, amount)); @@ -392,6 +478,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; } @@ -399,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 {} } @@ -412,7 +497,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; @@ -426,17 +512,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; @@ -454,4 +543,13 @@ contract Distributor is UUPSHelper { if (root == bytes32(0)) revert Errors.InvalidUninitializedRoot(); return currentHash == root; } + + /// @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/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/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/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/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/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/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/AaveTokenWrapper.sol b/contracts/partners/tokenWrappers/AaveTokenWrapper.sol deleted file mode 100644 index c3b190bd..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 transfered 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 04f07271..00000000 --- a/contracts/partners/tokenWrappers/BaseTokenWrapper.sol +++ /dev/null @@ -1,74 +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"; - -interface IDistributionCreator { - function distributor() external view returns (address); - - function feeRecipient() external view returns (address); -} - -abstract contract BaseMerklTokenWrapper is UUPSHelper, ERC20Upgradeable { - using SafeERC20 for IERC20; - - // ================================= CONSTANTS ================================= - - IDistributionCreator public constant DISTRIBUTOR_CREATOR = - IDistributionCreator(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 570129e4..00000000 --- a/contracts/partners/tokenWrappers/BobTokenWrapper.sol +++ /dev/null @@ -1,146 +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"; - -interface IDistributionCreator { - function distributor() external view returns (address); - function feeRecipient() external view returns (address); -} - -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 = IDistributionCreator(_distributionCreator).distributor(); - feeRecipient = IDistributionCreator(_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 = IDistributionCreator(_distributionCreator).distributor(); - distributor = _distributor; - distributionCreator = _distributionCreator; - emit MerklAddressesUpdated(_distributionCreator, _distributor); - _setFeeRecipient(); - } - - function setFeeRecipient() external { - _setFeeRecipient(); - } - - function _setFeeRecipient() internal { - address _feeRecipient = IDistributionCreator(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 51b1332d..00000000 --- a/contracts/partners/tokenWrappers/EtherealWrapper.sol +++ /dev/null @@ -1,147 +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"; - -interface IDistributionCreator { - function distributor() external view returns (address); - function feeRecipient() external view returns (address); -} - -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 = IDistributionCreator(_distributionCreator).distributor(); - feeRecipient = IDistributionCreator(_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 = IDistributionCreator(_distributionCreator).distributor(); - distributor = _distributor; - distributionCreator = _distributionCreator; - emit MerklAddressesUpdated(_distributionCreator, _distributor); - _setFeeRecipient(); - } - - function setFeeRecipient() external { - _setFeeRecipient(); - } - - function _setFeeRecipient() internal { - address _feeRecipient = IDistributionCreator(distributionCreator).feeRecipient(); - feeRecipient = _feeRecipient; - emit FeeRecipientUpdated(_feeRecipient); - } -} diff --git a/contracts/partners/tokenWrappers/NativeTokenWrapper.sol b/contracts/partners/tokenWrappers/NativeTokenWrapper.sol new file mode 100644 index 00000000..207d8ce4 --- /dev/null +++ b/contracts/partners/tokenWrappers/NativeTokenWrapper.sol @@ -0,0 +1,130 @@ +// 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 mintWithNative() 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; + } + + 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) {} +} diff --git a/contracts/partners/tokenWrappers/PointToken.sol b/contracts/partners/tokenWrappers/PointToken.sol index 7c2a3f3a..33cd96f2 100644 --- a/contracts/partners/tokenWrappers/PointToken.sol +++ b/contracts/partners/tokenWrappers/PointToken.sol @@ -2,12 +2,12 @@ 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 Angle Labs, Inc. +/// @author Merkl SAS /// @notice Reference contract for points systems within Merkl contract PointToken is ERC20 { mapping(address => bool) public minters; @@ -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 f9b7f4ed..8858d403 100644 --- a/contracts/partners/tokenWrappers/PufferPointTokenWrapper.sol +++ b/contracts/partners/tokenWrappers/PufferPointTokenWrapper.sol @@ -6,10 +6,11 @@ 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"; +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) { @@ -142,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; @@ -193,7 +184,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 +202,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/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/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; 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/contracts/partners/tokenWrappers/TokenTGEWrapper.sol b/contracts/partners/tokenWrappers/TokenTGEWrapper.sol index d7084c43..54ef77d7 100644 --- a/contracts/partners/tokenWrappers/TokenTGEWrapper.sol +++ b/contracts/partners/tokenWrappers/TokenTGEWrapper.sol @@ -6,15 +6,11 @@ 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"; - -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/contracts/struct/CampaignParameters.sol b/contracts/struct/CampaignParameters.sol index 41e641cd..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 overriden 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/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/Errors.sol b/contracts/utils/Errors.sol index 774ab2b1..78a44c31 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(); @@ -23,6 +24,8 @@ library Errors { error NoDispute(); error NoOverrideForCampaign(); error NotAllowed(); + error NotEnoughAllowance(); + error NotEnoughBalance(); error NotEnoughPayment(); error NotGovernor(); error NotGovernorOrGuardian(); @@ -30,6 +33,7 @@ library Errors { error NotTrusted(); error NotUpgradeable(); error NotWhitelisted(); + error OperatorNotAllowed(); error UnresolvedDispute(); error ZeroAddress(); error DisputeFundsTransferFailed(); diff --git a/contracts/utils/UUPSHelper.sol b/contracts/utils/UUPSHelper.sol index 74936945..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) { @@ -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(); _; } diff --git a/foundry.toml b/foundry.toml index 9bb4b751..c5cdde79 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}" @@ -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/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/DistributionCreator.s.sol b/scripts/DistributionCreator.s.sol index 13ad76ba..e97d9beb 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 { @@ -221,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); @@ -319,28 +298,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 +673,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/scripts/PointToken.s.sol b/scripts/PointToken.s.sol index e9c85488..fe276c06 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 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 = "kpk Points"; + string memory symbol = "kpkPoints"; 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 = 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(0x79b42b18c4479a6F0f0cf398CFF5674896A12AD1); - token.transfer(0x79b42b18c4479a6F0f0cf398CFF5674896A12AD1, 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 7db59f79..789b7b98 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"; @@ -25,25 +27,26 @@ contract DeployPullTokenWrapper is BaseScript { address distributionCreator = 0x8BB4C975Ff3c250e0ceEA271728547f3802B36Fd; // ------------------------------------------------------------------------ // TO EDIT - address underlying = 0xEc4ef66D4fCeEba34aBB4dE69dB391Bc5476ccc8; - 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 PullTokenWrapper()); - // Ethereum implementation of PullTokenWrapper - // address implementation = 0x979a04fd2f3A6a2B3945A715e24b974323E93567; + // address implementation = address(new PullTokenWrapperWithdraw()); + // address implementation = address(new PullTokenWrapperAllow()); + // 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 = "USDtb (wrapped)"; - symbol = "USDtb"; + // name = "USDT0 (wrapped)"; + // symbol = "USDT0"; + + // ------------------------------------------------------------------------ console.log("PullTokenWrapper Implementation:", address(implementation)); @@ -52,7 +55,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/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(); 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/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/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); + } +} diff --git a/test/DistributionCreator.t.sol b/test/DistributionCreator.t.sol index 8f58fc1b..b08c9d7e 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"; @@ -35,8 +34,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 +165,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); @@ -217,6 +212,47 @@ contract DistributionCreatorCreateReallocationTest is Fixture { }) ); + { + address[] memory users = new address[](1); + users[0] = bob; + + vm.prank(alice); + vm.expectRevert(Errors.InvalidReallocation.selector); + creator.reallocateCampaignRewards(campaignId, users, address(governor)); + + assertEq(creator.campaignReallocation(campaignId, alice), address(0)); + address[] memory listReallocation = creator.getCampaignListReallocation(campaignId); + assertEq(listReallocation.length, 0); + } + } + + function testUnit_ReallocationCampaignRewards_Success() public { + IERC20 rewardToken = IERC20(address(angle)); + uint256 amount = 100 ether; + uint32 startTimestamp = uint32(block.timestamp + 600); + + vm.prank(alice); + bytes32 campaignId = creator.createCampaign( + CampaignParameters({ + campaignId: bytes32(0), + creator: alice, + rewardToken: address(rewardToken), + amount: amount, + campaignType: 1, + startTimestamp: startTimestamp, + duration: 3600 * 24, + campaignData: abi.encode( + 0xbEEfa1aBfEbE621DF50ceaEF9f54FdB73648c92C, + new address[](0), + new address[](0), + "", + new bytes[](0), + new bytes[](0), + hex"" + ) + }) + ); + vm.prank(governor); // Create false tree distributor.updateTree( @@ -245,26 +281,63 @@ contract DistributionCreatorCreateReallocationTest is Fixture { 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] = bob; + users[0] = alice; - vm.prank(alice); - vm.expectRevert(Errors.InvalidOverride.selector); + 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.reallocateCampaignRewards(campaignId, users, address(governor)); + + assertEq(creator.campaignReallocation(campaignId, alice), address(governor)); + address[] memory listReallocation = creator.getCampaignListReallocation(campaignId); - assertEq(listReallocation.length, 0); + 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_revertWhen_AlreadyClaimed() public { - IERC20 rewardToken = IERC20(address(agEUR)); + function testUnit_ReallocationCampaignRewards_SuccessWhenOperator() public { + IERC20 rewardToken = IERC20(address(angle)); uint256 amount = 100 ether; uint32 startTimestamp = uint32(block.timestamp + 600); @@ -318,25 +391,46 @@ contract DistributionCreatorCreateReallocationTest is Fixture { 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] = bob; + users[0] = alice; - vm.prank(alice); - vm.expectRevert(Errors.InvalidOverride.selector); + 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, 0); + assertEq(listReallocation.length, 1); + assertEq(listReallocation[0], alice); } } - function testUnit_ReallocationCampaignRewards_Success() public { + function testUnit_ReallocationCampaignRewards_SuccessWhenGovernor() public { IERC20 rewardToken = IERC20(address(angle)); uint256 amount = 100 ether; uint32 startTimestamp = uint32(block.timestamp + 600); @@ -364,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), @@ -392,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); } @@ -404,7 +497,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)); @@ -414,7 +507,7 @@ contract DistributionCreatorCreateReallocationTest is Fixture { address[] memory users = new address[](1); users[0] = alice; - vm.prank(alice); + vm.prank(governor); creator.reallocateCampaignRewards(campaignId, users, address(governor)); assertEq(creator.campaignReallocation(campaignId, alice), address(governor)); @@ -460,8 +553,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); @@ -537,6 +628,192 @@ 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.prank(governor); + creator.toggleCampaignOperator(alice, governor); + + 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; @@ -663,7 +940,7 @@ contract DistributionCreatorOverrideTest is Fixture { hex"" ); - vm.expectRevert(Errors.InvalidOverride.selector); + vm.expectRevert(Errors.OperatorNotAllowed.selector); vm.prank(bob); creator.overrideCampaign( campaignId, @@ -679,7 +956,7 @@ contract DistributionCreatorOverrideTest is Fixture { }) ); - vm.expectRevert(Errors.InvalidOverride.selector); + vm.expectRevert(Errors.OperatorNotAllowed.selector); vm.prank(bob); creator.overrideCampaign( campaignId, @@ -1131,11 +1408,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; @@ -1152,12 +1429,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 @@ -1179,6 +1455,7 @@ contract UpgradeDistributionCreatorTest is Test, JsonReader { vm.startPrank(deployer); MockToken(address(rewardToken)).mint(deployer, amount); rewardToken.approve(address(distributionCreator), amount); + distributionCreator.acceptConditions(); // Create test campaign testCampaignId = distributionCreator.createCampaign( @@ -1208,8 +1485,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 @@ -1217,16 +1494,10 @@ 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); - assertEq(distributionCreator.isWhitelistedToken(this.readAddress(chainId, "EUR.AgToken")), 1); + assertEq(distributionCreator.isWhitelistedToken(0xA61BeB4A3d02decb01039e378237032B351125B4), 1); assertEq(distributionCreator._nonces(testAddr), 4); assertEq( distributionCreator.userSignatures(testAddr), @@ -1512,151 +1783,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/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..a694b3e5 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"; @@ -30,11 +31,6 @@ contract DistributionCreatorTest is Fixture { numEpoch = 25; 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 +88,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 { @@ -264,133 +262,719 @@ contract Test_DistributionCreator_CreateDistribution is DistributionCreatorTest } } -contract Test_DistributionCreator_CreateDistributions is DistributionCreatorTest { - function test_RevertWhen_CampaignAlreadyExists() public { - DistributionParameters memory distribution = DistributionParameters({ - uniV3Pool: address(pool), +contract Test_DistributionCreator_CreateCampaign is DistributionCreatorTest { + function test_RevertWhen_CampaignDurationIsZero() public { + CampaignParameters memory campaign = CampaignParameters({ + campaignId: keccak256("TEST"), + creator: address(0), + campaignData: hex"ab", 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"" + campaignType: 0, + startTimestamp: uint32(block.timestamp + 1), + duration: 0 }); - vm.expectRevert(Errors.CampaignAlreadyExists.selector); + vm.expectRevert(Errors.CampaignDurationBelowHour.selector); - DistributionParameters[] memory distributions = new DistributionParameters[](2); - distributions[0] = distribution; - distributions[1] = distribution; vm.prank(alice); - creator.createDistributions(distributions); + creator.createCampaign(campaign); + (campaign); } - function test_Success() public { - DistributionParameters[] memory distributions = new DistributionParameters[](2); - distributions[0] = DistributionParameters({ - uniV3Pool: address(pool), - rewardToken: address(angle), - positionWrappers: positionWrappers, - wrapperTypes: wrapperTypes, + function test_RevertWhen_CampaignRewardTokenNotWhitelisted() public { + CampaignParameters memory campaign = CampaignParameters({ + campaignId: keccak256("TEST"), + creator: address(0), + campaignData: hex"ab", + rewardToken: address(alice), 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"" + campaignType: 0, + startTimestamp: uint32(block.timestamp + 1), + duration: 3600 }); - distributions[1] = DistributionParameters({ - uniV3Pool: address(pool), + vm.expectRevert(Errors.CampaignRewardTokenNotWhitelisted.selector); + + vm.prank(alice); + creator.createCampaign(campaign); + } + + function test_RevertWhen_CampaignRewardTooLow() public { + CampaignParameters memory campaign = CampaignParameters({ + campaignId: keccak256("TEST"), + creator: address(0), + campaignData: hex"ab", 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"" + amount: 1e8 - 1, + campaignType: 0, + startTimestamp: uint32(block.timestamp + 1), + duration: 3600 }); + vm.expectRevert(Errors.CampaignRewardTooLow.selector); + vm.prank(alice); - creator.createDistributions(distributions); + creator.createCampaign(campaign); } -} -contract Test_DistributionCreator_CreateCampaign is DistributionCreatorTest { - function test_RevertWhen_CampaignDurationIsZero() public { + function test_Success() public { + uint256 amount = 1e8; CampaignParameters memory campaign = CampaignParameters({ campaignId: keccak256("TEST"), creator: address(0), campaignData: hex"ab", rewardToken: address(angle), - amount: 1e10, + amount: amount, campaignType: 0, startTimestamp: uint32(block.timestamp + 1), - duration: 0 + duration: 3600 }); - vm.expectRevert(Errors.CampaignDurationBelowHour.selector); vm.prank(alice); creator.createCampaign(campaign); - (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, + 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_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({ + campaignId: keccak256("TEST"), + creator: address(0), + 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, + 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_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); + } + + function test_SuccessFromPreDepositedBalanceAndAllowanceWithNoFees() 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), 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); + } + + 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_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_RevertWhen_CampaignRewardTokenNotWhitelisted() public { + function test_SuccessFromPreDepositedBalanceAndNoAllowanceWithFees() public { + uint256 amount = 1e8; CampaignParameters memory campaign = CampaignParameters({ campaignId: keccak256("TEST"), - creator: address(0), + creator: address(alice), campaignData: hex"ab", - rewardToken: address(alice), - amount: 1e10, + rewardToken: address(angle), + amount: amount, campaignType: 0, startTimestamp: uint32(block.timestamp + 1), duration: 3600 }); - vm.expectRevert(Errors.CampaignRewardTokenNotWhitelisted.selector); - vm.prank(alice); - creator.createCampaign(campaign); - } + vm.prank(governor); + creator.setFeeRecipient(dylan); - function test_RevertWhen_CampaignRewardTooLow() public { - CampaignParameters memory campaign = CampaignParameters({ - campaignId: keccak256("TEST"), - creator: address(0), - campaignData: hex"ab", - rewardToken: address(angle), - amount: 1e8 - 1, - campaignType: 0, - startTimestamp: uint32(block.timestamp + 1), - duration: 3600 - }); - vm.expectRevert(Errors.CampaignRewardTooLow.selector); + angle.mint(address(charlie), 1e10); + vm.prank(charlie); + angle.approve(address(creator), type(uint256).max); - vm.prank(alice); - creator.createCampaign(campaign); + { + 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_Success() public { + function test_SuccessFromPreDepositedBalanceAndNoFeeRecipient() public { uint256 amount = 1e8; CampaignParameters memory campaign = CampaignParameters({ campaignId: keccak256("TEST"), - creator: address(0), + creator: address(alice), campaignData: hex"ab", rewardToken: address(angle), amount: amount, @@ -399,8 +983,23 @@ contract Test_DistributionCreator_CreateCampaign is DistributionCreatorTest { duration: 3600 }); - vm.prank(alice); - creator.createCampaign(campaign); + 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; @@ -434,7 +1033,7 @@ contract Test_DistributionCreator_CreateCampaign is DistributionCreatorTest { bytes memory fetchedCampaignData ) = creator.campaignList(creator.campaignLookup(campaignId)); assertEq(alice, fetchedCreator); - assertEq((amount * 9) / 10, fetchedAmount); // amount minus 10% fees + assertEq((amount * 9) / 10, fetchedAmount); assertEq(address(angle), fetchedRewardToken); assertEq(campaign.campaignType, fetchedCampaignType); assertEq(campaign.startTimestamp, fetchedStartTimestamp); @@ -443,21 +1042,48 @@ contract Test_DistributionCreator_CreateCampaign is DistributionCreatorTest { assertEq(campaignId, fetchedCampaignId); } - function test_Succeed_CampaignStartInThePast() public { + function test_SuccessFromPreDepositedBalanceAndNoAllowanceWithFeesButNoRecipient() public { uint256 amount = 1e8; CampaignParameters memory campaign = CampaignParameters({ campaignId: keccak256("TEST"), - creator: address(0), + creator: address(alice), campaignData: hex"ab", rewardToken: address(angle), amount: amount, campaignType: 0, - startTimestamp: uint32(block.timestamp - 1), + startTimestamp: uint32(block.timestamp + 1), duration: 3600 }); - vm.prank(alice); - creator.createCampaign(campaign); + 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; @@ -491,7 +1117,7 @@ contract Test_DistributionCreator_CreateCampaign is DistributionCreatorTest { bytes memory fetchedCampaignData ) = creator.campaignList(creator.campaignLookup(campaignId)); assertEq(alice, fetchedCreator); - assertEq((amount * 9) / 10, fetchedAmount); // amount minus 10% fees + assertEq((amount * 9) / 10, fetchedAmount); assertEq(address(angle), fetchedRewardToken); assertEq(campaign.campaignType, fetchedCampaignType); assertEq(campaign.startTimestamp, fetchedStartTimestamp); @@ -701,35 +1327,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); @@ -742,8 +1339,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); } @@ -855,104 +1452,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); } } @@ -973,3 +1479,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(); + } +} diff --git a/test/unit/Distributor.t.sol b/test/unit/Distributor.t.sol index a2427b27..6e623cd0 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({ @@ -478,19 +478,17 @@ 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 { - console.log(alice, bob, address(angle), address(agEUR)); vm.prank(governor); distributor.updateTree( MerkleTree({ @@ -590,6 +588,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 +968,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)); + } +} diff --git a/test/unit/partners/tokenWrappers/NativeTokenWrapper.t.sol b/test/unit/partners/tokenWrappers/NativeTokenWrapper.t.sol new file mode 100644 index 00000000..a5a7c9f7 --- /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_mintWithNative is NativeTokenWrapperTest { + function test_RevertWhen_NotAllowed() public { + vm.deal(bob, 10 ether); + + vm.expectRevert(Errors.NotAllowed.selector); + vm.prank(bob); + wrapper.mintWithNative{ 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.mintWithNative{ 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.mintWithNative{ 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.mintWithNative{ 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); + } +} diff --git a/test/unit/partners/tokenWrappers/PullTokenWrapperAllow.t.sol b/test/unit/partners/tokenWrappers/PullTokenWrapperAllow.t.sol new file mode 100644 index 00000000..f1f32135 --- /dev/null +++ b/test/unit/partners/tokenWrappers/PullTokenWrapperAllow.t.sol @@ -0,0 +1,491 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.17; + +import { Test } from "forge-std/Test.sol"; +import { console } from "forge-std/console.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"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/// @dev Mock contract to simulate the Distributor +contract MockDistributor { + PullTokenWrapperAllow public wrapper; + + function setWrapper(address _wrapper) external { + wrapper = PullTokenWrapperAllow(_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 PullTokenWrapperAllowTest is Fixture { + PullTokenWrapperAllow public wrapper; + PullTokenWrapperAllow 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 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))); + 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_PullTokenWrapperAllow_Initialize is PullTokenWrapperAllowTest { + PullTokenWrapperAllow w; + + function setUp() public override { + super.setUp(); + w = PullTokenWrapperAllow(deployUUPS(address(new PullTokenWrapperAllow()), 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_PullTokenWrapperAllow_Mint is PullTokenWrapperAllowTest { + 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_PullTokenWrapperAllow_SetHolder is PullTokenWrapperAllowTest { + 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_PullTokenWrapperAllow_BeforeTokenTransfer is PullTokenWrapperAllowTest { + 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 bobAngleBalanceBefore = angle.balanceOf(bob); + uint256 aliceAngleBalanceBefore = angle.balanceOf(alice); + + // Mint to bob + vm.prank(alice); + wrapper.mint(50 ether); + vm.prank(alice); + wrapper.transfer(bob, 50 ether); + + // Charlie should NOT receive underlying tokens from alice + assertEq(angle.balanceOf(bob), bobAngleBalanceBefore); + // Alice's balance should remain unchanged for this transfer + assertEq(angle.balanceOf(alice), aliceAngleBalanceBefore); + // 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), aliceAngleBalance); + + vm.expectRevert("ERC20: transfer amount exceeds balance"); + 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_PullTokenWrapperAllow_AfterTokenTransfer is PullTokenWrapperAllowTest { + 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); + } +} + +contract Test_PullTokenWrapperAllow_SetFeeRecipient is PullTokenWrapperAllowTest { + 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_PullTokenWrapperAllow_Decimals is PullTokenWrapperAllowTest { + function test_Success_MatchesUnderlyingToken() public { + assertEq(wrapper.decimals(), angle.decimals()); + } +} + +contract Test_PullTokenWrapperAllow_Integration is PullTokenWrapperAllowTest { + 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_PullTokenWrapperAllow_EdgeCases is PullTokenWrapperAllowTest { + 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); + } +}