DeFiFoundrySolidity
16,653 OP
View results
Submission Details
Severity: medium
Valid

Inflated `totalAssets` in `StrategyMainnet`, `StrategyArb`, and `StrategyOp` Contracts

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) {
// transmuter.claim(claimable, address(this)); // Not executed in the original code
}
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 {
// 1. User deposits 1e18 asset
console.log("Step 1: User deposits 1e18 asset");
vm.prank(user);
strategy.deposit(1e18, user);
// totalAssets should reflect the deposit (1e18 unexchanged)
uint256 initialTA = strategy.totalAssets();
console.log("Initial totalAssets:", initialTA);
assertEq(initialTA, 1e18, "Initial totalAssets mismatch");
// 2. Add underlying without converting it to asset (inflate totalAssets)
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);
// totalAssets now shows 1.5e18 (inflated due to underlying)
assertEq(inflatedTA, 1.5e18, "Total assets not inflated as expected");
// 3. Report (simulate a cycle, no conversion)
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);
// We don't expect profit, the vulnerability is about inflated totalAssets.
// 4. User tries to redeem 1e18 shares (the original amount)
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);
// Check if user did NOT receive the full inflated amount (1.5e18).
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"
);
// This demonstrates the vulnerability.
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

  • Foundry: Used to develop and run tests to validate the vulnerability.

  • Manual Code Review: Confirmed the issue exists across all three contracts.

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) {
// transmuter.claim(claimable, address(this));
}
// @> Added conversion step below to avoid inflated totalAssets
// @> If we have underlying, convert it to asset to ensure accurate accounting
uint256 undBal = underlying.balanceOf(address(this)); // <@ Convert underlying to asset to fix vulnerability
if (undBal > 0) {
_swapUnderlyingToAsset(undBal); // <@ Perform the actual swap
}
uint256 unexchanged = transmuter.getUnexchangedBalance(address(this));
uint256 underlyingBalance = underlying.balanceOf(address(this));
_totalAssets =
unexchanged +
asset.balanceOf(address(this)) +
underlyingBalance;
}
// @> New helper function to swap underlying into asset
function _swapUnderlyingToAsset(uint256 _amount) internal {
require(nRoutes > 0, "No route set"); // <@ For example, use the first route if available
uint256 balBefore = asset.balanceOf(address(this));
// @> Setting a minimum out slightly less than amount for example
// @> In a real scenario, you'd calculate a proper minOut based on oracle or slippage
uint256 minOut = (_amount * 995) / 1000; // <@ Accept small slippage for example
router.exchange(
routes[0],
swapParams[0],
_amount,
minOut,
pools[0],
address(this)
);
uint256 balAfter = asset.balanceOf(address(this)); // <@ Ensure we got enough asset
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.

Updates

Appeal created

inallhonesty Lead Judge 7 months ago
Submission Judgement Published
Validated
Assigned finding tags:

balanceDeployed() and _harvestAndReport() add WETH and alETH, but they have different prices

bshyuunn Auditor
7 months ago
rolando Submitter
7 months ago
strapontin Auditor
7 months ago
rolando Submitter
7 months ago
strapontin Auditor
7 months ago
rolando Submitter
7 months ago
strapontin Auditor
7 months ago
rolando Submitter
7 months ago
inallhonesty Lead Judge
7 months ago
inallhonesty Lead Judge 7 months ago
Submission Judgement Published
Validated
Assigned finding tags:

balanceDeployed() and _harvestAndReport() add WETH and alETH, but they have different prices

Dormant WETH is not properly treated

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.