Skip to main content

WTF Solidity极简入门: 57. 闪电贷

我最近在重新学 Solidity,巩固一下细节,也写一个“WTF Solidity极简入门”,供小白们使用(编程大佬可以另找教程),每周更新 1-3 讲。

推特:@0xAA_Science@WTFAcademy_

社区:Discord微信群官网 wtf.academy

所有代码和教程开源在 github: github.com/AmazingAng/WTF-Solidity


“闪电贷攻击”这个词大家一定听说过,但是什么是闪电贷?如何编写闪电贷合约?这一讲,我们将介绍区块链中的闪电贷,实现基于Uniswap V2,Uniswap V3,和AAVE V3的闪电贷合约,并使用Foundry进行测试。

闪电贷

你第一次听说"闪电贷"一定是在Web3,因为Web2没有这个东西。闪电贷(Flashloan)是DeFi的一种创新,它允许用户在一个交易中借出并迅速归还资金,而无需提供任何抵押。

想象一下,你突然在市场中发现了一个套利机会,但是需要准备100万u的资金才能完成套利。在Web2,你去银行申请贷款,需要审批,很可能错过套利的机会。另外,如果套利失败,你不光要支付利息,还需要归还损失的本金。

而在Web3,你可以在DeFI平台(Uniswap,AAVE,Dodo)中进行闪电贷获取资金,就可以在无担保的情况下借100万u的代币,执行链上套利,最后再归还贷款和利息。

闪电贷利用了以太坊交易的原子性:一个交易(包括其中的所有操作)要么完全执行,要么完全不执行。如果一个用户尝试使用闪电贷并在同一个交易中没有归还资金,那么整个交易都会失败并被回滚,就像它从未发生过一样。因此,DeFi平台不需要担心借款人还不上款,因为还不上的话就意味着钱没借出去;同时,借款人也不用担心套利不成功,因为套利不成功的话就还不上款,也就意味着借钱没成功。

闪电贷实战

下面,我们分别介绍如何在Uniswap V2,Uniswap V3,和AAVE V3的实现闪电贷合约。

1. Uniswap V2闪电贷

Uniswap V2 Pair合约的swap()函数支持闪电贷。与闪电贷业务相关的代码如下:

function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external lock {
// 其他逻辑...

// 乐观的发送代币到to地址
if (amount0Out > 0) _safeTransfer(_token0, to, amount0Out);
if (amount1Out > 0) _safeTransfer(_token1, to, amount1Out);

// 调用to地址的回调函数uniswapV2Call
if (data.length > 0) IUniswapV2Callee(to).uniswapV2Call(msg.sender, amount0Out, amount1Out, data);

// 其他逻辑...

// 通过k=x*y公式,检查闪电贷是否归还成功
require(balance0Adjusted.mul(balance1Adjusted) >= uint(_reserve0).mul(_reserve1).mul(1000**2), 'UniswapV2: K');
}

swap()函数中:

  1. 先将池子中的代币乐观的转移给了to地址。
  2. 如果传入的data长度大于0,就会调用to地址的回调函数uniswapV2Call,执行闪电贷逻辑。
  3. 最后通过k=x*y检查闪电贷是否归还成功,如果不成功,则回滚交易。

下面,我们完成闪电贷合约UniswapV2Flashloan.sol。我们让它继承IUniswapV2Callee,并将闪电贷的核心逻辑写在回调函数uniswapV2Call中。

整体逻辑很简单,在闪电贷函数flashloan()中,我们从Uniswap V2的WETH-DAI池子借WETH。触发闪电贷之后,回调函数uniswapV2Call会被Pair合约调用,我们不进行套利,仅在计算利息后归还闪电贷。Uniswap V2闪电贷的利息为每笔0.3%

注意:回调函数一定要做好权限控制,确保只有Uniswap的Pair合约可以调用,否则的话合约中的资金会被黑客盗光。

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

import "./Lib.sol";

// UniswapV2闪电贷回调接口
interface IUniswapV2Callee {
function uniswapV2Call(address sender, uint amount0, uint amount1, bytes calldata data) external;
}

// UniswapV2闪电贷合约
contract UniswapV2Flashloan is IUniswapV2Callee {
address private constant UNISWAP_V2_FACTORY =
0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f;

address private constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F;
address private constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;

IUniswapV2Factory private constant factory = IUniswapV2Factory(UNISWAP_V2_FACTORY);

IERC20 private constant weth = IERC20(WETH);

IUniswapV2Pair private immutable pair;

constructor() {
pair = IUniswapV2Pair(factory.getPair(DAI, WETH));
}

// 闪电贷函数
function flashloan(uint wethAmount) external {
// calldata长度大于1才能触发闪电贷回调函数
bytes memory data = abi.encode(WETH, wethAmount);

// amount0Out是要借的DAI, amount1Out是要借的WETH
pair.swap(0, wethAmount, address(this), data);
}

// 闪电贷回调函数,只能被 DAI/WETH pair 合约调用
function uniswapV2Call(
address sender,
uint amount0,
uint amount1,
bytes calldata data
) external {
// 确认调用的是 DAI/WETH pair 合约
address token0 = IUniswapV2Pair(msg.sender).token0(); // 获取token0地址
address token1 = IUniswapV2Pair(msg.sender).token1(); // 获取token1地址
assert(msg.sender == factory.getPair(token0, token1)); // ensure that msg.sender is a V2 pair

// 解码calldata
(address tokenBorrow, uint256 wethAmount) = abi.decode(data, (address, uint256));

// flashloan 逻辑,这里省略
require(tokenBorrow == WETH, "token borrow != WETH");

// 计算flashloan费用
// fee / (amount + fee) = 3/1000
// 向上取整
uint fee = (amount1 * 3) / 997 + 1;
uint amountToRepay = amount1 + fee;

// 归还闪电贷
weth.transfer(address(pair), amountToRepay);
}
}

Foundry测试合约UniswapV2Flashloan.t.sol

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

import "forge-std/Test.sol";
import "../src/UniswapV2Flashloan.sol";

address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;

contract UniswapV2FlashloanTest is Test {
IWETH private weth = IWETH(WETH);

UniswapV2Flashloan private flashloan;

function setUp() public {
flashloan = new UniswapV2Flashloan();
}

function testFlashloan() public {
// 换weth,并转入flashloan合约,用做手续费
weth.deposit{value: 1e18}();
weth.transfer(address(flashloan), 1e18);
// 闪电贷借贷金额
uint amountToBorrow = 100 * 1e18;
flashloan.flashloan(amountToBorrow);
}

// 手续费不足,会revert
function testFlashloanFail() public {
// 换weth,并转入flashloan合约,用做手续费
weth.deposit{value: 1e18}();
weth.transfer(address(flashloan), 3e17);
// 闪电贷借贷金额
uint amountToBorrow = 100 * 1e18;
// 手续费不足
vm.expectRevert();
flashloan.flashloan(amountToBorrow);
}
}

在测试合约中,我们分别测试了手续费充足和不足的情况,你可以在安装Foundry后使用下面的命令行进行测试(你可以将RPC换成其他以太坊RPC):

FORK_URL=https://singapore.rpc.blxrbdn.com
forge test --fork-url $FORK_URL --match-path test/UniswapV2Flashloan.t.sol -vv

2. Uniswap V3闪电贷

与Uniswap V2在swap()交换函数中间接支持闪电贷不同,Uniswap V3在Pool池合约中加入了flash()函数直接支持闪电贷,核心代码如下:

function flash(
address recipient,
uint256 amount0,
uint256 amount1,
bytes calldata data
) external override lock noDelegateCall {
// 其他逻辑...

// 乐观的发送代币到to地址
if (amount0 > 0) TransferHelper.safeTransfer(token0, recipient, amount0);
if (amount1 > 0) TransferHelper.safeTransfer(token1, recipient, amount1);

// 调用to地址的回调函数uniswapV3FlashCallback
IUniswapV3FlashCallback(msg.sender).uniswapV3FlashCallback(fee0, fee1, data);

// 检查闪电贷是否归还成功
uint256 balance0After = balance0();
uint256 balance1After = balance1();
require(balance0Before.add(fee0) <= balance0After, 'F0');
require(balance1Before.add(fee1) <= balance1After, 'F1');

// sub is safe because we know balanceAfter is gt balanceBefore by at least fee
uint256 paid0 = balance0After - balance0Before;
uint256 paid1 = balance1After - balance1Before;

// 其他逻辑...
}

下面,我们完成闪电贷合约UniswapV3Flashloan.sol。我们让它继承IUniswapV3FlashCallback,并将闪电贷的核心逻辑写在回调函数uniswapV3FlashCallback中。

整体逻辑与V2的类似,在闪电贷函数flashloan()中,我们从Uniswap V3的WETH-DAI池子借WETH。触发闪电贷之后,回调函数uniswapV3FlashCallback会被Pool合约调用,我们不进行套利,仅在计算利息后归还闪电贷。Uniswap V3每笔闪电贷的手续费与交易手续费一致。

注意:回调函数一定要做好权限控制,确保只有Uniswap的Pair合约可以调用,否则的话合约中的资金会被黑客盗光。

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

import "./Lib.sol";

// UniswapV3闪电贷回调接口
// 需要实现并重写uniswapV3FlashCallback()函数
interface IUniswapV3FlashCallback {
/// 在实现中,你必须偿还池中由 flash 发送的代币及计算出的费用金额。
/// 调用此方法的合约必须经由官方 UniswapV3Factory 部署的 UniswapV3Pool 检查。
/// @param fee0 闪电贷结束时,应支付给池的 token0 的费用金额
/// @param fee1 闪电贷结束时,应支付给池的 token1 的费用金额
/// @param data 通过 IUniswapV3PoolActions#flash 调用由调用者传递的任何数据
function uniswapV3FlashCallback(
uint256 fee0,
uint256 fee1,
bytes calldata data
) external;
}

// UniswapV3闪电贷合约
contract UniswapV3Flashloan is IUniswapV3FlashCallback {
address private constant UNISWAP_V3_FACTORY = 0x1F98431c8aD98523631AE4a59f267346ea31F984;

address private constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F;
address private constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
uint24 private constant poolFee = 3000;

IERC20 private constant weth = IERC20(WETH);
IUniswapV3Pool private immutable pool;

constructor() {
pool = IUniswapV3Pool(getPool(DAI, WETH, poolFee));
}

function getPool(
address _token0,
address _token1,
uint24 _fee
) public pure returns (address) {
PoolAddress.PoolKey memory poolKey = PoolAddress.getPoolKey(
_token0,
_token1,
_fee
);
return PoolAddress.computeAddress(UNISWAP_V3_FACTORY, poolKey);
}

// 闪电贷函数
function flashloan(uint wethAmount) external {
bytes memory data = abi.encode(WETH, wethAmount);
IUniswapV3Pool(pool).flash(address(this), 0, wethAmount, data);
}

// 闪电贷回调函数,只能被 DAI/WETH pair 合约调用
function uniswapV3FlashCallback(
uint fee0,
uint fee1,
bytes calldata data
) external {
// 确认调用的是 DAI/WETH pair 合约
require(msg.sender == address(pool), "not authorized");

// 解码calldata
(address tokenBorrow, uint256 wethAmount) = abi.decode(data, (address, uint256));

// flashloan 逻辑,这里省略
require(tokenBorrow == WETH, "token borrow != WETH");

// 归还闪电贷
weth.transfer(address(pool), wethAmount + fee1);
}
}

Foundry测试合约UniswapV3Flashloan.t.sol

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

import {Test, console2} from "forge-std/Test.sol";
import "../src/UniswapV3Flashloan.sol";

address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;

contract UniswapV2FlashloanTest is Test {
IWETH private weth = IWETH(WETH);

UniswapV3Flashloan private flashloan;

function setUp() public {
flashloan = new UniswapV3Flashloan();
}

function testFlashloan() public {
// 换weth,并转入flashloan合约,用做手续费
weth.deposit{value: 1e18}();
weth.transfer(address(flashloan), 1e18);

uint balBefore = weth.balanceOf(address(flashloan));
console2.logUint(balBefore);
// 闪电贷借贷金额
uint amountToBorrow = 1 * 1e18;
flashloan.flashloan(amountToBorrow);
}

// 手续费不足,会revert
function testFlashloanFail() public {
// 换weth,并转入flashloan合约,用做手续费
weth.deposit{value: 1e18}();
weth.transfer(address(flashloan), 1e17);
// 闪电贷借贷金额
uint amountToBorrow = 100 * 1e18;
// 手续费不足
vm.expectRevert();
flashloan.flashloan(amountToBorrow);
}
}

在测试合约中,我们分别测试了手续费充足和不足的情况,你可以在安装Foundry后使用下面的命令行进行测试(你可以将RPC换成其他以太坊RPC):

FORK_URL=https://singapore.rpc.blxrbdn.com
forge test --fork-url $FORK_URL --match-path test/UniswapV3Flashloan.t.sol -vv

3. AAVE V3闪电贷

AAVE是去中心的借贷平台,它的Pool合约通过flashLoan()flashLoanSimple()两个函数支持单资产和多资产的闪电贷。这里,我们仅利用flashLoan()实现单个资产(WETH)的闪电贷。

下面,我们完成闪电贷合约AaveV3Flashloan.sol。我们让它继承IFlashLoanSimpleReceiver,并将闪电贷的核心逻辑写在回调函数executeOperation中。

整体逻辑与V2的类似,在闪电贷函数flashloan()中,我们从AAVE V3的WETH池子借WETH。触发闪电贷之后,回调函数executeOperation会被Pool合约调用,我们不进行套利,仅在计算利息后归还闪电贷。AAVE V3闪电贷的手续费默认为每笔0.05%,比Uniswap的要低。

注意:回调函数一定要做好权限控制,确保只有AAVE的Pool合约可以调用,并且发起者是本合约,否则的话合约中的资金会被黑客盗光。

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

import "./Lib.sol";

interface IFlashLoanSimpleReceiver {
/**
* @notice 在接收闪电借款资产后执行操作
* @dev 确保合约能够归还债务 + 额外费用,例如,具有
* 足够的资金来偿还,并已批准 Pool 提取总金额
* @param asset 闪电借款资产的地址
* @param amount 闪电借款资产的数量
* @param premium 闪电借款资产的费用
* @param initiator 发起闪电贷款的地址
* @param params 初始化闪电贷款时传递的字节编码参数
* @return 如果操作的执行成功则返回 True,否则返回 False
*/
function executeOperation(
address asset,
uint256 amount,
uint256 premium,
address initiator,
bytes calldata params
) external returns (bool);
}

// AAVE V3闪电贷合约
contract AaveV3Flashloan {
address private constant AAVE_V3_POOL =
0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2;

address private constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;

ILendingPool public aave;

constructor() {
aave = ILendingPool(AAVE_V3_POOL);
}

// 闪电贷函数
function flashloan(uint256 wethAmount) external {
aave.flashLoanSimple(address(this), WETH, wethAmount, "", 0);
}

// 闪电贷回调函数,只能被 pool 合约调用
function executeOperation(address asset, uint256 amount, uint256 premium, address initiator, bytes calldata)
external
returns (bool)
{
// 确认调用的是 DAI/WETH pair 合约
require(msg.sender == AAVE_V3_POOL, "not authorized");
// 确认闪电贷发起者是本合约
require(initiator == address(this), "invalid initiator");

// flashloan 逻辑,这里省略

// 计算flashloan费用
// fee = 5/1000 * amount
uint fee = (amount * 5) / 10000 + 1;
uint amountToRepay = amount + fee;

// 归还闪电贷
IERC20(WETH).approve(AAVE_V3_POOL, amountToRepay);

return true;
}
}

Foundry测试合约AaveV3Flashloan.t.sol

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

import "forge-std/Test.sol";
import "../src/AaveV3Flashloan.sol";

address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;

contract UniswapV2FlashloanTest is Test {
IWETH private weth = IWETH(WETH);

AaveV3Flashloan private flashloan;

function setUp() public {
flashloan = new AaveV3Flashloan();
}

function testFlashloan() public {
// 换weth,并转入flashloan合约,用做手续费
weth.deposit{value: 1e18}();
weth.transfer(address(flashloan), 1e18);
// 闪电贷借贷金额
uint amountToBorrow = 100 * 1e18;
flashloan.flashloan(amountToBorrow);
}

// 手续费不足,会revert
function testFlashloanFail() public {
// 换weth,并转入flashloan合约,用做手续费
weth.deposit{value: 1e18}();
weth.transfer(address(flashloan), 4e16);
// 闪电贷借贷金额
uint amountToBorrow = 100 * 1e18;
// 手续费不足
vm.expectRevert();
flashloan.flashloan(amountToBorrow);
}
}

在测试合约中,我们分别测试了手续费充足和不足的情况,你可以在安装Foundry后使用下面的命令行进行测试(你可以将RPC换成其他以太坊RPC):

FORK_URL=https://singapore.rpc.blxrbdn.com
forge test --fork-url $FORK_URL --match-path test/AaveV3Flashloan.t.sol -vv

总结

这一讲,我们介绍了闪电贷,它允许用户在一个交易中借出并迅速归还资金,而无需提供任何抵押。并且,我们分别实现了Uniswap V2,Uniswap V3,和AAVE的闪电贷合约。

通过闪电贷,我们能够无抵押的撬动海量资金进行无风险套利或漏洞攻击。你准备用闪电贷做些什么呢?