Stratax Contracts

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

`Stratax::getMaxLeverage` returns an unachievable maximum leverage that is inconsistent with `calculateOpenParams`, causing reverts for valid-looking leverage values

Author Revealed upon completion

Stratax::getMaxLeverage returns an unachievable maximum leverage that is inconsistent with calculateOpenParams, causing reverts for valid-looking leverage values

Description

Stratax::getMaxLeverage computes the theoretical maximum leverage as 1 / (1 - LTV), without accounting for the BORROW_SAFETY_MARGIN (95%) or the flash loan fee that calculateOpenParams applies when actually computing position parameters:

function getMaxLeverage(uint256 _ltv) public pure returns (uint256 maxLeverage) {
require(_ltv > 0 && _ltv < LTV_PRECISION, "Invalid LTV");
// Maximum leverage = 1 / (1 - LTV)
@> maxLeverage = (LEVERAGE_PRECISION * LEVERAGE_PRECISION) / (LTV_PRECISION - _ltv);
}

Inside calculateOpenParams, the borrow amount is reduced by BORROW_SAFETY_MARGIN (95% of max LTV), and the flash loan repayment check requires the borrow (converted to collateral terms) to cover the flash loan principal plus fee:

function calculateOpenParams(TradeDetails memory details)
public
view
returns (uint256 flashLoanAmount, uint256 borrowAmount)
{
// ...
uint256 maxLeverage = getMaxLeverage(ltv);
@> require(details.desiredLeverage <= maxLeverage, "Desired leverage exceeds maximum");
flashLoanAmount =
(details.collateralAmount * (details.desiredLeverage - LEVERAGE_PRECISION)) / LEVERAGE_PRECISION;
uint256 totalCollateral = details.collateralAmount + flashLoanAmount;
uint256 totalCollateralValueUSD =
(totalCollateral * details.collateralTokenPrice) / (10 ** details.collateralTokenDec);
@> uint256 borrowValueUSD = (totalCollateralValueUSD * ltv * BORROW_SAFETY_MARGIN) / (LTV_PRECISION * 10000);
borrowAmount = (borrowValueUSD * (10 ** details.borrowTokenDec)) / details.borrowTokenPrice;
uint256 flashLoanFee = (flashLoanAmount * flashLoanFeeBps) / FLASHLOAN_FEE_PREC;
uint256 minRequiredAfterSwap = flashLoanAmount + flashLoanFee;
uint256 borrowValueInCollateral = (borrowAmount * details.borrowTokenPrice * (10 ** details.collateralTokenDec))
/ (details.collateralTokenPrice * (10 ** details.borrowTokenDec));
@> require(borrowValueInCollateral >= minRequiredAfterSwap, "Insufficient borrow to repay flash loan");
return (flashLoanAmount, borrowAmount);
}

The constraint for the position to be viable is:

borrowValueInCollateral >= flashLoanAmount + flashLoanFee

Substituting through the formulas, the actual maximum leverage L_max that satisfies this constraint is:

L_max = (1 + fee) / ((1 + fee) - LTV × safetyMargin)

Where fee = flashLoanFeeBps / FLASHLOAN_FEE_PREC and safetyMargin = BORROW_SAFETY_MARGIN / 10000.

For USDC with 75% LTV (ltv = 7500), BORROW_SAFETY_MARGIN = 9500, and flashLoanFeeBps = 9:

getMaxLeverage Actual achievable max
Formula 1 / (1 - 0.75) 1.0009 / (1.0009 - 0.7125)
Result 4.00x ~3.47x

Any leverage value between ~3.47x and 4.00x will pass the getMaxLeverage check on the first require but revert on the second require with "Insufficient borrow to repay flash loan". This is a ~13% range of leverage values that getMaxLeverage advertises as valid but are actually impossible to use.

Risk

Likelihood:

  • getMaxLeverage is a public view function clearly intended to be called by users or frontends to determine the valid leverage range before calling calculateOpenParams or createLeveragedPosition. Any user who queries this function and then uses a leverage value near the returned maximum will hit the revert.

  • The gap between the theoretical and actual maximum widens as LTV increases — precisely the assets where users are most likely to push for maximum leverage.

Impact:

  • Users receive misleading information about the maximum leverage available, leading to failed transactions and wasted gas.

  • Frontend integrations that rely on getMaxLeverage to populate UI sliders or validate inputs will present an incorrect upper bound, allowing users to select leverage values that will always revert.

  • The two-step failure (first check passes, second check reverts) produces a confusing "Insufficient borrow to repay flash loan" error that gives no indication the real issue is that the requested leverage exceeds the effective maximum.

Proof of Concept

  1. A user queries getMaxLeverage(USDC) which returns 4x (40000) for 75% LTV

  2. The user calls calculateOpenParams with desiredLeverage = 38000 (3.8x) — below the advertised max

  3. The call reverts with "Insufficient borrow to repay flash loan" because 3.8x exceeds the effective maximum of ~3.47x

Add the following test to test/fork/Stratax.t.sol

function test_GetMaxLeverageReturnsUnachievableValue() public {
console.log("");
console.log("============================================================");
console.log(" PoC: getMaxLeverage Returns Unachievable Maximum");
console.log("============================================================");
// Query the max leverage for USDC (75% LTV)
uint256 maxLeverage = stratax.getMaxLeverage(address(USDC));
console.log("");
console.log(" getMaxLeverage(USDC) returns: ", maxLeverage);
console.log(" (Interpreted as: ", maxLeverage / 10000, ".", (maxLeverage % 10000) / 100, "x )");
// Try a leverage slightly below the advertised max (3.8x)
uint256 desiredLeverage = 38_000;
uint256 collateralAmount = 1000 * 10 ** 6;
console.log("");
console.log(" Attempting calculateOpenParams with:");
console.log(" desiredLeverage: ", desiredLeverage, "(3.8x)");
console.log(" collateralAmount: $", collateralAmount / 1e6);
console.log("");
console.log(" 3.8x is below the advertised 4x max, so this SHOULD work...");
// This should succeed according to getMaxLeverage, but it reverts
vm.expectRevert("Insufficient borrow to repay flash loan");
stratax.calculateOpenParams(
Stratax.TradeDetails({
collateralToken: address(USDC),
borrowToken: address(WETH),
desiredLeverage: desiredLeverage,
collateralAmount: collateralAmount,
collateralTokenPrice: 0,
borrowTokenPrice: 0,
collateralTokenDec: 6,
borrowTokenDec: 18
})
);
console.log("");
console.log("============================================================");
console.log(" RESULT: calculateOpenParams reverted at 3.8x despite");
console.log(" getMaxLeverage advertising 4x as the maximum");
console.log("============================================================");
console.log("");
}

Recommended Mitigation

Update getMaxLeverage to account for BORROW_SAFETY_MARGIN and flashLoanFeeBps, so the returned value reflects the actual achievable maximum:

- function getMaxLeverage(uint256 _ltv) public pure returns (uint256 maxLeverage) {
+ function getMaxLeverage(uint256 _ltv) public view returns (uint256 maxLeverage) {
require(_ltv > 0 && _ltv < LTV_PRECISION, "Invalid LTV");
- maxLeverage = (LEVERAGE_PRECISION * LEVERAGE_PRECISION) / (LTV_PRECISION - _ltv);
+ // effectiveLTV = ltv * safetyMargin (scaled)
+ uint256 effectiveLTV = (_ltv * BORROW_SAFETY_MARGIN) / 10000;
+ // fee factor = (1 + flashLoanFee) scaled to FLASHLOAN_FEE_PREC
+ uint256 feeFactor = FLASHLOAN_FEE_PREC + flashLoanFeeBps;
+ // denominator = feeFactor * LTV_PRECISION - effectiveLTV * FLASHLOAN_FEE_PREC
+ uint256 denominator = (feeFactor * LTV_PRECISION) - (effectiveLTV * FLASHLOAN_FEE_PREC);
+ // maxLeverage = feeFactor * LEVERAGE_PRECISION * LTV_PRECISION / denominator
+ maxLeverage = (feeFactor * LEVERAGE_PRECISION * LTV_PRECISION) / denominator;
}

Note: this changes the function from pure to view since it now reads BORROW_SAFETY_MARGIN and flashLoanFeeBps from state. The getMaxLeverage(address) overload is already view so no interface change is needed there.

Support

FAQs

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

Give us feedback!