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

Security

Reentrancy protection

Every function that moves value is guarded, and every one follows checks-effects-interactions strictly.

Guarded functions

solidity
// GravityStakingstake  claim  claimMany  withdraw  emergencyWithdraw // PloutoRevenueRouterclaimPonsFees  routeUnallocatedRevenue  sweepDonationsToReserve // BuybackExecutorexecuteBuyback // PloutoReserveexecuteWithdrawal

All nonReentrant via OpenZeppelin's ReentrancyGuard.

Effects before interactions

The reward harvest performs no transfer at all:

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;          // written before any ETH moves    totalRewardsClaimed += amount;}

The caller sends ETH afterwards. Even without the guard, a re-entrant call would find rewardDebt already updated and compute zero.

Withdrawal is the same shape: the position is zeroed and the totals decremented before either transfer.

The router

solidity
unallocatedRevenue = 0;totalRevenueRouted += amount;// ... all totals written, RouteRecord pushed, event emitted ...if (toBuybacks != 0) IBuybackExecutor(buybackExecutor).fundBuyback{value: toBuybacks}();if (toStakers  != 0) IGravityStaking(gravityStaking).notifyRewardETH{value: toStakers}();if (toReserve  != 0) IPloutoReserve(reserve).depositRevenue{value: toReserve}();

A downstream contract cannot re-enter and observe a partially-written split.

The executor

Budget is debited before the route runs:

solidity
buybackBudget -= ethIn;totalEthSpent += ethIn;// ... then the swap, then retirement

And unlockCallback — the one function an external contract calls into during execution — is locked to the PoolManager:

solidity
if (msg.sender != address(poolManager)) revert NotPoolManager();

Tested with a live attacker

The suite includes a contract that attempts to re-enter claim from its receive():

solidity
receive() external payable {    if (!attacked) {        attacked = true;        staking.claim(positionId);    }}

The re-entrant call reverts, which bubbles up and fails the transfer. The test asserts the attacker's balance is zero afterwards — nothing was drained.

Pull, not push

Rewards are never pushed to stakers. They accrue in the accumulator and are pulled by the owner. A single staker whose receive() reverts cannot block anyone else's claim, and cannot block distribution.

ETH transfer failures

solidity
function _sendEth(address to, uint256 amount) private {    (bool ok,) = payable(to).call{value: amount}("");    if (!ok) revert EthTransferFailed(to, amount);}

A failed transfer reverts the whole transaction rather than silently continuing. Nothing is ever marked claimed without actually being sent.