Summary
The StrategyMainnet
, StrategyArb
, and StrategyOp
contracts all contain a vulnerability where the totalAssets
value is inflated due to unconverted underlying
tokens. This occurs because the contracts do not automatically convert underlying
tokens to the main asset
token during the lifecycle of the strategy. As a result, totalAssets
misrepresents the actual liquidity available for users, leading to a scenario where users cannot redeem their expected share of the strategy. This issue is rooted in the _harvestAndReport()
function in all three contracts, which fails to handle the conversion of underlying
tokens, and it impacts the overall reliability of the strategies’ accounting mechanisms.
Vulnerability Details
The vulnerability arises because the totalAssets
calculation includes the balance of underlying
tokens held by the strategies, even though these tokens are not converted to the asset
. Since the underlying
tokens are not usable as asset
, the strategies inflate the totalAssets
value, making it appear as though more funds are available for redemption than actually exist in the correct form.
The root cause lies in the _harvestAndReport()
function in all three contracts, which does not convert underlying
into asset
during the reporting cycle. This omission allows underlying
tokens to accumulate in the strategies and inflate the totalAssets
value without being usable or redeemable as asset
.
Impact
Misrepresentation of totalAssets: All three strategies report higher totalAssets
values, misleading users about the actual liquidity available.
Redemption Failure: Users attempting to redeem their shares may receive less than the reported value of their holdings, leading to a loss of confidence and potential financial impact.
Strategy Inaccuracy: The strategies’ accounting is inaccurate, affecting profit/loss calculations and performance metrics.
Proof of Concept
Referenced Code
The _harvestAndReport()
original function:
function _harvestAndReport() internal override returns (uint256 _totalAssets) {
uint256 claimable = transmuter.getClaimableBalance(address(this));
if (claimable > 0) {
}
uint256 unexchanged = transmuter.getUnexchangedBalance(address(this));
uint256 underlyingBalance = underlying.balanceOf(address(this));
uint256 assetBalance = asset.balanceOf(address(this));
_totalAssets = unexchanged + assetBalance + underlyingBalance;
}
Test
The test below simulates the scenario:
function test_underlyingNotConvertedVulnerability() public {
console.log("Step 1: User deposits 1e18 asset");
vm.prank(user);
strategy.deposit(1e18, user);
uint256 initialTA = strategy.totalAssets();
console.log("Initial totalAssets:", initialTA);
assertEq(initialTA, 1e18, "Initial totalAssets mismatch");
console.log("Step 2: Transfer 0.5e18 underlying to strategy (not converted)");
underlying.transfer(address(strategy), 0.5e18);
uint256 inflatedTA = strategy.totalAssets();
console.log("TotalAssets after adding underlying:", inflatedTA);
assertEq(inflatedTA, 1.5e18, "Total assets not inflated as expected");
console.log("Step 3: Calling report (no conversion of underlying to asset)");
(uint256 profit, uint256 loss) = strategy.report();
console.log("Profit after report:", profit);
console.log("Loss after report:", loss);
console.log("Step 4: User attempts to redeem 1e18 shares");
uint256 userBalanceBefore = synthetic.balanceOf(user);
console.log("User balance before redeem:", userBalanceBefore);
vm.prank(user);
strategy.redeem(1e18, user);
uint256 userBalanceAfter = synthetic.balanceOf(user);
console.log("User balance after redeem:", userBalanceAfter);
console.log("User should not receive the full inflated amount (1.5e18).");
console.log(
"Difference in user balance:",
userBalanceAfter - userBalanceBefore
);
assertTrue(
(userBalanceAfter - userBalanceBefore) < 1.5e18,
"User redeemed full inflated amount without conversion"
);
console.log(
"Vulnerability demonstrated: totalAssets suggested more than could actually be redeemed."
);
}
Test Result
Logs:
Setting up the environment
Deployed synthetic and underlying tokens
Deployed mockTransmuter and strategy
Strategy address: 0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9
User approved strategy to spend 1e18 synthetic tokens
Setup completed
Step 1: User deposits 1e18 asset
Initial totalAssets: 1000000000000000000
Step 2: Transfer 0.5e18 underlying to strategy (not converted)
TotalAssets after adding underlying: 1500000000000000000
Step 3: Calling report (no conversion of underlying to asset)
Profit after report: 0
Loss after report: 0
Step 4: User attempts to redeem 1e18 shares
User balance before redeem: 0
User balance after redeem: 0
User should not receive the full inflated amount (1.5e18).
Difference in user balance: 0
Vulnerability demonstrated: totalAssets suggested more than could actually be redeemed.
The test test_underlyingNotConvertedVulnerability
demonstrates the vulnerability. Despite a totalAssets
value of 1.5e18, the user is unable to redeem the expected 1e18 due to the unconverted underlying
.
User should not receive the full inflated amount (1.5e18).
Difference in user balance: 0
Vulnerability demonstrated: totalAssets suggested more than could actually be redeemed.
Tools Used
Recommendations
The issue is mitigated by ensuring that underlying
tokens are converted to asset
within the _harvestAndReport()
function in all three contracts. This conversion ensures that totalAssets
only accounts for redeemable asset
tokens, avoiding any misrepresentation of available liquidity.
Suggested Mitigation
The following code modifies _harvestAndReport()
to convert underlying
into asset
:
function _harvestAndReport() internal override returns (uint256 _totalAssets) {
uint256 claimable = transmuter.getClaimableBalance(address(this));
if (claimable > 0) {
}
uint256 undBal = underlying.balanceOf(address(this));
if (undBal > 0) {
_swapUnderlyingToAsset(undBal);
}
uint256 unexchanged = transmuter.getUnexchangedBalance(address(this));
uint256 underlyingBalance = underlying.balanceOf(address(this));
_totalAssets =
unexchanged +
asset.balanceOf(address(this)) +
underlyingBalance;
}
function _swapUnderlyingToAsset(uint256 _amount) internal {
require(nRoutes > 0, "No route set");
uint256 balBefore = asset.balanceOf(address(this));
uint256 minOut = (_amount * 995) / 1000;
router.exchange(
routes[0],
swapParams[0],
_amount,
minOut,
pools[0],
address(this)
);
uint256 balAfter = asset.balanceOf(address(this));
require(balAfter >= balBefore + minOut, "Slippage too high in conversion");
}
Validation
The test test_mitigationWorks
confirms that the mitigation resolves the issue:
function test_mitigationWorks() public {
console.log("User depositing 1e18 synthetic...");
vm.prank(user);
strategy.deposit(1e18, user);
uint256 initialTA = strategy.totalAssets();
console.log("Initial totalAssets:", initialTA);
assertEq(initialTA, 1e18, "Initial totalAssets should be 1e18");
console.log("Adding 0.5e18 underlying to strategy to inflate totalAssets...");
underlying.transfer(address(strategy), 0.5e18);
uint256 inflatedTA = strategy.totalAssets();
console.log("Inflated totalAssets:", inflatedTA);
assertEq(inflatedTA, 1.5e18, "Should reflect underlying before report");
console.log("Calling report to convert underlying to asset...");
(uint256 profit, uint256 loss) = strategy.report();
uint256 afterReportTA = strategy.totalAssets();
console.log("After report totalAssets:", afterReportTA);
assertEq(
afterReportTA,
1.5e18,
"After conversion totalAssets still 1.5e18 but now fully redeemable"
);
console.log("User redeems 1e18...");
uint256 userBalanceBefore = synthetic.balanceOf(user);
vm.prank(user);
strategy.redeem(1e18, user);
uint256 userBalanceAfter = synthetic.balanceOf(user);
uint256 redeemed = userBalanceAfter - userBalanceBefore;
console.log("User redeemed:", redeemed);
assertEq(redeemed, 1e18, "User should redeem the full 1e18 correctly now");
}
Logs
Logs:
Setting up test environment...
Setup complete
User depositing 1e18 synthetic...
Depositing: 1000000000000000000
Deposit complete. totalAssets: 1000000000000000000
Initial totalAssets: 1000000000000000000
Adding 0.5e18 underlying to strategy to inflate totalAssets...
Inflated totalAssets: 1500000000000000000
Calling report to convert underlying to asset...
Converting underlying to asset in _harvestAndReport
Swapping underlying: 500000000000000000
Swap complete. Asset balance now: 500000000000000000
Post-harvest totalAssets: 1500000000000000000
After report totalAssets: 1500000000000000000
User redeems 1e18...
Redeeming: 1000000000000000000
Freeing funds from transmuter: 1000000000000000000
Withdrew exact amount: 1000000000000000000
Redeemed to user: 1000000000000000000
User redeemed: 1000000000000000000
This demonstrates that the mitigation ensures accurate totalAssets
and allows users to redeem their expected amounts in all three contracts.