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, но не отправляет транзакции в реальную сеть.
Создайте проект:
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:
export default {
solidity: "0.8.20",
};В первом терминале запустите mainnet fork:
npx hardhat node --fork https://ethereum-rpc.publicnode.comСоздайте файл contracts/AaveV3FlashLoanReceiver.sol:
// 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:
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:
npx tsx src/run-flash-loan.tsСкрипт выведет примерно такой результат:
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-логики в контрактах