Summary
A strict requirement in the claimAndSwap() (and related _swapUnderlyingToAsset()) functions across StrategyArb, StrategyMainnet, and StrategyOp can cause a Denial of Service (DoS) under certain market conditions. Specifically, the condition:
require(_minOut > _amountClaim, "minOut too low");
only permits swaps when a strict “premium” ratio of alETH to WETH is achievable. If the real market conditions do not allow _minOut > _amountClaim, the swap reverts and can repeatedly fail. This leads to inoperability in the strategy’s core functionality—converting WETH back to alETH and depositing it into the transmuter—causing a potential operational lock or severe disruption in yield generation.
Note: While the original codebase might treat this restriction as a “feature” to secure profitable swaps only, it can unintentionally lock the strategy under unfavorable or even near-1:1 market conditions.
Vulnerability Details
1. Location in the Codebase
2. Why the Condition Causes a DoS
Strict Premium Requirement: Swaps only execute if _minOut (the desired output amount in alETH) strictly exceeds the input WETH amount. If the market price of alETH vs. WETH does not allow a ratio > 1:1 (i.e., no real premium), the transaction reverts.
Repeated Failures: Subsequent attempts at claimAndSwap() or _swapUnderlyingToAsset() with the same or similar parameters also revert, effectively blocking the strategy from its main function—turning claimed WETH back into alETH.
Potential “Lock-In” of Funds: Depending on whether the claim portion and the swap are atomic, users (or keepers) cannot proceed with normal operations if they must always expect a premium. This can leave funds unutilized or stuck in a state where no reallocation occurs.
Counterargument (Design Intent):
A defender might say that “requiring a premium is by design: the strategy only executes profitable swaps.” However, this design choice leads to a scenario where the strategy cannot operate at all if market conditions never (or rarely) yield a premium, hence a practical DoS in real-world use.
Impact
-
Yield Generation Block
-
Partial or Extended Protocol Lock
While not all of Alchemix necessarily stops functioning, the affected strategies become inoperative, preventing normal claim-and-swap cycles.
In severe or prolonged depeg conditions (e.g., alETH < WETH but not sufficiently below to yield a premium), repeated reverts can create a practical lock of the strategy functionality—a form of DoS.
-
Capital Efficiency Loss
WETH that is intended to be swapped to alETH remains “unconverted,” reducing the protocol’s capital utilization.
Even if the entire transaction reverts (and thus the WETH remains in the transmuter or elsewhere), the strategy cannot progress to generate yield.
-
Extended Depeg Scenarios
If alETH is trading consistently below 1:1 with WETH, or the transmuter’s slow conversion is drawn out, the condition _minOut > _amountClaim becomes harder to satisfy. This exacerbates the blocking effect.
Severity Justification:
The inability to perform intended operations can be classified as a high-severity DoS or major operational risk, impacting user confidence and the protocol’s overall functionality.
Proof of Concept (PoC)
Test Code Exposing the Vulnerability
The following suite of tests in Foundry reproduces various DoS scenarios. Each test sets up an environment with mocks, assigns a claimable balance in the Transmuter, and verifies whether the swap fails (reverts) or succeeds.
* @dev Test #1: Confirm DoS due to 'minOut <= amountClaim' => revert("minOut too low")
*/
function testRevert_MinOutBelowOrEqualAmountClaim() public {
console2.log("==== testRevert_MinOutBelowOrEqualAmountClaim: Start ====");
console2.log("Setting 50e18 as claimable WETH in MockTransmuter...");
mockTransmuter.setClaimable(address(strategy), 50e18);
vm.startPrank(keeper);
console2.log(
"Attempting claimAndSwap with minOut == amountClaim (50e18)... Expecting revert."
);
vm.expectRevert(bytes("minOut too low"));
strategy.claimAndSwap(50e18, 50e18, 0);
console2.log(
"Attempting claimAndSwap with minOut < amountClaim (40e18)... Expecting revert."
);
vm.expectRevert(bytes("minOut too low"));
strategy.claimAndSwap(50e18, 40e18, 0);
vm.stopPrank();
console2.log(
"==== testRevert_MinOutBelowOrEqualAmountClaim: Completed ====\n"
);
}
* @dev Test #2: Revert if router cannot meet minOut => "Router: Not enough output"
*/
function testRevert_SlippageTooHigh() public {
console2.log("==== testRevert_SlippageTooHigh: Start ====");
console2.log("Setting 100e18 as claimable WETH in MockTransmuter...");
mockTransmuter.setClaimable(address(strategy), 100e18);
vm.startPrank(keeper);
console2.log(
"Attempting claimAndSwap with minOut > router's output capacity... Expecting revert."
);
vm.expectRevert(bytes("Router: Not enough output"));
strategy.claimAndSwap(100e18, 101e18, 0);
vm.stopPrank();
console2.log("==== testRevert_SlippageTooHigh: Completed ====\n");
}
* @dev Test #3: Successful claimAndSwap when ratio >= minOut
*/
function testClaimAndSwapSuccess() public {
console2.log("==== testClaimAndSwapSuccess: Start ====");
console2.log("Deploying new MockRouter with ratio=105 (1.05:1)...");
MockCurveRouter router105 = new MockCurveRouter(105, address(mockSynthetic));
console2.log("Deploying a new StrategyMainnetMock with updated router...");
vm.startPrank(keeper);
StrategyMainnetMock newStrategy = new StrategyMainnetMock(
address(mockSynthetic),
address(mockTransmuter),
address(mockUnderlying),
address(router105)
);
vm.stopPrank();
console2.log(
"Setting 100e18 as claimable WETH in MockTransmuter for the new strategy..."
);
mockTransmuter.setClaimable(address(newStrategy), 100e18);
console2.log("Transferring 100e18 synthetic tokens to the new strategy...");
MockToken(address(mockSynthetic)).transfer(address(newStrategy), 100e18);
vm.startPrank(keeper);
console2.log("Attempting claimAndSwap with minOut=101 and ratio=1.05:1...");
newStrategy.claimAndSwap(100e18, 101e18, 0);
vm.stopPrank();
console2.log("ClaimAndSwap succeeded with ratio > 1:1 and minOut satisfied.");
console2.log("==== testClaimAndSwapSuccess: Completed ====\n");
}
* @dev Test #4: Multiple actors (A, B, C) fail to claim/swap due to a 1:1 depeg scenario,
* illustrating a DoS.
*/
function testDOSDueToDepeg() public {
console2.log("==== testDOSDueToDepeg: Start ====");
console2.log(
"Setting 3 different claimable amounts for 3 different calls..."
);
mockTransmuter.setClaimable(address(strategy), 150e18);
uint256 actor1Amount = 50e18;
uint256 actor2Amount = 50e18;
uint256 actor3Amount = 50e18;
vm.startPrank(keeper);
console2.log(
"Actor A attempts claimAndSwap(50, 50) => expecting revert due to 'minOut too low'"
);
vm.expectRevert(bytes("minOut too low"));
strategy.claimAndSwap(actor1Amount, actor1Amount, 0);
console2.log(
"Actor B attempts claimAndSwap(50, 50) => expecting revert as well..."
);
vm.expectRevert(bytes("minOut too low"));
strategy.claimAndSwap(actor2Amount, actor2Amount, 0);
console2.log(
"Actor C attempts claimAndSwap(50, 50) => expecting revert again..."
);
vm.expectRevert(bytes("minOut too low"));
strategy.claimAndSwap(actor3Amount, actor3Amount, 0);
vm.stopPrank();
console2.log(
"All attempts failed => system is effectively locked under 1:1 ratio (depeg scenario)."
);
console2.log("==== testDOSDueToDepeg: Completed ====\n");
}
* @dev Test #5: DoS scenario triggered by a sub-1:1 ratio (e.g., 0.95), where no premium is possible
* and the strategy reverts with "minOut too low" before calling the router.
*/
function testDoSScenarioWithDePeg() public {
console2.log("==== testDoSScenarioWithDePeg: Start ====");
console2.log("Deploying a MockRouter with ratio=95 (depegged)...");
MockCurveRouter router95 = new MockCurveRouter(95, address(mockSynthetic));
console2.log("Deploying StrategyMainnetMock with depegged ratio...");
vm.startPrank(keeper);
StrategyMainnetMock newStrategy = new StrategyMainnetMock(
address(mockSynthetic),
address(mockTransmuter),
address(mockUnderlying),
address(router95)
);
vm.stopPrank();
console2.log("Configuring 200e18 as claimable WETH for the new strategy...");
mockTransmuter.setClaimable(address(newStrategy), 200e18);
console2.log("Transferring 200e18 synthetic tokens to the new strategy...");
MockToken(address(mockSynthetic)).transfer(address(newStrategy), 200e18);
vm.startPrank(keeper);
console2.log("==== Multiple user attempts under DoPeg < 1:1 ratio ====");
console2.log(
"User#1: claimAndSwap(100, 100) => expecting 'minOut too low'..."
);
vm.expectRevert(bytes("minOut too low"));
newStrategy.claimAndSwap(100e18, 100e18, 0);
console2.log("User#2: claimAndSwap(50, 50) => expecting 'minOut too low'...");
vm.expectRevert(bytes("minOut too low"));
newStrategy.claimAndSwap(50e18, 50e18, 0);
console2.log("User#3: claimAndSwap(30, 25) => expecting 'minOut too low'...");
vm.expectRevert(bytes("minOut too low"));
newStrategy.claimAndSwap(30e18, 25e18, 0);
console2.log("User#4: claimAndSwap(20, 15) => expecting 'minOut too low'...");
vm.expectRevert(bytes("minOut too low"));
newStrategy.claimAndSwap(20e18, 15e18, 0);
vm.stopPrank();
console2.log(
"==== All attempts reverted => DoS scenario under sub-1:1 ratio ===="
);
console2.log("==== testDoSScenarioWithDePeg: Completed ====\n");
}
Test Results
The logs confirm that in 1:1 or sub-1:1 scenarios, transactions fail with minOut too low, causing an operational lock for the strategy:
[PASS] testClaimAndSwapSuccess() (gas: 1102377)
Logs:
==== setUp: Initializing test environment ====
Deploying MockToken for synthetic and underlying assets...
Deploying MockTransmuter and MockRouter with 1:1 ratio...
Deploying StrategyMainnetMock with the Keeper...
Transferring 100 synthetic tokens to the strategy...
==== setUp: Completed test environment initialization ====
==== testClaimAndSwapSuccess: Start ====
Deploying new MockRouter with ratio=105 (1.05:1)...
Deploying a new StrategyMainnetMock with updated router...
Setting 100e18 as claimable WETH in MockTransmuter for the new strategy...
Transferring 100e18 synthetic tokens to the new strategy...
Attempting claimAndSwap with minOut=101 and ratio=1.05:1...
ClaimAndSwap succeeded with ratio > 1:1 and minOut satisfied.
==== testClaimAndSwapSuccess: Completed ====
[PASS] testDOSDueToDepeg() (gas: 67382)
Logs:
==== setUp: Initializing test environment ====
Deploying MockToken for synthetic and underlying assets...
Deploying MockTransmuter and MockRouter with 1:1 ratio...
Deploying StrategyMainnetMock with the Keeper...
Transferring 100 synthetic tokens to the strategy...
==== setUp: Completed test environment initialization ====
==== testDOSDueToDepeg: Start ====
Setting 3 different claimable amounts for 3 different calls...
Actor A attempts claimAndSwap(50, 50) => expecting revert due to 'minOut too low'
Actor B attempts claimAndSwap(50, 50) => expecting revert as well...
Actor C attempts claimAndSwap(50, 50) => expecting revert again...
All attempts failed => system is effectively locked under 1:1 ratio (depeg scenario).
==== testDOSDueToDepeg: Completed ====
[PASS] testDoSScenarioWithDePeg() (gas: 1009643)
Logs:
==== setUp: Initializing test environment ====
Deploying MockToken for synthetic and underlying assets...
Deploying MockTransmuter and MockRouter with 1:1 ratio...
Deploying StrategyMainnetMock with the Keeper...
Transferring 100 synthetic tokens to the strategy...
==== setUp: Completed test environment initialization ====
==== testDoSScenarioWithDePeg: Start ====
Deploying a MockRouter with ratio=95 (depegged)...
Deploying StrategyMainnetMock with depegged ratio...
Configuring 200e18 as claimable WETH for the new strategy...
Transferring 200e18 synthetic tokens to the new strategy...
==== Multiple user attempts under DoPeg < 1:1 ratio ====
User#1: claimAndSwap(100, 100) => expecting 'minOut too low'...
User#2: claimAndSwap(50, 50) => expecting 'minOut too low'...
User#3: claimAndSwap(30, 25) => expecting 'minOut too low'...
User#4: claimAndSwap(20, 15) => expecting 'minOut too low'...
==== All attempts reverted => DoS scenario under sub-1:1 ratio ====
==== testDoSScenarioWithDePeg: Completed ====
[PASS] testRevert_MinOutBelowOrEqualAmountClaim() (gas: 42600)
Logs:
==== setUp: Initializing test environment ====
Deploying MockToken for synthetic and underlying assets...
Deploying MockTransmuter and MockRouter with 1:1 ratio...
Deploying StrategyMainnetMock with the Keeper...
Transferring 100 synthetic tokens to the strategy...
==== setUp: Completed test environment initialization ====
==== testRevert_MinOutBelowOrEqualAmountClaim: Start ====
Setting 50e18 as claimable WETH in MockTransmuter...
Attempting claimAndSwap with minOut == amountClaim (50e18)... Expecting revert.
Attempting claimAndSwap with minOut < amountClaim (40e18)... Expecting revert.
==== testRevert_MinOutBelowOrEqualAmountClaim: Completed ====
[PASS] testRevert_SlippageTooHigh() (gas: 134691)
Logs:
==== setUp: Initializing test environment ====
Deploying MockToken for synthetic and underlying assets...
Deploying MockTransmuter and MockRouter with 1:1 ratio...
Deploying StrategyMainnetMock with the Keeper...
Transferring 100 synthetic tokens to the strategy...
==== setUp: Completed test environment initialization ====
==== testRevert_SlippageTooHigh: Start ====
Setting 100e18 as claimable WETH in MockTransmuter...
Attempting claimAndSwap with minOut > routers output capacity... Expecting revert.
==== testRevert_SlippageTooHigh: Completed ====
Suite result: ok. 5 passed; 0 failed; 0 skipped; finished in 1.84ms (2.36ms CPU time)
All tests passed; reverts confirm the DoS behavior when no premium >1:1 is available.
Test Code and Mitigation Results
To avoid the DoS, the verification > _amountClaim is relaxed to >= _amountClaim. Two new tests are added to validate the mitigation:
* @dev Test: Multiple users executing claimAndSwap calls in sequence,
* verifying the mitigation logic. The Strategy starts with 300 synthetic
* tokens and claims are made at a 1:1 ratio.
*/
function testMultipleUsersWithMitigation() public {
console2.log("==== testMultipleUsersWithMitigation: start ====");
console2.log("Check strategy synthetic balance = 300e18 ...");
assertEq(
mockSynthetic.balanceOf(address(strategy)),
300e18,
"Strategy should start with 300 synthetic"
);
vm.startPrank(keeper);
console2.log("Keeper calls claimAndSwap(100, 100, 0) ...");
strategy.claimAndSwap(100e18, 100e18, 0);
console2.log("Keeper calls claimAndSwap(150, 150, 0) ...");
strategy.claimAndSwap(150e18, 150e18, 0);
console2.log("Keeper calls claimAndSwap(50, 50, 0) ...");
strategy.claimAndSwap(50e18, 50e18, 0);
vm.stopPrank();
console2.log(
"==== testMultipleUsersWithMitigation: all calls succeeded ===="
);
assertTrue(true, "All claimAndSwap calls succeeded with 1:1 ratio");
}
* @dev Test: Confirm no strict DoS when router ratio is 0.95.
*/
function testMitigationNoStrictDoS_Ratio95() public {
console2.log("==== testMitigationNoStrictDoS_Ratio95: start ====");
console2.log("Deploying a second router with ratio=95...");
MockCurveRouter router95 = new MockCurveRouter(95, address(mockSynthetic));
console2.log("Transfer 1e24 to router95 so it can do swaps...");
MockToken(address(mockSynthetic)).transfer(address(router95), 1e24);
console2.log("Deploying a new Strategy with router95...");
vm.startPrank(keeper);
StrategyMainnetMockMitigated newStrategy = new StrategyMainnetMockMitigated(
address(mockSynthetic),
address(mockTransmuter),
address(mockUnderlying),
address(router95)
);
vm.stopPrank();
console2.log("Transfer 200e18 to newStrategy...");
MockToken(address(mockSynthetic)).transfer(address(newStrategy), 200e18);
console2.log("Set 200 claimable for newStrategy...");
mockTransmuter.setClaimable(address(newStrategy), 200e18);
console2.log(
"Keeper calls claimAndSwap(200, 200, 0) expecting revert due to slippage..."
);
vm.startPrank(keeper);
vm.expectRevert(bytes("Router: Not enough output"));
newStrategy.claimAndSwap(200e18, 200e18, 0);
console2.log(
"Keeper calls claimAndSwap(200, 190, 0) expecting revert because minOut condition not met..."
);
vm.expectRevert(bytes("minOut condition not met"));
newStrategy.claimAndSwap(200e18, 190e18, 0);
vm.stopPrank();
console2.log("==== testMitigationNoStrictDoS_Ratio95: done ====");
}
Mitigation Test Results
[PASS] testMitigationNoStrictDoS_Ratio95() (gas: 1269831)
Logs:
==== setUp: Deploying tokens with 3e24 supply each ====
==== setUp: Deploying Transmuter and Router (ratio=100) ====
==== setUp: Transfer 1e24 to primary Router ====
==== setUp: Deploying Strategy with keeper ====
==== setUp: Transfer 300e18 to Strategy ====
==== setUp: Set 300 claimable for Strategy ====
==== setUp: Done ====
==== testMitigationNoStrictDoS_Ratio95: start ====
Deploying a second router with ratio=95...
Transfer 1e24 to router95 so it can do swaps...
Deploying a new Strategy with router95...
Transfer 200e18 to newStrategy...
Set 200 claimable for newStrategy...
Keeper calls claimAndSwap(200, 200, 0) expecting revert due to slippage...
Keeper calls claimAndSwap with amountClaim = 200000000000000000000 minOut = 200000000000000000000
Calling transmuter.claim with _amountClaim = 200000000000000000000
Balance before swap: 200000000000000000000
Keeper calls claimAndSwap(200, 190, 0) expecting revert because minOut condition not met...
Keeper calls claimAndSwap with amountClaim = 200000000000000000000 minOut = 190000000000000000000
Calling transmuter.claim with _amountClaim = 200000000000000000000
==== testMitigationNoStrictDoS_Ratio95: done ====
[PASS] testMultipleUsersWithMitigation() (gas: 253420)
Logs:
==== setUp: Deploying tokens with 3e24 supply each ====
==== setUp: Deploying Transmuter and Router (ratio=100) ====
==== setUp: Transfer 1e24 to primary Router ====
==== setUp: Deploying Strategy with keeper ====
==== setUp: Transfer 300e18 to Strategy ====
==== setUp: Set 300 claimable for Strategy ====
==== setUp: Done ====
==== testMultipleUsersWithMitigation: start ====
Check strategy synthetic balance = 300e18 ...
Keeper calls claimAndSwap(100, 100, 0) ...
Keeper calls claimAndSwap with amountClaim = 100000000000000000000 minOut = 100000000000000000000
Calling transmuter.claim with _amountClaim = 100000000000000000000
Balance before swap: 300000000000000000000
Balance after swap: 400000000000000000000
Re-depositing into Transmuter: 400000000000000000000
Keeper calls claimAndSwap(150, 150, 0) ...
Keeper calls claimAndSwap with amountClaim = 150000000000000000000 minOut = 150000000000000000000
Calling transmuter.claim with _amountClaim = 150000000000000000000
Balance before swap: 0
Balance after swap: 150000000000000000000
Re-depositing into Transmuter: 150000000000000000000
Keeper calls claimAndSwap(50, 50, 0) ...
Keeper calls claimAndSwap with amountClaim = 50000000000000000000 minOut = 50000000000000000000
Calling transmuter.claim with _amountClaim = 50000000000000000000
Balance before swap: 0
Balance after swap: 50000000000000000000
Re-depositing into Transmuter: 50000000000000000000
==== testMultipleUsersWithMitigation: all calls succeeded ====
Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 1.54ms (1.14ms CPU time)
It is confirmed that after relaxing the requirement to require(_minOut >= _amountClaim, "minOut condition not met"), swaps can be executed at 1:1 without reverting.
This eliminates the strict DoS caused by the “premium > 1:1” condition.
However, if the pool objectively cannot provide sufficient tokens (due to actual slippage), the transaction will still revert, which is expected behavior for a slippage check, not a design-induced DoS.
Observations
Depeg Scenarios: The original vulnerability causes a permanent revert if no premium above 1:1 exists. With the mitigation, the strategy can continue operating even at parity or slight negative deviations.
Slippage Checks Are Retained: The mitigation does not remove the revert in cases where liquidity or minimum output is not met, but it prevents a “total lock” caused solely by requiring >1:1.
Security Impact: Allowing 1:1 removes the DoS risk, but projects can implement additional safeguards to ensure swaps are not executed at excessive losses (e.g., using dynamic _minOut, oracles, etc.).
Conclusion
This PoC demonstrates how the restriction require(_minOut > _amountClaim) in claimAndSwap() causes a Denial of Service when the alETH/WETH ratio does not exceed 1:1. The tests highlight the failure in multiple scenarios (1:1, sub-1:1, and insufficient router capacity) and subsequently validate the fix by applying a >= check instead of >. This change resolves the DoS in 1:1 scenarios, allowing the strategy to function uninterrupted under normal market conditions.
In summary:
Vulnerability: All swaps are blocked if no premium > 1:1 is available.
Mitigation: Change require(_minOut > _amountClaim) to require(_minOut >= _amountClaim) with an updated message, such as minOut condition not met.
Result: Swaps at 1:1 (or close) are allowed without automatic reverts, preventing the strategy from becoming inoperable.
Tools Used
-
Manual Code Review
-
Foundry
Created test scenarios in Foundry to simulate calls to claimAndSwap() or _swapUnderlyingToAsset() with _minOut > _amountClaim.
Verified repeated transaction reverts in environments mimicking actual user or keeper calls.
Recommendations
-
Relax or Remove the Strict > _amountClaim Check
-
Incorporate a Flexible Slippage / Oracle Mechanism
Dynamically compute _minOut based on real-time on-chain data or oracles, adding a small buffer for slippage.
Example: If 1 WETH ~= 0.99 alETH, allow _minOut = 0.98 * _amountClaim. This avoids a revert while still enforcing a safety margin.
-
Introduce a Fallback / Standby Mode
-
“Force Swap” or Management Override
-
Parameterizable Route Updates
The current code references multiple routes. Ensure that management or keepers can update _minOut or route parameters on demand, preventing a scenario where unchangeable, overly strict slippage settings cause permanent lock.
Rationale:
While some operators might prefer only profitable swaps, a total inability to execute any swap under less-than-ideal conditions is more damaging in the long term. Introducing the above improvements would prevent a “hard revert” scenario, thereby avoiding DoS.
Conclusion
By relaxing the rigid “premium-only” requirement or adding fallback logic, the protocol can avoid an unintended DoS that renders these Alchemix-based strategies inoperable under certain (potentially prolonged) market conditions. This ensures capital efficiency, continuous yield generation, and overall protocol stability.