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