Stratax Contracts

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

Unwind logic resupplies profits instead of transferring to user

Author Revealed upon completion

Description

  • When a user unwinds a leveraged position, any surplus received from the unwind swap (i.e., the amount of debt token that exceeds the flash‑loan repayment) represents the user’s realized PnL / remaining equity. Users expect this surplus to be returned to their wallet as part of exiting the position.

  • At the end of _executeUnwindOperation, if the swap returnAmount exceeds totalDebt (flash‑loan amount + premium), the contract does not transfer the surplus to the user. Instead, it re‑supplies the surplus back to Aave as additional collateral of the contract itself, keeping the user’s capital locked in the protocol and preventing a full exit.

// Stratax.sol::_executeUnwindOperation
uint256 totalDebt = _amount + _premium;
require(returnAmount >= totalDebt, "Insufficient funds to repay flash loan");
// @> BUG: surplus is automatically re-supplied to Aave instead of being transferred to the user
if (returnAmount - totalDebt > 0) {
IERC20(_asset).approve(address(aavePool), returnAmount - totalDebt);
aavePool.supply(_asset, returnAmount - totalDebt, address(this), 0);
}
IERC20(_asset).approve(address(aavePool), totalDebt);
emit PositionUnwound(user, unwindParams.collateralToken, _asset, _amount, withdrawnAmount);

Risk

Likelihood: High

  • Users routinely unwind positions; whenever the swap delivers more than the flash‑loan repayment (common with positive PnL or small favorable price moves), the current logic will re‑supply the surplus instead of returning it to the user.

  • The existing fork tests even expect users to receive tokens back on unwind, which conflicts with the implementation (no user transfer in unwind). This mismatch is likely to surface during normal testing/integration.

Impact: High

  • Funds stuck / UX breakage: Users cannot fully exit; realized profits remain in the contract’s Aave position, forcing additional actions (and costs) to retrieve them.

  • Integration confusion: Off‑chain systems (bots/UIs) that assume proceeds flow to the user’s wallet will display wrong balances or fail post‑unwind accounting.

Proof of Concept

  • Copy test test_UnwindResuppliesProfitsInsteadOfTransferringToUser() to test/fork/Stratax.t.sol: inside the StrataxForkTest contract.

  • Copy mock contract MockOneInchRouter to test/fork/Stratax.t.sol: after the StrataxForkTest contract.

  • Run command forge test --mt test_UnwindResuppliesProfitsInsteadOfTransferringToUser --via-ir -vv.

function test_UnwindResuppliesProfitsInsteadOfTransferringToUser() public {
// --- Baseline: zero out owner balances to avoid fork residue/dust ---
deal(USDC, ownerTrader, 0);
deal(WETH, ownerTrader, 0);
// --- Deploy a fresh Stratax instance wired to the mock router ---
MockOneInchRouter mock = new MockOneInchRouter();
Stratax impl = new Stratax();
UpgradeableBeacon b = new UpgradeableBeacon(address(impl), address(this));
bytes memory initData = abi.encodeWithSelector(
Stratax.initialize.selector,
AAVE_POOL,
AAVE_PROTOCOL_DATA_PROVIDER,
address(mock), // use mock router
USDC,
address(strataxOracle)
);
BeaconProxy p = new BeaconProxy(address(b), initData);
Stratax s = Stratax(address(p));
s.transferOwnership(ownerTrader);
// --- Open a small position (we’ll force a big USDC return on the swap) ---
uint256 collateralAmount = 500 * 1e6; // 500 USDC
(uint256 flAmount, uint256 borrowAmount) = s.calculateOpenParams(
Stratax.TradeDetails({
collateralToken: USDC,
borrowToken: WETH,
desiredLeverage: 30_000, // 3x
collateralAmount: collateralAmount,
collateralTokenPrice: 0,
borrowTokenPrice: 0,
collateralTokenDec: 6,
borrowTokenDec: 18
})
);
// Fund user with USDC to open
deal(USDC, ownerTrader, collateralAmount);
// Fund router with plenty of USDC to simulate a very favorable swap on OPEN
uint256 hugeUSDC = flAmount * 2;
deal(USDC, address(mock), hugeUSDC);
// swapData for OPEN: input=WETH (borrowed), output=USDC (collateral), router pays hugeUSDC back
bytes memory openSwapData = abi.encode(WETH, USDC, hugeUSDC);
vm.startPrank(ownerTrader);
IERC20(USDC).approve(address(s), collateralAmount);
s.createLeveragedPosition(
USDC,
flAmount,
collateralAmount,
WETH,
borrowAmount,
openSwapData,
0 // minReturnAmount
);
vm.stopPrank();
// --- Unwind: force a large WETH return so there is a clear surplus ---
(uint256 collateralToWithdraw, uint256 debtAmount) = s.calculateUnwindParams(USDC, WETH);
uint256 hugeWETH = debtAmount + 10 ether; // ensure surplus beyond flash-loan + premium
deal(WETH, address(mock), hugeWETH);
// swapData for UNWIND: input=USDC (withdrawn), output=WETH (debt asset), router pays hugeWETH back
bytes memory unwindSwapData = abi.encode(USDC, WETH, hugeWETH);
// Snapshot balances just before unwind to compute deltas precisely
uint256 ownerUSDCBefore = IERC20(USDC).balanceOf(ownerTrader);
uint256 ownerWETHBefore = IERC20(WETH).balanceOf(ownerTrader);
vm.startPrank(ownerTrader);
s.unwindPosition(
USDC,
collateralToWithdraw,
WETH,
debtAmount,
unwindSwapData,
0 // minReturnAmount
);
vm.stopPrank();
// --- Assertions: user's balances did NOT increase; surplus is re-supplied ---
// 1) Owner wallet did NOT receive any additional tokens during unwind
uint256 ownerUSCDAfter = IERC20(USDC).balanceOf(ownerTrader);
uint256 ownerWETHAfter = IERC20(WETH).balanceOf(ownerTrader);
assertEq(ownerUSCDAfter, ownerUSDCBefore, "USDC should not be sent to user on unwind");
assertEq(ownerWETHAfter, ownerWETHBefore, "WETH should not be sent to user on unwind");
// 2) Contract does not retain loose tokens post-unwind (they were re-supplied)
uint256 contractUSCDAfter = IERC20(USDC).balanceOf(address(s));
uint256 contractWETHAfter = IERC20(WETH).balanceOf(address(s));
assertEq(contractUSCDAfter, 0, "USDC left in contract");
assertEq(contractWETHAfter, 0, "WETH left in contract");
// 3) Aave position still has collateral -> surplus was re-supplied, not paid out
(uint256 totalCollAfter, , , , , uint256 hfAfter) =
IPool(AAVE_POOL).getUserAccountData(address(s));
assertTrue(totalCollAfter > 0, "Collateral should remain due to resupply");
assertTrue(hfAfter > 1e18, "Health factor should be healthy");
}
/// @dev Mock 1inch-like router that (1) pulls ALL approved `inputToken`
/// from msg.sender and (2) returns `outputAmount` of `outputToken`.
/// Calldata ABI: abi.encode(inputToken, outputToken, outputAmount)
contract MockOneInchRouter {
function _pullAllApproved(address token, address from) internal returns (uint256 spent) {
uint256 allowance = IERC20(token).allowance(from, address(this));
uint256 bal = IERC20(token).balanceOf(from);
spent = allowance < bal ? allowance : bal;
if (spent > 0) {
require(IERC20(token).transferFrom(from, address(this), spent), "mock: pull failed");
}
}
fallback() external payable {
(address inputToken, address outputToken, uint256 outputAmount) =
abi.decode(msg.data, (address, address, uint256));
// 1) consume ALL approved input (so no borrow-token dust is left)
uint256 spent = _pullAllApproved(inputToken, msg.sender);
// 2) send requested output to the caller (Stratax)
require(IERC20(outputToken).transfer(msg.sender, outputAmount), "mock: pay out failed");
// 3) return (returnAmount, spentAmount) like DEX aggregators
bytes memory out = abi.encode(outputAmount, spent);
assembly {
return(add(out, 0x20), mload(out))
}
}
}

Output:

[⠊] Compiling...
No files changed, compilation skipped
Ran 1 test for test/fork/Stratax.t.sol:StrataxForkTest
[PASS] test_UnwindResuppliesProfitsInsteadOfTransferringToUser() (gas: 7481825)
Logs:
Available swap data files: 3
Randomly selected block: 24329390
Current fork block number is: 24329390
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.12s (30.17ms CPU time)
Ran 1 test suite in 1.12s (1.12s CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Recommended Mitigation

  • Transfer the unwind surplus to the user (decoded from the callback params) or make it configurable via an explicit option.

function _executeUnwindOperation(address _asset, uint256 _amount, uint256 _premium, bytes calldata _params)
internal
returns (bool)
{
(, address user, UnwindParams memory unwindParams) =
abi.decode(_params, (OperationType, address, UnwindParams));
// ... repay, withdraw, swap ...
uint256 totalDebt = _amount + _premium;
require(returnAmount >= totalDebt, "Insufficient funds to repay flash loan");
- // Supply any leftover tokens back to Aave
- // Note: There might be other positions open, so unwinding one position will increase the health factor
- if (returnAmount - totalDebt > 0) {
- IERC20(_asset).approve(address(aavePool), returnAmount - totalDebt);
- aavePool.supply(_asset, returnAmount - totalDebt, address(this), 0);
- }
+ // Transfer any leftover (profit/equity) to the user who initiated unwind
+ if (returnAmount > totalDebt) {
+ uint256 surplus = returnAmount - totalDebt;
+ IERC20(_asset).transfer(user, surplus);
+ }
IERC20(_asset).approve(address(aavePool), totalDebt);
emit PositionUnwound(user, unwindParams.collateralToken, _asset, _amount, withdrawnAmount);
return true;
}

Support

FAQs

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

Give us feedback!