Stratax Contracts

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

`Stratax::calculateUnwindParams` returns aggregate debt across all positions sharing the same borrow token, causing cross-position interference or DOS on unwind

Author Revealed upon completion

Stratax::calculateUnwindParams returns aggregate debt across all positions sharing the same borrow token, causing cross-position interference or DOS on unwind

Description

Stratax::calculateUnwindParams reads the outstanding debt via balanceOf on Aave's variable debt token:

function calculateUnwindParams(address _collateralToken, address _borrowToken)
public
view
returns (uint256 collateralToWithdraw, uint256 debtAmount)
{
(,, address debtToken) = aaveDataProvider.getReserveTokensAddresses(_borrowToken);
@> debtAmount = IERC20(debtToken).balanceOf(address(this));
uint256 debtTokenPrice = IStrataxOracle(strataxOracle).getPrice(_borrowToken);
uint256 collateralTokenPrice = IStrataxOracle(strataxOracle).getPrice(_collateralToken);
collateralToWithdraw = (debtTokenPrice * debtAmount * 10 ** IERC20(_collateralToken).decimals())
/ (collateralTokenPrice * 10 ** IERC20(_borrowToken).decimals());
collateralToWithdraw = (collateralToWithdraw * 1050) / 1000;
return (collateralToWithdraw, debtAmount);
}

Since all positions created through a single Stratax contract share the same Aave account, balanceOf on the variable debt token returns the aggregate debt across every open position that borrows the same token — not the debt for any individual position. There are no guards on Stratax:createLeveragedPosition preventing multiple positions with the same borrow token.

For example, a user could have:

  • Position 1: USDC collateral / WETH debt

  • Position 2: LINK collateral / WETH debt

Both positions contribute to the same WETH variable debt balance. When the user calls calculateUnwindParams(USDC, WETH) intending to unwind only Position 1, the returned debtAmount includes Position 2's WETH debt as well.

This inflated debtAmount then:

  1. Becomes the flash loan amount in Stratax::unwindPosition, flash loaning far more WETH than needed

  2. aavePool.repay in Stratax:"_executeUnwindOperation repays all WETH debt, unwinding Position 2 as collateral damage

  3. collateralToWithdraw is calculated based on the total debt, attempting to withdraw more USDC collateral than Position 1 warrants

The third point is where the critical failure occurs. Since Position 1's USDC collateral only backs Position 1's share of the debt, the aavePool.withdraw call attempts to burn more aUSDC than the contract holds — causing the entire unwind transaction to revert. This effectively makes it impossible to unwind any individual position via the normal flow once multiple positions share a borrow token.

Risk

Likelihood:

  • The contract is designed for users to hold multiple leveraged positions simultaneously — the createLeveragedPosition function has no guard preventing a second position that borrows the same token as an existing one. Since each Stratax proxy holds all positions under a single Aave account, any two positions sharing a borrow token (e.g., USDC/WETH and LINK/WETH) will pool their variable debt balances.

  • This is not an edge case it is very common for users to borrow the same asset across multiple positions on Aave

Impact:

  • The collateralToWithdraw value returned by Stratax::calculateUnwindParams is derived from the aggregate debt, so it will exceed the aToken collateral actually deposited for the target position. When Stratax::unwindPosition passes this inflated value to aavePool.withdraw, Aave reverts because the contract attempts to burn more aTokens than it holds for that collateral type. The user's position is stuck — they cannot unwind through the protocol's intended flow.

If the target position's collateral happens to be large enough to cover the inflated withdrawal (an unlikely but possible scenario), the unwind succeeds but repays the total WETH debt across all positions in a single flash loan. This wipes out the debt backing the other position, leaving its collateral stranded in Aave with no corresponding debt, and the user absorbs unnecessary flash loan fees on the excess amount.

Proof of Concept

  1. A user opens a position using USDC as collateral borrowing WETH

  2. The user opens a second posititon uisng LINK as collateral borrowing WETH

  3. The user then tries to unwind the USDC/WETH position, but it reverts as the calcualted debt accounts for the WETH across both positions

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

function test_AggreagteDebtAcrossPositions() public {
console.log("");
console.log("==========================================================================");
console.log(" POC: calculateUnwindParams aggregates debt across ALL positions");
console.log("==========================================================================");
// ── Position 1: USDC / WETH at 3x leverage ──────────────────────────
uint256 collateralAmountUsdc = 1000 * 10 ** 6; // 1,000 USDC
(uint256 flashLoanAmount, uint256 borrowAmount) = stratax.calculateOpenParams(
Stratax.TradeDetails({
collateralToken: address(USDC),
borrowToken: address(WETH),
desiredLeverage: 30_000,
collateralAmount: collateralAmountUsdc,
collateralTokenPrice: 0,
borrowTokenPrice: 0,
collateralTokenDec: 6,
borrowTokenDec: 18
})
);
(bytes memory openSwapData,) = get1inchSwapData(WETH, USDC, borrowAmount, address(stratax));
deal(USDC, ownerTrader, collateralAmountUsdc);
vm.startPrank(ownerTrader);
IERC20(USDC).approve(address(stratax), collateralAmountUsdc);
stratax.createLeveragedPosition(
USDC, flashLoanAmount, collateralAmountUsdc, WETH, borrowAmount, openSwapData, (flashLoanAmount * 950) / 1000
);
// Query aToken / debtToken balances after Position 1
(address aUsdc,,) = IProtocolDataProvider(AAVE_PROTOCOL_DATA_PROVIDER).getReserveTokensAddresses(USDC);
(,, address variableDebtWeth) = IProtocolDataProvider(AAVE_PROTOCOL_DATA_PROVIDER).getReserveTokensAddresses(WETH);
uint256 aUsdcAfterPos1 = IERC20(aUsdc).balanceOf(address(stratax));
uint256 wethDebtAfterPos1 = IERC20(variableDebtWeth).balanceOf(address(stratax));
console.log("");
console.log("--- Position 1 opened: 1,000 USDC collateral / WETH debt @ 3x ---");
console.log(" aUSDC held by Stratax: ", aUsdcAfterPos1 / 1e6, "USDC");
console.log(" WETH debt (variableDebt): WETH", wethDebtAfterPos1);
// ── Position 2: LINK / WETH at 2.5x leverage ────────────────────────
uint256 collateralAmountLink = 110 * 10 ** 18; // 110 LINK
(uint256 flashLoanAmountTwo, uint256 borrowAmountTwo) = stratax.calculateOpenParams(
Stratax.TradeDetails({
collateralToken: address(LINK),
borrowToken: address(WETH),
desiredLeverage: 25_000,
collateralAmount: collateralAmountLink,
collateralTokenPrice: 0,
borrowTokenPrice: 0,
collateralTokenDec: 18,
borrowTokenDec: 18
})
);
(bytes memory openSwapDataTwo,) = get1inchSwapData(WETH, LINK, borrowAmountTwo, address(stratax));
deal(LINK, ownerTrader, collateralAmountLink);
IERC20(LINK).approve(address(stratax), collateralAmountLink);
stratax.createLeveragedPosition(
LINK, flashLoanAmountTwo, collateralAmountLink, WETH, borrowAmountTwo, openSwapDataTwo, (flashLoanAmountTwo * 950) / 1000
);
// Query balances after Position 2
(address aLink,,) = IProtocolDataProvider(AAVE_PROTOCOL_DATA_PROVIDER).getReserveTokensAddresses(LINK);
uint256 aLinkAfterPos2 = IERC20(aLink).balanceOf(address(stratax));
uint256 wethDebtAfterPos2 = IERC20(variableDebtWeth).balanceOf(address(stratax));
uint256 wethDebtFromPos2Only = wethDebtAfterPos2 - wethDebtAfterPos1;
console.log("");
console.log("--- Position 2 opened: 110 LINK collateral / WETH debt @ 2.5x ---");
console.log(" aLINK held by Stratax: ", aLinkAfterPos2 / 1e18, "LINK");
console.log(" WETH debt from Pos 2 only: WETH", wethDebtFromPos2Only);
console.log(" Total WETH debt (both pos): WETH", wethDebtAfterPos2);
// ── The Bug: calculateUnwindParams uses total debt, not per-position ─
(uint256 collateralToWithdraw, uint256 debtAmount) = stratax.calculateUnwindParams(USDC, WETH);
console.log("");
console.log("==========================================================================");
console.log(" BUG: Calling calculateUnwindParams(USDC, WETH)");
console.log("==========================================================================");
console.log(" debtAmount returned: WETH", debtAmount);
console.log(" actual WETH debt from Pos 1: WETH", wethDebtAfterPos1);
console.log(" -> debtAmount includes debt from BOTH positions (Pos 1 + Pos 2)");
console.log("");
console.log(" collateralToWithdraw returned: ", collateralToWithdraw / 1e6, "USDC");
console.log(" actual aUSDC held (from Pos 1): ", aUsdcAfterPos1 / 1e6, "USDC");
console.log(" -> Stratax will try to withdraw MORE aUSDC than it actually deposited");
console.log(" -> This causes Aave's withdraw() to revert");
// ── Attempt unwind — expect revert ───────────────────────────────────
(bytes memory unwindSwapData,) = get1inchSwapData(USDC, WETH, collateralToWithdraw, address(stratax));
vm.expectRevert();
stratax.unwindPosition(USDC, collateralToWithdraw, WETH, debtAmount, unwindSwapData, (debtAmount * 950) / 1000);
vm.stopPrank();
console.log("");
console.log("==========================================================================");
console.log(" RESULT: unwindPosition reverted as expected");
console.log("==========================================================================");
}
Expected Output:
==========================================================================
POC: calculateUnwindParams aggregates debt across ALL positions
==========================================================================
--- Position 1 opened: 1,000 USDC collateral / WETH debt @ 3x ---
aUSDC held by Stratax: 3122 USDC
WETH debt (variableDebt): WETH 1068527807874949244
--- Position 2 opened: 110 LINK collateral / WETH debt @ 2.5x ---
aLINK held by Stratax: 281 LINK
WETH debt from Pos 2 only: WETH 764765788351779679
Total WETH debt (both pos): WETH 1833293596226728923
==========================================================================
BUG: Calling calculateUnwindParams(USDC, WETH)
==========================================================================
debtAmount returned: WETH 1833293596226728923
actual WETH debt from Pos 1: WETH 1068527807874949244
-> debtAmount includes debt from BOTH positions (Pos 1 + Pos 2)
collateralToWithdraw returned: 3850 USDC
actual aUSDC held (from Pos 1): 3122 USDC
-> Stratax will try to withdraw MORE aUSDC than it actually deposited
-> This causes Aave's withdraw() to revert
==========================================================================
RESULT: unwindPosition reverted as expected
==========================================================================

Recommended Mitigation

Track per-position debt internally rather than relying on the aggregate Aave debt token balance:

+ struct Position {
+ address collateralToken;
+ address borrowToken;
+ uint256 debtAmount;
+ }
+
+ mapping(uint256 => Position) public positions;
+ uint256 public positionCount;
function calculateUnwindParams(uint256 _positionId)
public
view
returns (uint256 collateralToWithdraw, uint256 debtAmount)
{
- (,, address debtToken) = aaveDataProvider.getReserveTokensAddresses(_borrowToken);
- debtAmount = IERC20(debtToken).balanceOf(address(this));
+ Position memory pos = positions[_positionId];
+ debtAmount = pos.debtAmount;
// ...
}

Support

FAQs

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

Give us feedback!