Skip to content
Protocol contracts not yet configured for this build
Enter app

Architecture

GravityStaking

Holds staked PLOUTO and divides ETH rewards by lock-weighted stake. It never mints, and it never distributes PLOUTO as a reward.

Constants

solidity
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

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

solidity
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

solidity
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

solidity
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

RoleCan
DEFAULT_ADMIN_ROLESet the router once; grant and revoke roles.
PAUSER_ROLEPause and unpause new staking.
EMERGENCY_ROLEDeclare or clear an emergency.

No role can withdraw a staker's principal, alter a position, or change a multiplier.

Invariants

text
Σ open position.amount == totalStakedΣ open position.weight == totalWeighttotalRewardsClaimed    <= totalRewardsReceivedPLOUTO.balanceOf(this) >= totalStakedaddress(this).balance  >= totalRewardsReceived - totalRewardsClaimed