FrothWethStaking
0x3e2729cca8f9d097dd73a3d2f99e670a85e75e41
Verification
Verified
v0.8.24+commit.e11b9ed9
Type
Contract
13,292 bytes
ABI entries
78
32 read · 17 write
License
none
Contract information
- Address
- 0x3e2729cca8f9d097dd73a3d2f99e670a85e75e41
- Chain
- Robinhood Chain (4663)
- Compiler
- v0.8.24+commit.e11b9ed9
- Optimization
- Enabled
- Creator
- 0x3f96366920…bFf337C9B8
- Creation tx
- 0x1fad189fbf…f6af17d5c5
Token
Not a token
This contract does not expose ERC-20 metadata.
Read contract (32)
MAX_COOLDOWN() → uint256
MAX_DURATION() → uint256
MAX_REWARD_TOKENS() → uint256
MIN_DURATION() → uint256
PRECISION() → uint256
balanceOf(address) → uint256
claimableRewards(address) → address[], uint256[]
cooldownEnd(address) → uint256
cooldownInfo(address) → uint256, uint256, bool
cooldownPeriod() → uint256
coolingDown(address) → uint256
earned(address, address) → uint256
getRewardTokens() → address[]
lastTimeRewardApplicable(address) → uint256
owner() → address
paused() → bool
pendingOwner() → address
principalOf(address) → uint256
recoverableRewards(address) → uint256
renounceOwnership()
rewardData(address) → uint256, uint64, uint64, uint256, bool
rewardDistributor(address) → address
rewardForRemainingPeriod(address) → uint256
rewardPerToken(address) → uint256
rewardPerYear(address) → uint256
rewardTokens(uint256) → address
rewardTokensLength() → uint256
rewards(address, address) → uint256
rewardsOwed(address) → uint256
stakingToken() → address
totalStaked() → uint256
userRewardPerTokenPaid(address, address) → uint256
Events (16)
CooldownCancelledCooldownPeriodUpdatedCooldownStartedOwnershipTransferStartedOwnershipTransferredPausedRecoveredRewardDistributorUpdatedRewardExcessRecoveredRewardNotifiedRewardPaidRewardScheduleCancelledRewardTokenAddedStakedUnpausedWithdrawn
ABI
[
{
"inputs": [
{
"internalType": "address",
"name": "_stakingToken",
"type": "address"
},
{
"internalType": "address",
"name": "_owner",
"type": "address"
}
],
"stateMutability": "nonpayable",
"type": "constructor"
},
{
"inputs": [
{
"internalType": "address",
"name": "target",
"type": "address"
}
],
"name": "AddressEmptyCode",
"type": "error"
},
{
"inputs": [
{
"internalType": "address",
"name": "account",
"type": "address"
}
],
"name": "AddressInsufficientBalance",
"type": "error"
},
{
"inputs": [],
"name": "EnforcedPause",
"type": "error"
},
{
"inputs": [],
"name": "ExpectedPause",
"type": "error"
},
{
"inputs": [],
"name": "FailedInnerCall",
"type": "error"
},
{
"inputs": [],
"name": "MathOverflowedMulDiv",
"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": "OwnershipRenunciationDisabled",
"type": "error"
},
{
"inputs": [],
"name": "ReentrancyGuardReentrantCall",
"type": "error"
},
{
"inputs": [
{
"internalType": "uint8",
"name": "bits",
"type": "uint8"
},
{
"internalType": "uint256",
"name": "value",
"type": "uint256"
}
],
"name": "SafeCastOverflowedUintDowncast",
"type": "error"
},
{
"inputs": [
{
"internalType": "address",
"name": "token",
"type": "address"
}
],
"name": "SafeERC20FailedOperation",
"type": "error"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "user",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "amount",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint256",
"name": "remainingCooling",
"type": "uint256"
}
],
"name": "CooldownCancelled",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": false,
"internalType": "uint256",
"name": "cooldownPeriod",
"type": "uint256"
}
],
"name": "CooldownPeriodUpdated",
"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": "totalCooling",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint256",
"name": "cooldownEnd",
"type": "uint256"
}
],
"name": "CooldownStarted",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "previousOwner",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "newOwner",
"type": "address"
}
],
"name": "OwnershipTransferStarted",
"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": "address",
"name": "account",
"type": "address"
}
],
"name": "Paused",
"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": true,
"internalType": "address",
"name": "distributor",
"type": "address"
}
],
"name": "RewardDistributorUpdated",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "token",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "RewardExcessRecovered",
"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": "duration",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint256",
"name": "rate",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint64",
"name": "periodFinish",
"type": "uint64"
}
],
"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": "token",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "cancelledAt",
"type": "uint256"
}
],
"name": "RewardScheduleCancelled",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "token",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "distributor",
"type": "address"
}
],
"name": "RewardTokenAdded",
"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": false,
"internalType": "address",
"name": "account",
"type": "address"
}
],
"name": "Unpaused",
"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_COOLDOWN",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "MAX_DURATION",
"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": [],
"name": "MIN_DURATION",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "PRECISION",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "acceptOwnership",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "token",
"type": "address"
},
{
"internalType": "address",
"name": "distributor",
"type": "address"
}
],
"name": "addRewardToken",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"name": "balanceOf",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "cancelCooldown",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "token",
"type": "address"
}
],
"name": "cancelSchedule",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "account",
"type": "address"
}
],
"name": "claimableRewards",
"outputs": [
{
"internalType": "address[]",
"name": "tokens",
"type": "address[]"
},
{
"internalType": "uint256[]",
"name": "amounts",
"type": "uint256[]"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"name": "cooldownEnd",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "user",
"type": "address"
}
],
"name": "cooldownInfo",
"outputs": [
{
"internalType": "uint256",
"name": "cooling",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "endsAt",
"type": "uint256"
},
{
"internalType": "bool",
"name": "withdrawable",
"type": "bool"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "cooldownPeriod",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"name": "coolingDown",
"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": "getReward",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address[]",
"name": "tokens",
"type": "address[]"
}
],
"name": "getReward",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "getRewardTokens",
"outputs": [
{
"internalType": "address[]",
"name": "",
"type": "address[]"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "token",
"type": "address"
}
],
"name": "lastTimeRewardApplicable",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "token",
"type": "address"
},
{
"internalType": "uint256",
"name": "amount",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "duration",
"type": "uint256"
}
],
"name": "notifyRewardAmount",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "owner",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "pause",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "paused",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "pendingOwner",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "user",
"type": "address"
}
],
"name": "principalOf",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"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": "token",
"type": "address"
},
{
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "recoverExcessRewardToken",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "token",
"type": "address"
}
],
"name": "recoverableRewards",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "renounceOwnership",
"outputs": [],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"name": "rewardData",
"outputs": [
{
"internalType": "uint256",
"name": "rewardRate",
"type": "uint256"
},
{
"internalType": "uint64",
"name": "periodFinish",
"type": "uint64"
},
{
"internalType": "uint64",
"name": "lastUpdateTime",
"type": "uint64"
},
{
"internalType": "uint256",
"name": "rewardPerTokenStored",
"type": "uint256"
},
{
"internalType": "bool",
"name": "exists",
"type": "bool"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"name": "rewardDistributor",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "token",
"type": "address"
}
],
"name": "rewardForRemainingPeriod",
"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": [
{
"internalType": "address",
"name": "token",
"type": "address"
}
],
"name": "rewardPerYear",
"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": [],
"name": "rewardTokensLength",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"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": "",
"type": "address"
}
],
"name": "rewardsOwed",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "newCooldownPeriod",
"type": "uint256"
}
],
"name": "setCooldownPeriod",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "token",
"type": "address"
},
{
"internalType": "address",
"name": "distributor",
"type": "address"
}
],
"name": "setRewardDistributor",
"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": [
{
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "startCooldown",
"outputs": [],
"stateMutability": "nonpayable",
"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": "unpause",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "",
"type": "address"
},
{
"internalType": "address",
"name": "",
"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.24;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {Ownable2Step, Ownable} from "@openzeppelin/contracts/access/Ownable2Step.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol";
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol";
/// @title FrothWethStaking
/// @notice Stake FROTH and earn WETH (and optionally further reward tokens), each
/// streamed per-second at its own rate. Exiting requires a cooldown during
/// which the staked amount stops earning. No lock, no boost, no position token.
/// @dev Reward engine is Synthetix/Curve MultiRewards: a per-token rewardPerToken
/// accumulator keyed by user address. Weight == earning balance (1:1, no multiplier),
/// so reward state only changes on discrete user actions and accounting stays O(1)
/// per token. Cooldown is one slot per address; starting a new cooldown adds to the
/// pending amount and resets the clock on the combined total. rewardsOwed tracks
/// streamed-but-unclaimed rewards per token (conservatively ceil-rounded), which
/// lets the owner recover un-streamed excess (empty-pool periods, donations) via
/// recoverExcessRewardToken and makes the notify solvency check exact.
contract FrothWethStaking is Ownable2Step, ReentrancyGuard, Pausable {
using SafeERC20 for IERC20;
using SafeCast for uint256;
/// @notice Fixed-point scale of the rewardPerToken accumulator.
/// @dev Must stay far above `totalStaked / (rewardRate * delta)` or the floor
/// division in rewardPerToken truncates an interval's accrual to zero while
/// updateReward advances lastUpdateTime anyway -- silently destroying the
/// stream and reclassifying it as owner-recoverable excess. 1e18 is not
/// enough for low-decimal reward tokens (a 6-decimal rate is ~1e4/s, which
/// floors to zero against any realistic totalStaked). All scaled products
/// use 512-bit `Math.mulDiv`, so intermediate multiplication cannot overflow;
/// a call only reverts if the final uint256 result itself is unrepresentable.
uint256 public constant PRECISION = 1e36;
uint256 public constant MAX_REWARD_TOKENS = 20;
uint256 public constant MIN_DURATION = 1 hours;
uint256 public constant MAX_DURATION = 365 days;
uint256 public constant MAX_COOLDOWN = 30 days;
IERC20 public immutable stakingToken;
uint256 public cooldownPeriod = 7 days;
mapping(address => uint256) public balanceOf;
uint256 public totalStaked;
mapping(address => uint256) public coolingDown;
mapping(address => uint256) public cooldownEnd;
struct RewardData {
uint256 rewardRate;
uint64 periodFinish;
uint64 lastUpdateTime;
uint256 rewardPerTokenStored;
bool exists;
}
address[] public rewardTokens;
mapping(address => RewardData) public rewardData;
mapping(address => address) public rewardDistributor;
mapping(address => mapping(address => uint256)) public userRewardPerTokenPaid;
mapping(address => mapping(address => uint256)) public rewards;
/// @notice Streamed-but-unclaimed rewards per token, accrued whenever the global
/// accumulator advances. Ceil-rounded so it always covers the sum of what
/// users can eventually claim; rounding dust errs toward staying reserved.
mapping(address => uint256) public rewardsOwed;
error OwnershipRenunciationDisabled();
event Staked(address indexed user, uint256 amount);
event CooldownStarted(address indexed user, uint256 amount, uint256 totalCooling, uint256 cooldownEnd);
event CooldownCancelled(address indexed user, uint256 amount, uint256 remainingCooling);
event Withdrawn(address indexed user, uint256 amount);
event RewardPaid(address indexed user, address indexed token, uint256 amount);
event RewardTokenAdded(address indexed token, address indexed distributor);
event RewardNotified(address indexed token, uint256 amount, uint256 duration, uint256 rate, uint64 periodFinish);
event RewardDistributorUpdated(address indexed token, address indexed distributor);
event RewardScheduleCancelled(address indexed token, uint256 cancelledAt);
event CooldownPeriodUpdated(uint256 cooldownPeriod);
event Recovered(address indexed token, uint256 amount);
event RewardExcessRecovered(address indexed token, uint256 amount);
constructor(address _stakingToken, address _owner) Ownable(_owner) {
require(_stakingToken != address(0), "staking token zero");
stakingToken = IERC20(_stakingToken);
}
/// @notice Ownership is an operational safety role and cannot be renounced.
/// @dev Use the two-step transfer flow to rotate the controlling multisig. Without
/// an owner, active schedules could not be cancelled and excess or stray tokens
/// could never be recovered.
function renounceOwnership() public view override onlyOwner {
revert OwnershipRenunciationDisabled();
}
modifier updateReward(address account) {
uint256 len = rewardTokens.length;
for (uint256 i; i < len; ++i) {
address token = rewardTokens[i];
RewardData storage r = rewardData[token];
uint256 newPerToken = rewardPerToken(token);
uint256 deltaPerToken = newPerToken - r.rewardPerTokenStored;
if (deltaPerToken != 0 && totalStaked != 0) {
// Ceil so rewardsOwed >= the sum of user claims: users floor
// balance * accumulatedDelta across many intervals in one division,
// which can exceed the sum of per-interval global floors by dust.
rewardsOwed[token] += Math.mulDiv(deltaPerToken, totalStaked, PRECISION, Math.Rounding.Ceil);
}
r.rewardPerTokenStored = newPerToken;
r.lastUpdateTime = lastTimeRewardApplicable(token).toUint64();
if (account != address(0)) {
rewards[token][account] = earned(account, token);
userRewardPerTokenPaid[token][account] = r.rewardPerTokenStored;
}
}
_;
}
modifier onlyDistributor(address token) {
require(msg.sender == rewardDistributor[token] || msg.sender == owner(), "not distributor");
_;
}
function stake(uint256 amount) external nonReentrant whenNotPaused updateReward(msg.sender) {
require(amount > 0, "zero amount");
balanceOf[msg.sender] += amount;
totalStaked += amount;
stakingToken.safeTransferFrom(msg.sender, address(this), amount);
emit Staked(msg.sender, amount);
}
function startCooldown(uint256 amount) external nonReentrant updateReward(msg.sender) {
require(amount > 0, "zero amount");
require(amount <= balanceOf[msg.sender], "exceeds staked");
balanceOf[msg.sender] -= amount;
totalStaked -= amount;
uint256 newCooling = coolingDown[msg.sender] + amount;
coolingDown[msg.sender] = newCooling;
cooldownEnd[msg.sender] = block.timestamp + cooldownPeriod;
emit CooldownStarted(msg.sender, amount, newCooling, cooldownEnd[msg.sender]);
}
/// @notice Return cooling funds to the earning set, reversing startCooldown.
/// @dev Intentionally NOT `whenNotPaused`, unlike `stake`, even though both raise
/// `balanceOf`/`totalStaked`. `pause` means "stop new deposits, don't trap
/// anyone" -- it is not an incident freeze. Exits (`startCooldown`, `withdraw`)
/// and claims (`getReward`) stay open for the same reason, and reward streaming
/// is never paused. The asymmetry is therefore deliberate: this path cannot
/// inject fresh capital, only restore a position the caller already staked.
/// Consequence to keep in mind when reaching for `pause`: it does not freeze
/// `totalStaked` or reward outflow, so it is not a containment tool for a
/// reward-accounting incident -- rewards keep streaming and claimable while
/// paused. See RUNBOOK "Pause semantics".
function cancelCooldown(uint256 amount) external nonReentrant updateReward(msg.sender) {
require(amount > 0, "zero amount");
uint256 cooling = coolingDown[msg.sender];
require(amount <= cooling, "exceeds cooling");
uint256 remaining = cooling - amount;
coolingDown[msg.sender] = remaining;
if (remaining == 0) cooldownEnd[msg.sender] = 0;
balanceOf[msg.sender] += amount;
totalStaked += amount;
emit CooldownCancelled(msg.sender, amount, remaining);
}
function withdraw(uint256 amount) external nonReentrant {
require(amount > 0, "zero amount");
require(amount <= coolingDown[msg.sender], "exceeds cooling");
require(block.timestamp >= cooldownEnd[msg.sender], "cooldown not elapsed");
uint256 remaining = coolingDown[msg.sender] - amount;
coolingDown[msg.sender] = remaining;
if (remaining == 0) cooldownEnd[msg.sender] = 0;
stakingToken.safeTransfer(msg.sender, amount);
emit Withdrawn(msg.sender, amount);
}
function getReward() public nonReentrant updateReward(msg.sender) {
uint256 len = rewardTokens.length;
for (uint256 i; i < len; ++i) {
_payReward(rewardTokens[i], msg.sender);
}
}
function getReward(address[] calldata tokens) external nonReentrant updateReward(msg.sender) {
for (uint256 i; i < tokens.length; ++i) {
require(rewardData[tokens[i]].exists, "unknown reward token");
_payReward(tokens[i], msg.sender);
}
}
function _payReward(address token, address user) internal {
uint256 amount = rewards[token][user];
if (amount > 0) {
rewards[token][user] = 0;
// Saturating: ceil rounding keeps rewardsOwed >= claims, but a wei-level
// rounding argument must never be able to brick getReward.
uint256 owed = rewardsOwed[token];
rewardsOwed[token] = amount >= owed ? 0 : owed - amount;
IERC20(token).safeTransfer(user, amount);
emit RewardPaid(user, token, amount);
}
}
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) {
RewardData storage r = rewardData[token];
if (totalStaked == 0) return r.rewardPerTokenStored;
uint256 delta = lastTimeRewardApplicable(token) - r.lastUpdateTime;
// `delta * rewardRate` is bounded by the funded schedule; mulDiv then
// applies PRECISION with a 512-bit intermediate.
return r.rewardPerTokenStored + Math.mulDiv(delta * r.rewardRate, PRECISION, totalStaked);
}
function earned(address account, address token) public view returns (uint256) {
uint256 perToken = rewardPerToken(token) - userRewardPerTokenPaid[token][account];
return Math.mulDiv(balanceOf[account], perToken, PRECISION) + rewards[token][account];
}
function claimableRewards(address account)
external
view
returns (address[] memory tokens, uint256[] memory amounts)
{
uint256 len = rewardTokens.length;
tokens = new address[](len);
amounts = new uint256[](len);
for (uint256 i; i < len; ++i) {
tokens[i] = rewardTokens[i];
amounts[i] = earned(account, rewardTokens[i]);
}
}
function rewardForRemainingPeriod(address token) external view returns (uint256) {
RewardData storage r = rewardData[token];
if (block.timestamp >= r.periodFinish) return 0;
return r.rewardRate * (r.periodFinish - block.timestamp);
}
/// @notice Current emission rate annualized — the APR numerator.
/// @dev Returns 0 once the stream has ended, mirroring rewardForRemainingPeriod.
/// `rewardRate` is never zeroed on expiry (notifyRewardAmount is its only
/// writer), so without this guard a finished program would report its last
/// rate as a live APR forever.
function rewardPerYear(address token) external view returns (uint256) {
RewardData storage r = rewardData[token];
if (block.timestamp >= r.periodFinish) return 0;
return r.rewardRate * 365 days;
}
function cooldownInfo(address user) external view returns (uint256 cooling, uint256 endsAt, bool withdrawable) {
cooling = coolingDown[user];
endsAt = cooldownEnd[user];
withdrawable = cooling > 0 && block.timestamp >= endsAt;
}
function principalOf(address user) external view returns (uint256) {
return balanceOf[user] + coolingDown[user];
}
/// @notice Reward-token balance not reserved for stakers: rewards that streamed
/// while the pool was empty, plus direct donations. Reserved = already
/// streamed-but-unclaimed (rewardsOwed + accrual since last checkpoint)
/// + everything still scheduled through periodFinish.
function recoverableRewards(address token) public view returns (uint256) {
RewardData storage r = rewardData[token];
uint256 applicable = lastTimeRewardApplicable(token);
uint256 scheduled = r.periodFinish > applicable ? r.rewardRate * (r.periodFinish - applicable) : 0;
uint256 pending = 0;
if (totalStaked != 0) {
pending =
Math.mulDiv(rewardPerToken(token) - r.rewardPerTokenStored, totalStaked, PRECISION, Math.Rounding.Ceil);
}
uint256 reserved = rewardsOwed[token] + pending + scheduled;
uint256 bal = IERC20(token).balanceOf(address(this));
return bal > reserved ? bal - reserved : 0;
}
function rewardTokensLength() external view returns (uint256) {
return rewardTokens.length;
}
function getRewardTokens() external view returns (address[] memory) {
return rewardTokens;
}
function addRewardToken(address token, address distributor) external onlyOwner {
require(token != address(0), "zero token");
require(token != address(stakingToken), "staking token");
require(!rewardData[token].exists, "already added");
require(rewardTokens.length < MAX_REWARD_TOKENS, "too many tokens");
rewardData[token].exists = true;
rewardDistributor[token] = distributor;
rewardTokens.push(token);
emit RewardTokenAdded(token, distributor);
}
function notifyRewardAmount(address token, uint256 amount, uint256 duration)
external
nonReentrant
onlyDistributor(token)
updateReward(address(0))
{
RewardData storage r = rewardData[token];
require(r.exists, "unknown reward token");
require(amount > 0, "zero amount");
require(duration >= MIN_DURATION && duration <= MAX_DURATION, "bad duration");
IERC20(token).safeTransferFrom(msg.sender, address(this), amount);
if (block.timestamp >= r.periodFinish) {
r.rewardRate = amount / duration;
} else {
uint256 remaining = r.periodFinish - block.timestamp;
uint256 leftover = remaining * r.rewardRate;
r.rewardRate = (amount + leftover) / duration;
}
require(r.rewardRate > 0, "reward rate zero");
// Only balance not already owed to stakers may back the new schedule.
uint256 balance = IERC20(token).balanceOf(address(this));
uint256 owed = rewardsOwed[token];
uint256 available = balance > owed ? balance - owed : 0;
require(r.rewardRate <= available / duration, "reward exceeds balance");
// A top-up may never make the schedule end sooner than it already would:
// `available` contains `leftover`, so the solvency check above cannot detect
// compression -- shortening `duration` scales both sides of it identically.
// To re-time a schedule, cancelSchedule + recoverExcessRewardToken + re-notify.
require(block.timestamp + duration >= r.periodFinish, "cannot shorten active period");
r.lastUpdateTime = block.timestamp.toUint64();
r.periodFinish = (block.timestamp + duration).toUint64();
emit RewardNotified(token, amount, duration, r.rewardRate, r.periodFinish);
}
function setRewardDistributor(address token, address distributor) external onlyOwner {
require(rewardData[token].exists, "unknown reward token");
rewardDistributor[token] = distributor;
emit RewardDistributorUpdated(token, distributor);
}
function setCooldownPeriod(uint256 newCooldownPeriod) external onlyOwner {
require(newCooldownPeriod <= MAX_COOLDOWN, "cooldown too long");
cooldownPeriod = newCooldownPeriod;
emit CooldownPeriodUpdated(newCooldownPeriod);
}
/// @dev Gates `stake` and nothing else -- by design it stops NEW deposits without
/// trapping existing stakers. It does NOT freeze the earning set
/// (`cancelCooldown` still restores a caller's own position), reward accrual,
/// `notifyRewardAmount`, or `getReward`. Do not rely on it to contain a
/// reward-accounting incident: rewards keep streaming and remain claimable
/// while paused. See RUNBOOK "Pause semantics".
function pause() external onlyOwner {
_pause();
}
function unpause() external onlyOwner {
_unpause();
}
/// @dev For registered reward tokens use recoverExcessRewardToken instead.
function recoverERC20(address token, uint256 amount) external onlyOwner nonReentrant {
require(token != address(stakingToken), "cannot recover staking token");
require(!rewardData[token].exists, "cannot recover reward token");
IERC20(token).safeTransfer(owner(), amount);
emit Recovered(token, amount);
}
/// @notice Stop a live reward stream, releasing its un-streamed remainder as
/// recoverable excess. The escape hatch for a mis-sized schedule: cancel,
/// recoverExcessRewardToken, then re-notify with the intended duration.
/// @dev Only ever moves rewardRate DOWN (to zero), so unlike a shortening
/// re-notify it cannot create a rate burst for a just-in-time staker to
/// capture. Already-streamed rewards are untouched: updateReward checkpoints
/// into rewardsOwed first, and recoverableRewards reserves that.
function cancelSchedule(address token) external onlyOwner nonReentrant updateReward(address(0)) {
RewardData storage r = rewardData[token];
require(r.exists, "unknown reward token");
require(block.timestamp < r.periodFinish, "no active schedule");
r.rewardRate = 0;
r.periodFinish = block.timestamp.toUint64();
emit RewardScheduleCancelled(token, block.timestamp);
}
/// @notice Recover reward tokens in excess of what is owed and scheduled — e.g.
/// rewards that streamed while totalStaked was 0, or direct donations.
/// Cannot touch user-claimable balances: recoverableRewards reserves
/// streamed-but-unclaimed (ceil-rounded) plus the full remaining schedule.
function recoverExcessRewardToken(address token, uint256 amount)
external
onlyOwner
nonReentrant
updateReward(address(0))
{
require(rewardData[token].exists, "unknown reward token");
require(amount <= recoverableRewards(token), "exceeds excess");
IERC20(token).safeTransfer(owner(), amount);
emit RewardExcessRecovered(token, amount);
}
}
Chain explorer2296msChain node91ms