Stratax Contracts

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

'no dust' post‑swap check may brick positions

Author Revealed upon completion

Description

  • DEX aggregators (like 1inch) don’t guarantee they will consume all of the approved input tokens. Due to routing choices, rounding, and min‑amount constraints, they may leave a tiny remainder (“dust”) of the borrow token in the caller. Robust flash‑loan callbacks tolerate tiny dust (or clean it up), instead of requiring exact zero.

  • In Stratax._executeOpenOperation, after swapping the borrowed token back to the flash‑loan asset via 1inch, the code enforces exactly zero leftover borrow tokens:

// Stratax.sol :: _executeOpenOperation
// ...
IERC20(flashParams.borrowToken).approve(address(oneInchRouter), flashParams.borrowAmount);
uint256 returnAmount = _call1InchSwap(...);
// @> BUG: strict equality—any 1 wei dust causes a revert
uint256 afterSwapBorrowTokenbalance = IERC20(flashParams.borrowToken).balanceOf(address(this));
require(afterSwapBorrowTokenbalance == prevBorrowTokenBalance, "Borrow token left in contract");

Risk

Likelihood: Medium

  • Aggregators frequently don’t spend full allowance, leaving minimal dust by design (router safety/rounding), especially across multi‑hop paths or partial‑fill scenarios.

  • Tokens with quirky transfer/rounding rules will produce small mismatches occasionally. Both conditions occur in normal operations.

Impact: High

  • Operational DoS on open: The flash‑loan callback reverts at the “no‑dust” check, bricking position creation even though the swap result is otherwise sufficient to repay the flash loan.

  • Poor UX / fragility: Small routing differences between blocks (or chains) unpredictably cause opens to fail, forcing operators to retry and absorb extra gas.

Proof of Concept

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

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

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

function test_NoDustCheckBricksOpenWhen1WeiLeftover() public {
// — Clean baseline —
deal(USDC, ownerTrader, 0);
deal(WETH, ownerTrader, 0);
// — Deploy Stratax instance with the dust‑leaving mock router —
MockOneInchRouterLeavesDust mock = new MockOneInchRouterLeavesDust();
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), // <-- mock router that leaves 1 wei dust
USDC,
address(strataxOracle)
);
BeaconProxy p = new BeaconProxy(address(b), initData);
Stratax s = Stratax(address(p));
s.transferOwnership(ownerTrader);
// — Compute “safe” open params (any valid borrow works for this PoC) —
uint256 collateralAmount = 1_000 * 1e6; // 1,000 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 & router —
deal(USDC, ownerTrader, collateralAmount);
uint256 premium = (flAmount * s.flashLoanFeeBps()) / s.FLASHLOAN_FEE_PREC();
uint256 minReturn = flAmount + premium; // what we need back to repay flash loan
deal(USDC, address(mock), minReturn); // router will pay this back to Stratax
// — Prepare swap: input = WETH (borrowed), output = USDC (flash-loan asset) —
bytes memory openSwapData = abi.encode(WETH, USDC, minReturn);
// — Expect revert precisely at the “no-dust” check —
vm.startPrank(ownerTrader);
IERC20(USDC).approve(address(s), collateralAmount);
vm.expectRevert(bytes("Borrow token left in contract"));
s.createLeveragedPosition(
USDC,
flAmount,
collateralAmount,
WETH,
borrowAmount,
openSwapData,
minReturn
);
vm.stopPrank();
}
/// @dev Mock 1inch-like router that (a) pulls allowance-1 of `inputToken`
/// (leaving 1 wei dust in Stratax) and (b) returns `outputAmount` of `outputToken`.
/// Calldata ABI: abi.encode(inputToken, outputToken, outputAmount)
contract MockOneInchRouterLeavesDust {
function _pullAllowanceMinusOne(address token, address from) internal returns (uint256 spent) {
uint256 allowance = IERC20(token).allowance(from, address(this));
uint256 bal = IERC20(token).balanceOf(from);
// pull min(allowance, balance) - 1 (leave exactly 1 wei if possible)
uint256 canSpend = allowance < bal ? allowance : bal;
if (canSpend > 0) {
spent = canSpend - 1;
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));
uint256 spent = _pullAllowanceMinusOne(inputToken, msg.sender);
// pay the requested output to Stratax so it can repay the flash loan
require(IERC20(outputToken).transfer(msg.sender, outputAmount), "mock: pay out failed");
// Return (returnAmount, spentAmount) like many 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_NoDustCheckBricksOpenWhen1WeiLeftover() (gas: 6907031)
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.26s (26.04ms CPU time)
Ran 1 test suite in 1.26s (1.26s CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Recommended Mitigation

Relax the check to tolerate tiny dust (or sweep it), e.g.:

  • Replace strict equality with a tolerance (configurable or a small constant).

  • Optionally sweep small borrow‑token dust by swapping it in a subsequent short path (only if dust ≥ a minimal economic threshold) or re‑supplying it to Aave.

- uint256 afterSwapBorrowTokenbalance = IERC20(flashParams.borrowToken).balanceOf(address(this));
- require(afterSwapBorrowTokenbalance == prevBorrowTokenBalance, "Borrow token left in contract");
+ uint256 afterSwapBorrowTokenbalance = IERC20(flashParams.borrowToken).balanceOf(address(this));
+ // Allow tiny dust to remain (e.g., 1 wei). Consider making this configurable.
+ uint256 DUST_TOLERANCE = 1;
+ require(
+ afterSwapBorrowTokenbalance <= prevBorrowTokenBalance + DUST_TOLERANCE,
+ "Borrow token dust exceeds tolerance"
+ );

Support

FAQs

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

Give us feedback!