Sample report — staking vault
A realistic AEDSC report on a typical staking vault. 2 HIGH (reentrancy + unchecked return) + 1 MED + 4 LOW. Classic patterns we still see weekly in 2026.
Two ship-blockers. Both have one-line fixes. Do not deploy to mainnet until F-01 (reentrancy in withdraw) and F-02 (unchecked reward token transfer) ship. Everything else is hardening and gas.
- HIGHF-01 · reentrancy-eth (slither) + confirmed by mythrilStakingVault.sol:97–104
Reentrancy in withdraw() — external call before state update
withdraw() sends ETH to msg.sender BEFORE setting balances[msg.sender] to 0. A malicious contract can re-enter withdraw() inside its fallback and drain the vault. Both Slither and Mythril flag this with HIGH confidence.
StakingVault.solvulnerablefunction withdraw() external { uint256 amount = balances[msg.sender]; require(amount > 0, "nothing to withdraw"); (bool ok, ) = msg.sender.call{value: amount}(''); require(ok, "send failed"); balances[msg.sender] = 0; }suggested patch drop-in fixfunction withdraw() external { uint256 amount = balances[msg.sender]; require(amount > 0, "nothing to withdraw"); + balances[msg.sender] = 0; (bool ok, ) = msg.sender.call{value: amount}(''); require(ok, "send failed"); - balances[msg.sender] = 0; }Fix: Apply checks-effects-interactions: set balances to 0 BEFORE the external call. Add ReentrancyGuard.nonReentrant as a belt-and-braces if your codebase has cross-function shared state.reentrancy-ethCWE-841SWC-107 - HIGHF-02 · unchecked-transferStakingVault.sol:146
Reward token transfer return value ignored in claim()
claim() calls rewardToken.transfer(msg.sender, pending) without checking the return value. If the reward token is later configured to be USDT or any non-reverting ERC-20, a silent failure leaves the user thinking they were paid while pending is reset.
StakingVault.solvulnerablependingRewards[msg.sender] = 0; rewardToken.transfer(msg.sender, pending);suggested patch drop-in fix- rewardToken.transfer(msg.sender, pending); + rewardToken.safeTransfer(msg.sender, pending);Fix: Use SafeERC20.safeTransfer or require(rewardToken.transfer(...)).unchecked-transfererc20 - MEDIUMF-03 · missing-input-validationStakingVault.sol:61
Reward rate setter has no upper bound
setRewardRate(uint256 _rate) accepts any value. A compromised owner could set it to type(uint256).max and drain the reward pool on the next claim() call.
StakingVault.solvulnerablefunction setRewardRate(uint256 _rate) external onlyOwner { rewardRate = _rate; }Fix: require(_rate <= MAX_REWARD_RATE) with MAX_REWARD_RATE pinned to ~2× expected. Move the setter behind a timelock for mainnet.centralization-risk - LOWF-04 · timestampStakingVault.sol:182
Use of block.timestamp for reward accrual
Reward accrual uses block.timestamp. Miners can shift by ±15s. For seconds-granularity reward rates this is negligible; flag for awareness only.
StakingVault.solvulnerableuint256 elapsed = block.timestamp - lastUpdate;Fix: Accept (drift is bounded). For ultra-precise scheduling consider block.number with a known average block time.timestamp - LOWF-05 · missing-zero-checkStakingVault.sol:55
Missing zero-address check on setRewardToken()
setRewardToken accepts address(0) silently.
StakingVault.solvulnerablerewardToken = IERC20(_token);Fix: require(_token != address(0), 'zero token').missing-zero-check - LOWF-06 · storage-packingStakingVault.sol:12–18
Storage slot ordering wastes one SSTORE
Two uint128 followed by a uint256 cost 2 storage slots instead of the minimum 1.5. Reordering saves ~20k gas per deposit().
StakingVault.solvulnerableuint128 public rewardRate; uint256 public lastUpdate; uint128 public rewardPool;Fix: Reorder so two uint128 sit together (one slot), with uint256 separate (one slot).gas-optimizationstorage-packing - LOWF-07 · calls-loopStakingVault.sol:208–215
Unbounded loop in distributeRewards() can hit block gas limit
distributeRewards() iterates over stakers[] with no cap. If stakers.length grows past ~500, the function reverts on out-of-gas, locking rewards.
StakingVault.solvulnerablefor (uint256 i = 0; i < stakers.length; i++) { ... }Fix: Use pull-payments (each user calls claim()) instead of push. Standard pattern for staking.dos-with-block-gas-limit - INFOF-08 · pragmaStakingVault.sol:1
Floating pragma
pragma solidity ^0.8.20
StakingVault.solvulnerablepragma solidity ^0.8.20;Fix: Pin to 0.8.24.pragma - INFOF-09 · external-functionStakingVault.sol:190
Internal function visibility could be private
_settle() is internal but only called from within the contract. private is more restrictive and signals intent.
StakingVault.solvulnerablefunction _settle() internal { ... }Fix: function _settle() private { ... }style - INFOF-10 · event-coverageStakingVault.sol:97, 142
No Withdrawn / Claimed events on hot paths
withdraw() and claim() change user state without emitting events, making off-chain indexing harder.
StakingVault.solvulnerable// (no event)Fix: emit Withdrawn(msg.sender, amount); emit Claimed(msg.sender, pending);events
Get one of these for your contract.
Free for the first scan. Founder Pro €29/mo with a 7-day free trial unlocks unlimited scans, priority queue, and a human triaging every report — rate locked for life.