// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; /// @title FledgeStaking /// @notice Lock FLEDGE in one of several time-locked pools (e.g. 1 month / /// 6 months / 1 year / 3 years) and earn a share of that pool's finite reward budget, /// distributed pro-rata to your share of the pool (Synthetix-style /// accumulator). Rewards accrue continuously; principal is claimed with /// rewards at unlock. Stake token == reward token == FLEDGE. /// @dev Trust properties: /// - NO owner. Nobody can withdraw users' stakes or rewards. /// - `rewardDistributor` is an immutable ADD-ONLY role: it can fund pools /// with rewards, nothing else. It cannot take principal, take rewards, or /// change lock terms — it is NOT a rug vector. /// - Safety valve: `emergencyWithdraw` lets a user pull their OWN principal /// early, forfeiting accrued rewards. User-initiated, principal-only. /// Stranded-reward semantics (INTENTIONAL, a consequence of no-owner): /// rewards emitted while a pool has zero stakers, rewards forfeited via /// emergencyWithdraw, and accumulator rounding dust all remain in the /// contract with no recovery path — for FLEDGE this is a de facto burn. /// Operational rule: call fund() on a pool only once it has stake, or /// accept that the empty-period emission is burned. /// HIGH-RISK reward math — AUDIT before mainnet / real funds. contract FledgeStaking is ReentrancyGuard { using SafeERC20 for IERC20; IERC20 public immutable token; // FLEDGE (stake + reward) address public immutable rewardDistributor; // add-only funder // Sanity bound on lock/emission windows (audit 2026-07-25). Far above any // real staking horizon, but it makes `block.timestamp + duration` fit a // uint64 with vast headroom — so the timestamp down-casts below can never // silently truncate (which could otherwise strand rewards or void a lock). uint64 public constant MAX_DURATION = 3650 days; // 10 years struct Pool { uint64 lockDuration; // seconds a stake is locked uint256 rewardRate; // reward tokens per second uint64 periodFinish; // emission end uint64 lastUpdateTime; uint256 rewardPerTokenStored; uint256 totalStaked; uint256 rewardBudget; // lifetime rewards funded (reference) } Pool[] public pools; mapping(uint256 => mapping(address => uint256)) public staked; mapping(uint256 => mapping(address => uint256)) public userRewardPerTokenPaid; mapping(uint256 => mapping(address => uint256)) public rewards; mapping(uint256 => mapping(address => uint64)) public unlockAt; event Staked(uint256 indexed pid, address indexed user, uint256 amount, uint64 unlockAt); event Withdrawn(uint256 indexed pid, address indexed user, uint256 principal, uint256 reward); event EmergencyWithdraw(uint256 indexed pid, address indexed user, uint256 principal); event Funded(uint256 indexed pid, uint256 amount, uint64 duration); constructor(address token_, address rewardDistributor_, uint64[] memory lockDurations) { require(token_ != address(0) && rewardDistributor_ != address(0), "zero addr"); require(lockDurations.length >= 1, "no pools"); token = IERC20(token_); rewardDistributor = rewardDistributor_; for (uint256 i = 0; i < lockDurations.length; i++) { require(lockDurations[i] > 0 && lockDurations[i] <= MAX_DURATION, "bad lock"); pools.push(Pool({ lockDuration: lockDurations[i], rewardRate: 0, periodFinish: 0, lastUpdateTime: 0, rewardPerTokenStored: 0, totalStaked: 0, rewardBudget: 0 })); } } // ── Reward accounting (Synthetix) ── function _lastApplicable(Pool storage p) internal view returns (uint256) { return block.timestamp < p.periodFinish ? block.timestamp : p.periodFinish; } function rewardPerToken(uint256 pid) public view returns (uint256) { Pool storage p = pools[pid]; if (p.totalStaked == 0) return p.rewardPerTokenStored; return p.rewardPerTokenStored + ((_lastApplicable(p) - p.lastUpdateTime) * p.rewardRate * 1e18) / p.totalStaked; } function earned(uint256 pid, address a) public view returns (uint256) { return (staked[pid][a] * (rewardPerToken(pid) - userRewardPerTokenPaid[pid][a])) / 1e18 + rewards[pid][a]; } modifier update(uint256 pid, address a) { Pool storage p = pools[pid]; p.rewardPerTokenStored = rewardPerToken(pid); p.lastUpdateTime = uint64(_lastApplicable(p)); if (a != address(0)) { rewards[pid][a] = earned(pid, a); userRewardPerTokenPaid[pid][a] = p.rewardPerTokenStored; } _; } // ── Funding (add-only role) ── function fund(uint256 pid, uint256 amount, uint64 duration) external nonReentrant update(pid, address(0)) { require(msg.sender == rewardDistributor, "not distributor"); require(duration > 0 && duration <= MAX_DURATION && amount > 0, "bad fund"); Pool storage p = pools[pid]; uint256 pre = token.balanceOf(address(this)); token.safeTransferFrom(msg.sender, address(this), amount); uint256 received = token.balanceOf(address(this)) - pre; if (block.timestamp >= p.periodFinish) { p.rewardRate = received / duration; } else { uint256 leftover = (p.periodFinish - block.timestamp) * p.rewardRate; p.rewardRate = (received + leftover) / duration; } require(p.rewardRate > 0, "rate 0"); p.periodFinish = uint64(block.timestamp + duration); p.lastUpdateTime = uint64(block.timestamp); p.rewardBudget += received; emit Funded(pid, received, duration); } // ── Stake / withdraw ── function stake(uint256 pid, uint256 amount) external nonReentrant update(pid, msg.sender) { require(amount > 0, "amount 0"); Pool storage p = pools[pid]; uint256 pre = token.balanceOf(address(this)); token.safeTransferFrom(msg.sender, address(this), amount); uint256 received = token.balanceOf(address(this)) - pre; staked[pid][msg.sender] += received; p.totalStaked += received; uint64 unlock = uint64(block.timestamp + p.lockDuration); unlockAt[pid][msg.sender] = unlock; // extends lock on additional stake emit Staked(pid, msg.sender, received, unlock); } /// @notice Withdraw principal + accrued rewards after your lock ends. function withdraw(uint256 pid) external nonReentrant update(pid, msg.sender) { require(block.timestamp >= unlockAt[pid][msg.sender], "locked"); uint256 principal = staked[pid][msg.sender]; require(principal > 0, "nothing staked"); uint256 reward = rewards[pid][msg.sender]; staked[pid][msg.sender] = 0; rewards[pid][msg.sender] = 0; pools[pid].totalStaked -= principal; token.safeTransfer(msg.sender, principal + reward); emit Withdrawn(pid, msg.sender, principal, reward); } /// @notice Break-glass: pull your OWN principal early, forfeiting rewards. /// User-initiated, principal-only — no owner is involved. function emergencyWithdraw(uint256 pid) external nonReentrant update(pid, msg.sender) { uint256 principal = staked[pid][msg.sender]; require(principal > 0, "nothing staked"); staked[pid][msg.sender] = 0; rewards[pid][msg.sender] = 0; // forfeit pools[pid].totalStaked -= principal; token.safeTransfer(msg.sender, principal); emit EmergencyWithdraw(pid, msg.sender, principal); } // ── Views ── function poolCount() external view returns (uint256) { return pools.length; } function aprInputs(uint256 pid) external view returns (uint256 rewardRate, uint256 totalStaked, uint64 periodFinish) { Pool storage p = pools[pid]; return (p.rewardRate, p.totalStaked, p.periodFinish); } }