DeFiFoundrySolidity
16,653 OP
View results
Submission Details
Severity: high
Invalid

A keeper could execute unprofitable trades that meet the minOut requirement but still result in value loss for the strategy

Summary

function claimAndSwap(uint256 _amountClaim, uint256 _minOut, IVeloRouter.route[] calldata _path) external onlyKeepers {
transmuter.claim(_amountClaim, address(this));
uint256 balBefore = asset.balanceOf(address(this));
_swapUnderlyingToAsset(_amountClaim, _minOut, _path);
uint256 balAfter = asset.balanceOf(address(this));
// @FOUND The balance check is performed on asset.balanceOf() instead of total strategy value
// This means the check doesn't account for the value reduction from claiming WETH
// Making it possible to execute trades that appear profitable locally but reduce total strategy value
require((balAfter - balBefore) >= _minOut, "Slippage too high");
transmuter.deposit(asset.balanceOf(address(this)), address(this));
}

The profitability check only considers the local balance change of the synthetic asset (alETH) during the swap, but ignores the value reduction from claiming the underlying asset (WETH). The balance comparison (balAfter - balBefore) >= _minOut is insufficient because:

  1. balBefore is taken after claiming WETH

  2. The check doesn't account for the total value change including the claimed WETH

  3. A swap could pass this check while still reducing the total strategy value (balanceDeployed())

This allows transactions that satisfy minOut but still result in a net loss when considering the total value of the strategy (WETH + alETH positions).

Vulnerability Details

In StrategyArb.claimAndSwap

function claimAndSwap(uint256 amount, uint256 minOut, IVeloRouter.route[] path) external onlyKeepers {
// @FOUND The claimAndSwap function allows value extraction through incorrect accounting
// The balanceBefore is captured after the claim, hiding the true value change
transmuter.claim(_amountClaim, address(this));
uint256 balanceBefore = asset.balanceOf(address(this));
_swapUnderlyingToAsset(_amountClaim, _minOut, _path);
uint256 balAfter = asset.balanceOf(address(this));
require((balAfter - balBefore) >= _minOut, "Slippage too high");
transmuter.deposit(asset.balanceOf(address(this)), address(this));
}

The root cause is a fundamental flaw in the accounting logic

  1. The function claims WETH from the transmuter

  2. Only then captures balanceBefore

  3. Performs the swap

  4. Checks if the swap produced minOut tokens

  5. Deposits back to transmuter

This sequence allows value extraction because

  • The balanceBefore is captured after claiming WETH, masking the true starting value

  • The check (balAfter - balBefore) >= _minOut only verifies the local swap outcome

  • The total strategy value (balanceDeployed) can decrease while still passing this check

Impact

A keeper can execute trades that appear profitable locally but reduce total strategy value

https://github.com/Cyfrin/2024-12-alchemix/blob/82798f4891e41959eef866bd1d4cb44fc1e26439/src/StrategyArb.sol#L122-L124

function balanceDeployed() public view returns (uint256) {
// @FOUND Total value can decrease while swap checks pass
return transmuter.getUnexchangedBalance(address(this)) +
underlying.balanceOf(address(this)) +
asset.balanceOf(address(this));
}

Recommendations

// StrategyArb.sol, StrategyOp.sol, StrategyMainnet.sol
function claimAndSwap(uint256 _amountClaim, uint256 _minOut, IVeloRouter.route[] calldata _path) external onlyKeepers {
- transmuter.claim(_amountClaim, address(this));
- uint256 balBefore = asset.balanceOf(address(this));
- _swapUnderlyingToAsset(_amountClaim, _minOut, _path);
- uint256 balAfter = asset.balanceOf(address(this));
- require((balAfter - balBefore) >= _minOut, "Slippage too high");
- transmuter.deposit(asset.balanceOf(address(this)), address(this));
+ // @Mitigation - Track total strategy value before operation
+ uint256 totalBefore = balanceDeployed();
+
+ // Execute claim and swap
+ transmuter.claim(_amountClaim, address(this));
+ _swapUnderlyingToAsset(_amountClaim, _minOut, _path);
+
+ // @Mitigation - Verify total strategy value increased
+ uint256 totalAfter = balanceDeployed();
+ require(totalAfter > totalBefore, "Trade must be profitable");
+
+ // @Mitigation - Add minimum profitability threshold
+ uint256 minProfitBps = 50; // 0.5%
+ require(
+ totalAfter >= totalBefore + (totalBefore * minProfitBps / 10000),
+ "Insufficient profit margin"
+ );
+
+ transmuter.deposit(asset.balanceOf(address(this)), address(this));
}
+ // @Mitigation - Add view function to calculate expected profit
+ function calculateExpectedProfit(
+ uint256 _amountClaim,
+ IVeloRouter.route[] calldata _path
+ ) external view returns (uint256 expectedProfit) {
+ // Simulate the swap and return expected profit
+ // This helps keepers validate trades before execution
+ }
Updates

Appeal created

inallhonesty Lead Judge 10 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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