GravityStaking
Holds staked PLOUTO and divides ETH rewards by lock-weighted stake. It never mints, and it never distributes PLOUTO as a reward.
Constants
uint64 public constant LOCK_7D = 7 days;uint64 public constant LOCK_30D = 30 days;uint64 public constant LOCK_90D = 90 days; uint256 public constant MULT_7D = 1.0e18;uint256 public constant MULT_30D = 1.5e18;uint256 public constant MULT_90D = 2.5e18; uint256 public constant MIN_STAKE = 1e18;uint256 private constant ACC_PRECISION = 1e27;Opening a position
uint256 balanceBefore = IERC20(token).balanceOf(address(this));IERC20(token).safeTransferFrom(msg.sender, address(this), amount);uint256 received = IERC20(token).balanceOf(address(this)) - balanceBefore;if (received != amount) revert FeeOnTransferToken(amount, received);The balance delta is measured rather than assumed. A taxing token is rejected loudly instead of booking a position larger than the tokens actually held.
Then, in this exact order:
uint256 accSnapshot = accRewardPerWeight; // before any flush totalStaked += amount;totalWeight += weight; _flushPending(); // after this position is counted _positions[positionId] = Position({ owner: msg.sender, amount: amount, weight: weight, createdAt: uint64(block.timestamp), unlockAt: unlockAt, rewardDebt: (weight * accSnapshot) / ACC_PRECISION // pre-flush snapshot});The ordering matters and was a real bug. Snapshotting before the flush keeps historical rewards out of reach; flushing after the weight is counted means the first staker after an empty period actually receives the rollover.
Rewards
function notifyRewardETH() external payable { if (msg.sender != revenueRouter) revert NotRouter(); if (msg.value == 0) revert NoRewardValue(); totalRewardsReceived += msg.value; uint256 amount = msg.value + pendingUndistributed; if (totalWeight == 0) { pendingUndistributed = amount; // held, not lost } else { pendingUndistributed = 0; accRewardPerWeight += (amount * ACC_PRECISION) / totalWeight; } emit RewardsNotified(msg.value, accRewardPerWeight, pendingUndistributed);}Harvesting is effects-only
function _harvest(uint256 positionId, address expectedOwner) private returns (uint256 amount) { Position storage p = _positions[positionId]; if (p.owner != expectedOwner) revert NotPositionOwner(positionId); if (p.amount == 0) revert PositionClosed(positionId); uint256 accrued = (p.weight * accRewardPerWeight) / ACC_PRECISION; amount = accrued > p.rewardDebt ? accrued - p.rewardDebt : 0; p.rewardDebt = accrued; totalRewardsClaimed += amount;}No transfer happens here. The caller sends ETH afterwards, and every public entry point is nonReentrant.
Withdrawal
withdraw requires block.timestamp >= unlockAt, harvests, zeroes the position, decrements the totals, emits, and only then transfers principal and rewards.
emergencyWithdraw requires emergencyMode, returns principal only, and recirculates the forfeited rewards to whoever is still staked.
Roles
| Role | Can |
|---|---|
DEFAULT_ADMIN_ROLE | Set the router once; grant and revoke roles. |
PAUSER_ROLE | Pause and unpause new staking. |
EMERGENCY_ROLE | Declare or clear an emergency. |
No role can withdraw a staker's principal, alter a position, or change a multiplier.
Invariants
Σ open position.amount == totalStakedΣ open position.weight == totalWeighttotalRewardsClaimed <= totalRewardsReceivedPLOUTO.balanceOf(this) >= totalStakedaddress(this).balance >= totalRewardsReceived - totalRewardsClaimed