Aave Flash Loan Deleveraging on a Fork

If I need to sell Aave collateral to repay debt, the order of operations matters. A flash loan can supply the repayment asset first, so the transaction can repay debt, withdraw collateral, sell it, and settle the temporary loan.

Below is a receiver design and the fork checks it still needs. It is not a completed deployment or a measured rescue result. I want to make the permissions and repayment path explicit before testing it.

The transaction has to do things in this order

Aave sends the borrowed asset to a receiver contract, calls the receiver’s executeOperation, then pulls back the amount plus its premium before the outer transaction completes. The receiver therefore needs to have enough of the debt asset at the end of its callback and must approve the Pool to pull it. That callback-and-approval requirement is in Aave’s receiver interface and Pool implementation. Aave V3 simple flash-loan receiver interface Aave V3 Pool source

For a variable-rate USDC debt backed by WETH, the atomic path is:

sequenceDiagram
    participant O as Position owner
    participant E as Executor
    participant P as Aave V3 Pool
    participant R as Swap router

    O->>E: Approve aWETH and request bounded unwind
    E->>P: flashLoanSimple(USDC, debtToRepay)
    P->>E: Transfer temporary USDC
    P->>E: executeOperation()
    E->>P: Repay owner's USDC variable debt
    E->>E: Pull exact approved aWETH from owner
    E->>P: Withdraw WETH from the transferred aWETH
    E->>R: Swap WETH to USDC with amountOutMinimum
    E->>P: Approve USDC principal + premium
    P->>E: Pull USDC principal + premium

The important detail is the aToken. A wallet’s Aave collateral is an aToken, not WETH sitting in the wallet. The owner must transfer a bounded amount of aWETH to the executor; the executor can then call withdraw for its own aTokens. Calling withdraw directly against the owner’s position would not work, because the Pool acts on msg.sender’s aToken balance. Aave documents the Pool as the entry point for repay and withdraw; use the official address book for the Pool and reserve addresses on the specific market rather than copying them from this article. Aave Pool reference Aave deployed-contract addresses

A deliberately constrained receiver

This is the core of the receiver I would put behind a local fork harness. It leaves the router adapter abstract because router calldata, fee tiers, and token support are market-specific. That boundary is intentional: the security decision is the minimum acceptable USDC output, not a convenient generic swap function.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {IPool} from "@aave/core-v3/contracts/interfaces/IPool.sol";
import {IFlashLoanSimpleReceiver} from "@aave/core-v3/contracts/flashloan/interfaces/IFlashLoanSimpleReceiver.sol";
import {IPoolAddressesProvider} from "@aave/core-v3/contracts/interfaces/IPoolAddressesProvider.sol";

interface ISwapAdapter {
    // Sends tokenIn from msg.sender and returns the amount of debtAsset received.
    function sell(
        address tokenIn,
        address debtAsset,
        uint256 amountIn,
        uint256 minimumDebtOut
    ) external returns (uint256 amountOut);
}

contract BoundedDeleverage is IFlashLoanSimpleReceiver {
    using SafeERC20 for IERC20;

    IPoolAddressesProvider public immutable ADDRESSES_PROVIDER;
    IPool public immutable POOL;
    address public immutable owner;
    address public immutable collateralAsset; // WETH, not aWETH
    address public immutable debtAsset;       // USDC in this example
    address public immutable aCollateral;
    ISwapAdapter public immutable adapter;

    struct Plan {
        uint256 debtToRepay;
        uint256 aTokenToSell;
        uint256 minimumDebtOut;
    }

    constructor(
        IPoolAddressesProvider provider,
        address positionOwner,
        address collateral,
        address debt,
        address aToken,
        ISwapAdapter swapAdapter
    ) {
        ADDRESSES_PROVIDER = provider;
        POOL = IPool(provider.getPool());
        owner = positionOwner;
        collateralAsset = collateral;
        debtAsset = debt;
        aCollateral = aToken;
        adapter = swapAdapter;
    }

    function start(Plan calldata plan) external {
        require(msg.sender == owner, "only position owner");
        require(plan.debtToRepay != 0 && plan.aTokenToSell != 0, "empty plan");
        // The owner has separately approved exactly this aToken allowance.
        POOL.flashLoanSimple(address(this), debtAsset, plan.debtToRepay, abi.encode(plan), 0);
    }

    function executeOperation(
        address asset,
        uint256 amount,
        uint256 premium,
        address initiator,
        bytes calldata params
    ) external override returns (bool) {
        require(msg.sender == address(POOL), "only pool");
        require(initiator == address(this) && asset == debtAsset, "unexpected loan");
        Plan memory plan = abi.decode(params, (Plan));
        require(amount == plan.debtToRepay, "unexpected amount");

        // 2 is Aave's variable-rate mode. This reduces the owner's debt first.
        IERC20(debtAsset).forceApprove(address(POOL), amount);
        POOL.repay(debtAsset, amount, 2, owner);

        // Transfer aTokens, then withdraw underlying owned by this executor.
        IERC20(aCollateral).safeTransferFrom(owner, address(this), plan.aTokenToSell);
        uint256 withdrawn = POOL.withdraw(collateralAsset, plan.aTokenToSell, address(this));
        IERC20(collateralAsset).forceApprove(address(adapter), withdrawn);
        uint256 received = adapter.sell(
            collateralAsset, debtAsset, withdrawn, plan.minimumDebtOut
        );

        uint256 owed = amount + premium;
        require(received >= owed, "swap cannot settle flash loan");
        IERC20(debtAsset).forceApprove(address(POOL), owed);
        return true;
    }
}

minimumDebtOut needs to cover the flash principal plus premium, not merely the quoted swap output. The premium is not a constant application setting: the Pool exposes FLASHLOAN_PREMIUM_TOTAL, and the Pool configurator can update it. Read it on the fork immediately before constructing the plan. Aave IPool interface

The receiver intentionally has no rescue, arbitrary-call, or owner-change function. A production system may need controlled recovery for dust, but adding it changes the approval and custody review substantially. The unused remainder of USDC and WETH should be handled explicitly in a separately reviewed version, not silently accumulated in this minimal example.

What the fork test must prove before this is a pass

Pin the fork block, impersonate only a test position, and treat a reverted transaction as a useful result. Forking preserves the deployed code, Aave reserve configuration, token behavior, and balances from that block; it does not recreate real block competition or execution latency. Foundry documents both fork configuration and account impersonation cheatcodes. Foundry fork testing Foundry caller cheatcodes

The harness should have these assertions:

CaseExpected resultWhy it matters
Valid bounded planOwner’s variable debt falls by debtToRepay; Pool can pull principal plus premiumProves the repayment and settlement order.
Actual swap output < principal + premiumRevert before completionThe receiver must have enough to settle; a low minimum alone does not force a revert.
No aWETH allowanceRevertThe contract cannot seize collateral without the owner’s prior approval.
aTokenToSell too largeRevert during the aToken transfer safety check or at the swap boundA post-repay position can still be unsafe if too much collateral leaves.
Wrong caller or callback assetRevertPrevents an outside caller or a different flash-loan asset from reusing the callback.

For a repeatable run, pin a block and record it beside the test result. The transaction receipt is the only honest gas number for that exact fork state:

forge test --fork-url "$ETH_RPC_URL" --fork-block-number 23000000 -vvv

I would not publish a single gas estimate as a budget. It changes with the swap route, Aave reserve state, compiler settings, and the fork block. Before a live transaction, estimate on a fresh fork, multiply by a conservative gas-price ceiling, and require the unwind’s expected economic benefit to exceed that dollar cost by a wide margin. At sub-$1,000 size, manually repaying with already-held USDC is often cheaper and simpler.

The non-obvious risks

Technical risk: a valid callback can still be the wrong action

The receiver’s callback is a privileged point in the transaction. It must authenticate both the Pool caller and the initiator, bind the asset and amount to the requested plan, and avoid user-controlled arbitrary calldata. A missing check can turn an automation contract into an approval-draining or fund-routing primitive. Use a maintained Aave package and pin its version; the older aave-v3-core repository is archived and points developers to its successor. Aave V3 core repository notice

Economic risk: the price may move between planning and inclusion

The minimum output protects the transaction by reverting, but it does not guarantee execution. A route that looked adequate in a local fork can fail after a price move, liquidity change, or premium update. A revert is preferable to selling collateral at an uncontrolled price, but it may arrive while the health factor continues to fall. Leave a large buffer; do not build this around the last fraction before liquidation.

Approval risk: aToken approval is collateral custody authority

An approval to transfer aTokens is more sensitive than it first appears. It authorizes a contract to pull the position’s claim on collateral. I would grant only the exact planned amount, execute promptly, and revoke the approval after the fork design becomes a live deployment. Better still, keep the first live version manual and retain the automation as a simulation tool.

Operational risk: the rescue path can be unavailable

Gas spikes, a sequencer outage, a paused reserve, a broken RPC, or a router failure can all prevent the transaction from landing. A flash-loan deleverage has several dependencies that a plain repay does not. Keep enough of the debt asset on the same chain for a manual repay, and know the direct Aave UI and contract path before relying on an executor.

When the atomic route justifies its complexity

Before implementing the adapter, I would review these boundaries: exact debt amount, exact aToken amount, explicit minimum output, authenticated callback, and a fork test that expects unsafe cases to revert.

For a small position, I would first consider the simpler route: hold enough debt asset to repay manually and use the watcher to act early. A flash-loan route earns its complexity only when moving collateral and sourcing the repayment asset atomically solves a real constraint. Test that route on a pinned fork first, then start with a cap that is comfortably below $1,000.