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
- Creator
- 0x90A016771D…d23D6851c6
- 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