GenesisStaking

0xbcdeaf2b5f501674ef5653bdc67a4da8a2169b27

Verification
Verified
v0.8.26+commit.8a97fa7a
Type
Contract
4,991 bytes
ABI entries
52
21 read · 11 write
License
none

Contract information

Address
0xbcdeaf2b5f501674ef5653bdc67a4da8a2169b27
Chain
Robinhood Chain (4663)
Compiler
v0.8.26+commit.8a97fa7a
Optimization
Enabled
Creation tx
0xab93232ad6…9a9de2df17

Token

Not a token

This contract does not expose ERC-20 metadata.

Read contract (21)

MAX_BPS()uint16
MAX_MIN_STAKE_PERIOD()uint256
MAX_PENALTY_BPS()uint16
distributor()address
earned(address)uint256
genesis()address
isPenaltyActive(address)bool
lastStakeTime(address)uint256
minStakePeriod()uint256
owner()address
penaltyBps()uint16
penaltyEndTime(address)uint256
periodFinish()uint256
rewardPerToken()uint256
rewardPerTokenStored()uint256
rewardRate()uint256
rewards(address)uint256
stakePaused()bool
stakedBalance(address)uint256
totalStaked()uint256
userRewardPerTokenPaid(address)uint256

Events (8)

MinStakePeriodUpdatedOwnershipTransferredPenaltyBpsUpdatedRewardPaidRewardsForfeitedStakePausedUpdatedStakedWithdrawn

ABI

[
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "genesis_",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "distributor_",
        "type": "address"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "constructor"
  },
  {
    "inputs": [],
    "name": "CannotRescueStakingToken",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "InsufficientStake",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "owner",
        "type": "address"
      }
    ],
    "name": "OwnableInvalidOwner",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "account",
        "type": "address"
      }
    ],
    "name": "OwnableUnauthorizedAccount",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "PenaltyTooHigh",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "PeriodTooLong",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "ReentrancyGuardReentrantCall",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      }
    ],
    "name": "SafeERC20FailedOperation",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "StakingPaused",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "ZeroAddress",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "ZeroAmount",
    "type": "error"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "period",
        "type": "uint256"
      }
    ],
    "name": "MinStakePeriodUpdated",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "previousOwner",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "newOwner",
        "type": "address"
      }
    ],
    "name": "OwnershipTransferred",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": false,
        "internalType": "uint16",
        "name": "bps",
        "type": "uint16"
      }
    ],
    "name": "PenaltyBpsUpdated",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "user",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "reward",
        "type": "uint256"
      }
    ],
    "name": "RewardPaid",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "user",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      }
    ],
    "name": "RewardsForfeited",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": false,
        "internalType": "bool",
        "name": "paused",
        "type": "bool"
      }
    ],
    "name": "StakePausedUpdated",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "user",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      }
    ],
    "name": "Staked",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "user",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      }
    ],
    "name": "Withdrawn",
    "type": "event"
  },
  {
    "inputs": [],
    "name": "MAX_BPS",
    "outputs": [
      {
        "internalType": "uint16",
        "name": "",
        "type": "uint16"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "MAX_MIN_STAKE_PERIOD",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "MAX_PENALTY_BPS",
    "outputs": [
      {
        "internalType": "uint16",
        "name": "",
        "type": "uint16"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "distributor",
    "outputs": [
      {
        "internalType": "contract GenesisRewardDistributor",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "account",
        "type": "address"
      }
    ],
    "name": "earned",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "exit",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "genesis",
    "outputs": [
      {
        "internalType": "contract IERC20",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "getReward",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "account",
        "type": "address"
      }
    ],
    "name": "isPenaltyActive",
    "outputs": [
      {
        "internalType": "bool",
        "name": "",
        "type": "bool"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "user",
        "type": "address"
      }
    ],
    "name": "lastStakeTime",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "minStakePeriod",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "owner",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "penaltyBps",
    "outputs": [
      {
        "internalType": "uint16",
        "name": "",
        "type": "uint16"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "account",
        "type": "address"
      }
    ],
    "name": "penaltyEndTime",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "periodFinish",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "renounceOwnership",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "to",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      }
    ],
    "name": "rescueToken",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "rewardPerToken",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "rewardPerTokenStored",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "rewardRate",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "user",
        "type": "address"
      }
    ],
    "name": "rewards",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "period",
        "type": "uint256"
      }
    ],
    "name": "setMinStakePeriod",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint16",
        "name": "bps",
        "type": "uint16"
      }
    ],
    "name": "setPenaltyBps",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "bool",
        "name": "paused",
        "type": "bool"
      }
    ],
    "name": "setStakePaused",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      }
    ],
    "name": "stake",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "stakePaused",
    "outputs": [
      {
        "internalType": "bool",
        "name": "",
        "type": "bool"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "user",
        "type": "address"
      }
    ],
    "name": "stakedBalance",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "totalStaked",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "newOwner",
        "type": "address"
      }
    ],
    "name": "transferOwnership",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "updateGlobalRewards",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "user",
        "type": "address"
      }
    ],
    "name": "userRewardPerTokenPaid",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      }
    ],
    "name": "withdraw",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  }
]

Source code

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

import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {GenesisRewardDistributor} from "./GenesisRewardDistributor.sol";

/// @notice Staking vault (tracker) for GENESIS, split GMX-style across two
/// contracts: THIS contract holds only staked principal plus already-earned
/// unclaimed rewards, while the reward pool lives at its own address in the
/// GenesisRewardDistributor and is pulled in via `distribute()` on every
/// interaction. Rewards stream at a fixed rate split pro-rata among stakers,
/// so the effective APY starts high and dilutes as TVL grows. Rewards are
/// pull-only (`getReward`) — never pushed. Realizing rewards (claim or
/// unstake) before `minStakePeriod` has elapsed since the user's last stake
/// forfeits `penaltyBps` of pending rewards straight back to the distributor,
/// automatically extending the stream. While nobody is staked the stream
/// pauses (`resetClock` on the first stake), so rewards never accrue to
/// nobody. `rescueToken` refuses GENESIS entirely — the owner can never touch
/// principal or earned rewards held here.
/// @dev All time math uses block.timestamp — on this Arbitrum Orbit chain
/// block.number returns the parent-chain block and must not be used.
contract GenesisStaking is Ownable, ReentrancyGuard {
    using SafeERC20 for IERC20;

    error ZeroAddress();
    error ZeroAmount();
    error InsufficientStake();
    error StakingPaused();
    error PenaltyTooHigh();
    error PeriodTooLong();
    error CannotRescueStakingToken();

    event Staked(address indexed user, uint256 amount);
    event Withdrawn(address indexed user, uint256 amount);
    event RewardPaid(address indexed user, uint256 reward);
    event RewardsForfeited(address indexed user, uint256 amount);
    event MinStakePeriodUpdated(uint256 period);
    event PenaltyBpsUpdated(uint16 bps);
    event StakePausedUpdated(bool paused);

    uint16 public constant MAX_BPS = 10_000;
    /// @notice Hard cap on the early-exit penalty (50%).
    uint16 public constant MAX_PENALTY_BPS = 5_000;
    uint256 public constant MAX_MIN_STAKE_PERIOD = 30 days;

    /// @notice Token both staked and paid as rewards.
    IERC20 public immutable genesis;
    /// @notice Reward vault this tracker pulls from.
    GenesisRewardDistributor public immutable distributor;

    uint256 public totalStaked;
    mapping(address user => uint256) public stakedBalance;

    /// @dev 1e18-scaled cumulative reward per staked token.
    uint256 public rewardPerTokenStored;
    mapping(address user => uint256) public userRewardPerTokenPaid;
    mapping(address user => uint256) public rewards;

    /// @notice Reset on every stake — topping up restarts the penalty window.
    mapping(address user => uint256) public lastStakeTime;
    uint256 public minStakePeriod = 7 days;
    uint16 public penaltyBps = 5_000;

    /// @notice Gates `stake` only; withdraw/claim can never be paused.
    bool public stakePaused;

    constructor(address genesis_, address distributor_) Ownable(msg.sender) {
        if (genesis_ == address(0) || distributor_ == address(0)) revert ZeroAddress();
        genesis = IERC20(genesis_);
        distributor = GenesisRewardDistributor(distributor_);
    }

    // ------------------------------------------------------------ views

    function rewardPerToken() public view returns (uint256) {
        if (totalStaked == 0) return rewardPerTokenStored;
        return rewardPerTokenStored + (distributor.pendingRewards() * 1e18) / totalStaked;
    }

    /// @notice Pending rewards for `account`, before any early-exit penalty.
    function earned(address account) public view returns (uint256) {
        return (stakedBalance[account] * (rewardPerToken() - userRewardPerTokenPaid[account])) / 1e18
            + rewards[account];
    }

    /// @notice Current emission rate (view proxy onto the distributor).
    function rewardRate() external view returns (uint256) {
        return distributor.rewardPerSecond();
    }

    /// @notice Projected end of the reward stream at the current rate and
    /// distributor balance. Moves forward when the pool is topped up or the
    /// stream pauses; equals now when the pool is dry or the rate is zero.
    function periodFinish() external view returns (uint256) {
        uint256 rate = distributor.rewardPerSecond();
        if (rate == 0) return block.timestamp;
        uint256 balance = genesis.balanceOf(address(distributor));
        uint256 pending = distributor.pendingRewards();
        uint256 remaining = balance > pending ? balance - pending : 0;
        return block.timestamp + remaining / rate;
    }

    /// @notice Timestamp after which `account` can realize rewards penalty-free.
    function penaltyEndTime(address account) external view returns (uint256) {
        return lastStakeTime[account] + minStakePeriod;
    }

    function isPenaltyActive(address account) public view returns (bool) {
        return block.timestamp < lastStakeTime[account] + minStakePeriod;
    }

    // ------------------------------------------------------------ user actions

    modifier updateReward(address account) {
        _updateGlobalRewards();
        if (account != address(0)) {
            rewards[account] = earned(account);
            userRewardPerTokenPaid[account] = rewardPerTokenStored;
        }
        _;
    }

    /// @dev Pulls the accrued stream from the distributor and folds it into
    /// rewardPerTokenStored. Deliberately does NOT pull while totalStaked == 0:
    /// stake() resets the distributor clock instead, so the paused stream can
    /// never allocate rewards to nobody.
    function _updateGlobalRewards() internal {
        if (totalStaked > 0) {
            uint256 amount = distributor.distribute();
            if (amount > 0) rewardPerTokenStored += (amount * 1e18) / totalStaked;
        }
    }

    /// @notice Public settle hook; the distributor calls this before rate
    /// changes and top-ups so they are never retroactive. Safe for anyone.
    function updateGlobalRewards() external {
        _updateGlobalRewards();
    }

    function stake(uint256 amount) external nonReentrant updateReward(msg.sender) {
        if (stakePaused) revert StakingPaused();
        if (amount == 0) revert ZeroAmount();
        if (totalStaked == 0) distributor.resetClock();
        genesis.safeTransferFrom(msg.sender, address(this), amount);
        totalStaked += amount;
        stakedBalance[msg.sender] += amount;
        lastStakeTime[msg.sender] = block.timestamp;
        emit Staked(msg.sender, amount);
    }

    function withdraw(uint256 amount) external nonReentrant updateReward(msg.sender) {
        _applyEarlyPenalty(msg.sender);
        _withdraw(amount);
    }

    function getReward() external nonReentrant updateReward(msg.sender) {
        _applyEarlyPenalty(msg.sender);
        _getReward();
    }

    /// @notice Withdraw the full stake and claim rewards in one tx.
    function exit() external nonReentrant updateReward(msg.sender) {
        _applyEarlyPenalty(msg.sender);
        _withdraw(stakedBalance[msg.sender]);
        _getReward();
    }

    /// @dev Penalty is applied by the external entrypoints, exactly once per
    /// call — exit() composes both helpers but must not double-haircut.
    function _withdraw(uint256 amount) internal {
        if (amount == 0) revert ZeroAmount();
        if (amount > stakedBalance[msg.sender]) revert InsufficientStake();
        totalStaked -= amount;
        stakedBalance[msg.sender] -= amount;
        genesis.safeTransfer(msg.sender, amount);
        emit Withdrawn(msg.sender, amount);
    }

    function _getReward() internal {
        uint256 reward = rewards[msg.sender];
        if (reward > 0) {
            rewards[msg.sender] = 0;
            genesis.safeTransfer(msg.sender, reward);
            emit RewardPaid(msg.sender, reward);
        }
    }

    /// @dev Applies to both claims and withdrawals inside the window — a
    /// penalty-free claim would let users claim first and unstake after,
    /// bypassing the exit penalty entirely. Principal is never touched.
    /// Forfeits go straight back to the distributor, extending the stream.
    function _applyEarlyPenalty(address account) internal {
        if (block.timestamp < lastStakeTime[account] + minStakePeriod) {
            uint256 forfeit = (rewards[account] * penaltyBps) / MAX_BPS;
            if (forfeit > 0) {
                rewards[account] -= forfeit;
                genesis.safeTransfer(address(distributor), forfeit);
                emit RewardsForfeited(account, forfeit);
            }
        }
    }

    // ------------------------------------------------------------ owner

    function setMinStakePeriod(uint256 period) external onlyOwner {
        if (period > MAX_MIN_STAKE_PERIOD) revert PeriodTooLong();
        minStakePeriod = period;
        emit MinStakePeriodUpdated(period);
    }

    function setPenaltyBps(uint16 bps) external onlyOwner {
        if (bps > MAX_PENALTY_BPS) revert PenaltyTooHigh();
        penaltyBps = bps;
        emit PenaltyBpsUpdated(bps);
    }

    function setStakePaused(bool paused) external onlyOwner {
        stakePaused = paused;
        emit StakePausedUpdated(paused);
    }

    /// @notice Recover tokens sent here by mistake. GENESIS is refused outright
    /// so neither staked principal nor earned unclaimed rewards can ever be
    /// pulled by the owner.
    function rescueToken(address token, address to, uint256 amount) external onlyOwner {
        if (to == address(0)) revert ZeroAddress();
        if (token == address(genesis)) revert CannotRescueStakingToken();
        IERC20(token).safeTransfer(to, amount);
    }
}
Chain explorer2405msChain node82ms