Normal behavior: When a position is unwound, the contract repays Aave debt with a flash loan, withdraws the collateral that backed the repaid debt from Aave, swaps it to the debt token, repays the flash loan, and supplies any surplus debt token back to Aave. The owner expects that after a full unwind (debt fully repaid), all collateral either is used to close the position or can be withdrawn to them.
Specific issue: The amount of collateral to withdraw is computed with integer division (rounds down). For a full unwind, the contract may therefore withdraw less than the total collateral in the position. That remainder stays in the contract’s Aave position (supplied to the Pool). Withdrawn collateral is always sent to address(this) and then swapped; none is ever sent to the owner. The only recovery function, recoverTokens, transfers tokens held in the contract’s ERC20 balance and does not withdraw from Aave. There is no function that withdraws Aave-supplied collateral to the owner. Collateral left in the Aave position after unwind is permanently stranded.
Likelihood:
The collateral-to-withdraw formula uses integer division and rounds down. On a full unwind (repay all debt), the requested amount is at most the formula result, so any remainder (total collateral minus withdrawn amount) stays in Aave.
Every full unwind can leave dust or a non-trivial remainder in the position depending on prices, decimals, and liqThreshold. There is no code path that withdraws this remainder to the owner.
Impact:
Collateral left in the contract’s Aave position after unwind is permanently locked. The owner cannot withdraw it via the protocol.
Value is stuck in the contract’s Aave position with no owner recovery mechanism, directly harming the position owner.
What the PoC demonstrates:
test_PoC_unwindFormulaRoundsDown_leavingStrandedRemainder — Uses the exact same formula as _executeUnwindOperation (Stratax.sol L574–576): collateralToWithdraw = (debt * debtPrice * collDec * LTV_PRECISION) / (collPrice * debtDec * liqThreshold). Integer division truncates. The test shows that when the position holds collateralToWithdraw + 1 wei (e.g. from prior supply rounding), the formula only requests collateralToWithdraw, so 1 wei (or more) remains in Aave after the unwind callback. That remainder is never withdrawn to the owner.
test_PoC_recoverTokens_onlyTransfersContractBalance_notAave — Stratax has 0 balance of the collateral token (all supplied collateral is in Aave). Calling recoverTokens(collateralToken, 1) triggers IERC20(_token).transfer(owner, 1) from the contract; the transfer reverts because the contract’s ERC20 balance is 0. This proves that recoverTokens only moves tokens in the contract’s wallet, not tokens supplied to the Aave Pool.
test_PoC_noWithdrawToOwnerFunction — Documents that no external function ever calls aavePool.withdraw(..., owner). The only withdraw in the codebase is inside the unwind callback and sends to address(this); that amount is then swapped. So any collateral left in Aave has no withdrawal path to the owner.
Add an owner-only function to withdraw Aave-supplied collateral to the owner, e.g. withdrawCollateralToOwner(address collateralToken, uint256 amount), which calls aavePool.withdraw(collateralToken, amount, owner) after checking that the position remains healthy if there is remaining debt (e.g. health factor > 1).
Alternatively, when unwinding and the resulting debt is zero, withdraw the full aToken balance (or the full supplied balance for that reserve) so that no collateral remains in Aave; then either swap the full amount as today or transfer the appropriate portion to the owner so that no value is left in the Pool with no withdrawal path.
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
Appeals are being carefully reviewed by our judges.
The contest is complete and the rewards are being distributed.