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
- Creator
- 0xb8217B4F13…4eA7C87CCf
- 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