Stratax Contracts

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

Stale Cached Flash Loan Fee in `calculateOpenParams()` View Helper

Author Revealed upon completion

Description

calculateOpenParams() uses a cached flashLoanFeeBps storage variable (L433) for its flash loan repayment sanity check instead of querying Aave's actual fee via IPool.FLASHLOAN_PREMIUM_TOTAL(). If Aave governance changes the flash loan premium, the cached value becomes stale and the view helper produces incorrect validation results.

The contract imports IPool (which exposes FLASHLOAN_PREMIUM_TOTAL()) but never queries it — instead, it duplicates this state in flashLoanFeeBps (L115), initialized to 9 at L186 and updatable only via the owner-gated setFlashLoanFee() (L272).

The stale fee affects the sanity check at L433-441:

@> uint256 flashLoanFee = (flashLoanAmount * flashLoanFeeBps) / FLASHLOAN_FEE_PREC; // L433
@> uint256 minRequiredAfterSwap = flashLoanAmount + flashLoanFee; // L434
// ...
@> require(borrowValueInCollateral >= minRequiredAfterSwap, "Insufficient borrow to repay flash loan"); // L441

Risk

Likelihood:

  • Aave fee changes are governance-gated, publicly announced, and historically rare

  • The owner has setFlashLoanFee() to resync, but must do so manually

  • The 5% safety margin makes the sanity check at L441 trivially satisfied even with significant fee drift

Impact:

  • calculateOpenParams() may validate parameters against an incorrect fee baseline — misleading but not dangerous since the function is view-only

  • No funds at risk: if parameters derived from the helper are insufficient, the actual execution reverts safely at L522 (require(returnAmount >= totalDebt)) with no state changes

  • Inconsistent code pattern: the contract duplicates state it doesn't own (flashLoanFeeBps shadows IPool.FLASHLOAN_PREMIUM_TOTAL())

Proof of Concept

The PoC demonstrates that flashLoanFeeBps and Aave's FLASHLOAN_PREMIUM_TOTAL can diverge, causing calculateOpenParams() to use a stale fee in its sanity check. It also shows that the BORROW_SAFETY_MARGIN absorbs the discrepancy in practice.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
import {Test} from "forge-std/Test.sol";
/// @notice Reproduces the fee divergence in Stratax.calculateOpenParams() (L433-441)
contract StaleFlashFeeTest is Test {
uint256 constant FLASHLOAN_FEE_PREC = 10000;
uint256 constant BORROW_SAFETY_MARGIN = 9500; // 95%
uint256 constant LTV_PRECISION = 1e4;
/// @notice Shows cached fee diverges from actual Aave fee
function test_feesDivergeAfterAaveGovernanceChange() public pure {
uint256 cachedFeeBps = 9; // Stratax storage: 0.09%
uint256 actualFeeBps = 20; // Aave changed to: 0.20%
uint256 flashLoanAmount = 2 ether; // 2 ETH flash loan (3x leverage on 1 ETH)
// What calculateOpenParams() computes (stale)
uint256 cachedFee = (flashLoanAmount * cachedFeeBps) / FLASHLOAN_FEE_PREC;
uint256 cachedMinRequired = flashLoanAmount + cachedFee;
// What Aave actually charges
uint256 actualFee = (flashLoanAmount * actualFeeBps) / FLASHLOAN_FEE_PREC;
uint256 actualMinRequired = flashLoanAmount + actualFee;
// The helper underestimates by the fee delta
assertEq(cachedFee, 0.0018 ether, "Cached fee: 0.09% of 2 ETH");
assertEq(actualFee, 0.004 ether, "Actual fee: 0.20% of 2 ETH");
assertGt(actualMinRequired, cachedMinRequired, "Stale fee underestimates repayment");
// Shortfall = 0.0022 ETH (~$4.40 at $2000/ETH)
uint256 shortfall = actualMinRequired - cachedMinRequired;
assertEq(shortfall, 0.0022 ether);
}
/// @notice Shows the 5% safety margin makes the fee discrepancy irrelevant in practice
function test_safetyMarginAbsorbsFeeDiscrepancy() public pure {
// Scenario: 3x leverage, 1 ETH collateral, WETH (80% LTV), same collateral/borrow token
uint256 flashLoanAmount = 2 ether;
uint256 totalCollateral = 3 ether; // 1 ETH user + 2 ETH flash
uint256 ltv = 8000; // 80%
// Borrow amount from calculateOpenParams (L426):
// borrowValueUSD = totalCollateralValueUSD * ltv * BORROW_SAFETY_MARGIN / (LTV_PRECISION * 10000)
// Simplified (same token, same price): borrowAmount = totalCollateral * ltv * BORROW_SAFETY_MARGIN / (LTV_PRECISION * 10000)
uint256 borrowAmount = (totalCollateral * ltv * BORROW_SAFETY_MARGIN) / (LTV_PRECISION * 10000);
assertEq(borrowAmount, 2.28 ether, "Borrow = 3 * 0.80 * 0.95 = 2.28 ETH");
// Even with a 10x fee increase (0.09% -> 0.9%), repayment is covered
uint256 extremeFeeBps = 90; // 0.9% -- 10x the default
uint256 extremeFee = (flashLoanAmount * extremeFeeBps) / FLASHLOAN_FEE_PREC;
uint256 extremeMinRequired = flashLoanAmount + extremeFee;
assertEq(extremeMinRequired, 2.018 ether, "Flash loan + 0.9% fee = 2.018 ETH");
// 2.28 ETH borrow >> 2.018 ETH required -- safety margin absorbs it
assertGt(borrowAmount, extremeMinRequired, "Safety margin covers even 10x fee increase");
// The fee would need to exceed ~14% before the sanity check binds:
// 2.28 = 2 + (2 * feeBps / 10000) => feeBps = (0.28 * 10000) / 2 = 1400 (14%)
uint256 breakingFeeBps = ((borrowAmount - flashLoanAmount) * FLASHLOAN_FEE_PREC) / flashLoanAmount;
assertEq(breakingFeeBps, 1400, "Fee must reach 14% before sanity check fails");
}
}

Recommended Mitigation

Query FLASHLOAN_PREMIUM_TOTAL() dynamically in calculateOpenParams() instead of using the cached storage variable. This eliminates the state duplication and ensures the helper always reflects Aave's actual fee:

// Ensure borrow amount when swapped back covers flash loan + fee
- uint256 flashLoanFee = (flashLoanAmount * flashLoanFeeBps) / FLASHLOAN_FEE_PREC;
+ uint256 currentFeeBps = uint256(aavePool.FLASHLOAN_PREMIUM_TOTAL());
+ uint256 flashLoanFee = (flashLoanAmount * currentFeeBps) / FLASHLOAN_FEE_PREC;
uint256 minRequiredAfterSwap = flashLoanAmount + flashLoanFee;

Support

FAQs

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

Give us feedback!