RobinStakingETH

0x2ce2b3b7bc9a0093681fc280b1bf77c30db3f1a3

Verification
Verified
v0.8.26+commit.8a97fa7a
Type
Contract
5,280 bytes
ABI entries
36
22 read · 5 write
License
none

Contract information

Address
0x2ce2b3b7bc9a0093681fc280b1bf77c30db3f1a3
Chain
Robinhood Chain (4663)
Compiler
v0.8.26+commit.8a97fa7a
Optimization
Enabled
Creation tx
0xfd06731d89…ec1ff58194

Token

Not a token

This contract does not expose ERC-20 metadata.

Read contract (22)

MAX_POSITIONS()uint256
earned(address, uint256)uint256
ethOwed(address)uint256
isUnlocked(address, uint256)bool
isValidLock(uint256)bool
lastTimeRewardApplicable()uint256
lastUpdateTime()uint256
openPositions(address)uint256
periodFinish()uint256
positions(address, uint256)tuple
positionsLength(address)uint256
rewardPerWeight()uint256
rewardPerWeightStored()uint256
rewardPool()uint256
rewardRate()uint256
rewardsDistribution()address
rewardsDuration()uint256
stakedOf(address)uint256
stakingToken()address
totalEthOwed()uint256
totalStaked()uint256
totalWeight()uint256

Events (7)

FundedOwedClaimedRewardAddedRewardDeferredRewardPaidStakedWithdrawn

ABI

[
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "_stakingToken",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "_rewardsDistribution",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "_rewardsDuration",
        "type": "uint256"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "constructor"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "from",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      }
    ],
    "name": "Funded",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "user",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      }
    ],
    "name": "OwedClaimed",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "reward",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "periodFinish",
        "type": "uint256"
      }
    ],
    "name": "RewardAdded",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "user",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      }
    ],
    "name": "RewardDeferred",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "user",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "id",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "reward",
        "type": "uint256"
      }
    ],
    "name": "RewardPaid",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "user",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "id",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "lockDays",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "unlockTime",
        "type": "uint256"
      }
    ],
    "name": "Staked",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "user",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "id",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "reward",
        "type": "uint256"
      }
    ],
    "name": "Withdrawn",
    "type": "event"
  },
  {
    "inputs": [],
    "name": "MAX_POSITIONS",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "claimOwed",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "user",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "id",
        "type": "uint256"
      }
    ],
    "name": "earned",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "name": "ethOwed",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "id",
        "type": "uint256"
      }
    ],
    "name": "getReward",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "user",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "id",
        "type": "uint256"
      }
    ],
    "name": "isUnlocked",
    "outputs": [
      {
        "internalType": "bool",
        "name": "",
        "type": "bool"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "d",
        "type": "uint256"
      }
    ],
    "name": "isValidLock",
    "outputs": [
      {
        "internalType": "bool",
        "name": "",
        "type": "bool"
      }
    ],
    "stateMutability": "pure",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "lastTimeRewardApplicable",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "lastUpdateTime",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "reward",
        "type": "uint256"
      }
    ],
    "name": "notifyRewardAmount",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "user",
        "type": "address"
      }
    ],
    "name": "openPositions",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "periodFinish",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "user",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "id",
        "type": "uint256"
      }
    ],
    "name": "positions",
    "outputs": [
      {
        "components": [
          {
            "internalType": "uint256",
            "name": "amount",
            "type": "uint256"
          },
          {
            "internalType": "uint256",
            "name": "weight",
            "type": "uint256"
          },
          {
            "internalType": "uint256",
            "name": "lockDays",
            "type": "uint256"
          },
          {
            "internalType": "uint256",
            "name": "unlockTime",
            "type": "uint256"
          },
          {
            "internalType": "uint256",
            "name": "rewardPerWeightPaid",
            "type": "uint256"
          },
          {
            "internalType": "uint256",
            "name": "rewards",
            "type": "uint256"
          }
        ],
        "internalType": "struct RobinStakingETH.Position",
        "name": "",
        "type": "tuple"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "user",
        "type": "address"
      }
    ],
    "name": "positionsLength",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "rewardPerWeight",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "rewardPerWeightStored",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "rewardPool",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "rewardRate",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "rewardsDistribution",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "rewardsDuration",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      },
      {
        "internalType": "uint256",
        "name": "lockDays",
        "type": "uint256"
      }
    ],
    "name": "stake",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "id",
        "type": "uint256"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "user",
        "type": "address"
      }
    ],
    "name": "stakedOf",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "stakingToken",
    "outputs": [
      {
        "internalType": "contract IRobinERC20",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "totalEthOwed",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "totalStaked",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "totalWeight",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "id",
        "type": "uint256"
      }
    ],
    "name": "withdraw",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "stateMutability": "payable",
    "type": "receive"
  }
]

Source code

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

/// @dev Minimal ERC20 surface used by the staking pool. NOTE: RobinToken only taxes DEX trades
///      (transfers where one side is the Uniswap pair); plain wallet<->contract transfers — exactly
///      what stake/withdraw do — are NOT taxed, so principal accounting is exact. We still measure
///      the actually-received amount on stake as a belt-and-suspenders guard.
interface IRobinERC20 {
    function transfer(address to, uint256 value) external returns (bool);
    function transferFrom(address from, address to, uint256 value) external returns (bool);
    function balanceOf(address account) external view returns (uint256);
}

/// @title RobinStakingETH
/// @notice Single-asset LOCKED staking, MULTI-POSITION, that pays rewards in NATIVE ETH (Robinhood
///         Chain's native gas token) instead of a token. Stake a RobinFun token (e.g. the main
///         ROBINFUN token) and earn a share of the project's fee revenue, streamed as ETH.
///
///         Each STAKER opens ONE OR MORE positions; every position picks its own lock of
///         14/30/45/60 days at stake time and can be neither withdrawn nor claimed until that
///         position's lock expires. A wallet may hold many positions at once, each independent.
///
///         Reward weighting (longer lock = bigger share): a position's weight = amount * lockDays,
///         and rewards accrue by weight (generalized Synthetix reward-per-weight). So a 60-day lock
///         earns ~4.3x the per-token rate of a 14-day lock. Rewards stream over `rewardsDuration`.
///
///         FUNDING (native ETH): the reward asset is ETH held by this contract (address(this).balance).
///         The distributor (the project treasury) sends ETH to this contract — a plain transfer that
///         `receive()` accepts, or anyone may donate — and then calls `notifyRewardAmount(reward)` to
///         (re)arm the stream for `reward` wei over `rewardsDuration`. This mirrors the audited
///         Synthetix StakingRewards pattern exactly, with the reward-token balance replaced by the
///         contract's ETH balance. There is NO ERC20 reward token, NO bridge, NO mainnet — everything
///         is native ETH on chain 4663.
///
///         Uses plain, battle-tested Synthetix accounting (reward-per-weight, monotonic checkpoints) —
///         NOT a lazy-start stream — so `lastTimeRewardApplicable() - lastUpdateTime` can never
///         underflow and no state transition can brick the pool. Known accepted cost: reward-seconds
///         elapsed while totalWeight == 0 (e.g. before the first staker, or any gap) are not
///         redistributed — they stay in the pool (as ETH), never over-paid, never underflow.
contract RobinStakingETH {
    /*//////////////////////////////////////////////////////////////
                                 IMMUTABLES
    //////////////////////////////////////////////////////////////*/

    IRobinERC20 public immutable stakingToken; // the token being staked (reward is native ETH)
    address public immutable rewardsDistribution; // the treasury — only caller of notifyRewardAmount
    uint256 public immutable rewardsDuration; // reward streaming window (seconds)

    /// @notice hard cap on simultaneously-OPEN positions per wallet (withdrawn slots free up, so this
    ///         is a live cap, never a lifetime one). Bounds only the wallet's own reads.
    uint256 public constant MAX_POSITIONS = 100;

    /*//////////////////////////////////////////////////////////////
                                  STORAGE
    //////////////////////////////////////////////////////////////*/

    uint256 public periodFinish;
    uint256 public rewardRate; // wei of ETH per second
    uint256 public lastUpdateTime;
    uint256 public rewardPerWeightStored;

    uint256 public totalStaked; // sum of principal (token) across all open positions
    uint256 public totalWeight; // sum of amount*lockDays across all open positions

    struct Position {
        uint256 amount; // staked principal (0 once withdrawn)
        uint256 weight; // amount * lockDays
        uint256 lockDays; // chosen lock (14/30/45/60)
        uint256 unlockTime; // timestamp this position unlocks
        uint256 rewardPerWeightPaid; // checkpoint
        uint256 rewards; // accrued, unclaimed (wei)
    }

    mapping(address => Position[]) private _positions;
    mapping(address => uint256) private _openCount; // number of currently-open positions per wallet

    /// @notice ETH rewards that a payout `call` failed to deliver (e.g. a contract staker without a
    ///         payable receiver). Credited here instead of reverting, so a rejecting receiver can NEVER
    ///         lock its own token principal — and the ETH stays claimable via claimOwed() once the
    ///         receiver can accept it. The ETH physically remains in the contract.
    mapping(address => uint256) public ethOwed;

    /// @notice Sum of all ethOwed across wallets. rewardPool() excludes this so the notifyRewardAmount
    ///         solvency check can never re-stream ETH already earmarked to a deferred recipient (which
    ///         would let a re-arm strand someone's claimOwed()). Enforces claimable-owed <= free pool.
    uint256 public totalEthOwed;

    /// @notice O(1) running total of open principal per wallet, so stakedOf() is a lookup instead of a
    ///         loop over the append-only _positions array (which a wallet could grow unbounded by
    ///         cycling stake/withdraw and thereby gas-DoS its own stakedOf view).
    mapping(address => uint256) private _stakedTotal;

    /*//////////////////////////////////////////////////////////////
                                  EVENTS
    //////////////////////////////////////////////////////////////*/

    event RewardAdded(uint256 reward, uint256 periodFinish);
    event Staked(address indexed user, uint256 indexed id, uint256 amount, uint256 lockDays, uint256 unlockTime);
    event Withdrawn(address indexed user, uint256 indexed id, uint256 amount, uint256 reward);
    event RewardPaid(address indexed user, uint256 indexed id, uint256 reward);
    event Funded(address indexed from, uint256 amount);
    event RewardDeferred(address indexed user, uint256 amount); // ETH send failed -> credited to ethOwed
    event OwedClaimed(address indexed user, uint256 amount);

    /*//////////////////////////////////////////////////////////////
                              REENTRANCY GUARD
    //////////////////////////////////////////////////////////////*/

    uint256 private _locked = 1;

    modifier nonReentrant() {
        require(_locked == 1, "REENTRANCY");
        _locked = 2;
        _;
        _locked = 1;
    }

    constructor(address _stakingToken, address _rewardsDistribution, uint256 _rewardsDuration) {
        require(_stakingToken != address(0) && _rewardsDistribution != address(0), "ZERO_ADDR");
        require(_rewardsDuration > 0, "ZERO_DURATION");
        stakingToken = IRobinERC20(_stakingToken);
        rewardsDistribution = _rewardsDistribution;
        rewardsDuration = _rewardsDuration;
    }

    /// @notice Accept native ETH funding (treasury deposits / fee sweeps / donations). The ETH just
    ///         sits in the reward pool until the distributor arms it via notifyRewardAmount.
    receive() external payable {
        emit Funded(msg.sender, msg.value);
    }

    /*//////////////////////////////////////////////////////////////
                              REWARD ACCOUNTING
    //////////////////////////////////////////////////////////////*/

    /// @dev Advance the global reward index (plain Synthetix). lastUpdateTime is only ever set to
    ///      lastTimeRewardApplicable() = min(now, periodFinish), which is monotonic and always <=
    ///      periodFinish, so rewardPerWeight()'s subtraction can never underflow.
    function _updateGlobal() internal {
        rewardPerWeightStored = rewardPerWeight();
        lastUpdateTime = lastTimeRewardApplicable();
    }

    /// @dev Bring one position's accrued rewards up to date against the current global index.
    function _accrue(address user, uint256 id) internal {
        Position storage p = _positions[user][id];
        p.rewards = _earned(p);
        p.rewardPerWeightPaid = rewardPerWeightStored;
    }

    function lastTimeRewardApplicable() public view returns (uint256) {
        return block.timestamp < periodFinish ? block.timestamp : periodFinish;
    }

    function rewardPerWeight() public view returns (uint256) {
        if (totalWeight == 0) return rewardPerWeightStored;
        return rewardPerWeightStored
            + ((lastTimeRewardApplicable() - lastUpdateTime) * rewardRate * 1e18) / totalWeight;
    }

    function _earned(Position storage p) internal view returns (uint256) {
        return (p.weight * (rewardPerWeight() - p.rewardPerWeightPaid)) / 1e18 + p.rewards;
    }

    /// @notice Accrued (unclaimed) ETH rewards of a specific position.
    function earned(address user, uint256 id) public view returns (uint256) {
        if (id >= _positions[user].length) return 0;
        return _earned(_positions[user][id]);
    }

    /// @notice ETH free to stream = full ETH balance MINUS ETH already earmarked to deferred
    ///         recipients (ethOwed). Staked principal is held in the TOKEN, not ETH, so nothing else is
    ///         subtracted. (Still includes ordinary accrued-but-unclaimed position rewards — the same
    ///         trusted-distributor property as Synthetix; the distributor only ever arms funded ETH.)
    ///         Guarded so it can never underflow-revert even if invariants were ever perturbed.
    function rewardPool() public view returns (uint256) {
        uint256 bal = address(this).balance;
        return bal > totalEthOwed ? bal - totalEthOwed : 0;
    }

    function isValidLock(uint256 d) public pure returns (bool) {
        return d == 14 || d == 30 || d == 45 || d == 60;
    }

    /*//////////////////////////////////////////////////////////////
                              POSITION VIEWS
    //////////////////////////////////////////////////////////////*/

    function positionsLength(address user) external view returns (uint256) {
        return _positions[user].length;
    }

    /// @notice Number of currently-OPEN (not-yet-withdrawn) positions a wallet holds.
    function openPositions(address user) external view returns (uint256) {
        return _openCount[user];
    }

    /// @notice Full position tuple (amount, weight, lockDays, unlockTime, rewardPerWeightPaid, rewards).
    function positions(address user, uint256 id) external view returns (Position memory) {
        return _positions[user][id];
    }

    /// @notice Total open principal a wallet has across all its positions (for display). O(1) — reads
    ///         the running total, so it can't be gas-DoS'd by a wallet that grows its _positions array.
    function stakedOf(address user) external view returns (uint256) {
        return _stakedTotal[user];
    }

    /// @notice Whether a specific position is unlocked (withdraw/claim allowed).
    function isUnlocked(address user, uint256 id) external view returns (bool) {
        if (id >= _positions[user].length) return false;
        return block.timestamp >= _positions[user][id].unlockTime;
    }

    /*//////////////////////////////////////////////////////////////
                               USER ACTIONS
    //////////////////////////////////////////////////////////////*/

    /// @notice Open a NEW locked position. A wallet may hold up to MAX_POSITIONS OPEN at once.
    /// @param lockDays one of {14,30,45,60}; this position cannot be touched until it expires.
    /// @return id the index of the new position in the caller's position list.
    function stake(uint256 amount, uint256 lockDays) external nonReentrant returns (uint256 id) {
        require(amount > 0, "ZERO_AMOUNT");
        require(isValidLock(lockDays), "BAD_LOCK");
        // Staking is open before the pool is ever armed (periodFinish == 0: rewardRate is still 0, so
        // stakers accrue nothing until funded) and while a stream is live (block.timestamp <
        // periodFinish). Once a stream ends, staking closes so no one locks funds into a spent pool
        // for zero reward — until the treasury re-arms with the next batch of fees.
        require(periodFinish == 0 || block.timestamp < periodFinish, "STREAM_ENDED");
        require(_openCount[msg.sender] < MAX_POSITIONS, "TOO_MANY_POSITIONS");

        _updateGlobal();

        uint256 before = stakingToken.balanceOf(address(this));
        require(stakingToken.transferFrom(msg.sender, address(this), amount), "XFER_FAIL");
        uint256 received = stakingToken.balanceOf(address(this)) - before;
        require(received > 0, "NO_TOKENS");

        uint256 w = received * lockDays; // reward weight: longer lock -> bigger share
        uint256 unlock = block.timestamp + lockDays * 1 days;
        id = _positions[msg.sender].length;
        _positions[msg.sender].push(
            Position({
                amount: received,
                weight: w,
                lockDays: lockDays,
                unlockTime: unlock,
                rewardPerWeightPaid: rewardPerWeightStored,
                rewards: 0
            })
        );
        _openCount[msg.sender] += 1;
        _stakedTotal[msg.sender] += received;
        totalStaked += received;
        totalWeight += w;
        emit Staked(msg.sender, id, received, lockDays, unlock);
    }

    /// @notice Withdraw one position's full principal (token) + its accrued reward (ETH). Reverts
    ///         until that position's lock expires. The position slot is emptied (id stays valid but
    ///         inert). CEI: all state is updated before the token transfer and the ETH send, and the
    ///         nonReentrant guard blocks re-entry through the ETH call.
    function withdraw(uint256 id) public nonReentrant {
        require(id < _positions[msg.sender].length, "BAD_ID");
        _updateGlobal();
        _accrue(msg.sender, id);

        Position storage p = _positions[msg.sender][id];
        uint256 amt = p.amount;
        require(amt > 0, "NO_STAKE");
        require(block.timestamp >= p.unlockTime, "LOCKED");

        uint256 reward = p.rewards;
        totalStaked -= amt;
        totalWeight -= p.weight;
        _stakedTotal[msg.sender] -= amt;
        p.amount = 0;
        p.weight = 0;
        p.rewards = 0;
        _openCount[msg.sender] -= 1;

        // Principal is the token; reward is native ETH — two distinct assets. Principal is ALWAYS
        // returned and the weight is ALWAYS freed; the ETH reward is best-effort (credited to ethOwed
        // if the recipient rejects it). So a contract staker that can't receive ETH can never lock its
        // own principal, nor strand its weight in totalWeight (which would dilute every other staker).
        require(stakingToken.transfer(msg.sender, amt), "XFER_FAIL");
        _payReward(msg.sender, reward);
        emit Withdrawn(msg.sender, id, amt, reward);
    }

    /// @notice Claim one position's accrued ETH reward without unstaking. Reverts until it unlocks.
    function getReward(uint256 id) public nonReentrant {
        require(id < _positions[msg.sender].length, "BAD_ID");
        _updateGlobal();
        _accrue(msg.sender, id);

        Position storage p = _positions[msg.sender][id];
        require(p.amount > 0, "NO_STAKE");
        require(block.timestamp >= p.unlockTime, "LOCKED");
        uint256 reward = p.rewards;
        if (reward > 0) {
            p.rewards = 0;
            _payReward(msg.sender, reward);
            emit RewardPaid(msg.sender, id, reward);
        }
    }

    /// @dev Best-effort native-ETH payout. NEVER reverts on a failed send: the ETH stays in the
    ///      contract and is credited to ethOwed for the recipient to pull later via claimOwed(). This
    ///      guarantees principal recovery + weight release can't be blocked by a recipient that rejects
    ///      ETH. Callers zero the position's rewards BEFORE calling this (CEI), and all callers hold
    ///      the nonReentrant guard, so the external call can neither double-pay nor re-enter.
    function _payReward(address to, uint256 amount) internal {
        if (amount == 0) return;
        (bool ok,) = to.call{value: amount}("");
        if (!ok) {
            ethOwed[to] += amount;
            totalEthOwed += amount; // earmark so rewardPool()/solvency stops counting it as free
            emit RewardDeferred(to, amount);
        }
    }

    /// @notice Pull ETH rewards that a previous payout `call` failed to deliver (e.g. once the
    ///         recipient can accept ETH). Reverts if the send still fails, so nothing is lost.
    function claimOwed() external nonReentrant {
        uint256 amt = ethOwed[msg.sender];
        require(amt > 0, "NOTHING_OWED");
        ethOwed[msg.sender] = 0;
        totalEthOwed -= amt; // un-earmark; the ETH is about to leave the contract
        (bool ok,) = msg.sender.call{value: amt}("");
        require(ok, "ETH_FAIL");
        emit OwedClaimed(msg.sender, amt);
    }

    /*//////////////////////////////////////////////////////////////
                           REWARDS DISTRIBUTION
    //////////////////////////////////////////////////////////////*/

    /// @notice (Re)arm the ETH reward stream for `reward` wei over `rewardsDuration`. Only the
    ///         treasury (rewardsDistribution). The ETH must already be held by this contract (sent via
    ///         `receive()` beforehand or in a prior sweep); the solvency check below enforces that the
    ///         contract physically holds enough ETH to pay the resulting rate for the full window.
    ///         Leftover from an in-flight stream rolls into the new rate (standard Synthetix).
    function notifyRewardAmount(uint256 reward) external nonReentrant {
        require(msg.sender == rewardsDistribution, "NOT_DISTRIBUTION");
        _updateGlobal();
        if (block.timestamp >= periodFinish) {
            rewardRate = reward / rewardsDuration;
        } else {
            uint256 remaining = periodFinish - block.timestamp;
            uint256 leftover = remaining * rewardRate;
            rewardRate = (reward + leftover) / rewardsDuration;
        }
        require(rewardRate > 0, "ZERO_RATE");
        // Solvency: the contract must hold enough FREE ETH to cover the full stream at this rate.
        // rewardPool() = balance - totalEthOwed, so ETH already earmarked to deferred recipients can
        // never be re-committed to a new stream (which would otherwise strand their claimOwed()).
        // (Still counts ordinary accrued-unclaimed position rewards — the trusted-distributor property
        // inherited from Synthetix; the distributor only arms ETH it has actually funded.)
        require(rewardRate <= rewardPool() / rewardsDuration, "REWARD_TOO_HIGH");

        lastUpdateTime = block.timestamp;
        periodFinish = block.timestamp + rewardsDuration;
        emit RewardAdded(reward, periodFinish);
    }
}
Chain explorer1685msChain node82ms