китаев.tech

Flash loan через Aave V3

Как взять flash loan в Aave V3 через flashLoanSimple, обработать executeOperation и вернуть amount + premium в одной транзакции.

Flash loan живёт одну транзакцию

Flash loan — это заём без collateral, который нужно вернуть до конца той же транзакции.

Aave V3 сначала переводит asset в receiver-контракт, затем вызывает executeOperation. Если receiver не вернёт amount + premium, вся транзакция откатится.


Берём WETH flash loan

Пример запускается на локальном fork Ethereum mainnet. Он использует настоящий Aave V3 Pool и WETH, но не отправляет транзакции в реальную сеть.

Создайте проект:

bash
mkdir aave-v3-flash-loan-example
cd aave-v3-flash-loan-example

npm init -y
npm pkg set type="module"
npm install ethers tsx solc
npm install --save-dev hardhat typescript @types/node
mkdir contracts src

Создайте файл hardhat.config.js:

javascript
export default {
  solidity: "0.8.20",
};

В первом терминале запустите mainnet fork:

bash
npx hardhat node --fork https://ethereum-rpc.publicnode.com

Создайте файл contracts/AaveV3FlashLoanReceiver.sol:

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

interface IERC20 {
    function approve(address spender, uint256 amount) external returns (bool);
    function transfer(address to, uint256 amount) external returns (bool);
    function balanceOf(address account) external view returns (uint256);
}

interface IPool {
    function flashLoanSimple(
        address receiverAddress,
        address asset,
        uint256 amount,
        bytes calldata params,
        uint16 referralCode
    ) external;
}

contract AaveV3FlashLoanReceiver {
    IPool public immutable POOL;
    address public immutable owner;

    event FlashLoanExecuted(
        address indexed asset,
        uint256 amount,
        uint256 premium,
        uint256 balanceAfter
    );

    error OnlyOwner();
    error OnlyPool();
    error InvalidInitiator();
    error NotEnoughToRepay();

    constructor(address pool) {
        POOL = IPool(pool);
        owner = msg.sender;
    }

    modifier onlyOwner() {
        if (msg.sender != owner) {
            revert OnlyOwner();
        }
        _;
    }

    function requestFlashLoan(address asset, uint256 amount) external onlyOwner {
        POOL.flashLoanSimple(address(this), asset, amount, bytes(""), 0);
    }

    function executeOperation(
        address asset,
        uint256 amount,
        uint256 premium,
        address initiator,
        bytes calldata
    ) external returns (bool) {
        if (msg.sender != address(POOL)) {
            revert OnlyPool();
        }

        if (initiator != address(this)) {
            revert InvalidInitiator();
        }

        uint256 amountOwed = amount + premium;
        uint256 balance = IERC20(asset).balanceOf(address(this));

        if (balance < amountOwed) {
            revert NotEnoughToRepay();
        }

        IERC20(asset).approve(address(POOL), amountOwed);

        emit FlashLoanExecuted(asset, amount, premium, balance - amountOwed);

        return true;
    }

    function withdrawToken(address asset) external onlyOwner {
        IERC20 token = IERC20(asset);
        token.transfer(owner, token.balanceOf(address(this)));
    }
}

Создайте файл src/run-flash-loan.ts:

typescript
import { readFileSync } from "node:fs";
import { ethers } from "ethers";
import solc from "solc";

const LOCAL_FORK_RPC_URL = "http://127.0.0.1:8545";

const HARDHAT_ACCOUNT_PRIVATE_KEY =
  "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";

const AAVE_V3_POOL_ADDRESS = "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2";
const WETH_ADDRESS = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
const WETH_DECIMALS = 18;

const WETH_ABI = [
  "function deposit() external payable",
  "function transfer(address to,uint256 amount) external returns (bool)",
  "function balanceOf(address account) external view returns (uint256)",
];

function compileReceiver() {
  const source = readFileSync("contracts/AaveV3FlashLoanReceiver.sol", "utf8");

  const input = {
    language: "Solidity",
    sources: {
      "AaveV3FlashLoanReceiver.sol": {
        content: source,
      },
    },
    settings: {
      outputSelection: {
        "*": {
          "*": ["abi", "evm.bytecode.object"],
        },
      },
    },
  };

  const output = JSON.parse(solc.compile(JSON.stringify(input)));

  for (const error of output.errors ?? []) {
    if (error.severity === "error") {
      throw new Error(error.formattedMessage);
    }
  }

  const contract =
    output.contracts["AaveV3FlashLoanReceiver.sol"].AaveV3FlashLoanReceiver;

  return {
    abi: contract.abi,
    bytecode: `0x${contract.evm.bytecode.object}`,
  };
}

const provider = new ethers.JsonRpcProvider(LOCAL_FORK_RPC_URL);
const wallet = new ethers.Wallet(HARDHAT_ACCOUNT_PRIVATE_KEY, provider);
const weth = new ethers.Contract(WETH_ADDRESS, WETH_ABI, wallet);

const { abi, bytecode } = compileReceiver();
const factory = new ethers.ContractFactory(abi, bytecode, wallet);

const receiver = await factory.deploy(AAVE_V3_POOL_ADDRESS);
await receiver.waitForDeployment();

const receiverAddress = await receiver.getAddress();
console.log(`Receiver: ${receiverAddress}`);

// Receiver должен иметь немного WETH заранее, чтобы оплатить flash loan premium.
const premiumBuffer = ethers.parseUnits("0.01", WETH_DECIMALS);

await (await weth.deposit({ value: premiumBuffer })).wait();
await (await weth.transfer(receiverAddress, premiumBuffer)).wait();

const balanceBefore = await weth.balanceOf(receiverAddress);
console.log(
  `Receiver WETH before: ${ethers.formatUnits(balanceBefore, WETH_DECIMALS)}`,
);

const loanAmount = ethers.parseUnits("10", WETH_DECIMALS);
const tx = await receiver.requestFlashLoan(WETH_ADDRESS, loanAmount);
const receipt = await tx.wait();

const balanceAfter = await weth.balanceOf(receiverAddress);

console.log(`Flash loan tx: ${receipt?.hash}`);
console.log(`Borrowed: ${ethers.formatUnits(loanAmount, WETH_DECIMALS)} WETH`);
console.log(
  `Receiver WETH after: ${ethers.formatUnits(balanceAfter, WETH_DECIMALS)}`,
);

Во втором терминале запустите flash loan:

bash
npx tsx src/run-flash-loan.ts

Скрипт выведет примерно такой результат:

text
Receiver: 0x5FbDB2315678afecb367f032d93F642f64180aa3
Receiver WETH before: 0.01
Flash loan tx: 0x...
Borrowed: 10.0 WETH
Receiver WETH after: 0.005

Оставшийся WETH меньше начального buffer, потому что Aave забрал premium.

Как это работает

flashLoanSimple() — версия flash loan для одного asset. Она дешевле и проще, чем multi-asset flashLoan.

receiverAddress — контракт, который получит WETH и обязан реализовать executeOperation.

executeOperation() — callback, который Aave Pool вызывает после перевода flash loan суммы.

amount — сумма займа. В примере это 10 WETH.

premium — комиссия Aave за flash loan. Её тоже нужно вернуть в той же транзакции.

IERC20(asset).approve(address(POOL), amountOwed) — разрешает Pool забрать amount + premium обратно.

premiumBuffer — заранее отправленный WETH на receiver. В реальной стратегии premium должен покрываться прибылью от arbitrage, liquidation или другого действия внутри executeOperation.

Где писать бизнес-логику

Flash loan полезен только если внутри executeOperation есть действие, которое приносит больше, чем premium и gas.

Типичные сценарии:

  • arbitrage между DEX
  • liquidation чужой unhealthy-позиции
  • collateral swap
  • debt refinancing

В примере бизнес-логики нет. Контракт просто берёт WETH и сразу разрешает Aave забрать долг обратно. Это минимальный skeleton, на который можно безопасно навесить стратегию.

Частые ошибки

Не иметь денег на premium → даже если amount возвращается полностью, без premium транзакция откатится.

Забыть approve в executeOperation → Pool не сможет забрать repayment, и flash loan откатится.

Доверять внешнему caller → проверяйте msg.sender == address(POOL) и initiator == address(this).

Делать стратегию без slippage checks → arbitrage или swap внутри flash loan должен контролировать минимальный результат, иначе MEV и price impact съедят прибыль.


Что дальше

  • Borrow из Aave V3 — сравнить обычный borrow с flash loan
  • Deposit в Aave V3 — понять, как Aave учитывает liquidity и aTokens
  • Reentrancy — проверить риски callback-логики в контрактах

Материалы китаev.tech публикуются в образовательных целях и не являются инвестиционной рекомендацией. Примеры кода и описания протоколов — для обучения; использование в продакшне на ваш собственный риск. Дисклеймер и политика конфиденциальности.