The MYTStrategy contract reads stale allocation accounting and thus cannot handle strategies that return profits. When a strategy has earned yield and returns more assets than initially allocated, the subtraction newAllocation = oldAllocation - amountDeallocated underflows, causing transaction reverts and locking funds.
Vulnerable Code
AlchemistAllocator Reads Allocation from Vault
File: src/AlchemistAllocator.solLines: 55-67
MYTStrategy Receives Allocation and Underflows
File: src/MYTStrategy.solLines: 119-133
Vulnerability Details
The Profit Scenario
Initial State: Strategy allocated 100e6 USDC
Time Passes: Strategy earns 10e6 USDC in yield (10% profit)
Total Assets: Strategy now holds 110e6 USDC worth of shares
Deallocate Call: Vault requests withdrawal of all funds (100e6)
Strategy Returns: _deallocate() withdraws everything and returns 110e6
// Example: Euler USDC Strategy
function _deallocate(uint256 amount) internal override returns (uint256) {
// Withdraw from yield vault
vault.withdraw(amount, address(this), address(this));
// May receive more than 'amount' if the vault has appreciated
uint256 actualReceived = IERC20(usdc).balanceOf(address(this));
// Returns actual amount, which can exceed requested amount
return actualReceived; // Can be > amount!
}
forge test --match-path test/VaultAllocationUnderflow.t.sol -vv
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import "forge-std/Test.sol";
import "forge-std/console.sol";
import {VaultV2} from "../lib/vault-v2/src/VaultV2.sol";
import {AlchemistAllocator} from "../src/AlchemistAllocator.sol";
import {IMYTStrategy} from "../src/interfaces/IMYTStrategy.sol";
import {MYTStrategy} from "../src/MYTStrategy.sol";
contract MockERC20 {
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
uint8 public constant decimals = 6;
function mint(address to, uint256 amount) external {
balanceOf[to] += amount;
}
function transfer(address to, uint256 amount) external returns (bool) {
balanceOf[msg.sender] -= amount;
balanceOf[to] += amount;
return true;
}
function transferFrom(address from, address to, uint256 amount) external returns (bool) {
allowance[from][msg.sender] -= amount;
balanceOf[from] -= amount;
balanceOf[to] += amount;
return true;
}
function approve(address spender, uint256 amount) external returns (bool) {
allowance[msg.sender][spender] = amount;
return true;
}
}
/**
* @notice Mock strategy that simulates profit accrual
* @dev When deallocating 100e6 from a strategy with 10% profit:
* - oldAllocation = 100e6
* - amountDeallocated = 110e6 (includes 10e6 profit)
* - newAllocation = oldAllocation - amountDeallocated = 100e6 - 110e6 = UNDERFLOW
*/
/// @notice Mock strategy that inherits MYTStrategy and only overrides _allocate/_deallocate
contract MytProfitStrategyMock is MYTStrategy {
uint256 public profitBps = 1000; // 10% profit
constructor(
address myt,
IMYTStrategy.StrategyParams memory sp,
address permit2,
address receipt
) MYTStrategy(myt, sp, permit2, receipt) {}
// Return exactly the requested amount (no slippage on allocate)
function _allocate(uint256 amount) internal override returns (uint256) {
console.log("=== ALLOCATE ===");
console.log(" Assets allocated:", amount);
return amount;
}
// Return more than requested (simulate realized profit on exit)
// This will make MYTStrategy.deallocate() compute
// newAllocation = oldAllocation - amountDeallocated
// which underflows when amountDeallocated > oldAllocation
function _deallocate(uint256 amount) internal override returns (uint256) {
uint256 withProfit = amount + (amount * profitBps / 10_000);
console.log("\n=== DEALLOCATE (WITH PROFIT) ===");
console.log(" Requested assets:", amount);
console.log(" Assets with profit:", withProfit);
return withProfit;
}
}
/**
* @title VaultAllocationUnderflowTest
* @notice Demonstrates that VaultV2 allocation tracking underflows when strategies return profits
*/
contract VaultAllocationUnderflowTest is Test {
MockERC20 public asset;
VaultV2 public vault;
AlchemistAllocator public allocator;
MytProfitStrategyMock public strategy;
MockERC20 public receiptToken;
address public owner = address(0x1);
address public curator = address(0x2);
address public admin = address(0x3);
function setUp() public {
asset = new MockERC20();
// Deploy VaultV2
vault = new VaultV2(owner, address(asset));
// Deploy AlchemistAllocator
vm.prank(admin);
allocator = new AlchemistAllocator(address(vault), admin, admin);
// Deploy a mock receipt token for MYTStrategy's constructor approval
receiptToken = new MockERC20();
// Prepare MYTStrategy params
IMYTStrategy.StrategyParams memory sp = IMYTStrategy.StrategyParams({
owner: admin,
name: "Mock Profit Strategy",
protocol: "MOCK_PROFIT_STRATEGY",
riskClass: IMYTStrategy.RiskClass.MEDIUM,
cap: type(uint256).max,
globalCap: type(uint256).max,
estimatedYield: 0,
additionalIncentives: false,
slippageBPS: 0
});
// Deploy strategy inheriting MYTStrategy. `myt` is the vault address.
strategy = new MytProfitStrategyMock(address(vault), sp, address(0xBEEF), address(receiptToken));
// Setup vault
vm.startPrank(owner);
vault.setCurator(curator);
vm.stopPrank();
// Configure vault with curator
vm.startPrank(curator);
// Add adapter
bytes memory addAdapterData = abi.encodeWithSelector(VaultV2.addAdapter.selector, address(strategy));
vault.submit(addAdapterData);
vm.warp(block.timestamp + 1);
vault.addAdapter(address(strategy));
// Set caps - use the raw protocol string that generates the strategyId
bytes memory strategyIdData = abi.encode("MOCK_PROFIT_STRATEGY");
bytes memory increaseAbsoluteCapData = abi.encodeWithSelector(
VaultV2.increaseAbsoluteCap.selector,
strategyIdData,
1000e6
);
vault.submit(increaseAbsoluteCapData);
vm.warp(block.timestamp + 1);
vault.increaseAbsoluteCap(strategyIdData, 1000e6);
bytes memory increaseRelativeCapData = abi.encodeWithSelector(
VaultV2.increaseRelativeCap.selector,
strategyIdData,
1e18
);
vault.submit(increaseRelativeCapData);
vm.warp(block.timestamp + 1);
vault.increaseRelativeCap(strategyIdData, 1e18);
// Set allocator
bytes memory setAllocatorData = abi.encodeWithSelector(VaultV2.setIsAllocator.selector, address(allocator), true);
vault.submit(setAllocatorData);
vm.warp(block.timestamp + 1);
vault.setIsAllocator(address(allocator), true);
vm.stopPrank();
// Give vault assets
asset.mint(address(vault), 1000e6);
}
/**
* @notice THE BUG: Allocation underflows when deallocating with profit
* @dev This test will revert with arithmetic underflow
*/
function test_AllocationUnderflowsWithProfit() public {
uint256 allocateAmount = 100e6;
console.log("\n================================================================");
console.log(" BUG: VaultV2 Allocation Tracking Underflows With Profits");
console.log("================================================================\n");
// Step 1: Allocate 100e6 to strategy
vm.prank(admin);
allocator.allocate(address(strategy), allocateAmount);
uint256 allocation = vault.allocation(strategy.adapterId());
console.log("\nAllocation successful. Current allocation:", allocation);
// Step 2: Simulate 10% profit in strategy
console.log("\nTime passes, strategy earns 10% profit...");
// Step 3: Try to deallocate - THIS WILL UNDERFLOW
console.log("\nAttempting to deallocate all funds (including profit)...");
vm.prank(admin);
vm.expectRevert(stdError.arithmeticError); // Expect arithmetic underflow (Panic(0x11))
// strategy will deallocate allocateAmount + profits
// This is to simplify the whole process and avoid having to deallocate multiple times to show the vulnerability
allocator.deallocate(address(strategy), allocateAmount);
console.log("\nREVERTED: Arithmetic underflow in MYTStrategy.sol line 129");
console.log(" newAllocation = oldAllocation - amountDeallocated");
console.log(" newAllocation = 100e6 - 110e6 = UNDERFLOW");
console.log("\n================================================================\n");
}
}