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
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