← Back to Blog

DeFi Vault Harvesting Security: 6 Critical Vulnerabilities

2026-04-18 defi-security autocompound vault-security harvest-manipulation reentrancy mev oracle-manipulation slippage

Autocompounding vaults sit at the intersection of every DeFi primitive: they hold depositor funds, interact with external yield farms, swap reward tokens on DEXes, and update share prices atomically. The compound effect that makes these vaults attractive — automatically reinvesting accrued yield so interest earns interest — is precisely what makes them dangerous to implement. Every harvest transaction is a publicly visible state transition that moves large amounts of value through multiple external protocols in a single call.

Production autocompound vaults from protocols like Beefy, Yearn, Convex, and countless forks have suffered losses from attackers who understand the harvest lifecycle better than the protocol developers who wrote it. The vulnerabilities are rarely exotic: most trace back to missing slippage parameters, inadequate access controls on the harvest function, naive fee accounting, non-standard ERC-20 token handling, or trust assumptions about reward tokens that external teams deploy independently.

This post examines six distinct vulnerability classes that appear repeatedly in vault and autocompound audits. Each section walks through a concrete Solidity bug, explains the exploit path, and shows the correct implementation pattern.

1. Harvest Front-Running via MEV

Unprotected vault harvest functions allow MEV searchers to execute sandwich attacks by depositing before yield compounding and withdrawing immediately after at an inflated share price.

When a harvest transaction is broadcast to the public mempool, it advertises an imminent share price increase to anyone watching. An MEV bot sees the pending transaction, deposits into the vault at the current (pre-harvest) price, waits for the harvest to execute and inflate the share price, then immediately withdraws at the higher price. Existing depositors bear the dilution while the attacker captures yield they did not earn.

Additionally, vault withdrawal and deposit logic must properly interface with external yield farming protocols. External farming contracts (such as MasterChef or Convex pools) do not track deposits from raw token transfers alone; the vault must explicitly approve and invoke farm.deposit() to credit its position. During withdrawals, withdraw() must invoke farm.withdraw() to pull liquidity back into the vault before returning assets to the user. All ERC-20 transfers must use OpenZeppelin's SafeERC20 to handle non-standard tokens, and deposit calculations must guard against division-by-zero if external farm assets drop to zero.

Crucially, when implementing withdrawal lock periods (LOCK_BLOCKS) to mitigate front-running, vaults must update depositBlock[msg.sender] = block.number on every deposit. If a vault only sets the lockup block when initial shares are zero (if (shares[msg.sender] == 0)), an attacker holding a tiny residual share balance (e.g. 1 wei) can deposit massive funds right before harvest() without resetting depositBlock, allowing them to withdraw in the exact same block and completely bypass the lockup delay.

Vulnerable:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IERC20 {
    function transfer(address to, uint256 amount) external returns (bool);
    function transferFrom(address from, address to, uint256 amount) external returns (bool);
}

interface IFarm {
    function totalAssets() external view returns (uint256);
    function compound() external;
    function withdraw(uint256 amount) external;
}

contract AutoVault {
    IERC20 public asset;
    IFarm public farm;
    uint256 public totalShares;
    mapping(address => uint256) public shares;

    function deposit(uint256 amount) external {
        uint256 nav = farm.totalAssets();
        // VULNERABLE: If totalShares > 0 and nav == 0, causes division-by-zero revert
        uint256 toMint = totalShares == 0 ? amount : (amount * totalShares) / nav;

        // VULNERABLE: Direct token transfer without calling farm.deposit() does not record farm position
        // VULNERABLE: Unchecked ERC-20 return value
        asset.transferFrom(msg.sender, address(farm), amount);
        shares[msg.sender] += toMint;
        totalShares += toMint;
    }

    // No delay, no access control — any MEV bot sandwiches this
    function harvest() external {
        farm.compound(); // pulls rewards, swaps, reinvests — raises totalAssets
    }

    // VULNERABLE: Does not pull assets back from farm prior to transfer
    function withdraw(uint256 shareAmt) external {
        uint256 nav = farm.totalAssets();
        uint256 out = (shareAmt * nav) / totalShares;
        shares[msg.sender] -= shareAmt;
        totalShares -= shareAmt;
        asset.transfer(msg.sender, out); // Reverts: contract holds 0 asset balance
    }
}

The attacker deposits just before harvest() is mined, minting shares at the stale NAV. One block later, NAV has risen from compounding. The attacker withdraws, pocketing the spread. Furthermore, because AutoVault never invoked farm.deposit() to register its stake and never pulled underlying tokens from farm, withdraw() attempts to transfer tokens directly from AutoVault's empty balance, reverting every call.

Fixed:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

interface IFarm {
    function totalAssets() external view returns (uint256);
    function deposit(uint256 amount) external;
    function compound() external;
    function withdraw(uint256 amount) external;
}

contract AutoVault {
    using SafeERC20 for IERC20;

    IERC20 public asset;
    IFarm public farm;
    uint256 public totalShares;
    mapping(address => uint256) public shares;
    mapping(address => uint256) public depositBlock;
    uint256 public constant LOCK_BLOCKS = 2;
    address public keeper;

    constructor(IERC20 _asset, IFarm _farm, address _keeper) {
        require(_keeper != address(0), "zero keeper");
        asset = _asset;
        farm = _farm;
        keeper = _keeper;
    }

    modifier onlyKeeper() {
        require(msg.sender == keeper, "not keeper");
        _;
    }

    function deposit(uint256 amount) external {
        uint256 nav = farm.totalAssets();
        if (totalShares > 0) {
            require(nav > 0, "zero nav");
        }
        uint256 toMint = totalShares == 0 ? amount : (amount * totalShares) / nav;

        // Receive tokens into vault first, approve farm, then explicitly call farm.deposit()
        asset.safeTransferFrom(msg.sender, address(this), amount);
        asset.forceApprove(address(farm), amount);
        farm.deposit(amount);

        // Update lockup block on every deposit to prevent lockup bypass via pre-existing shares
        depositBlock[msg.sender] = block.number;

        shares[msg.sender] += toMint;
        totalShares += toMint;
    }

    // Restricted to trusted keeper — uses private mempool or flashbots
    function harvest() external onlyKeeper {
        farm.compound();
    }

    function withdraw(uint256 shareAmt) external {
        require(block.number >= depositBlock[msg.sender] + LOCK_BLOCKS, "too soon");
        uint256 nav = farm.totalAssets();
        require(nav > 0, "zero nav");

        uint256 out = (shareAmt * nav) / totalShares;
        shares[msg.sender] -= shareAmt;
        totalShares -= shareAmt;

        // Pull assets from farm back to vault before transferring to user
        farm.withdraw(out);
        asset.safeTransfer(msg.sender, out);
    }
}

Restricting harvest() to a keeper that submits via a private relay (Flashbots, MEV Blocker) removes the mempool exposure. Updating depositBlock on every deposit ensures that an attacker holding pre-existing shares cannot bypass the lockup period when making top-up deposits immediately before a harvest. Calling farm.deposit(amount) registers the vault's balance with the external farm, while calling farm.withdraw(out) guarantees liquidity is retrieved from the yield farm before distribution.

Detection tips: Look for harvest() or compound() functions with no access control modifier and no minimum deposit-hold requirement. Verify that deposit() calls explicit external farm deposit methods (farm.deposit()) rather than sending tokens directly to the farm contract. In Foundry tests, simulate a sandwich: call deposit(), then harvest(), then withdraw() in consecutive blocks and compare the attacker's exit balance against their entry cost.

2. Share Price Inflation via Direct Token Donation & Phantom Asset Inflation

Querying raw token balances directly for NAV calculations exposes vaults to share price manipulation via direct token donations, while failing to swap reward tokens through a DEX router causes internal accounting desynchronization and fund locking.

Before the harvest runs, an attacker can transfer reward tokens or underlying assets directly to the vault contract address. If the vault's NAV calculation naively reads raw balanceOf(address(this)) or adds different asset balances without valuation, direct token transfers artificially inflate the apparent share price. Furthermore, if an attacker directly donates rewardToken to the contract address and triggers compounding, an un-tracked reward balance causes the internal accounting ledger (trackedWant) to grow without minting shares, making subsequent depositors vulnerable to first-deposit and inflation attacks.

Crucially, internal reinvestment logic must execute an actual DEX swap (e.g., via Uniswap V2/V3 Router) to convert rewardToken into physical want tokens. When swapping reward tokens, the vault must calculate and pass a non-zero minWantOut parameter (derived via an on-chain TWAP oracle or off-chain keeper verification) rather than passing 0, ensuring that yield is protected from DEX sandwich attacks during auto-compounding. If code simply transfers rewardToken to a dead address or mock burns it without acquiring actual want tokens while incrementing trackedWant, the internal ledger and physical want.balanceOf(address(this)) desynchronize. When users later invoke withdraw(), the transaction reverts due to an insufficient physical want balance, permanently locking user capital.

To fully protect against share inflation attacks, vaults must adopt ERC-4626 style virtual shares and virtual assets offsets (VIRTUAL_SHARES and VIRTUAL_ASSETS) in share calculations alongside internal reward accounting to decouple vault state from direct balance donations.

Vulnerable:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IERC20 {
    function transfer(address to, uint256 amount) external returns (bool);
    function transferFrom(address from, address to, uint256 amount) external returns (bool);
    function balanceOf(address account) external view returns (uint256);
}

contract StrategyVault {
    IERC20 public rewardToken; // e.g. USDC (6 decimals)
    IERC20 public want;        // e.g. WETH (18 decimals)
    uint256 public totalShares;
    mapping(address => uint256) public shares;

    // VULNERABLE: Direct raw balance summation of two different tokens without valuation
    // or decimal conversion, open to direct token donation inflation.
    function totalAssets() public view returns (uint256) {
        return want.balanceOf(address(this)) + rewardToken.balanceOf(address(this));
    }

    function deposit(uint256 amount) external {
        uint256 nav = totalAssets();
        uint256 toMint = totalShares == 0 ? amount : (amount * totalShares) / nav;
        want.transferFrom(msg.sender, address(this), amount);
        shares[msg.sender] += toMint;
        totalShares += toMint;
    }

    function harvest() external {
        uint256 rewardBal = rewardToken.balanceOf(address(this));
        _swapAndReinvest(rewardBal);
    }

    function _swapAndReinvest(uint256 amount) internal {
        // VULNERABLE: Does not swap or transfer rewardToken out, leaving balance intact
    }

    // VULNERABLE: Completely missing withdraw() function!
}

An attacker donates tokens directly to the contract address, inflating totalAssets(). A victim who deposits immediately after the donation mints significantly fewer shares. In addition, if reward balances are not deducted during compounding, repeated harvests generate phantom asset growth.

Fixed:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

interface IUniswapV2Router {
    function swapExactTokensForTokens(
        uint amountIn,
        uint amountOutMin,
        address[] calldata path,
        address to,
        uint deadline
    ) external returns (uint[] memory amounts);
}

interface IOracle {
    function getAmountOut(address tokenIn, address tokenOut, uint256 amountIn) external view returns (uint256);
}

contract StrategyVault {
    using SafeERC20 for IERC20;

    IERC20 public rewardToken; // e.g. USDC (6 decimals)
    IERC20 public want;        // e.g. WETH (18 decimals)
    IUniswapV2Router public router;
    IOracle public oracle;
    address public keeper;
    uint256 public slippageBps = 100; // 1% max slippage

    uint256 public totalShares;
    uint256 public trackedWant;   // internal want accounting
    uint256 public trackedReward; // internal reward accounting to ignore direct donations
    mapping(address => uint256) public shares;

    // Virtual shares and assets offsets to protect against share price inflation attacks (ERC-4626)
    uint256 private constant VIRTUAL_SHARES = 10**3;
    uint256 private constant VIRTUAL_ASSETS = 1;

    constructor(IERC20 _rewardToken, IERC20 _want, IUniswapV2Router _router, IOracle _oracle, address _keeper) {
        require(_keeper != address(0), "zero keeper");
        rewardToken = _rewardToken;
        want = _want;
        router = _router;
        oracle = _oracle;
        keeper = _keeper;
    }

    modifier onlyKeeper() {
        require(msg.sender == keeper, "not keeper");
        _;
    }

    function totalAssets() public view returns (uint256) {
        return trackedWant; // reads internal accounting ledger, immune to direct donations
    }

    // Explicit notification function for legitimate reward deposits
    function notifyRewardAmount(uint256 amount) external {
        rewardToken.safeTransferFrom(msg.sender, address(this), amount);
        trackedReward += amount;
    }

    function deposit(uint256 amount) external {
        require(amount > 0, "zero amount");

        // Virtual offset calculation for inflation protection
        uint256 toMint = (amount * (totalShares + VIRTUAL_SHARES)) / (totalAssets() + VIRTUAL_ASSETS);

        want.safeTransferFrom(msg.sender, address(this), amount);
        trackedWant += amount; // credit internal ledger
        shares[msg.sender] += toMint;
        totalShares += toMint;
    }

    function harvest() external onlyKeeper {
        // Use trackedReward rather than raw balanceOf to ignore direct rewardToken donations
        uint256 rewardToSwap = trackedReward;
        if (rewardToSwap == 0) return;

        trackedReward = 0; // reset internal reward tracker before swap

        // Calculate minimum expected output via oracle to prevent slippage/sandwich exploits
        uint256 expectedWant = oracle.getAmountOut(address(rewardToken), address(want), rewardToSwap);
        uint256 minWantOut = (expectedWant * (10000 - slippageBps)) / 10000;

        uint256 wantGained = _swapAndReinvest(rewardToSwap, minWantOut);
        trackedWant += wantGained; // credit actual swapped want tokens
    }

    function withdraw(uint256 shareAmt) external {
        require(shareAmt > 0, "zero share");
        uint256 nav = totalAssets();
        require(nav > 0, "zero nav");

        // Calculate output using virtual offset matching deposit scaling
        uint256 out = (shareAmt * (nav + VIRTUAL_ASSETS)) / (totalShares + VIRTUAL_SHARES);
        require(out <= trackedWant, "exceeds tracked assets");

        shares[msg.sender] -= shareAmt;
        totalShares -= shareAmt;
        trackedWant -= out; // update internal ledger before transfer

        want.safeTransfer(msg.sender, out);
    }

    function _swapAndReinvest(uint256 amount, uint256 minWantOut) internal returns (uint256) {
        rewardToken.forceApprove(address(router), amount);

        address[] memory path = new address[](2);
        path[0] = address(rewardToken);
        path[1] = address(want);

        // Execute actual DEX router swap with TWAP-bounded minWantOut to protect yield
        uint256[] memory amounts = router.swapExactTokensForTokens(
            amount,
            minWantOut,
            path,
            address(this),
            block.timestamp
        );

        return amounts[amounts.length - 1]; // actual want tokens credited from DEX swap
    }
}

Detection tips: Search the codebase for calls to balanceOf(address(this)) inside NAV or totalAssets() calculations. Verify that internal reinvestment routines (_swapAndReinvest) actually invoke DEX routers to swap rewardToken into want tokens and credit physical assets with validated amountOutMin values. Ensure share minting includes virtual offsets (VIRTUAL_SHARES / VIRTUAL_ASSETS) and that withdraw() is fully implemented to allow share redemption while properly updating internal ledgers (trackedWant).

3. Unbounded Slippage & Unvalidated Deadlines in Swap-and-Reinvest

Swapping reward tokens on decentralized exchanges without minimum output thresholds (amountOutMin = 0) or access-controlled execution deadlines allows sandwich bots to manipulate pool spot prices and extract harvest yield.

The harvest loop must swap reward tokens into the vault's base asset before reinvesting. If the swap call does not specify a minimum output amount or if it passes block.timestamp without access control, a searcher can sandwich the harvest transaction.

Even if require(deadline >= block.timestamp) is checked inside harvest(uint256 deadline), leaving the function un-gated (external) allows any MEV bot to call harvest(block.timestamp) or harvest(type(uint256).max) directly, bypassing off-chain deadline protections entirely. Crucially, when rewardBal == 0, attempting a DEX swap will revert on routers like Uniswap V2 with INSUFFICIENT_INPUT_AMOUNT, so an explicit zero-balance check is required.

Vulnerable:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IERC20 {
    function approve(address spender, uint256 amount) external returns (bool);
    function balanceOf(address account) external view returns (uint256);
}

interface IUniswapV2Router {
    function swapExactTokensForTokens(
        uint amountIn,
        uint amountOutMin,
        address[] calldata path,
        address to,
        uint deadline
    ) external returns (uint[] memory amounts);
}

contract HarvestSwapper {
    IUniswapV2Router public router;
    IERC20 public rewardToken;
    IERC20 public want;

    // VULNERABLE: Unprotected external entry point permits arbitrary deadline arguments
    // VULNERABLE: Reverts when rewardBal == 0 due to UniswapV2 zero input amount error
    function harvest(uint256 deadline) external {
        require(deadline >= block.timestamp, "expired deadline");
        uint256 rewardBal = rewardToken.balanceOf(address(this));
        rewardToken.approve(address(router), rewardBal);

        address[] memory path = new address[](2);
        path[0] = address(rewardToken);
        path[1] = address(want);

        // amountOutMin is 0 — accepts any output rate without slippage bounds
        router.swapExactTokensForTokens(rewardBal, 0, path, address(this), deadline);
    }
}

With amountOutMin = 0 and an un-gated harvest endpoint, sandwich bots push spot prices against the vault before execution, draining yield on every harvest transaction.

Fixed:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

interface IUniswapV2Router {
    function swapExactTokensForTokens(
        uint amountIn,
        uint amountOutMin,
        address[] calldata path,
        address to,
        uint deadline
    ) external returns (uint[] memory amounts);
}

interface IOracle {
    function getAmountOut(address tokenIn, address tokenOut, uint256 amountIn) external view returns (uint256);
}

contract HarvestSwapper {
    using SafeERC20 for IERC20;

    IUniswapV2Router public router;
    IOracle public oracle; // TWAP oracle for reward token price
    IERC20 public rewardToken;
    IERC20 public want;
    address public keeper;
    uint256 public slippageBps = 100; // 1% max slippage

    constructor(
        IUniswapV2Router _router,
        IOracle _oracle,
        IERC20 _rewardToken,
        IERC20 _want,
        address _keeper
    ) {
        require(_keeper != address(0), "zero keeper");
        router = _router;
        oracle = _oracle;
        rewardToken = _rewardToken;
        want = _want;
        keeper = _keeper;
    }

    modifier onlyKeeper() {
        require(msg.sender == keeper, "not keeper");
        _;
    }

    // Access control ensures only trusted keepers pass validated deadlines
    function harvest(uint256 deadline) external onlyKeeper {
        require(deadline >= block.timestamp, "expired deadline");
        uint256 rewardBal = rewardToken.balanceOf(address(this));

        // Early return if no rewards available to prevent DEX router zero input revert
        if (rewardBal == 0) return;

        // OpenZeppelin SafeERC20 forceApprove
        rewardToken.forceApprove(address(router), rewardBal);

        // Query time-weighted price to compute minimum output threshold
        uint256 expectedOut = oracle.getAmountOut(address(rewardToken), address(want), rewardBal);
        uint256 minOut = (expectedOut * (10000 - slippageBps)) / 10000;

        address[] memory path = new address[](2);
        path[0] = address(rewardToken);
        path[1] = address(want);

        router.swapExactTokensForTokens(rewardBal, minOut, path, address(this), deadline);
    }
}

Detection tips: Search for swapExactTokensForTokens, exactInputSingle, or equivalent DEX router invocations. Flag any call where amountOutMin is set to 0, or where harvest(deadline) lacks access control (onlyKeeper). Check for early returns (if (rewardBal == 0) return;) before calling external DEX routers to avoid unexpected transaction reverts.

4. Harvester Fee Extracted from Entire Reward Pool

Computing harvester keeper fees from the total vault reward balance instead of newly accrued yield deltas causes excessive fee extraction whenever rewards accumulate over multi-block periods.

Many autocompound vaults pay a small fee to keepers maintaining the harvest cadence. A common bug is computing the keeper fee as a percentage of the total reward token balance held by the vault, rather than only the rewards accrued since the prior harvest. If rewards accumulate over extended periods, an attacker calling harvest() claims a fee over the entire accrued balance, extracting far more value than intended.

Attempts to track cumulative reward balances using static checkpoints after swap execution often fail because swapping transfers reward tokens out of the contract, resetting stored balances to zero. To accurately measure newly accrued yield, the vault must measure the balance delta immediately before and after claiming rewards from the external farm (balanceAfterClaim - balanceBeforeClaim).

Vulnerable:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IERC20 {
    function transfer(address to, uint256 amount) external returns (bool);
    function balanceOf(address account) external view returns (uint256);
}

contract FeeVault {
    IERC20 public rewardToken;
    uint256 public keeperFeeBps = 50; // 0.5%

    function harvest() external {
        uint256 rewardBal = rewardToken.balanceOf(address(this));

        // Fee is taken from the entire balance, not just newly accrued yield
        uint256 keeperFee = (rewardBal * keeperFeeBps) / 10000;
        rewardToken.transfer(msg.sender, keeperFee);

        uint256 toCompound = rewardBal - keeperFee;
        _swapAndReinvest(toCompound);
    }

    function _swapAndReinvest(uint256 amount) internal {}
}

Fixed:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

interface IFarm {
    function claimRewards() external;
}

interface IUniswapV2Router {
    function swapExactTokensForTokens(
        uint amountIn,
        uint amountOutMin,
        address[] calldata path,
        address to,
        uint deadline
    ) external returns (uint[] memory amounts);
}

interface IOracle {
    function getAmountOut(address tokenIn, address tokenOut, uint256 amountIn) external view returns (uint256);
}

contract FeeVault {
    using SafeERC20 for IERC20;

    IERC20 public rewardToken;
    IERC20 public want;
    IFarm public farm;
    IUniswapV2Router public router;
    IOracle public oracle;
    address public keeper;
    uint256 public keeperFeeBps = 50; // 0.5%
    uint256 public slippageBps = 100;  // 1%

    constructor(IERC20 _rewardToken, IERC20 _want, IFarm _farm, IUniswapV2Router _router, IOracle _oracle, address _keeper) {
        require(_keeper != address(0), "zero keeper");
        rewardToken = _rewardToken;
        want = _want;
        farm = _farm;
        router = _router;
        oracle = _oracle;
        keeper = _keeper;
    }

    modifier onlyKeeper() {
        require(msg.sender == keeper, "not keeper");
        _;
    }

    function harvest() external onlyKeeper {
        uint256 balBefore = rewardToken.balanceOf(address(this));

        // Explicitly claim rewards from the external farm contract
        farm.claimRewards();

        uint256 balAfter = rewardToken.balanceOf(address(this));

        // Calculate fee strictly on newly claimed yield deltas
        uint256 newRewards = balAfter > balBefore ? balAfter - balBefore : 0;

        uint256 keeperFee = (newRewards * keeperFeeBps) / 10000;
        if (keeperFee > 0) {
            rewardToken.safeTransfer(msg.sender, keeperFee);
        }

        uint256 toCompound = rewardToken.balanceOf(address(this));
        if (toCompound > 0) {
            _swapAndReinvest(toCompound);
        }
    }

    function _swapAndReinvest(uint256 amount) internal returns (uint256) {
        rewardToken.forceApprove(address(router), amount);

        uint256 expectedWant = oracle.getAmountOut(address(rewardToken), address(want), amount);
        uint256 minWantOut = (expectedWant * (10000 - slippageBps)) / 10000;

        address[] memory path = new address[](2);
        path[0] = address(rewardToken);
        path[1] = address(want);

        uint256[] memory amounts = router.swapExactTokensForTokens(
            amount,
            minWantOut,
            path,
            address(this),
            block.timestamp
        );
        return amounts[amounts.length - 1];
    }
}

Detection tips: Inspect all fee calculations in harvest paths for their base amount. Fees must be applied strictly to newly claimed yield deltas — comparing balanceOf before and after invoking reward claim functions. Verify that _swapAndReinvest uses SafeERC20 while executing actual DEX router swaps to credit underlying assets with TWAP-bounded minimum output parameters.

5. Reward Token Price Oracle Manipulation & Precision Flaws

Relying on DEX spot reserves for harvest profitability checks invites flash loan manipulation, while improper type conversions in TWAP window calculations, arithmetic overflow during Q64.96 TWAP price conversion, and incomplete tick ranges in tick math trigger runtime panics and corrupt oracle results.

Some autocompound systems use an on-chain price oracle to verify whether a harvest transaction is economically viable — for instance, executing only when the USD value of rewards exceeds expected gas fees. If the oracle relies on spot reserves from a low-liquidity DEX pool, an attacker can manipulate reserve ratios via flash loans to trigger or suppress harvest calls.

When evaluating USD valuation, token decimal mismatches must be normalized. For example, if minHarvestValueUSD is defined in 18 decimals (100e18) and the reward token is a 6-decimal asset like USDC, multiplying raw rewardBal (6 decimals) by an 18-decimal oracle price yields a 6-decimal USD value. Comparing a 6-decimal value against an 18-decimal threshold causes harvest() to revert permanently. Querying IERC20Metadata(rewardToken).decimals() to scale rewardBal to 18 decimals before value computation prevents this failure.

Furthermore, oracle calculations suffer from three critical casting and arithmetic pitfalls:
1. Type Casting Out-of-Bounds Panic: In Solidity 0.8.0+, casting integers beyond destination limits does not wrap around silently to negative values; instead, it triggers an immediate runtime panic revert (Panic 0x11 or Panic 0x21). In expressions like int56(int32(twapWindow)), intermediate conversion through int32 is redundant. Since twapWindow represents time in seconds (typically 300 to 1800 seconds), casting directly via int56(uint56(twapWindow)) or int56(int256(twapWindow)) ensures safe, clean type promotion.
2. TWAP Price Arithmetic Overflow: In fixed-point Q64.96 TWAP price conversions, calculating (priceX96 * priceX96 * 1e18) >> 192 attempts to multiply priceX96 * priceX96 * 1e18 before bit-shifting. For asset prices exceeding $\approx \$1.85$ (sqrtPriceX96 $\ge 1.36 \times 2^{96}$), this intermediate value exceeds type(uint256).max ($\approx 1.1579 \times 10^{77}$), triggering an automatic arithmetic overflow panic revert (Panic 0x11). Re-ordering the bit-shift operations to ((priceX96 * priceX96) >> 96) * 1e18 >> 96 scales intermediate values safely within uint256 bounds.
3. TickMath Completeness: Uniswap V3 valid tick indices range from MIN_TICK = -887272 to MAX_TICK = 887272. Truncated TickMath implementations that stop at bit mask 0x40 (tick index 127) fail for all $|tick| \ge 128$, outputting corrupted price ratios. Production contracts must include the complete binary exponentiation series up to 0x80000 (tick index 524,288).

Vulnerable:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IERC20 {
    function balanceOf(address account) external view returns (uint256);
}

interface IUniswapV2Pair {
    function getReserves() external view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast);
}

contract OracleVault {
    IUniswapV2Pair public rewardPair; // REWARD/USDC spot pool
    IERC20 public rewardToken;
    uint256 public minHarvestValueUSD = 100e18; // 18 decimals

    function shouldHarvest() public view returns (bool) {
        (uint112 r0, uint112 r1,) = rewardPair.getReserves();
        // Spot price — vulnerable to flash loan manipulation within the same block
        uint256 rewardPriceUSD = (uint256(r1) * 1e18) / uint256(r0);
        uint256 rewardBal = rewardToken.balanceOf(address(this));

        // VULNERABLE: If rewardToken has 6 decimals (e.g. USDC), valueUSD is 6 decimals.
        // Comparing 6-decimal valueUSD to 18-decimal minHarvestValueUSD (100e18) always fails!
        uint256 valueUSD = (rewardBal * rewardPriceUSD) / 1e18;
        return valueUSD >= minHarvestValueUSD;
    }

    function harvest() external {
        require(shouldHarvest(), "not profitable");
        _compound();
    }

    function _compound() internal {}
}

Fixed:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

interface IUniswapV3Pool {
    function observe(uint32[] calldata secondsAgos)
        external
        view
        returns (int56[] memory tickCumulatives, uint160[] memory secondsPerLiquidityCumulativeX128s);
}

library TickMath {
    int24 internal constant MIN_TICK = -887272;
    int24 internal constant MAX_TICK = 887272;

    function getSqrtRatioAtTick(int24 tick) internal pure returns (uint160 sqrtPriceX96) {
        require(tick >= MIN_TICK && tick <= MAX_TICK, "TICK_BOUNDS");
        unchecked {
            uint256 absTick = tick < 0 ? uint256(-int256(tick)) : uint256(int256(tick));
            // Complete Uniswap V3 Q64.96 bit-shift exponentiation covering all valid tick ranges
            uint256 ratio = absTick & 0x1 != 0 ? 0xfffcb933bd6fad37aa2d162d1a594001 : 0x100000000000000000000000000000000;
            if (absTick & 0x2 != 0) ratio = (ratio * 0xfff97272373d413259a46990580e213a) >> 128;
            if (absTick & 0x4 != 0) ratio = (ratio * 0xfff2e50f5f656932ef12357cf3c7fdcc) >> 128;
            if (absTick & 0x8 != 0) ratio = (ratio * 0xffe5caca7e10e4e61c3624eaa0941cd0) >> 128;
            if (absTick & 0x10 != 0) ratio = (ratio * 0xffcb9843d60f6159c9db58835c926644) >> 128;
            if (absTick & 0x20 != 0) ratio = (ratio * 0xff973b41fa98c081472e6896dfb254c0) >> 128;
            if (absTick & 0x40 != 0) ratio = (ratio * 0xff2ea16466c96a3843ec78b326b52861) >> 128;
            if (absTick & 0x80 != 0) ratio = (ratio * 0xfe5d66bfd8a7d98735fc881301379af0) >> 128;
            if (absTick & 0x100 != 0) ratio = (ratio * 0xfcbbe002354d92476179651123cc336c) >> 128;
            if (absTick & 0x200 != 0) ratio = (ratio * 0xf9724b96d032e43917ee0e89292e0a6b) >> 128;
            if (absTick & 0x400 != 0) ratio = (ratio * 0xf2e50e57e4e1a06900f135b1d9ed3015) >> 128;
            if (absTick & 0x800 != 0) ratio = (ratio * 0xe5c9c4b60089722f214db041498b8c2c) >> 128;
            if (absTick & 0x1000 != 0) ratio = (ratio * 0xce0759e1f5791c7f99be449cc8b7a4c7) >> 128;
            if (absTick & 0x2000 != 0) ratio = (ratio * 0xa62b821756295b28201f805a8b79f67a) >> 128;
            if (absTick & 0x4000 != 0) ratio = (ratio * 0x6c35d799be52467d0be0540d5885cc05) >> 128;
            if (absTick & 0x8000 != 0) ratio = (ratio * 0x2e061803ae28562d9804e1fe817a0c8b) >> 128;
            if (absTick & 0x10000 != 0) ratio = (ratio * 0x1e8b575e76e9a9f20a0665476a58e698) >> 128;
            if (absTick & 0x20000 != 0) ratio = (ratio * 0x448092a42ac3a37d64265d59f0002b5) >> 128;
            if (absTick & 0x40000 != 0) ratio = (ratio * 0x209a6ee000b1a04ec400f074d68e88) >> 128;
            if (absTick & 0x80000 != 0) ratio = (ratio * 0x13cbe31c70819ce94103555f949c0) >> 128;

            if (tick > 0) ratio = type(uint256).max / ratio;

            sqrtPriceX96 = uint160((ratio >> 32) + (ratio % (1 << 32) == 0 ? 0 : 1));
        }
    }
}

contract OracleVault {
    using SafeERC20 for IERC20;

    IUniswapV3Pool public rewardPool; // Uniswap V3 TWAP Pool
    IERC20 public rewardToken;
    uint32 public twapWindow = 1800;   // 30-minute TWAP window
    uint256 public minHarvestValueUSD = 100e18; // $100 threshold in 18 decimals
    address public keeper;

    constructor(IUniswapV3Pool _rewardPool, IERC20 _rewardToken, address _keeper) {
        require(_keeper != address(0), "zero keeper");
        rewardPool = _rewardPool;
        rewardToken = _rewardToken;
        keeper = _keeper;
    }

    modifier onlyKeeper() {
        require(msg.sender == keeper, "not keeper");
        _;
    }

    function getTwapPriceUSD() public view returns (uint256) {
        uint32[] memory secondsAgos = new uint32[](2);
        secondsAgos[0] = twapWindow;
        secondsAgos[1] = 0;

        (int56[] memory tickCumulatives,) = rewardPool.observe(secondsAgos);
        int56 tickDelta = tickCumulatives[1] - tickCumulatives[0];

        // Safe type conversion using int56(uint56(twapWindow)) avoiding intermediate conversion panics
        int24 avgTick = int24(tickDelta / int56(uint56(twapWindow)));

        // Safe Q64.96 sqrt price calculation via full TickMath library
        uint160 sqrtPriceX96 = TickMath.getSqrtRatioAtTick(avgTick);

        // Convert sqrtPriceX96 to 18-decimal price safely without uint256 intermediate overflow:
        // ((sqrtPriceX96^2) >> 96) * 1e18 >> 96
        uint256 priceX96 = uint256(sqrtPriceX96);
        return ((priceX96 * priceX96) >> 96) * 1e18 >> 96;
    }

    function shouldHarvest() public view returns (bool) {
        uint256 rewardPriceUSD = getTwapPriceUSD();
        uint256 rawBal = rewardToken.balanceOf(address(this));

        // Normalize token decimals to 18 decimals (handling 6-decimal tokens like USDC/USDT)
        uint8 decimals = IERC20Metadata(address(rewardToken)).decimals();
        uint256 normalizedBal = decimals < 18 
            ? rawBal * (10 ** (18 - decimals)) 
            : rawBal / (10 ** (decimals - 18));

        uint256 valueUSD = (normalizedBal * rewardPriceUSD) / 1e18;
        return valueUSD >= minHarvestValueUSD;
    }

    function harvest() external onlyKeeper {
        require(shouldHarvest(), "not profitable");
        _compound();
    }

    function _compound() internal {
        // Safe yield compounding logic
    }
}

Detection tips: Audit oracle calls for reliance on DEX spot reserves (getReserves()) and replace them with TWAP or Chainlink feeds. Inspect integer type casting in TWAP tick arithmetic for out-of-bounds conversion panics (int56(uint56(twapWindow))). Verify that fixed-point TWAP price conversions do not multiply large numbers prior to right-shifting, and ensure complete TickMath binary exponentiation bounds. Verify that token decimal normalization occurs before comparing asset values against fixed 18-decimal USD thresholds.

6. Read-Only & Cross-Function Reentrancy in Harvest State Transitions

Executing external token interactions or ETH transfers during yield compounding before updating internal vault accounting allows attackers to re-enter share calculations or manipulate total asset readings.

Harvest workflows often interact with multiple protocols: pulling yield, swapping rewards on DEXes, and distributing fee cuts to treasury or keeper addresses. If a vault transfers native ETH or triggers an ERC-721/1155 fallback hook during harvest before updating its internal trackedWant or total asset state, an attacker can re-enter the contract within the callback window.

In a read-only reentrancy attack, the attacker does not mutate state inside the callback; instead, they call deposit() or query totalAssets() on a third-party lending protocol that relies on the vault's share price. Because the harvest function has partially claimed yield but has not yet updated total assets or minted internal shares, the share price appears artificially depressed or inflated during execution.

Furthermore, cross-function reentrancy occurs when an un-gated deposit() or withdraw() entry point is invoked while harvest() is midway through state updates. Crucially, harvest() must never accept an arbitrary yieldAmount input parameter from callers. Trusting unverified external inputs allows malicious callers to inflate trackedWant without depositing underlying tokens, leaving the vault insolvent. Instead, the vault must measure the actual balance delta (balAfter - balBefore) resulting from external yield claim interactions and credit only verified gains to the internal ledger.

Vulnerable:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IERC20 {
    function transfer(address to, uint256 amount) external returns (bool);
    function transferFrom(address from, address to, uint256 amount) external returns (bool);
    function balanceOf(address account) external view returns (uint256);
}

contract ReentrantVault {
    IERC20 public want;
    uint256 public totalShares;
    uint256 public trackedWant;
    mapping(address => uint256) public shares;

    function totalAssets() public view returns (uint256) {
        return trackedWant;
    }

    function deposit(uint256 amount) external {
        uint256 nav = totalAssets();
        uint256 toMint = totalShares == 0 ? amount : (amount * totalShares) / nav;
        want.transferFrom(msg.sender, address(this), amount);
        trackedWant += amount;
        shares[msg.sender] += toMint;
        totalShares += toMint;
    }

    // VULNERABLE: Accepts unverified yieldAmount parameter from external caller
    // VULNERABLE: Transfers ETH fee to keeper BEFORE updating internal trackedWant balance
    // VULNERABLE: Lacks reentrancy protection on harvest() and deposit()
    function harvest(uint256 yieldAmount, uint256 feeETH) external {
        // Send ETH fee callback triggers fallback in attacker contract
        (bool success, ) = msg.sender.call{value: feeETH}("");
        require(success, "fee transfer failed");

        // Internal accounting state updated AFTER external interaction using unverified yieldAmount
        trackedWant += yieldAmount;
    }

    receive() external payable {}
}

During msg.sender.call{value: feeETH}(""), the recipient's fallback() executes and calls deposit() back on ReentrantVault. Because trackedWant has not yet been incremented, the attacker mints shares at the stale nav, capturing a disproportionate share of the incoming yield once harvest() completes. Alternatively, a malicious caller could pass an inflated yieldAmount to fake ledger gains.

Fixed:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

interface IFarm {
    function claimRewards() external;
}

contract ReentrantVault is ReentrancyGuard {
    using SafeERC20 for IERC20;

    IERC20 public want;
    IFarm public farm;
    uint256 public totalShares;
    uint256 public trackedWant;
    address public keeper;
    mapping(address => uint256) public shares;

    constructor(IERC20 _want, IFarm _farm, address _keeper) {
        require(_keeper != address(0), "zero keeper");
        want = _want;
        farm = _farm;
        keeper = _keeper;
    }

    modifier onlyKeeper() {
        require(msg.sender == keeper, "not keeper");
        _;
    }

    function totalAssets() public view returns (uint256) {
        return trackedWant;
    }

    function deposit(uint256 amount) external nonReentrant {
        require(amount > 0, "zero amount");
        uint256 nav = totalAssets();
        uint256 toMint = totalShares == 0 ? amount : (amount * totalShares) / nav;

        want.safeTransferFrom(msg.sender, address(this), amount);
        trackedWant += amount; // Effect before state transition completes
        shares[msg.sender] += toMint;
        totalShares += toMint;
    }

    function harvest(uint256 feeETH) external onlyKeeper nonReentrant {
        uint256 balBefore = want.balanceOf(address(this));

        // Interaction with external farm to claim actual yield
        farm.claimRewards();

        uint256 balAfter = want.balanceOf(address(this));
        uint256 yieldGained = balAfter > balBefore ? balAfter - balBefore : 0;

        // Checks-Effects-Interactions: Update internal accounting using actual balance delta BEFORE external fee transfers
        trackedWant += yieldGained;

        // External ETH fee transfer executed after internal accounting is updated
        if (feeETH > 0) {
            (bool success, ) = msg.sender.call{value: feeETH}("");
            require(success, "fee transfer failed");
        }
    }

    receive() external payable {}
}

Applying OpenZeppelin's ReentrancyGuard across deposit(), withdraw(), and harvest() prevents cross-function reentrancy during harvest execution. Measuring actual balance changes (balAfter - balBefore) rather than trusting external yieldAmount inputs prevents accounting manipulation, while updating internal ledger state prior to external ETH transfers strictly enforces the Checks-Effects-Interactions (CEI) pattern.

Detection tips: Check if harvest() accepts arbitrary yield arguments or makes external token/ETH transfers before updating internal NAV accounting. Ensure that OpenZeppelin's nonReentrant modifier is applied consistently across deposit(), withdraw(), and harvest() entry points to block cross-function call paths.


Vault Harvest Security Audit Checklist

Before deploying an autocompound vault to mainnet, verify every item on this checklist:

  1. Access Control & MEV Protection: Is harvest() restricted to authorized keeper addresses (onlyKeeper) and submitted via private transaction relays (Flashbots / MEV Blocker)?
  2. Deposit Hold Duration: Is a minimum deposit lock period (LOCK_BLOCKS) enforced, and does it update depositBlock on every deposit to prevent lockup bypass?
  3. Internal Balance Accounting: Does totalAssets() read an internal tracking ledger (trackedWant) rather than raw balanceOf(address(this)) to resist direct token donation attacks?
  4. Inflation Offsets: Does share minting utilize ERC-4626 virtual shares and assets offsets (VIRTUAL_SHARES / VIRTUAL_ASSETS) to prevent first-depositor share price manipulation?
  5. DEX Slippage Bounds: Are all reward swaps executed with validated non-zero minimum output parameters (minWantOut) derived from TWAP oracles?
  6. Keeper Fee Base: Are harvester keeper fees calculated strictly from newly claimed yield deltas (balAfter - balBefore) rather than total reward token balances?
  7. Oracle Precision & Type Safety: Are token decimals normalized before value checks, is TWAP tick arithmetic formatted safely (int56(uint56(twapWindow))) using a complete TickMath bit-shift library, and is price exponentiation guarded against uint256 overflow?
  8. Reentrancy Protection: Are nonReentrant guards applied across deposit(), withdraw(), and harvest(), with state updates (derived from actual balance deltas) performed prior to external transfers?

Frequently Asked Questions (FAQ)

What causes share price inflation attacks in autocompound vaults?

Share price inflation attacks occur when a vault calculates its total asset balance using raw balanceOf(address(this)) calls. An attacker donates tokens directly to the contract address, driving up the asset-to-share ratio. When a subsequent user deposits, the rounded-down share calculation mints zero or near-zero shares for their deposit. Vaults defend against this by maintaining internal asset accounting ledgers and adding virtual share offsets (VIRTUAL_SHARES = 1000, VIRTUAL_ASSETS = 1).

How do sandwich bots exploit vault harvest functions?

Sandwich bots monitor the public mempool for pending harvest() calls. They front-run the transaction by depositing funds into the vault at pre-harvest share prices. Once the harvest compounds yield and elevates the net asset value (NAV), the bot back-runs the harvest by immediately withdrawing their shares at the higher price, diluting legitimate long-term depositors.

Why is amountOutMin = 0 dangerous during reward token swaps?

Setting amountOutMin = 0 instructs the DEX router to accept any quantity of output tokens, regardless of current market rates. MEV searchers detect the unprotected swap and execute a sandwich attack: they manipulate pool reserves before the harvest swap and capture the price movement immediately after, extracting nearly 100% of the harvested yield.


Protect your protocol from harvest manipulation, share price inflation, and oracle bugs. Try scanning your vault smart contracts with ContractScan today.

Scan your contract for this vulnerability
Free QuickScan — Unlimited quick scans. No signup required.. No signup required.
Scan a Contract →