// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; /// @notice Fixed-principal staking with separately prefunded ERC20 rewards. /// @dev Standard non-rebasing, non-taxed ERC20s only. Raw stock-token units are used. contract EarnStonkPool is Ownable2Step, ReentrancyGuard { using SafeERC20 for IERC20; uint256 public constant BPS = 10_000; uint256 private constant SCALE = 1e27; IERC20 public immutable stakeToken; IERC20 public immutable rewardToken; struct Fees { uint16 escrowBps; uint16 earlyDepositBps; uint16 earlyWithdrawalBps; uint16 middleDepositBps; uint16 middleWithdrawalBps; uint16 lateDepositBps; uint16 lateWithdrawalBps; uint32 earlyDuration; uint32 middleDuration; } struct Position { uint256 gross; uint256 weight; uint64 openedAt; Fees fees; } Fees public fees; address public feeRecipient; bool public depositsPaused; bool public emergency; uint256 public totalPrincipal; uint256 public totalWeight; uint256 public rewardRate; uint256 public periodFinish; uint256 public lastUpdate; uint256 public rewardPerWeightStored; uint256 public totalFunded; uint256 public totalClaimed; uint256 public unusedRewards; mapping(address => Position[]) private positions; mapping(address => uint256) public weightOf; mapping(address => uint256) public paidPerWeight; mapping(address => uint256) public accrued; event Staked(address indexed user, uint256 indexed positionId, uint256 amount, uint256 escrow); event Withdrawn(address indexed user, uint256 indexed positionId, uint256 returnedAmount, uint256 fee); event Claimed(address indexed user, uint256 amount); event RewardsFunded(uint256 amount, uint256 duration, uint256 rate); event FeesUpdated(Fees fees); event FeeRecipientUpdated(address indexed previous, address indexed current); event DepositsPaused(bool paused); event EmergencyActivated(); event UnusedRewardsRecovered(uint256 amount); constructor(address stake, address reward, address admin, address recipient) Ownable(admin) { require(stake != reward && stake.code.length > 0 && reward.code.length > 0, "Invalid tokens"); require(recipient != address(0) && recipient != address(this), "Invalid recipient"); stakeToken = IERC20(stake); rewardToken = IERC20(reward); feeRecipient = recipient; fees = Fees(300, 300, 100, 150, 50, 0, 0, 7 days, 15 days); } function positionCount(address user) external view returns (uint256) { return positions[user].length; } function positionAt(address user, uint256 id) external view returns (Position memory) { return positions[user][id]; } function rewardPerWeight() public view returns (uint256) { uint256 end = Math.min(block.timestamp, periodFinish); if (totalWeight == 0 || end <= lastUpdate) return rewardPerWeightStored; return rewardPerWeightStored + Math.mulDiv((end - lastUpdate) * rewardRate, SCALE, totalWeight); } function earned(address user) public view returns (uint256) { return accrued[user] + Math.mulDiv(weightOf[user], rewardPerWeight() - paidPerWeight[user], SCALE); } function _update(address user) internal { uint256 end = Math.min(block.timestamp, periodFinish); if (totalWeight == 0 && end > lastUpdate) unusedRewards += (end - lastUpdate) * rewardRate; rewardPerWeightStored = rewardPerWeight(); lastUpdate = Math.min(block.timestamp, periodFinish); if (user != address(0)) { accrued[user] = earned(user); paidPerWeight[user] = rewardPerWeightStored; } } function stake(uint256 amount) external nonReentrant { require(!depositsPaused && !emergency, "Deposits paused"); require(amount > 0, "Zero amount"); require(block.timestamp < periodFinish && rewardRate > 0, "Rewards not active"); _update(msg.sender); uint256 beforeBalance = stakeToken.balanceOf(address(this)); stakeToken.safeTransferFrom(msg.sender, address(this), amount); require(stakeToken.balanceOf(address(this)) - beforeBalance == amount, "Unsupported token"); uint256 escrow = Math.mulDiv(amount, fees.escrowBps, BPS); uint256 weight = amount - escrow; require(weight > 0, "Zero weight"); positions[msg.sender].push(Position(amount, weight, uint64(block.timestamp), fees)); totalPrincipal += amount; totalWeight += weight; weightOf[msg.sender] += weight; emit Staked(msg.sender, positions[msg.sender].length - 1, amount, escrow); } function previewWithdrawal(address user, uint256 id) public view returns (uint256 returnedAmount, uint256 fee) { Position storage p = positions[user][id]; uint256 age = block.timestamp - p.openedAt; Fees memory f = p.fees; uint256 depositBps; uint256 withdrawalBps; if (!emergency) { if (age < f.earlyDuration) { depositBps = f.earlyDepositBps; withdrawalBps = f.earlyWithdrawalBps; } else if (age <= f.middleDuration) { depositBps = f.middleDepositBps; withdrawalBps = f.middleWithdrawalBps; } else { depositBps = f.lateDepositBps; withdrawalBps = f.lateWithdrawalBps; } } fee = Math.mulDiv(p.gross, depositBps, BPS) + Math.mulDiv(p.gross, withdrawalBps, BPS); returnedAmount = p.gross - fee; } /// @notice Withdraw a whole deposit; accrued rewards remain claimable separately. function withdraw(uint256 id) external nonReentrant { Position storage p = positions[msg.sender][id]; require(p.gross != 0, "Position closed"); _update(msg.sender); (uint256 returnedAmount, uint256 fee) = previewWithdrawal(msg.sender, id); totalPrincipal -= p.gross; totalWeight -= p.weight; weightOf[msg.sender] -= p.weight; p.gross = 0; p.weight = 0; stakeToken.safeTransfer(msg.sender, returnedAmount); if (fee != 0) stakeToken.safeTransfer(feeRecipient, fee); emit Withdrawn(msg.sender, id, returnedAmount, fee); } function claim() external nonReentrant { _update(msg.sender); uint256 amount = accrued[msg.sender]; require(amount != 0, "No rewards"); accrued[msg.sender] = 0; totalClaimed += amount; rewardToken.safeTransfer(msg.sender, amount); emit Claimed(msg.sender, amount); } /// @notice Funding does not replace or dilute an active reward schedule. function fundRewards(uint256 amount, uint256 duration) external onlyOwner nonReentrant { require(!emergency && block.timestamp >= periodFinish, "Schedule active or emergency"); require(duration >= 1 hours && duration <= 365 days && amount >= duration, "Invalid schedule"); // Bounds keep reward index arithmetic safe even for very small deposits. require(amount <= 1e36 && totalFunded + amount <= 1e45, "Funding limit"); _update(address(0)); uint256 beforeBalance = rewardToken.balanceOf(address(this)); rewardToken.safeTransferFrom(msg.sender, address(this), amount); require(rewardToken.balanceOf(address(this)) - beforeBalance == amount, "Unsupported token"); totalFunded += amount; rewardRate = amount / duration; unusedRewards += amount % duration; lastUpdate = block.timestamp; periodFinish = block.timestamp + duration; emit RewardsFunded(amount, duration, rewardRate); } function setFees(Fees calldata f) external onlyOwner { require(f.escrowBps < BPS, "Invalid escrow"); require(f.earlyDepositBps <= f.escrowBps && f.middleDepositBps <= f.escrowBps && f.lateDepositBps <= f.escrowBps, "Fee exceeds escrow"); require(uint256(f.earlyDepositBps) + f.earlyWithdrawalBps <= BPS && uint256(f.middleDepositBps) + f.middleWithdrawalBps <= BPS && uint256(f.lateDepositBps) + f.lateWithdrawalBps <= BPS, "Invalid fee total"); require(f.earlyDuration > 0 && f.middleDuration > f.earlyDuration, "Invalid durations"); fees = f; emit FeesUpdated(f); } function setFeeRecipient(address recipient) external onlyOwner { require(recipient != address(0) && recipient != address(this), "Invalid recipient"); emit FeeRecipientUpdated(feeRecipient, recipient); feeRecipient = recipient; } function setDepositsPaused(bool paused) external onlyOwner { require(!emergency, "Emergency permanent"); depositsPaused = paused; emit DepositsPaused(paused); } /// @notice Permanently stop this pool and waive all exit fees; preserve principal and earned rewards. function emergencyExit() external onlyOwner nonReentrant { require(!emergency, "Already emergency"); _update(address(0)); if (periodFinish > block.timestamp) unusedRewards += (periodFinish - block.timestamp) * rewardRate; emergency = true; depositsPaused = true; periodFinish = block.timestamp; lastUpdate = block.timestamp; rewardRate = 0; emit EmergencyActivated(); } /// @notice Recover only rewards never allocated to stakers, sent to current owner. function recoverUnusedRewards() external onlyOwner nonReentrant { _update(address(0)); uint256 amount = unusedRewards; require(amount > 0, "No unused rewards"); unusedRewards = 0; rewardToken.safeTransfer(owner(), amount); emit UnusedRewardsRecovered(amount); } /// @dev Prevent accidentally abandoning emergency/admin capabilities. function renounceOwnership() public override onlyOwner { revert("Use two-step transfer"); } }