The claimAndSwap function in the Strategy contracts allows for claiming and swapping underlying assets to achieve gains. However, while this operation can increase the strategy’s funds, it does not update the internal accounting variable _totalAssets. As a result, the contract’s view of total assets becomes stale until report() is called, which can lead to incorrect share calculations for users who deposit or withdraw in the interim.
When claimAndSwap is called, the contract claims from the transmuter, swaps to the synthetic asset (aleth), and redeposits it. This effectively increases the Strategy’s holdings. However, this gain is not reflected in the _totalAssets value used within share calculation functions.
This function successfully increases the contract’s asset balance. However, no direct call is made to update _totalAssets. The share calculations rely on _totalAssets as follows:
If a user deposits or withdraws after claimAndSwap but before report() is called, the ratio of shares-to-assets is distorted. Neither the users who previously deposited nor the new investors are properly accounted for in terms of their share of the profits. Similarly, upon withdrawal, if report() is not called to update _totalAssets, users may not realize the gains made from the claimAndSwap operation.
This vulnerability also exists in StrategyArb and StrategyOp contracts due to similar logic.
pragma solidity ^0.8.18;
import "forge-std/console.sol";
import {Setup, ERC20, IStrategyInterface} from "./utils/Setup.sol";
import {IStrategyInterfaceVelo} from "../interfaces/IStrategyInterface.sol";
import {IStrategyInterfaceRamses} from "../interfaces/IStrategyInterface.sol";
import {IVeloRouter} from "../interfaces/IVelo.sol";
import {IRamsesRouter} from "../interfaces/IRamses.sol";
import {TokenizedStrategy} from "@tokenized-strategy/TokenizedStrategy.sol";
interface Strategy is IStrategyInterface {
function balanceDeployed() external returns (uint256);
}
contract PocTest is Setup {
function setUp() public virtual override {
super.setUp();
}
function test_poc_claimandswap_dont_change_totalassets() public {
uint256 deposit_amount = 10e18;
mintAndDepositIntoStrategy(strategy, user, deposit_amount);
console.log("Deposit assets", deposit_amount);
console.log("Before Exchange Strategy(address(strategy)).balanceDeployed(): ", Strategy(address(strategy)).balanceDeployed());
console.log("Before Exchange Unexchanged Balance: ", transmuter.getUnexchangedBalance(address(strategy)));
console.log("Before Exchange Exchangable Balance: ", transmuter.getExchangedBalance(address(strategy)));
console.log("Before Exchage TotalAssets: ", TokenizedStrategy(address(strategy)).totalAssets());
uint256 totalAssetsBefore = TokenizedStrategy(address(strategy)).totalAssets();
uint256 balanceDeployedBefore = Strategy(address(strategy)).balanceDeployed();
keeperExchange(deposit_amount);
uint256 claimable = strategy.claimableBalance();
vm.prank(keeper);
strategy.claimAndSwap(claimable, claimable * 101 / 100, 0);
console.log("Before Exchange Strategy(address(strategy)).balanceDeployed(): ", Strategy(address(strategy)).balanceDeployed());
console.log("Before Exchange Unexchanged Balance: ", transmuter.getUnexchangedBalance(address(strategy)));
console.log("Before Exchange Exchangable Balance: ", transmuter.getExchangedBalance(address(strategy)));
console.log("Before Exchage TotalAssets: ", TokenizedStrategy(address(strategy)).totalAssets());
uint256 totalAssetsAfter = TokenizedStrategy(address(strategy)).totalAssets();
uint256 balanceDeployedAfter = Strategy(address(strategy)).balanceDeployed();
assertEq(totalAssetsBefore, totalAssetsAfter);
assertGe(balanceDeployedAfter, balanceDeployedBefore);
uint256 beforeAssetBalance = asset.balanceOf(user);
vm.prank(user);
strategy.redeem(deposit_amount, user, user);
console.log("Withdraw assets", asset.balanceOf(user) - beforeAssetBalance);
}
function keeperExchange(uint256 _amount) public {
vm.roll(1);
deployMockYieldToken();
addMockYieldToken();
depositToAlchemist(_amount);
airdropToMockYield(_amount / 2);
vm.prank(whale);
asset.transfer(user2, _amount);
vm.prank(user2);
asset.approve(address(transmuter), _amount);
vm.prank(user2);
transmuter.deposit(_amount /2 , user2);
vm.roll(1);
harvestMockYield();
vm.prank(address(transmuterKeeper));
transmuterBuffer.exchange(address(underlying));
skip(7 days);
vm.roll(5);
vm.prank(user2);
transmuter.deposit(_amount /2 , user2);
vm.prank(address(transmuterKeeper));
transmuterBuffer.exchange(address(underlying));
}
}
$ forge test --mt test_poc_claimandswap_dont_change_totalassets --fork-url https:
Ran 1 test for src/test/testpoc.t.sol:PocTest
[PASS] test_poc_claimandswap_dont_change_totalassets() (gas: 3765589)
Logs:
Before Exchange Strategy(address(strategy)).balanceDeployed(): 10000000000000000000
Before Exchange Unexchanged Balance: 10000000000000000000
Before Exchange Exchangable Balance: 0
Before Exchage TotalAssets: 10000000000000000000
Before Exchange Strategy(address(strategy)).balanceDeployed(): 10001251595926077985
Before Exchange Unexchanged Balance: 10001251595926077985
Before Exchange Exchangable Balance: 0
Before Exchage TotalAssets: 10000000000000000000
Withdraw asset.balanceOf(user) 10000000000000000000
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 19.02s (15.51s CPU time)
Ran 1 test suite in 20.02s (19.20s CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)