StakingVault

0x3b7d138260da13399b4969e58ecb5dbd9d972831

Verification
Verified
v0.8.24+commit.e11b9ed9
Type
Contract
6,458 bytes
ABI entries
54
18 read · 12 write
License
none

Contract information

Address
0x3b7d138260da13399b4969e58ecb5dbd9d972831
Chain
Robinhood Chain (4663)
Compiler
v0.8.24+commit.e11b9ed9
Optimization
Enabled
Creation tx
0xa5bef37415…17dbe89ae0

Token

Not a token

This contract does not expose ERC-20 metadata.

Read contract (18)

MAX_LOCK()uint256
MAX_REWARD_TOKENS()uint256
balanceOf(address)uint256
earned(address, address)uint256
lastTimeRewardApplicable(address)uint256
lockDuration()uint256
owner()address
rewardData(address)address, uint256, uint256, uint256, uint256, uint256
rewardForDuration(address)uint256
rewardPerToken(address)uint256
rewardTokenCount()uint256
rewardTokens(uint256)address
rewards(address, address)uint256
stakingToken()address
totalSupply()uint256
unlockAt(address)uint256
userPerTokenPaid(address, address)uint256
weth()address

Events (8)

LockDurationSetOwnershipTransferredRecoveredRewardAddedRewardNotifiedRewardPaidStakedWithdrawn

ABI

[
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "stakingToken_",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "weth_",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "lockDuration_",
        "type": "uint256"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "constructor"
  },
  {
    "inputs": [],
    "name": "CannotRecover",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "FeeOnTransferToken",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "LockTooLong",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NotDistributor",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NotOwner",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "PeriodActive",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "ReentrancyGuardReentrantCall",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "RewardTokenExists",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "RewardTooHigh",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      }
    ],
    "name": "SafeERC20FailedOperation",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "unlockTime",
        "type": "uint256"
      }
    ],
    "name": "StillLocked",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "TooManyRewardTokens",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "UnknownRewardToken",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "ZeroAddress",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "ZeroAmount",
    "type": "error"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "duration",
        "type": "uint256"
      }
    ],
    "name": "LockDurationSet",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "from",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "to",
        "type": "address"
      }
    ],
    "name": "OwnershipTransferred",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      }
    ],
    "name": "Recovered",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "duration",
        "type": "uint256"
      }
    ],
    "name": "RewardAdded",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "periodFinish",
        "type": "uint256"
      }
    ],
    "name": "RewardNotified",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "user",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "amount",
        "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"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "unlockAt",
        "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_LOCK",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "MAX_REWARD_TOKENS",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "distributor",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "duration",
        "type": "uint256"
      }
    ],
    "name": "addReward",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "account",
        "type": "address"
      }
    ],
    "name": "balanceOf",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "account",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      }
    ],
    "name": "earned",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "exit",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "getReward",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      }
    ],
    "name": "lastTimeRewardApplicable",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "lockDuration",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      }
    ],
    "name": "notifyRewardAmount",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "notifyRewardETH",
    "outputs": [],
    "stateMutability": "payable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "owner",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      }
    ],
    "name": "recoverERC20",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "name": "rewardData",
    "outputs": [
      {
        "internalType": "address",
        "name": "distributor",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "duration",
        "type": "uint256"
      },
      {
        "internalType": "uint256",
        "name": "periodFinish",
        "type": "uint256"
      },
      {
        "internalType": "uint256",
        "name": "rate",
        "type": "uint256"
      },
      {
        "internalType": "uint256",
        "name": "lastUpdate",
        "type": "uint256"
      },
      {
        "internalType": "uint256",
        "name": "perTokenStored",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      }
    ],
    "name": "rewardForDuration",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      }
    ],
    "name": "rewardPerToken",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "rewardTokenCount",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "name": "rewardTokens",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "name": "rewards",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "distributor",
        "type": "address"
      }
    ],
    "name": "setDistributor",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "duration",
        "type": "uint256"
      }
    ],
    "name": "setLockDuration",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "duration",
        "type": "uint256"
      }
    ],
    "name": "setRewardDuration",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      }
    ],
    "name": "stake",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "stakingToken",
    "outputs": [
      {
        "internalType": "contract IERC20",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "totalSupply",
    "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": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "name": "unlockAt",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "name": "userPerTokenPaid",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "weth",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "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.24;

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 {IWETH9} from "./interfaces/IUniswapV3.sol";

/// @title StakingVault
/// @notice Stake one ERC20; earn any number of reward tokens, streamed evenly
///         (per second, pro-rata to stake) over each reward period.
///
/// Provenance — this is NOT novel math. The accounting is Synthetix's
/// StakingRewards / Curve-ecosystem MultiRewards, one of the most audited and
/// battle-tested staking designs in DeFi (rewardPerTokenStored / earned()).
/// Deliberate deviations from stock MultiRewards, each small and reviewable:
///
///  1. WITHDRAWAL LOCK — every stake (re)locks the staker's whole balance for
///     `lockDuration` (e.g. 10 days). Withdrawals revert until it passes.
///     Reward claims are never locked.
///  2. NATIVE ETH REWARDS — `notifyRewardETH()` wraps to WETH and streams it
///     like any other reward token.
///  3. SAME-TOKEN REWARDS — the staking token itself can be a reward token.
///     The stock Synthetix balance check would let rewards overcommit staker
///     principal; `_availableRewardBalance` subtracts staked principal so
///     reward streams can only ever spend surplus deposits.
///
/// Deploy one vault per staking token (constructor arg) — pointing at an
/// interim token now and redeploying for the real one later is the intended
/// usage pattern, not a workaround.
///
/// STAKING TOKEN REQUIREMENTS — standard non-rebasing ERC20 only. Fee-on-
/// transfer tokens are rejected at stake time (received must equal credited);
/// rebasing tokens can't be detected on-chain and MUST NOT get a vault, or
/// principal accounting drifts from real balances. Every LaunchToken qualifies.
contract StakingVault is ReentrancyGuard {
    using SafeERC20 for IERC20;

    // ── config ───────────────────────────────────────────────────────────────
    IERC20 public immutable stakingToken;
    address public immutable weth;
    address public owner;
    uint256 public lockDuration; // applies to stakes made after any change
    uint256 public constant MAX_LOCK = 30 days;
    uint256 public constant MAX_REWARD_TOKENS = 8; // getReward loops all tokens

    struct Reward {
        address distributor; // may notify (besides owner)
        uint256 duration; // seconds each notified batch streams over
        uint256 periodFinish;
        uint256 rate; // tokens per second (1e18-scaled by token decimals)
        uint256 lastUpdate;
        uint256 perTokenStored;
    }

    address[] public rewardTokens;
    mapping(address => Reward) public rewardData;
    mapping(address => mapping(address => uint256)) public userPerTokenPaid; // user => token => paid
    mapping(address => mapping(address => uint256)) public rewards; // user => token => accrued

    uint256 private _totalSupply;
    mapping(address => uint256) private _balances;
    mapping(address => uint256) public unlockAt; // user => stake unlock time

    // ── events ───────────────────────────────────────────────────────────────
    event Staked(address indexed user, uint256 amount, uint256 unlockAt);
    event Withdrawn(address indexed user, uint256 amount);
    event RewardPaid(address indexed user, address indexed token, uint256 amount);
    event RewardAdded(address indexed token, uint256 duration);
    event RewardNotified(address indexed token, uint256 amount, uint256 periodFinish);
    event LockDurationSet(uint256 duration);
    event OwnershipTransferred(address indexed from, address indexed to);
    event Recovered(address indexed token, uint256 amount);

    error NotOwner();
    error NotDistributor();
    error ZeroAmount();
    error ZeroAddress();
    error StillLocked(uint256 unlockTime);
    error RewardTokenExists();
    error UnknownRewardToken();
    error TooManyRewardTokens();
    error RewardTooHigh();
    error LockTooLong();
    error PeriodActive();
    error CannotRecover();
    error FeeOnTransferToken();

    modifier onlyOwner() {
        if (msg.sender != owner) revert NotOwner();
        _;
    }

    modifier updateReward(address account) {
        uint256 n = rewardTokens.length;
        for (uint256 i; i < n; ++i) {
            address token = rewardTokens[i];
            Reward storage r = rewardData[token];
            r.perTokenStored = rewardPerToken(token);
            r.lastUpdate = lastTimeRewardApplicable(token);
            if (account != address(0)) {
                rewards[account][token] = earned(account, token);
                userPerTokenPaid[account][token] = r.perTokenStored;
            }
        }
        _;
    }

    constructor(address stakingToken_, address weth_, uint256 lockDuration_) {
        if (stakingToken_ == address(0) || weth_ == address(0)) revert ZeroAddress();
        if (lockDuration_ > MAX_LOCK) revert LockTooLong();
        stakingToken = IERC20(stakingToken_);
        weth = weth_;
        lockDuration = lockDuration_;
        owner = msg.sender;
    }

    // ── views (verbatim Synthetix math) ──────────────────────────────────────
    function totalSupply() external view returns (uint256) {
        return _totalSupply;
    }

    function balanceOf(address account) external view returns (uint256) {
        return _balances[account];
    }

    function lastTimeRewardApplicable(address token) public view returns (uint256) {
        uint256 finish = rewardData[token].periodFinish;
        return block.timestamp < finish ? block.timestamp : finish;
    }

    function rewardPerToken(address token) public view returns (uint256) {
        Reward storage r = rewardData[token];
        if (_totalSupply == 0) return r.perTokenStored;
        return r.perTokenStored
            + ((lastTimeRewardApplicable(token) - r.lastUpdate) * r.rate * 1e18) / _totalSupply;
    }

    function earned(address account, address token) public view returns (uint256) {
        return (_balances[account] * (rewardPerToken(token) - userPerTokenPaid[account][token]))
            / 1e18 + rewards[account][token];
    }

    function rewardForDuration(address token) external view returns (uint256) {
        Reward storage r = rewardData[token];
        return r.rate * r.duration;
    }

    function rewardTokenCount() external view returns (uint256) {
        return rewardTokens.length;
    }

    // ── staking ──────────────────────────────────────────────────────────────
    /// @notice Stake tokens. (Re)locks your ENTIRE staked balance for
    ///         `lockDuration` from now — topping up extends the lock.
    function stake(uint256 amount) external nonReentrant updateReward(msg.sender) {
        if (amount == 0) revert ZeroAmount();
        _totalSupply += amount;
        _balances[msg.sender] += amount;
        uint256 unlock = block.timestamp + lockDuration;
        unlockAt[msg.sender] = unlock;
        // The credit above must equal what actually arrives: a fee-on-transfer
        // staking token would deliver less while withdraw() pays nominal,
        // leaving the vault insolvent for the last withdrawers — reject it.
        // (Rebasing tokens are undetectable here and unsupported; every
        // LaunchToken is a plain OZ ERC20, so ours always pass.)
        uint256 balBefore = stakingToken.balanceOf(address(this));
        stakingToken.safeTransferFrom(msg.sender, address(this), amount);
        if (stakingToken.balanceOf(address(this)) - balBefore != amount) {
            revert FeeOnTransferToken();
        }
        emit Staked(msg.sender, amount, unlock);
    }

    /// @notice Withdraw staked tokens after the lock has passed.
    function withdraw(uint256 amount) public nonReentrant updateReward(msg.sender) {
        if (amount == 0) revert ZeroAmount();
        if (block.timestamp < unlockAt[msg.sender]) revert StillLocked(unlockAt[msg.sender]);
        _totalSupply -= amount;
        _balances[msg.sender] -= amount; // reverts on underflow
        stakingToken.safeTransfer(msg.sender, amount);
        emit Withdrawn(msg.sender, amount);
    }

    /// @notice Claim all accrued rewards. Never lock-gated.
    function getReward() public nonReentrant updateReward(msg.sender) {
        uint256 n = rewardTokens.length;
        for (uint256 i; i < n; ++i) {
            address token = rewardTokens[i];
            uint256 amount = rewards[msg.sender][token];
            if (amount > 0) {
                rewards[msg.sender][token] = 0;
                IERC20(token).safeTransfer(msg.sender, amount);
                emit RewardPaid(msg.sender, token, amount);
            }
        }
    }

    /// @notice Withdraw the full stake and claim all rewards.
    function exit() external {
        withdraw(_balances[msg.sender]);
        getReward();
    }

    // ── reward funding ───────────────────────────────────────────────────────
    /// @notice Register a reward token with a streaming duration.
    function addReward(address token, address distributor, uint256 duration) external onlyOwner {
        if (token == address(0)) revert ZeroAddress();
        if (duration == 0) revert ZeroAmount();
        if (rewardData[token].duration != 0) revert RewardTokenExists();
        if (rewardTokens.length >= MAX_REWARD_TOKENS) revert TooManyRewardTokens();
        rewardTokens.push(token);
        rewardData[token].duration = duration;
        rewardData[token].distributor = distributor;
        emit RewardAdded(token, duration);
    }

    /// @notice Deposit `amount` of a reward token; it streams evenly to stakers
    ///         over the token's duration (topping up an active period folds the
    ///         remainder into the new stream — stock Synthetix behavior).
    function notifyRewardAmount(address token, uint256 amount)
        external
        nonReentrant
        updateReward(address(0))
    {
        IERC20(token).safeTransferFrom(msg.sender, address(this), amount);
        _notify(token, amount);
    }

    /// @notice Deposit native ETH as rewards; wrapped to WETH and streamed.
    ///         WETH must have been added as a reward token first.
    function notifyRewardETH() external payable nonReentrant updateReward(address(0)) {
        IWETH9(weth).deposit{value: msg.value}();
        _notify(weth, msg.value);
    }

    function _notify(address token, uint256 amount) internal {
        Reward storage r = rewardData[token];
        if (r.duration == 0) revert UnknownRewardToken();
        if (msg.sender != owner && msg.sender != r.distributor) revert NotDistributor();
        if (amount == 0) revert ZeroAmount();

        if (block.timestamp >= r.periodFinish) {
            r.rate = amount / r.duration;
        } else {
            uint256 leftover = (r.periodFinish - block.timestamp) * r.rate;
            r.rate = (amount + leftover) / r.duration;
        }

        // The stream may only spend surplus reward balance — never staked
        // principal. This is the same-token fix over stock Synthetix, and it
        // also catches fee-on-transfer shortfalls.
        uint256 available = IERC20(token).balanceOf(address(this));
        if (token == address(stakingToken)) available -= _totalSupply;
        if (r.rate > available / r.duration) revert RewardTooHigh();

        r.lastUpdate = block.timestamp;
        r.periodFinish = block.timestamp + r.duration;
        emit RewardNotified(token, amount, r.periodFinish);
    }

    // ── admin ────────────────────────────────────────────────────────────────
    /// @notice Change a reward token's streaming duration (between periods only).
    function setRewardDuration(address token, uint256 duration) external onlyOwner {
        if (duration == 0) revert ZeroAmount();
        Reward storage r = rewardData[token];
        if (r.duration == 0) revert UnknownRewardToken();
        if (block.timestamp < r.periodFinish) revert PeriodActive();
        r.duration = duration;
        emit RewardAdded(token, duration);
    }

    function setDistributor(address token, address distributor) external onlyOwner {
        if (rewardData[token].duration == 0) revert UnknownRewardToken();
        rewardData[token].distributor = distributor;
    }

    /// @notice Change the lock for FUTURE stakes (existing locks are untouched).
    function setLockDuration(uint256 duration) external onlyOwner {
        if (duration > MAX_LOCK) revert LockTooLong();
        lockDuration = duration;
        emit LockDurationSet(duration);
    }

    function transferOwnership(address newOwner) external onlyOwner {
        if (newOwner == address(0)) revert ZeroAddress();
        emit OwnershipTransferred(owner, newOwner);
        owner = newOwner;
    }

    /// @notice Rescue tokens sent here by mistake. The staking token and every
    ///         registered reward token are unrecoverable — staker principal and
    ///         committed rewards can never be pulled by the owner.
    function recoverERC20(address token, uint256 amount) external onlyOwner {
        if (token == address(stakingToken) || rewardData[token].duration != 0) {
            revert CannotRecover();
        }
        IERC20(token).safeTransfer(owner, amount);
        emit Recovered(token, amount);
    }
}
Chain explorer4344msChain node77ms