WENBlocksManager.sol

Manages weekly airdrops using Merkle trees, penalty redistribution, Uniswap V2 liquidity provisioning, and LP token vesting schedules.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";

interface IUniswapV2Factory {
    function getPair(address tokenA, address tokenB) external view returns (address pair);
}

interface IUniswapV2Router {
    function factory() external view returns (address);

    function addLiquidityETH(
        address token,
        uint amountTokenDesired,
        uint amountTokenMin,
        uint amountETHMin,
        address to,
        uint deadline
    ) external payable returns (uint amountToken, uint amountETH, uint liquidity);

    function addLiquidity(
        address tokenA,
        address tokenB,
        uint amountADesired,
        uint amountBDesired,
        uint amountAMin,
        uint amountBMin,
        address to,
        uint deadline
    ) external returns (uint amountA, uint amountB, uint liquidity);
}
 
interface IWNM {
    function assignExternalStakingRewards(uint256 _amountWNM, uint256 _amountWBLK, uint256 WUNI) external;
    function stake(address owner, string memory _stakename, uint256 _amount, uint256 _stakingdays, uint256 _wblkamount) external;
    function getCurrentDay() external returns (uint256);
    function incrementDay() external;
}

interface IMintableERC20 is IERC20 {
    function mint(address to, uint256 amount) external;
}

/// @title WENBlocksManager
/// @author Yonko
/// @notice Handles airdrops, penalties, and LP token vesting for the WEN ecosystem.
/// @dev Integrates Merkle proof verification, liquidity provisioning, and staking incentives.
contract WENBlocksManager is Ownable, ReentrancyGuard {
    /// @notice Represents an airdrop round (weekly).
    /// @dev Used to track Merkle root, airdrop start day, and remaining unclaimed tokens.
    struct Airdrop {
        bytes32 merkleRoot;
        uint256 startDay;
        uint256 unclaimedWNM;
        uint256 unclaimedWBLK;
        uint256 unclaimedWUNI;
    }

    /// @notice Represents a user's LP position received from liquidity provisioning.
    /// @dev Tracks total LP received, amount claimed, and vesting start day.
    struct LPPosition {
        address lpToken;
        uint256 totalLP;
        uint256 claimedLP;
        uint64 vestingStart;
    }

    /// @notice Tracks LP positions by user
    mapping(address => LPPosition[]) public userLPPositions;
    /// @notice Stores weekly airdrop info
    mapping(uint256 => Airdrop) public airdrops;
    /// @notice Tracks whether a user has claimed for a given airdrop
    mapping(address => mapping(uint256 => bool)) public hasClaimed;

    /// @notice Interface to the WNM contract (used for staking and inflation interaction).
    IWNM public wnmContract;

    /// @notice Mintable WNM token contract (ERC20-compatible).
    IMintableERC20 public immutable tWNM;

    /// @notice Mintable WBLK token contract (ERC20-compatible).
    IMintableERC20 public immutable tWBLK;

    /// @notice Mintable WUNI token contract (ERC20-compatible).
    IMintableERC20 public immutable tWUNI;

    /// @notice Mintable WNT token contract (ERC20-compatible).
    IMintableERC20 public immutable tWNT;

    /// @notice UniswapV2 router used for liquidity provisioning.
    IUniswapV2Router public uniswapRouter;

    /// @notice Address of the deployed UniswapV2 router.
    address public immutable uniswapRouterAddress;

    /// @notice Address of deadWallet.
    /// @dev Hardcoded to ensure the deadWallet cannot be changed.
    address public constant deadWallet = 0x000000000000000000000000000000000000dEaD;
    
    /// @notice XEN token address used for ERC20-based liquidity provisioning.
    /// @dev Hardcoded to ensure only the approved XEN token is used as the liquidity pair.
    address public constant xenToken = 0xF2e42B36F62d6B060Fd4b0b2E30080A6f70e6296;

    /// @notice Address of the server address.
    address public server;

    /// @notice Address of WNT token.
    address public wntAddress;

    /// @notice Address of WNM token.
    address public wnmAddress;

    /// @notice Address of WBLK token.
    address public wblkAddress;

    /// @notice Address of WUNI token.
    address public wuniAddress;

    /// @notice Total taxed WNM available for liquidity provisioning.
    uint256 public liquidityWNM;

    /// @notice Total taxed WBLK available for liquidity provisioning.
    uint256 public liquidityWBLK;

    /// @notice Total taxed WUNI available for liquidity provisioning.
    uint256 public liquidityWUNI;

    /// @notice Total WNT available for liquidity provisioning.
    uint256 public liquidityWNT;

    /// @notice Tracks the current week ID (used for airdrops).
    uint256 public currentWeek;

    /// @notice Last WEN day when penalties were applied to past airdrops.
    uint256 public lastProcessedDay;
    
    /// @notice Emitted when a new airdrop is created.
    /// @param weekId ID of the airdrop week.
    /// @param merkleRoot Merkle root used for claim verification.
    /// @param startDay The WEN day when the airdrop started.
    event AirdropCreated(uint256 weekId, bytes32 merkleRoot, uint256 startDay);

    /// @notice Emitted when a user claims vested LP tokens.
    /// @param user The address of the user claiming LP tokens.
    /// @param token The LP token being claimed.
    /// @param amount The amount of LP tokens claimed.
    event LPClaimed(address indexed user, address indexed token, uint256 amount);

    /// @notice Emitted when unclaimed airdrop tokens are taxed.
    /// @param weekId The week for which the tax was applied.
    /// @param taxedWNM The amount of WNM tokens taxed.
    /// @param taxedWBLK The amount of WBLK tokens taxed.
    /// @param taxedWUNI The amount of WUNI tokens taxed.
    event TaxedTokens(uint256 weekId, uint256 taxedWNM, uint256 taxedWBLK, uint256 taxedWUNI);

    /// @notice Emitted when taxed tokens are minted and added to liquidity.
    /// @param amountWNM The amount of WNM tokens minted.
    /// @param amountWBLK The amount of WBLK tokens minted.
    /// @param amountWUNI The amount of WUNI tokens minted.
    event MintedTaxedTokens(uint256 amountWNM, uint256 amountWBLK, uint256 amountWUNI);

    /// @notice Emitted when a user successfully claims their airdrop.
    /// @param account The user who claimed the airdrop.
    /// @param weekId The week of the airdrop being claimed.
    /// @param amountWNM Amount of WNM claimed.
    /// @param amountWBLK Amount of WBLK claimed.
    /// @param amountWUNI Amount of WUNI claimed.
    event Claimed(address indexed account, uint256 weekId, uint256 amountWNM, uint256 amountWBLK, uint256 amountWUNI);

    /// @notice Emitted when a user provides liquidity with ETH and receives LP tokens.
    /// @param user The address of the user who provided the liquidity.
    /// @param token The ERC20 token paired with ETH.
    /// @param amountToken The amount of the ERC20 token added.
    /// @param amountETH The amount of ETH added.
    /// @param lpToken The address of the LP token received.
    /// @param lpAmount The amount of LP tokens received.
    event LiquidityProvidedWithETH(address indexed user, address indexed token, uint256 amountToken, uint256 amountETH, address indexed lpToken, uint256 lpAmount);

    /// @notice Emitted when liquidity is successfully provided and LP tokens are received.
    /// @param user The address of the user who provided the liquidity.
    /// @param tokenA The first token used in the pair (usually WEN token).
    /// @param tokenB The second token in the pair (ETH or XEN).
    /// @param amountA The amount of tokenA provided.
    /// @param amountB The amount of tokenB provided.
    /// @param lpAmount The amount of LP tokens received.
    event LiquidityProvidedWithXEN(address indexed user, address indexed tokenA, address indexed tokenB, uint256 amountA, uint256 amountB, uint256 lpAmount);

    /// @notice Restricts function access to the designated server address.
    /// @dev This modifier ensures that only the `server` can call the function.
    /// Reverts if the caller is not the current server.
    modifier onlyServer() {
        require(msg.sender == server, "Only server can call");
        _;
    }

    /// @notice Deploys the WENBlocksManager contract and initializes token and router addresses.
    /// @param _tWNM The address of the WNM token (mintable).
    /// @param _tWBLK The address of the WBLK token (mintable).
    /// @param _tWUNI The address of the WUNI token (mintable).
    /// @param _uniswapRouterAddress The address of the UniswapV2 router.
    constructor(address _tWNM, address _tWBLK, address _tWUNI, address _tWNT, address _server, address _uniswapRouterAddress) Ownable(msg.sender) {
        require(_tWNM != address(0) && _tWBLK != address(0) && _tWUNI != address(0), "Invalid address");
        tWNM = IMintableERC20(_tWNM);
        tWBLK = IMintableERC20(_tWBLK);
        tWUNI = IMintableERC20(_tWUNI);
        tWNT = IMintableERC20(_tWNT);
        wnmContract = IWNM(_tWNM);
        wntAddress = _tWNT;
        wnmAddress = _tWNM;
        wblkAddress = _tWBLK;
        wuniAddress = _tWUNI;
        server = _server;
        liquidityWNT = 10_000_000 * 1e18;
        uniswapRouterAddress = _uniswapRouterAddress;
        uniswapRouter = IUniswapV2Router(_uniswapRouterAddress);
        lastProcessedDay = 1; // Daily penalties start from day 6
    }

    /// @notice Fallback function to receive ETH. Required for adding ETH liquidity.
    receive() external payable {}

    /// @notice Updates the server address authorized to call server-only functions.
    /// @dev Can only be called by the current `server` address.
    /// @param newServer The new address to set as the authorized server.
    function updateServer(address newServer) external onlyServer {
        require(newServer != address(0), "Invalid server");
        server = newServer;
    }

    /// @notice Updates penalties on unclaimed airdrops once per day.
    /// @dev Iterates over the last 7 weeks and applies daily tax if needed.
    function updateDailyPenalty() external {
        uint256 _currentDay = wnmContract.getCurrentDay();
        if (_currentDay <= lastProcessedDay) return;

        uint256 startWeek = currentWeek > 8 ? currentWeek - 7 : 1;
        for (uint256 weekId = startWeek; weekId <= currentWeek; weekId++) {
            _applyDailyPenalty(weekId);
        }
        lastProcessedDay = _currentDay;
    }

    /// @notice Creates a new weekly airdrop with a Merkle root and token totals.
    /// @param _merkleRoot The Merkle root representing eligible claimants and amounts.
    /// @param totalWNM Total WNM tokens allocated for the airdrop.
    /// @param totalWBLK Total WBLK tokens allocated for the airdrop.
    /// @param totalWUNI Total WUNI tokens allocated for the airdrop.
    function createAirdrop(bytes32 _merkleRoot, uint256 totalWNM, uint256 totalWBLK, uint256 totalWUNI) external onlyServer {
        require(_merkleRoot != bytes32(0), "Invalid Merkle Root");
        uint256 _currentDay = wnmContract.getCurrentDay();
        currentWeek++;
        airdrops[currentWeek] = Airdrop({
            merkleRoot: _merkleRoot,
            startDay: _currentDay,
            unclaimedWNM: totalWNM,
            unclaimedWBLK: totalWBLK,
            unclaimedWUNI: totalWUNI
        });

        emit AirdropCreated(currentWeek, _merkleRoot, _currentDay);
    }

    /// @notice Claims tokens from a weekly airdrop using a valid Merkle proof.
    /// @dev Applies penalty based on how many days passed since airdrop start.
    /// @param weekId The airdrop week to claim from.
    /// @param amountWNM Amount of WNM tokens to claim.
    /// @param amountWBLK Amount of WBLK tokens to claim.
    /// @param amountWUNI Amount of WUNI tokens to claim.
    /// @param proof Merkle proof verifying the user's eligibility.
    /// @param asStake If true, stakes WNM directly into the WNM contract instead of minting.
    function claimAirdrop(
        uint256 weekId,
        uint256 amountWNM,
        uint256 amountWBLK,
        uint256 amountWUNI,
        bytes32[] calldata proof,
        bool asStake
    ) external nonReentrant {
        Airdrop storage airdrop = airdrops[weekId];
        require(airdrop.merkleRoot != bytes32(0), "Airdrop does not exist");
        require(!hasClaimed[msg.sender][weekId], "Already claimed");

        bytes32 leaf = keccak256(abi.encodePacked(msg.sender, amountWNM, amountWBLK, amountWUNI));
        require(MerkleProof.verify(proof, airdrop.merkleRoot, leaf), "Invalid proof");

        hasClaimed[msg.sender][weekId] = true;

        uint256 penaltyRate;
        uint256 _currentDay = wnmContract.getCurrentDay();
        uint256 elapsedDays = _currentDay - airdrop.startDay;

        if (elapsedDays < 7) {
            penaltyRate = 0;
        } else if (elapsedDays >= 7) {
            penaltyRate = elapsedDays > 57 ? 100 : (elapsedDays - 7) * 2;
        }

        if (amountWNM > 0) {
            uint256 amountToMint = calculateMintAfterPenalty(amountWNM, penaltyRate);
            airdrop.unclaimedWNM -= amountWNM;
            if (asStake) {
                wnmContract.stake(msg.sender, "AIRDROP", amountToMint, 365, amountWBLK);
            } else {
                tWNM.mint(msg.sender, amountToMint);
            }
        }
        if (amountWBLK > 0) {
            uint256 amountToMint = calculateMintAfterPenalty(amountWBLK, penaltyRate);
            airdrop.unclaimedWBLK -= amountWBLK;
            if (!asStake) {
                tWBLK.mint(msg.sender, amountToMint);
            }
        }
        if (amountWUNI > 0) {
            uint256 amountToMint = calculateMintAfterPenalty(amountWUNI, penaltyRate);
            airdrop.unclaimedWUNI -= amountWUNI;
            tWUNI.mint(msg.sender, amountToMint);
        }

        emit Claimed(msg.sender, weekId, amountWNM, amountWBLK, amountWUNI);
    }
    
    /// @dev Calculates mint amount after applying penalty
    /// @param amount Original amount in base units (1 = 1 wei)
    /// @param rate Penalty rate in percentage (0-100)
    /// @notice Uses 1e18 scaling internally to maintain precision
    function calculateMintAfterPenalty(uint256 amount, uint256 rate) internal pure returns (uint256) {
        uint256 base = amount * 1e18;
        uint256 penalty = base * rate / 100;
        return base - penalty;
    }
    
    /// @notice Internally applies a 1% penalty per day on unclaimed tokens from an airdrop.
    /// @param weekId The ID of the airdrop to apply penalties on.
    function _applyDailyPenalty(uint256 weekId) internal {
        Airdrop storage airdrop = airdrops[weekId];
        uint256 _currentDay = wnmContract.getCurrentDay();
        uint256 elapsedDays = _currentDay - airdrop.startDay;
        
        if (elapsedDays <= 7) return; // No penalty before 7 days
        if (elapsedDays > 57) return; // No penalty before 7 days

        // 1% penalty rate but applied twice - once for assigning staking rewards and once for the liquidity, so the penalty in total is 2%
        uint256 tmptaxedWNM = airdrop.unclaimedWNM / 100;
        uint256 tmptaxedWBLK = airdrop.unclaimedWBLK / 100;
        uint256 tmptaxedWUNI = airdrop.unclaimedWUNI / 100;
        if (weekId == 1 && (tmptaxedWNM > 0 || tmptaxedWBLK > 0 || tmptaxedWUNI > 0)) {
            tWNM.mint(deadWallet, tmptaxedWNM * 6 / 10); // 60% of the tmptaxedWNM which is 30% of the total 2% penalty
            tWBLK.mint(deadWallet, tmptaxedWBLK * 6 / 10); // 60% of the tmptaxedWBLK which is 30% of the total 2% penalty
            tWUNI.mint(deadWallet, tmptaxedWUNI * 6 / 10); // 60% of the tmptaxedWUNI which is 30% of the total 2% penalty
            wnmContract.assignExternalStakingRewards(tmptaxedWNM * 4 / 10, tmptaxedWBLK * 4 / 10, tmptaxedWUNI * 4 / 10);
        } else if (tmptaxedWNM > 0 || tmptaxedWBLK > 0 || tmptaxedWUNI > 0) {
            wnmContract.assignExternalStakingRewards(tmptaxedWNM, tmptaxedWBLK, tmptaxedWUNI);
        }
        liquidityWNM += tmptaxedWNM;
        liquidityWBLK += tmptaxedWBLK;
        liquidityWUNI += tmptaxedWUNI;

        emit TaxedTokens(weekId, tmptaxedWNM, tmptaxedWBLK, tmptaxedWUNI);
    }
    
    /// @notice Converts taxed tokens into LP tokens by providing ETH and adding liquidity.
    /// @param token The token to provide as liquidity (WNM/WBLK/WUNI).
    /// @param amount The amount of token to convert into LP via Uniswap.
    function mintTaxedTokenWithLiquidity(address token, uint256 amount, bool useETH, uint256 xenAmount) external payable nonReentrant {
        require(amount > 0, "Amount must be > 0");
        if (token == wnmAddress) {
            require(amount <= liquidityWNM, "Not enough WNM liquidity");
            tWNM.mint(address(this), amount);
            liquidityWNM -= amount;
        } else if (token == wblkAddress) {
            require(amount <= liquidityWBLK, "Not enough WBLK liquidity");
            tWBLK.mint(address(this), amount);
            liquidityWBLK -= amount;
        } else if (token == wuniAddress) {
            require(amount <= liquidityWUNI, "Not enough WUNI liquidity");
            tWUNI.mint(address(this), amount);
            liquidityWUNI -= amount;
        } else if (token == wntAddress) {
            require(amount <= liquidityWNT, "Not enough WNT liquidity");
            tWNT.mint(address(this), amount);
            liquidityWNT -= amount;
        } else {
            revert("Unsupported token");
        }

        if (useETH) {
            _addLiquidityWithETH(amount, token); // your existing ETH logic
        } else {
            _addLiquidityWithERC20(token, xenToken, amount, xenAmount);
        }

        emit MintedTaxedTokens(token == wnmAddress ? amount : 0, token == wblkAddress ? amount : 0, token == wuniAddress ? amount : 0);
    }
    
    /// @notice Internally provides liquidity with ETH by pairing the WEN token and ETH on Uniswap.
    /// @dev Uses Uniswap's `addLiquidityETH` and emits an event after storing LP position.
    /// @param tokenAmount The amount of WEN token to be paired with ETH.
    /// @param token The address of the WEN token being provided.
    /// @custom:emit Emits a `LiquidityProvidedWithETH` event upon successful liquidity provision.
    function _addLiquidityWithETH(uint256 tokenAmount, address token) internal {
        require(IERC20(token).approve(uniswapRouterAddress, tokenAmount), "Token approval failed");
        address wethToken = 0xae13d989daC2f0dEbFf460aC112a837C89BAa7cd;
        // Add liquidity
        (uint256 amountToken, uint256 amountETH, uint256 liquidity) = uniswapRouter.addLiquidityETH{ value: msg.value }(
            token,
            tokenAmount,
            tokenAmount, // Minimum amount of tokens to add
            msg.value * 98 / 100, // Minimum amount of ETH to add
            address(this),          // Receiver of liquidity tokens
            block.timestamp + 300   // Deadline
        );
        // ✅ Get actual LP token address from Uniswap factory
        address factory = IUniswapV2Router(uniswapRouterAddress).factory();
        address lpToken = IUniswapV2Factory(factory).getPair(token, wethToken);
        require(lpToken != address(0), "LP token address not found");

        _addLPPosition(msg.sender, lpToken, liquidity);

        emit LiquidityProvidedWithETH(msg.sender, token, amountToken, amountETH, lpToken, liquidity);
    }

    /// @notice Internally provides liquidity with ERC20 tokens (WEN + XEN) on Uniswap.
    /// @dev Transfers XEN from user, approves both tokens, and adds liquidity. Emits LP event.
    /// @param tokenA The WEN token to be paired.
    /// @param tokenB The XEN token used for pairing (must be xenToken).
    /// @param amountA The amount of WEN token provided.
    /// @param amountB The amount of XEN token provided.
    /// @custom:emit Emits `LiquidityProvidedWithXEN` upon success.
    function _addLiquidityWithERC20(address tokenA, address tokenB, uint256 amountA, uint256 amountB) internal {
        require(IERC20(tokenB).transferFrom(msg.sender, address(this), amountB), "XEN transfer failed");
        require(IERC20(tokenA).approve(uniswapRouterAddress, amountA), "TokenA approval failed");
        require(IERC20(tokenB).approve(uniswapRouterAddress, amountB), "TokenB approval failed");

        (uint256 amountTokenA, uint256 amountTokenB, uint256 liquidity) = IUniswapV2Router(uniswapRouterAddress).addLiquidity(
            tokenA,
            tokenB,
            amountA,
            amountB,
            amountA,
            amountB * 98 / 100,
            address(this),
            block.timestamp + 300
        );

        // Get LP token address and store position
        address factory = IUniswapV2Router(uniswapRouterAddress).factory();
        address lpToken = IUniswapV2Factory(factory).getPair(tokenA, tokenB);
        require(lpToken != address(0), "LP token address not found");

        _addLPPosition(msg.sender, lpToken, liquidity);

        emit LiquidityProvidedWithXEN(msg.sender, tokenA, tokenB, amountTokenA, amountTokenB, liquidity);
    }

    
    /// @notice Stores the LP position for the user with vesting start day.
    /// @param user The user who receives the
    function _addLPPosition(address user, address lpToken, uint256 liquidity) internal {
        uint256 _currentDay = wnmContract.getCurrentDay();
        LPPosition memory newPosition = LPPosition({
            lpToken: lpToken,
            totalLP: liquidity,
            claimedLP: 0,
            vestingStart: _currentDay
        });
        userLPPositions[user].push(newPosition);
    }
    
    /// @notice Returns a paginated list of a user's LP positions.
    /// @dev Allows fetching LP positions in ranges to support frontend pagination.
    /// @param user The address of the user whose LP positions to retrieve.
    /// @param startIndex The starting index of the LP position array (inclusive).
    /// @param endIndex The ending index of the LP position array (exclusive).
    /// @return result A dynamic array containing the user's LP positions in the specified range.
    function getUserLPPositionsPaginated(address user, uint256 startIndex, uint256 endIndex) external view returns (LPPosition[] memory) {
        uint256 length = userLPPositions[user].length;
        if (endIndex > length) {
            endIndex = length;
        } else {
            endIndex = endIndex;
        }
        require(endIndex > startIndex, "Invalid indices");

        // Determine the size of the result array
        uint256 resultSize = endIndex - startIndex;
        LPPosition[] memory result = new LPPosition[](resultSize);

        // Copy the range of LP positions to the result array
        for (uint256 i = 0; i < resultSize; i++) {
            result[i] = userLPPositions[user][startIndex + i];
        }

        return result;
    }
    
    /// @notice Allows users to claim vested LP tokens based on a 30-day linear vesting schedule.
    /// @dev Releases 10% of the total LP per 30-day interval. Only available if user has LP and vesting has started.
    /// @param index The index of the LP position in the user's LP position array.
    /// @custom:reverts Reverts if the LP index is invalid, no LP exists, or no claimable tokens are available.
    function claimLP(uint256 index) external nonReentrant {
        require(index < userLPPositions[msg.sender].length, "Invalid LP index");
        uint256 _currentDay = wnmContract.getCurrentDay();
        LPPosition storage position = userLPPositions[msg.sender][index];
        require(position.totalLP > 0, "No LP tokens available");

        uint256 vestingStart = position.vestingStart;
        uint256 daysPassed = _currentDay - vestingStart;
        require(daysPassed >= 30, "Cannot claim yet, wait for 30 days");

        // Calculate claimable intervals
        uint256 claimableIntervals = daysPassed / 30;
        if (claimableIntervals > 10) {
            claimableIntervals = 10;
        }
        uint256 maxClaimable = (position.totalLP * claimableIntervals * 10) / 100;
        uint256 alreadyClaimed = position.claimedLP;
        uint256 claimable = maxClaimable - alreadyClaimed;

        require(claimable > 0, "No claimable LP tokens");

        // Update claimed amount
        position.claimedLP += claimable;

        // Transfer the claimable LP tokens to the user
        IERC20 lpToken = IERC20(position.lpToken);
        require(lpToken.transfer(msg.sender, claimable), "LP transfer failed");

        emit LPClaimed(msg.sender, position.lpToken, claimable);
    }
}

Last updated