Stratax Contracts

First Flight #57
Beginner FriendlyDeFi
100 EXP
Submission Details
Impact: medium
Likelihood: high

Missing handling for target leverage 1x: revert or creation of leveraged position instead of supply-only

Author Revealed upon completion

Root + Impact

Description

  • Normal behavior: The protocol allows `desiredLeverage >= LEVERAGE_PRECISION` (1x). For 1x, no extra collateral from a flash loan is needed: the user supplies collateral C and the intended total collateral is C, so flash loan amount should be 0 and no borrow/swap should occur (supply-only position).

  • Specific issue: When `desiredLeverage == LEVERAGE_PRECISION` (1x), `calculateOpenParams` returns `flashLoanAmount == 0` but still returns a positive `borrowAmount`. The code never treats “zero flash loan” as a degenerate case. It always calls Aave’s `flashLoanSimple` with that amount and, in the callback, always performs supply, borrow, swap, and then supplies any swap output. So either (1) the pool reverts on a 0-amount flash loan and the user cannot open 1x, or (2) the pool allows it and the user receives a leveraged position (collateral = C + swap output, debt > 0) instead of a 1x supply-only position.

Root cause in the codebase:

*@> `src/Stratax.sol:389`** — `require(details.desiredLeverage >= LEVERAGE_PRECISION, ...)` explicitly allows 1x, but there is no branch that disallows or specially handles 1x later.
@> `src/Stratax.sol:411-412`** — For `desiredLeverage == LEVERAGE_PRECISION`, `flashLoanAmount` is set to 0; borrow amounts are still computed from `totalCollateral = C`, so `borrowAmount > 0` is returned. The contract returns `(0, borrowAmount)` with no indication that 1x should skip the flash-loan flow.
@> `src/Stratax.sol:323,339`** — `createLeveragedPosition` only checks `_collateralAmount > 0`. It does not check `_flashLoanAmount` and unconditionally calls `flashLoanSimple(..., _flashLoanAmount, ...)`, so a 0-amount flash loan is requested when the caller uses 1x params.
@> `src/Stratax.sol:494,500-506,528-531`** — In `_executeOpenOperation`, `totalCollateral = _amount + flashParams.collateralAmount` (so when `_amount == 0`, only user collateral is supplied). The code then always borrows `flashParams.borrowAmount` and swaps. When `_amount` and `_premium` are 0, `totalDebt == 0`, so the condition `returnAmount - totalDebt > 0` is true for any positive swap output, and the full `returnAmount` is supplied to Aave. Final collateral is therefore `C + returnAmount` with debt `borrowAmount`, i.e. a leveraged position, not 1x.

Risk

Likelihood:

  • The protocol’s public API allows `desiredLeverage == LEVERAGE_PRECISION` (1x). Any caller that uses `calculateOpenParams(..., desiredLeverage: 10000, ...)` and then `createLeveragedPosition(..., flashLoanAmount, ..., borrowAmount, ...)` with those return values will request a 0-size flash loan and a positive borrow. There is no code path that avoids this when 1x is selected.

  • Whether the outcome is revert or wrong leverage depends only on the external pool’s behavior (revert on 0 amount or not). In both cases the protocol’s behavior is wrong for the advertised 1x case.Impact:

Impact

  • If the pool reverts on 0-amount flash loan: Users attempting to open a 1x position suffer a revert (DoS). Gas is spent and no position is opened. The advertised “1x” option is unusable

  • If the pool allows 0-amount flash loan: The user receives a leveraged position (collateral > C, debt > 0) instead of a 1x supply-only position. They are exposed to borrowing and liquidation risk they did not intend. This can lead to unexpected liquidations and loss of funds when the user believed they had no leverage.

Proof of Concept

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Test} from "forge-std/Test.sol";
import {Stratax} from "../../src/Stratax.sol";
import {StrataxOracle} from "../../src/StrataxOracle.sol";
import {ConstantsEtMainnet} from "../Constants.t.sol";
import {UpgradeableBeacon} from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol";
import {BeaconProxy} from "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol";
/**
* @title PoC: Leverage 1x degenerate case
* @notice Proof of Concept for finding "Missing handling for target leverage 1x"
* @dev Run with: forge test --match-contract PoC_Leverage1xDegenerate -vvv
*/
contract PoC_Leverage1xDegenerate is Test, ConstantsEtMainnet {
Stratax public stratax;
Stratax public strataxImplementation;
UpgradeableBeacon public beacon;
BeaconProxy public proxy;
StrataxOracle public strataxOracle;
uint256 public constant LEVERAGE_PRECISION = 1e4;
uint256 public constant LTV_80_PCT = 8000;
function setUp() public {
vm.mockCall(USDC_PRICE_FEED, abi.encodeWithSignature("decimals()"), abi.encode(uint8(8)));
vm.mockCall(WETH_PRICE_FEED, abi.encodeWithSignature("decimals()"), abi.encode(uint8(8)));
vm.mockCall(
USDC_PRICE_FEED,
abi.encodeWithSignature("latestRoundData()"),
abi.encode(uint80(0), int256(1e8), uint256(0), uint256(0), uint80(0))
);
vm.mockCall(
WETH_PRICE_FEED,
abi.encodeWithSignature("latestRoundData()"),
abi.encode(uint80(0), int256(1e8), uint256(0), uint256(0), uint80(0))
);
strataxOracle = new StrataxOracle();
strataxOracle.setPriceFeed(USDC, USDC_PRICE_FEED);
strataxOracle.setPriceFeed(WETH, WETH_PRICE_FEED);
strataxImplementation = new Stratax();
beacon = new UpgradeableBeacon(address(strataxImplementation), address(this));
bytes memory initData = abi.encodeWithSelector(
Stratax.initialize.selector,
AAVE_POOL,
AAVE_PROTOCOL_DATA_PROVIDER,
INCH_ROUTER,
USDC,
address(strataxOracle)
);
proxy = new BeaconProxy(address(beacon), initData);
stratax = Stratax(address(proxy));
_mockAaveReserveConfig(USDC, 6, LTV_80_PCT);
_mockAaveReserveConfig(WETH, 18, LTV_80_PCT);
}
function _mockAaveReserveConfig(address asset, uint256 decimals, uint256 ltv) internal {
vm.mockCall(
AAVE_PROTOCOL_DATA_PROVIDER,
abi.encodeWithSignature("getReserveConfigurationData(address)", asset),
abi.encode(
decimals,
ltv,
uint256(0),
uint256(0),
uint256(0),
true,
true,
false,
true,
false
)
);
}
/**
* PoC Step 1: For desiredLeverage == 1x, calculateOpenParams returns flashLoanAmount == 0
* but borrowAmount > 0. So the protocol encourages calling createLeveragedPosition
* with (0, borrowAmount), which triggers the bug.
*/
function test_PoC_1x_calculateOpenParams_returns_zero_flash_loan_positive_borrow() public {
uint256 userCollateral = 1000 * 1e6;
(uint256 flashLoanAmount, uint256 borrowAmount) = stratax.calculateOpenParams(
Stratax.TradeDetails({
collateralToken: address(USDC),
borrowToken: address(WETH),
desiredLeverage: LEVERAGE_PRECISION, // 1x
collateralAmount: userCollateral,
collateralTokenPrice: 1e8,
borrowTokenPrice: 1e8,
collateralTokenDec: 6,
borrowTokenDec: 18
})
);
assertEq(flashLoanAmount, 0, "PoC: 1x yields zero flash loan amount");
assertGt(borrowAmount, 0, "PoC: 1x still yields positive borrow amount - flow will borrow and swap");
}
/**
* PoC Step 2 (logic): If the pool did NOT revert on amount=0, the callback would run with
* _amount=0, _premium=0. Then:
* - totalCollateral = 0 + userCollateral = C (only user collateral supplied)
* - borrow(borrowAmount) and swap would still execute
* - totalDebt = 0, so returnAmount - totalDebt = returnAmount; full swap output supplied to Aave
* - Final position: collateral = C + returnAmount, debt = borrowAmount -> effective leverage > 1
* This test asserts that math: for 1x we expect finalCollateral == userCollateral, but the
* execution path would produce finalCollateral = userCollateral + returnAmount.
*/
function test_PoC_1x_execution_path_would_produce_leveraged_position_not_1x() public pure {
uint256 userCollateral = 1000 * 1e6;
uint256 flashLoanAmount = 0;
uint256 borrowAmount = 1e18; // example positive borrow
uint256 returnAmountFromSwap = 500 * 1e6; // example: swap returns 500 USDC
// What 1x should be: only user collateral supplied, no debt
uint256 expectedCollateral1x = userCollateral;
uint256 expectedDebt1x = 0;
// What the actual code path does when _amount=0: supply C, borrow B, swap, then supply (returnAmount - 0)
uint256 totalDebt = flashLoanAmount + 0; // _premium = 0
uint256 leftoverSupplied = returnAmountFromSwap - totalDebt; // full returnAmount
uint256 actualCollateral = userCollateral + leftoverSupplied; // C + returnAmount
uint256 actualDebt = borrowAmount;
assertEq(expectedCollateral1x, userCollateral);
assertEq(expectedDebt1x, 0);
assertGt(actualCollateral, userCollateral, "PoC: actual path supplies more than user collateral");
assertGt(actualDebt, 0, "PoC: actual path leaves debt > 0");
assertGt(
(actualCollateral * LEVERAGE_PRECISION) / userCollateral,
LEVERAGE_PRECISION,
"PoC: effective leverage would be > 1x"
);
}
}

Test 1 — API returns (0, positive borrow) for 1x:
test_PoC_1x_calculateOpenParams_returns_zero_flash_loan_positive_borrow calls calculateOpenParams with desiredLeverage: LEVERAGE_PRECISION (1x) and asserts flashLoanAmount == 0 and borrowAmount > 0. So the protocol itself returns params that lead to a 0-size flash loan and a positive borrow.

Test 2 — Execution path would produce leverage > 1:
test_PoC_1x_execution_path_would_produce_leveraged_position_not_1x asserts the callback logic when _amount == 0: initial supply is only user collateral C, then the code borrows and swaps and supplies the full returnAmount (because totalDebt == 0). So final collateral = C + returnAmount and debt > 0, i.e. effective leverage > 1x.

E2E on fork: Call createLeveragedPosition(flashLoanToken, 0, collateralAmount, borrowToken, borrowAmount, swapData, minReturn) with the return values of calculateOpenParams(1x). If the pool reverts on 0 amount, the tx reverts (DoS). If it allows 0, the resulting position has collateral > user collateral and debt > 0 (wrong leverage).


Recommended Mitigation

Option A (simplest): Disallow 1x so the degenerate case is out of scope. In calculateOpenParams, require strictly greater than 1x:

require(details.desiredLeverage > LEVERAGE_PRECISION, "Leverage must be > 1x");

Optionally add the same check in createLeveragedPosition when it is used with params from calculateOpenParams (e.g. require _flashLoanAmount > 0), and document that 1x is not supported.

Option B (support true 1x): Explicitly handle the case when no flash loan is needed. For example, in createLeveragedPosition, when _flashLoanAmount == 0: only pull user collateral, supply it to Aave, and return; do not call flashLoanSimple, and do not borrow or swap. That way 1x corresponds to a supply-only position with no leverage.

Support

FAQs

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

Give us feedback!