RobinStakingETH
0x2ce2b3b7bc9a0093681fc280b1bf77c30db3f1a3
Verification
Verified
v0.8.26+commit.8a97fa7a
Type
Contract
5,280 bytes
ABI entries
36
22 read · 5 write
License
none
Contract information
- Address
- 0x2ce2b3b7bc9a0093681fc280b1bf77c30db3f1a3
- Chain
- Robinhood Chain (4663)
- Compiler
- v0.8.26+commit.8a97fa7a
- Optimization
- Enabled
- Creator
- 0xf94a68B1d0…fd5fc58821
- Creation tx
- 0xfd06731d89…ec1ff58194
Token
Not a token
This contract does not expose ERC-20 metadata.
Read contract (22)
MAX_POSITIONS() → uint256
earned(address, uint256) → uint256
ethOwed(address) → uint256
isUnlocked(address, uint256) → bool
isValidLock(uint256) → bool
lastTimeRewardApplicable() → uint256
lastUpdateTime() → uint256
openPositions(address) → uint256
periodFinish() → uint256
positions(address, uint256) → tuple
positionsLength(address) → uint256
rewardPerWeight() → uint256
rewardPerWeightStored() → uint256
rewardPool() → uint256
rewardRate() → uint256
rewardsDistribution() → address
rewardsDuration() → uint256
stakedOf(address) → uint256
stakingToken() → address
totalEthOwed() → uint256
totalStaked() → uint256
totalWeight() → uint256
Events (7)
FundedOwedClaimedRewardAddedRewardDeferredRewardPaidStakedWithdrawn
ABI
[
{
"inputs": [
{
"internalType": "address",
"name": "_stakingToken",
"type": "address"
},
{
"internalType": "address",
"name": "_rewardsDistribution",
"type": "address"
},
{
"internalType": "uint256",
"name": "_rewardsDuration",
"type": "uint256"
}
],
"stateMutability": "nonpayable",
"type": "constructor"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "from",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "Funded",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "user",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "OwedClaimed",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": false,
"internalType": "uint256",
"name": "reward",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint256",
"name": "periodFinish",
"type": "uint256"
}
],
"name": "RewardAdded",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "user",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "RewardDeferred",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "user",
"type": "address"
},
{
"indexed": true,
"internalType": "uint256",
"name": "id",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint256",
"name": "reward",
"type": "uint256"
}
],
"name": "RewardPaid",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "user",
"type": "address"
},
{
"indexed": true,
"internalType": "uint256",
"name": "id",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint256",
"name": "amount",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint256",
"name": "lockDays",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint256",
"name": "unlockTime",
"type": "uint256"
}
],
"name": "Staked",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "user",
"type": "address"
},
{
"indexed": true,
"internalType": "uint256",
"name": "id",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint256",
"name": "amount",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint256",
"name": "reward",
"type": "uint256"
}
],
"name": "Withdrawn",
"type": "event"
},
{
"inputs": [],
"name": "MAX_POSITIONS",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "claimOwed",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "user",
"type": "address"
},
{
"internalType": "uint256",
"name": "id",
"type": "uint256"
}
],
"name": "earned",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"name": "ethOwed",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "id",
"type": "uint256"
}
],
"name": "getReward",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "user",
"type": "address"
},
{
"internalType": "uint256",
"name": "id",
"type": "uint256"
}
],
"name": "isUnlocked",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "d",
"type": "uint256"
}
],
"name": "isValidLock",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "pure",
"type": "function"
},
{
"inputs": [],
"name": "lastTimeRewardApplicable",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "lastUpdateTime",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "reward",
"type": "uint256"
}
],
"name": "notifyRewardAmount",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "user",
"type": "address"
}
],
"name": "openPositions",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "periodFinish",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "user",
"type": "address"
},
{
"internalType": "uint256",
"name": "id",
"type": "uint256"
}
],
"name": "positions",
"outputs": [
{
"components": [
{
"internalType": "uint256",
"name": "amount",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "weight",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "lockDays",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "unlockTime",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "rewardPerWeightPaid",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "rewards",
"type": "uint256"
}
],
"internalType": "struct RobinStakingETH.Position",
"name": "",
"type": "tuple"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "user",
"type": "address"
}
],
"name": "positionsLength",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "rewardPerWeight",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "rewardPerWeightStored",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "rewardPool",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "rewardRate",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "rewardsDistribution",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "rewardsDuration",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "amount",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "lockDays",
"type": "uint256"
}
],
"name": "stake",
"outputs": [
{
"internalType": "uint256",
"name": "id",
"type": "uint256"
}
],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "user",
"type": "address"
}
],
"name": "stakedOf",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "stakingToken",
"outputs": [
{
"internalType": "contract IRobinERC20",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "totalEthOwed",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "totalStaked",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "totalWeight",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "id",
"type": "uint256"
}
],
"name": "withdraw",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"stateMutability": "payable",
"type": "receive"
}
]Source code
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;
/// @dev Minimal ERC20 surface used by the staking pool. NOTE: RobinToken only taxes DEX trades
/// (transfers where one side is the Uniswap pair); plain wallet<->contract transfers — exactly
/// what stake/withdraw do — are NOT taxed, so principal accounting is exact. We still measure
/// the actually-received amount on stake as a belt-and-suspenders guard.
interface IRobinERC20 {
function transfer(address to, uint256 value) external returns (bool);
function transferFrom(address from, address to, uint256 value) external returns (bool);
function balanceOf(address account) external view returns (uint256);
}
/// @title RobinStakingETH
/// @notice Single-asset LOCKED staking, MULTI-POSITION, that pays rewards in NATIVE ETH (Robinhood
/// Chain's native gas token) instead of a token. Stake a RobinFun token (e.g. the main
/// ROBINFUN token) and earn a share of the project's fee revenue, streamed as ETH.
///
/// Each STAKER opens ONE OR MORE positions; every position picks its own lock of
/// 14/30/45/60 days at stake time and can be neither withdrawn nor claimed until that
/// position's lock expires. A wallet may hold many positions at once, each independent.
///
/// Reward weighting (longer lock = bigger share): a position's weight = amount * lockDays,
/// and rewards accrue by weight (generalized Synthetix reward-per-weight). So a 60-day lock
/// earns ~4.3x the per-token rate of a 14-day lock. Rewards stream over `rewardsDuration`.
///
/// FUNDING (native ETH): the reward asset is ETH held by this contract (address(this).balance).
/// The distributor (the project treasury) sends ETH to this contract — a plain transfer that
/// `receive()` accepts, or anyone may donate — and then calls `notifyRewardAmount(reward)` to
/// (re)arm the stream for `reward` wei over `rewardsDuration`. This mirrors the audited
/// Synthetix StakingRewards pattern exactly, with the reward-token balance replaced by the
/// contract's ETH balance. There is NO ERC20 reward token, NO bridge, NO mainnet — everything
/// is native ETH on chain 4663.
///
/// Uses plain, battle-tested Synthetix accounting (reward-per-weight, monotonic checkpoints) —
/// NOT a lazy-start stream — so `lastTimeRewardApplicable() - lastUpdateTime` can never
/// underflow and no state transition can brick the pool. Known accepted cost: reward-seconds
/// elapsed while totalWeight == 0 (e.g. before the first staker, or any gap) are not
/// redistributed — they stay in the pool (as ETH), never over-paid, never underflow.
contract RobinStakingETH {
/*//////////////////////////////////////////////////////////////
IMMUTABLES
//////////////////////////////////////////////////////////////*/
IRobinERC20 public immutable stakingToken; // the token being staked (reward is native ETH)
address public immutable rewardsDistribution; // the treasury — only caller of notifyRewardAmount
uint256 public immutable rewardsDuration; // reward streaming window (seconds)
/// @notice hard cap on simultaneously-OPEN positions per wallet (withdrawn slots free up, so this
/// is a live cap, never a lifetime one). Bounds only the wallet's own reads.
uint256 public constant MAX_POSITIONS = 100;
/*//////////////////////////////////////////////////////////////
STORAGE
//////////////////////////////////////////////////////////////*/
uint256 public periodFinish;
uint256 public rewardRate; // wei of ETH per second
uint256 public lastUpdateTime;
uint256 public rewardPerWeightStored;
uint256 public totalStaked; // sum of principal (token) across all open positions
uint256 public totalWeight; // sum of amount*lockDays across all open positions
struct Position {
uint256 amount; // staked principal (0 once withdrawn)
uint256 weight; // amount * lockDays
uint256 lockDays; // chosen lock (14/30/45/60)
uint256 unlockTime; // timestamp this position unlocks
uint256 rewardPerWeightPaid; // checkpoint
uint256 rewards; // accrued, unclaimed (wei)
}
mapping(address => Position[]) private _positions;
mapping(address => uint256) private _openCount; // number of currently-open positions per wallet
/// @notice ETH rewards that a payout `call` failed to deliver (e.g. a contract staker without a
/// payable receiver). Credited here instead of reverting, so a rejecting receiver can NEVER
/// lock its own token principal — and the ETH stays claimable via claimOwed() once the
/// receiver can accept it. The ETH physically remains in the contract.
mapping(address => uint256) public ethOwed;
/// @notice Sum of all ethOwed across wallets. rewardPool() excludes this so the notifyRewardAmount
/// solvency check can never re-stream ETH already earmarked to a deferred recipient (which
/// would let a re-arm strand someone's claimOwed()). Enforces claimable-owed <= free pool.
uint256 public totalEthOwed;
/// @notice O(1) running total of open principal per wallet, so stakedOf() is a lookup instead of a
/// loop over the append-only _positions array (which a wallet could grow unbounded by
/// cycling stake/withdraw and thereby gas-DoS its own stakedOf view).
mapping(address => uint256) private _stakedTotal;
/*//////////////////////////////////////////////////////////////
EVENTS
//////////////////////////////////////////////////////////////*/
event RewardAdded(uint256 reward, uint256 periodFinish);
event Staked(address indexed user, uint256 indexed id, uint256 amount, uint256 lockDays, uint256 unlockTime);
event Withdrawn(address indexed user, uint256 indexed id, uint256 amount, uint256 reward);
event RewardPaid(address indexed user, uint256 indexed id, uint256 reward);
event Funded(address indexed from, uint256 amount);
event RewardDeferred(address indexed user, uint256 amount); // ETH send failed -> credited to ethOwed
event OwedClaimed(address indexed user, uint256 amount);
/*//////////////////////////////////////////////////////////////
REENTRANCY GUARD
//////////////////////////////////////////////////////////////*/
uint256 private _locked = 1;
modifier nonReentrant() {
require(_locked == 1, "REENTRANCY");
_locked = 2;
_;
_locked = 1;
}
constructor(address _stakingToken, address _rewardsDistribution, uint256 _rewardsDuration) {
require(_stakingToken != address(0) && _rewardsDistribution != address(0), "ZERO_ADDR");
require(_rewardsDuration > 0, "ZERO_DURATION");
stakingToken = IRobinERC20(_stakingToken);
rewardsDistribution = _rewardsDistribution;
rewardsDuration = _rewardsDuration;
}
/// @notice Accept native ETH funding (treasury deposits / fee sweeps / donations). The ETH just
/// sits in the reward pool until the distributor arms it via notifyRewardAmount.
receive() external payable {
emit Funded(msg.sender, msg.value);
}
/*//////////////////////////////////////////////////////////////
REWARD ACCOUNTING
//////////////////////////////////////////////////////////////*/
/// @dev Advance the global reward index (plain Synthetix). lastUpdateTime is only ever set to
/// lastTimeRewardApplicable() = min(now, periodFinish), which is monotonic and always <=
/// periodFinish, so rewardPerWeight()'s subtraction can never underflow.
function _updateGlobal() internal {
rewardPerWeightStored = rewardPerWeight();
lastUpdateTime = lastTimeRewardApplicable();
}
/// @dev Bring one position's accrued rewards up to date against the current global index.
function _accrue(address user, uint256 id) internal {
Position storage p = _positions[user][id];
p.rewards = _earned(p);
p.rewardPerWeightPaid = rewardPerWeightStored;
}
function lastTimeRewardApplicable() public view returns (uint256) {
return block.timestamp < periodFinish ? block.timestamp : periodFinish;
}
function rewardPerWeight() public view returns (uint256) {
if (totalWeight == 0) return rewardPerWeightStored;
return rewardPerWeightStored
+ ((lastTimeRewardApplicable() - lastUpdateTime) * rewardRate * 1e18) / totalWeight;
}
function _earned(Position storage p) internal view returns (uint256) {
return (p.weight * (rewardPerWeight() - p.rewardPerWeightPaid)) / 1e18 + p.rewards;
}
/// @notice Accrued (unclaimed) ETH rewards of a specific position.
function earned(address user, uint256 id) public view returns (uint256) {
if (id >= _positions[user].length) return 0;
return _earned(_positions[user][id]);
}
/// @notice ETH free to stream = full ETH balance MINUS ETH already earmarked to deferred
/// recipients (ethOwed). Staked principal is held in the TOKEN, not ETH, so nothing else is
/// subtracted. (Still includes ordinary accrued-but-unclaimed position rewards — the same
/// trusted-distributor property as Synthetix; the distributor only ever arms funded ETH.)
/// Guarded so it can never underflow-revert even if invariants were ever perturbed.
function rewardPool() public view returns (uint256) {
uint256 bal = address(this).balance;
return bal > totalEthOwed ? bal - totalEthOwed : 0;
}
function isValidLock(uint256 d) public pure returns (bool) {
return d == 14 || d == 30 || d == 45 || d == 60;
}
/*//////////////////////////////////////////////////////////////
POSITION VIEWS
//////////////////////////////////////////////////////////////*/
function positionsLength(address user) external view returns (uint256) {
return _positions[user].length;
}
/// @notice Number of currently-OPEN (not-yet-withdrawn) positions a wallet holds.
function openPositions(address user) external view returns (uint256) {
return _openCount[user];
}
/// @notice Full position tuple (amount, weight, lockDays, unlockTime, rewardPerWeightPaid, rewards).
function positions(address user, uint256 id) external view returns (Position memory) {
return _positions[user][id];
}
/// @notice Total open principal a wallet has across all its positions (for display). O(1) — reads
/// the running total, so it can't be gas-DoS'd by a wallet that grows its _positions array.
function stakedOf(address user) external view returns (uint256) {
return _stakedTotal[user];
}
/// @notice Whether a specific position is unlocked (withdraw/claim allowed).
function isUnlocked(address user, uint256 id) external view returns (bool) {
if (id >= _positions[user].length) return false;
return block.timestamp >= _positions[user][id].unlockTime;
}
/*//////////////////////////////////////////////////////////////
USER ACTIONS
//////////////////////////////////////////////////////////////*/
/// @notice Open a NEW locked position. A wallet may hold up to MAX_POSITIONS OPEN at once.
/// @param lockDays one of {14,30,45,60}; this position cannot be touched until it expires.
/// @return id the index of the new position in the caller's position list.
function stake(uint256 amount, uint256 lockDays) external nonReentrant returns (uint256 id) {
require(amount > 0, "ZERO_AMOUNT");
require(isValidLock(lockDays), "BAD_LOCK");
// Staking is open before the pool is ever armed (periodFinish == 0: rewardRate is still 0, so
// stakers accrue nothing until funded) and while a stream is live (block.timestamp <
// periodFinish). Once a stream ends, staking closes so no one locks funds into a spent pool
// for zero reward — until the treasury re-arms with the next batch of fees.
require(periodFinish == 0 || block.timestamp < periodFinish, "STREAM_ENDED");
require(_openCount[msg.sender] < MAX_POSITIONS, "TOO_MANY_POSITIONS");
_updateGlobal();
uint256 before = stakingToken.balanceOf(address(this));
require(stakingToken.transferFrom(msg.sender, address(this), amount), "XFER_FAIL");
uint256 received = stakingToken.balanceOf(address(this)) - before;
require(received > 0, "NO_TOKENS");
uint256 w = received * lockDays; // reward weight: longer lock -> bigger share
uint256 unlock = block.timestamp + lockDays * 1 days;
id = _positions[msg.sender].length;
_positions[msg.sender].push(
Position({
amount: received,
weight: w,
lockDays: lockDays,
unlockTime: unlock,
rewardPerWeightPaid: rewardPerWeightStored,
rewards: 0
})
);
_openCount[msg.sender] += 1;
_stakedTotal[msg.sender] += received;
totalStaked += received;
totalWeight += w;
emit Staked(msg.sender, id, received, lockDays, unlock);
}
/// @notice Withdraw one position's full principal (token) + its accrued reward (ETH). Reverts
/// until that position's lock expires. The position slot is emptied (id stays valid but
/// inert). CEI: all state is updated before the token transfer and the ETH send, and the
/// nonReentrant guard blocks re-entry through the ETH call.
function withdraw(uint256 id) public nonReentrant {
require(id < _positions[msg.sender].length, "BAD_ID");
_updateGlobal();
_accrue(msg.sender, id);
Position storage p = _positions[msg.sender][id];
uint256 amt = p.amount;
require(amt > 0, "NO_STAKE");
require(block.timestamp >= p.unlockTime, "LOCKED");
uint256 reward = p.rewards;
totalStaked -= amt;
totalWeight -= p.weight;
_stakedTotal[msg.sender] -= amt;
p.amount = 0;
p.weight = 0;
p.rewards = 0;
_openCount[msg.sender] -= 1;
// Principal is the token; reward is native ETH — two distinct assets. Principal is ALWAYS
// returned and the weight is ALWAYS freed; the ETH reward is best-effort (credited to ethOwed
// if the recipient rejects it). So a contract staker that can't receive ETH can never lock its
// own principal, nor strand its weight in totalWeight (which would dilute every other staker).
require(stakingToken.transfer(msg.sender, amt), "XFER_FAIL");
_payReward(msg.sender, reward);
emit Withdrawn(msg.sender, id, amt, reward);
}
/// @notice Claim one position's accrued ETH reward without unstaking. Reverts until it unlocks.
function getReward(uint256 id) public nonReentrant {
require(id < _positions[msg.sender].length, "BAD_ID");
_updateGlobal();
_accrue(msg.sender, id);
Position storage p = _positions[msg.sender][id];
require(p.amount > 0, "NO_STAKE");
require(block.timestamp >= p.unlockTime, "LOCKED");
uint256 reward = p.rewards;
if (reward > 0) {
p.rewards = 0;
_payReward(msg.sender, reward);
emit RewardPaid(msg.sender, id, reward);
}
}
/// @dev Best-effort native-ETH payout. NEVER reverts on a failed send: the ETH stays in the
/// contract and is credited to ethOwed for the recipient to pull later via claimOwed(). This
/// guarantees principal recovery + weight release can't be blocked by a recipient that rejects
/// ETH. Callers zero the position's rewards BEFORE calling this (CEI), and all callers hold
/// the nonReentrant guard, so the external call can neither double-pay nor re-enter.
function _payReward(address to, uint256 amount) internal {
if (amount == 0) return;
(bool ok,) = to.call{value: amount}("");
if (!ok) {
ethOwed[to] += amount;
totalEthOwed += amount; // earmark so rewardPool()/solvency stops counting it as free
emit RewardDeferred(to, amount);
}
}
/// @notice Pull ETH rewards that a previous payout `call` failed to deliver (e.g. once the
/// recipient can accept ETH). Reverts if the send still fails, so nothing is lost.
function claimOwed() external nonReentrant {
uint256 amt = ethOwed[msg.sender];
require(amt > 0, "NOTHING_OWED");
ethOwed[msg.sender] = 0;
totalEthOwed -= amt; // un-earmark; the ETH is about to leave the contract
(bool ok,) = msg.sender.call{value: amt}("");
require(ok, "ETH_FAIL");
emit OwedClaimed(msg.sender, amt);
}
/*//////////////////////////////////////////////////////////////
REWARDS DISTRIBUTION
//////////////////////////////////////////////////////////////*/
/// @notice (Re)arm the ETH reward stream for `reward` wei over `rewardsDuration`. Only the
/// treasury (rewardsDistribution). The ETH must already be held by this contract (sent via
/// `receive()` beforehand or in a prior sweep); the solvency check below enforces that the
/// contract physically holds enough ETH to pay the resulting rate for the full window.
/// Leftover from an in-flight stream rolls into the new rate (standard Synthetix).
function notifyRewardAmount(uint256 reward) external nonReentrant {
require(msg.sender == rewardsDistribution, "NOT_DISTRIBUTION");
_updateGlobal();
if (block.timestamp >= periodFinish) {
rewardRate = reward / rewardsDuration;
} else {
uint256 remaining = periodFinish - block.timestamp;
uint256 leftover = remaining * rewardRate;
rewardRate = (reward + leftover) / rewardsDuration;
}
require(rewardRate > 0, "ZERO_RATE");
// Solvency: the contract must hold enough FREE ETH to cover the full stream at this rate.
// rewardPool() = balance - totalEthOwed, so ETH already earmarked to deferred recipients can
// never be re-committed to a new stream (which would otherwise strand their claimOwed()).
// (Still counts ordinary accrued-unclaimed position rewards — the trusted-distributor property
// inherited from Synthetix; the distributor only arms ETH it has actually funded.)
require(rewardRate <= rewardPool() / rewardsDuration, "REWARD_TOO_HIGH");
lastUpdateTime = block.timestamp;
periodFinish = block.timestamp + rewardsDuration;
emit RewardAdded(reward, periodFinish);
}
}
Chain explorer1685msChain node82ms