Reentrancy protection
Every function that moves value is guarded, and every one follows checks-effects-interactions strictly.
Guarded functions
// GravityStakingstake claim claimMany withdraw emergencyWithdraw // PloutoRevenueRouterclaimPonsFees routeUnallocatedRevenue sweepDonationsToReserve // BuybackExecutorexecuteBuyback // PloutoReserveexecuteWithdrawalAll nonReentrant via OpenZeppelin's ReentrancyGuard.
Effects before interactions
The reward harvest performs no transfer at all:
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
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:
buybackBudget -= ethIn;totalEthSpent += ethIn;// ... then the swap, then retirementAnd unlockCallback — the one function an external contract calls into during execution — is locked to the PoolManager:
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():
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
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.