219 lines
8.2 KiB
Solidity
219 lines
8.2 KiB
Solidity
// SPDX-License-Identifier: MIT
|
|
pragma solidity ^0.8.20;
|
|
|
|
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
|
|
import "@openzeppelin/contracts/access/Ownable.sol";
|
|
import {IERC20, IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";
|
|
|
|
// ─── Protocol constants ─────────────────────────────────────────────────────
|
|
|
|
uint256 constant MAX_SUPPLY = 1_000_000_000 ether;
|
|
address constant ZERO_ADDRESS = address(0);
|
|
|
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
|
|
/// @notice Staker position tracked per epoch.
|
|
struct StakerPosition {
|
|
address wallet;
|
|
uint256 stakedAmount;
|
|
uint256 rewardDebt;
|
|
uint64 epochJoined;
|
|
bool isActive;
|
|
}
|
|
|
|
/// @notice Pool lifecycle.
|
|
enum PoolStatus {
|
|
Active,
|
|
Paused,
|
|
Deprecated,
|
|
EmergencyShutdown
|
|
}
|
|
|
|
/// @notice 18-decimal fixed-point price.
|
|
type Price is uint256;
|
|
|
|
/// @notice Position receipt NFT identifier.
|
|
type PositionId is uint128;
|
|
|
|
// ─── Errors ─────────────────────────────────────────────────────────────────
|
|
|
|
error InsufficientStake(uint256 requested, uint256 available);
|
|
error PoolNotActive();
|
|
|
|
// ─── Events ─────────────────────────────────────────────────────────────────
|
|
|
|
event Staked(address indexed user, uint256 amount);
|
|
event Unstaked(address indexed user, uint256 amount);
|
|
|
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
/// @notice 30 bp protocol fee.
|
|
function protocolFee(uint256 amount) pure returns (uint256) {
|
|
return (amount * 30) / 10_000;
|
|
}
|
|
|
|
// ─── Interface ──────────────────────────────────────────────────────────────
|
|
|
|
interface IStakingPool {
|
|
function stake(uint256 amount) external;
|
|
function unstake(uint256 amount) external returns (uint256);
|
|
function stakedBalance(address user) external view returns (uint256);
|
|
}
|
|
|
|
// ─── Library ────────────────────────────────────────────────────────────────
|
|
|
|
/// @notice Fixed-point math for reward accumulator precision.
|
|
library RewardMath {
|
|
uint256 internal constant PRECISION = 1e18;
|
|
|
|
function mulPrecise(uint256 a, uint256 b) internal pure returns (uint256) {
|
|
return (a * b) / PRECISION;
|
|
}
|
|
|
|
function divPrecise(uint256 a, uint256 b) internal pure returns (uint256) {
|
|
require(b > 0, "RewardMath: division by zero");
|
|
return (a * PRECISION) / b;
|
|
}
|
|
}
|
|
|
|
// ─── Core pool ──────────────────────────────────────────────────────────────
|
|
|
|
/// @title StakingVault
|
|
/// @notice Liquid staking pool. Deposit the underlying ERC-20, receive
|
|
/// share tokens 1 : 1, accrue rewards over time.
|
|
contract StakingVault is ERC20, Ownable, IStakingPool {
|
|
using RewardMath for uint256;
|
|
|
|
// ── Storage ────────────────────────────────────────────────────────
|
|
|
|
mapping(address => uint256) public stakes;
|
|
uint256 public totalStaked;
|
|
address public guardian;
|
|
PoolStatus public status;
|
|
uint256 constant MIN_STAKE = 0.01 ether;
|
|
uint256 immutable launchTime;
|
|
Price public assetPrice;
|
|
uint256 public accRewardPerShare;
|
|
|
|
// ── Events ─────────────────────────────────────────────────────────
|
|
|
|
event RewardAccrued(uint256 indexed epoch, uint256 amount);
|
|
event EmergencyExit(address indexed user, uint256 amount);
|
|
|
|
// ── Modifiers ──────────────────────────────────────────────────────
|
|
|
|
modifier nonZero(uint256 amount) {
|
|
require(amount > 0, "StakingVault: zero amount");
|
|
_;
|
|
}
|
|
|
|
modifier whenPoolActive() {
|
|
require(status == PoolStatus.Active, "StakingVault: pool not active");
|
|
_;
|
|
}
|
|
|
|
// ── Constructor ────────────────────────────────────────────────────
|
|
|
|
constructor(
|
|
string memory name,
|
|
string memory symbol
|
|
) ERC20(name, symbol) Ownable(msg.sender) {
|
|
guardian = msg.sender;
|
|
launchTime = block.timestamp;
|
|
status = PoolStatus.Active;
|
|
}
|
|
|
|
// ── Core operations ────────────────────────────────────────────────
|
|
|
|
/// @inheritdoc IStakingPool
|
|
function stake(uint256 amount)
|
|
external
|
|
override
|
|
nonZero(amount)
|
|
whenPoolActive
|
|
{
|
|
uint256 fee = protocolFee(amount);
|
|
uint256 net = amount - fee;
|
|
|
|
stakes[msg.sender] += net;
|
|
totalStaked += net;
|
|
|
|
_mint(msg.sender, net);
|
|
emit Staked(msg.sender, net);
|
|
}
|
|
|
|
/// @inheritdoc IStakingPool
|
|
function unstake(uint256 amount)
|
|
external
|
|
override
|
|
nonZero(amount)
|
|
returns (uint256)
|
|
{
|
|
uint256 staked = stakes[msg.sender];
|
|
if (staked < amount) {
|
|
revert InsufficientStake(amount, staked);
|
|
}
|
|
|
|
stakes[msg.sender] = staked - amount;
|
|
totalStaked -= amount;
|
|
|
|
_burn(msg.sender, amount);
|
|
emit Unstaked(msg.sender, amount);
|
|
return amount;
|
|
}
|
|
|
|
/// @inheritdoc IStakingPool
|
|
function stakedBalance(address user) external view returns (uint256) {
|
|
return stakes[user];
|
|
}
|
|
|
|
// ── Emergency ──────────────────────────────────────────────────────
|
|
|
|
function emergencyWithdraw() external nonZero(stakes[msg.sender]) {
|
|
uint256 amount = stakes[msg.sender];
|
|
stakes[msg.sender] = 0;
|
|
totalStaked -= amount;
|
|
|
|
_burn(msg.sender, amount);
|
|
emit EmergencyExit(msg.sender, amount);
|
|
}
|
|
|
|
// ── ETH handling (native staking variant) ──────────────────────────
|
|
|
|
receive() external payable {}
|
|
fallback() external payable {}
|
|
}
|
|
|
|
// ─── Boosted pool ───────────────────────────────────────────────────────────
|
|
|
|
/// @title BoostedPool
|
|
/// @notice Wraps StakingVault with an additional reward layer.
|
|
/// Depositors earn base yield from the vault plus bonus
|
|
/// rewards funded by governance.
|
|
contract BoostedPool is StakingVault {
|
|
uint256 public bonusRate;
|
|
|
|
event BonusClaimed(address indexed user, uint256 reward);
|
|
|
|
constructor(
|
|
string memory name,
|
|
string memory symbol,
|
|
uint256 _bonusRate
|
|
) StakingVault(name, symbol) {
|
|
bonusRate = _bonusRate;
|
|
}
|
|
|
|
function pendingBonus(address user) public view returns (uint256) {
|
|
if (totalStaked == 0) return 0;
|
|
return stakes[user].mulPrecise(bonusRate);
|
|
}
|
|
|
|
function claimBonus() external {
|
|
uint256 reward = pendingBonus(msg.sender);
|
|
require(reward > 0, "BoostedPool: nothing to claim");
|
|
|
|
_mint(msg.sender, reward);
|
|
emit BonusClaimed(msg.sender, reward);
|
|
}
|
|
}
|