闪电贷与 ERC-3156 完全指南
目录
- 什么是闪电贷
- 闪电贷的核心原理:原子性
- 只有合约能用闪电贷,EOA 不行
- 闪电贷的主要使用场景
- ERC-3156 标准规范
- ERC-3156 完整实现示例
- 关键细节:谁来转账还款?
- 安全漏洞深度分析
- 防御措施清单
- 总结
什么是闪电贷
闪电贷(Flash Loan)是一种无需抵押的借贷方式,只不过它有一个铁律:必须在同一笔交易内还清。
换句话说:
- 你可以在一笔交易里借出几百万美元的代币
- 用这笔钱做任何操作(套利、清算、换仓……)
- 在这笔交易结束前,把本金 + 手续费还回去
- 如果还不上,整笔交易自动回滚,就像什么都没发生过
闪电贷的核心原理:原子性
以太坊交易具有原子性(Atomicity):一笔交易要么全部成功,要么全部失败回滚,不存在”部分完成”的状态。
闪电贷正是利用了这一点:
交易开始
↓
贷方:把 10,000 USDC 转给借方
↓
借方:用 10,000 USDC 做套利操作,赚了 200 USDC
↓
借方:把 10,100 USDC(本金 + 手续费)授权给贷方
↓
贷方:require(借方余额 >= 本金 + 手续费) ← 检查是否还款
↓
✅ 还款成功 → 交易提交
❌ 还款失败 → require 触发 revert,整笔交易回滚
交易结束
数值示例:
假设借方合约借了 10,000 USDC,手续费是 0.09%(即 9 USDC):
- 交易开始时贷方池子里有 500,000 USDC
- 借方收到 10,000 USDC
- 借方做完操作后必须授权贷方取走 10,009 USDC
- 如果借方最终只有 10,005 USDC,差了 4 USDC,
require失败,整笔交易回滚 - 贷方池子回到 500,000 USDC,好像什么都没发生
只有合约能用闪电贷,EOA 不行
普通的外部账户(EOA,即普通的钱包地址)无法使用闪电贷。
原因很简单:闪电贷需要在同一笔交易内完成”借款 → 操作 → 还款”三步。而 EOA 发出一笔交易只能调用一个合约函数,没有办法在中途”接收回调 → 执行逻辑 → 还款”。
只有智能合约才能实现这种逻辑,因为合约可以:
- 调用贷方的
flashLoan()函数 - 贷方在转账后,回调借方合约的
onFlashLoan()函数 - 借方合约在回调中执行套利等逻辑,然后批准贷方取走还款金额
闪电贷的主要使用场景
1. 套利(Arbitrage)
同一个代币在两个去中心化交易所价格不同:
- DEX A:1 ETH = 2,000 USDC
- DEX B:1 ETH = 2,050 USDC
操作:借 2,000 USDC → 在 DEX A 买 1 ETH → 在 DEX B 卖 1 ETH 得 2,050 USDC → 还 2,000 USDC + 手续费,净赚约 40 USDC。
2. 无资金清算(Liquidation)
当某个借贷协议里的头寸达到清算线时,清算人需要先偿还债务才能拿到抵押品。使用闪电贷,清算人可以:
借 USDC → 还清目标用户的债务 → 获得折扣抵押品 → 卖掉抵押品还闪电贷 → 净赚价差
3. 抵押品置换(Collateral Swap)
在借贷协议中把抵押品从 ETH 换成 WBTC,无需先凑齐本金:
借 USDC → 还清以 ETH 为抵押的贷款 → 取出 ETH → 卖 ETH 买 WBTC → 以 WBTC 重新抵押借款 → 还闪电贷
4. 再融资(Refinancing)
把利率高的借贷头寸迁移到利率低的协议:
从新协议借 → 还旧协议的债 → 在新协议重新开头寸 → 还闪电贷
5. 利用杠杆(Leverage Amplification)
通过多次”存入→借出→存入”循环来放大杠杆,原本需要多笔交易,用闪电贷可以在一笔交易内完成。
6. 协议攻击(Protocol Exploitation)
这是恶意使用场景:攻击者借入大量代币,操纵链上价格预言机或治理投票权,然后获利套现。
ERC-3156 标准规范
ERC-3156 是以太坊的闪电贷标准,定义了贷方和借方之间的统一接口,让不同协议的闪电贷可以互操作。
借方接口 IFlashBorrower
借方合约必须实现这个接口:
interface IERC3156FlashBorrower {
/**
* @dev 在收到闪电贷后,贷方会调用这个函数
* @param initiator 发起闪电贷的地址(通常是借方合约本身)
* @param token 借出的代币地址
* @param amount 借出的数量
* @param fee 需要额外支付的手续费
* @param data 调用者传入的任意附加数据
* @return 必须返回 keccak256("ERC3156FlashBorrower.onFlashLoan")
*/
function onFlashLoan(
address initiator,
address token,
uint256 amount,
uint256 fee,
bytes calldata data
) external returns (bytes32);
}
为什么要返回特定的 bytes32 值?
这是一个安全确认机制。只有”真正实现了这个接口、知道该返回什么”的合约,才能通过贷方的验证。如果随便一个合约的 fallback 函数被调用,它不会返回这个正确的哈希值,贷方就会 revert。
贷方接口 IFlashLender
贷方合约必须实现这个接口:
interface IERC3156FlashLender {
/**
* @dev 返回指定代币和数量的闪电贷手续费
*/
function flashFee(
address token,
uint256 amount
) external view returns (uint256);
/**
* @dev 返回指定代币的最大可借数量
*/
function maxFlashLoan(
address token
) external view returns (uint256);
/**
* @dev 执行闪电贷
* @param receiver 借方合约地址(必须实现 IERC3156FlashBorrower)
* @param token 借出的代币地址
* @param amount 借出的数量
* @param data 传递给借方的附加数据
*/
function flashLoan(
IERC3156FlashBorrower receiver,
address token,
uint256 amount,
bytes calldata data
) external returns (bool);
}
ERC-3156 完整实现示例
贷方合约实现
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract FlashLender is IERC3156FlashLender, ReentrancyGuard {
using SafeERC20 for IERC20;
// 手续费率:万分之九(0.09%)
uint256 public constant FEE_BPS = 9;
uint256 public constant BPS_DENOMINATOR = 10000;
// 支持的代币白名单
mapping(address => bool) public supportedTokens;
bytes32 public constant CALLBACK_SUCCESS =
keccak256("ERC3156FlashBorrower.onFlashLoan");
constructor(address[] memory tokens) {
for (uint256 i = 0; i < tokens.length; i++) {
supportedTokens[tokens[i]] = true;
}
}
function maxFlashLoan(address token) external view returns (uint256) {
if (!supportedTokens[token]) return 0;
return IERC20(token).balanceOf(address(this));
}
function flashFee(address token, uint256 amount) external view returns (uint256) {
require(supportedTokens[token], "Token not supported");
return (amount * FEE_BPS) / BPS_DENOMINATOR;
}
function flashLoan(
IERC3156FlashBorrower receiver,
address token,
uint256 amount,
bytes calldata data
) external nonReentrant returns (bool) {
require(supportedTokens[token], "Token not supported");
uint256 fee = (amount * FEE_BPS) / BPS_DENOMINATOR;
uint256 balanceBefore = IERC20(token).balanceOf(address(this));
require(balanceBefore >= amount, "Insufficient liquidity");
// 第一步:把代币转给借方
IERC20(token).safeTransfer(address(receiver), amount);
// 第二步:回调借方的 onFlashLoan 函数
bytes32 result = receiver.onFlashLoan(
msg.sender, // initiator
token,
amount,
fee,
data
);
// 第三步:验证回调返回值
require(result == CALLBACK_SUCCESS, "Invalid return value");
// 第四步:从借方取回本金 + 手续费
// 注意:是贷方主动取走,不是借方主动转回
IERC20(token).safeTransferFrom(
address(receiver),
address(this),
amount + fee
);
// 第五步:验证余额确实增加了手续费
uint256 balanceAfter = IERC20(token).balanceOf(address(this));
require(balanceAfter >= balanceBefore + fee, "Flash loan not repaid");
return true;
}
}
借方合约实现
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
contract FlashBorrower is IERC3156FlashBorrower {
using SafeERC20 for IERC20;
// ⚠️ 必须硬编码贷方地址,不能从参数传入
address public immutable flashLender;
address public immutable owner;
bytes32 public constant CALLBACK_SUCCESS =
keccak256("ERC3156FlashBorrower.onFlashLoan");
constructor(address _flashLender) {
flashLender = _flashLender;
owner = msg.sender;
}
// 发起闪电贷的入口函数
function executeFlashLoan(
address token,
uint256 amount,
bytes calldata data
) external {
require(msg.sender == owner, "Only owner");
IERC3156FlashLender(flashLender).flashLoan(
IERC3156FlashBorrower(this),
token,
amount,
data
);
}
// 贷方回调此函数
function onFlashLoan(
address initiator,
address token,
uint256 amount,
uint256 fee,
bytes calldata data
) external returns (bytes32) {
// ✅ 安全检查一:确认调用者是已知的贷方合约
require(msg.sender == flashLender, "Untrusted lender");
// ✅ 安全检查二:确认发起者是本合约自己(或授权地址)
require(initiator == address(this), "Untrusted initiator");
// --- 在这里执行借款后的业务逻辑 ---
// 例如:套利、清算等操作
// _doArbitrage(token, amount, data);
// 授权贷方取走还款金额(本金 + 手续费)
uint256 repayAmount = amount + fee;
IERC20(token).safeApprove(flashLender, repayAmount);
return CALLBACK_SUCCESS;
}
}
关键细节:谁来转账还款?
这是 ERC-3156 中最容易搞错的设计细节:
是贷方从借方拉走(pull)还款,而不是借方主动推送(push)还款给贷方。
❌ 错误理解:
借方 → (safeTransfer) → 贷方 ← 借方主动还钱
✅ 正确理解:
贷方 → (safeTransferFrom) → 贷方自己 ← 贷方主动取钱
所以借方只需要:
- 在
onFlashLoan中执行业务逻辑 - 调用
IERC20(token).approve(lender, amount + fee)授权贷方取款
贷方在调用完回调之后,会主动调用 safeTransferFrom 把钱取走。
安全漏洞深度分析
漏洞一:重入攻击
ERC-3156 的设计天然违反了”检查-效果-交互”(CEI)模式:
// 问题所在:先转出代币(交互),再检查还款(检查)
IERC20(token).safeTransfer(address(receiver), amount); // 交互在前
receiver.onFlashLoan(...); // 又一次外部调用
require(balanceAfter >= balanceBefore + fee, "..."); // 检查在后
因为资金已经转出去了,在回调期间合约内部状态是”不一致”的,攻击者可能趁机重入。
防御: 必须加 nonReentrant 修饰符(如 OpenZeppelin 的 ReentrancyGuard)。
漏洞二:价格操纵攻击
某些合约用 balanceOf(address(this)) 来判断价格或资金充足性:
// ⚠️ 危险的价格查询写法
function getPrice() public view returns (uint256) {
uint256 tokenBalance = IERC20(token).balanceOf(address(this));
uint256 ethBalance = address(this).balance;
return ethBalance * 1e18 / tokenBalance; // 简单的 spot price
}
攻击者可以:
- 借入大量 token(使合约内 token 减少)
- 此时
getPrice()返回虚高的价格 - 利用虚高价格进行操纵获利
- 还款,整个过程在一笔交易内完成
防御: 使用时间加权平均价格(TWAP)预言机,而非即时的 balanceOf 来计算价格。
漏洞三:缺少 msg.sender 验证
如果借方合约的 onFlashLoan 没有验证 msg.sender:
// ⚠️ 危险写法:任何人都能调用 onFlashLoan
function onFlashLoan(
address initiator,
address token,
uint256 amount,
uint256 fee,
bytes calldata data
) external returns (bytes32) {
// 没有检查 msg.sender!
// 攻击者直接调用这个函数,伪造 initiator = address(this)
// 然后合约会 approve 攻击者设定的金额
IERC20(token).approve(msg.sender, amount + fee);
return CALLBACK_SUCCESS;
}
攻击者直接调用 onFlashLoan,传入恶意参数,骗取授权。
防御:
require(msg.sender == flashLender, "Untrusted lender");
require(initiator == address(this), "Untrusted initiator");
漏洞四:未验证返回值
如果贷方没有检查 onFlashLoan 的返回值:
// ⚠️ 危险写法:没验证返回值
receiver.onFlashLoan(initiator, token, amount, fee, data);
// 接着直接 transferFrom,没有 require(result == CALLBACK_SUCCESS)
攻击者可以部署一个合约,其 fallback 函数接受一切调用并返回任意值,然后通过这个漏洞反复触发贷方向受害合约转账,导致资金损失。
防御:
bytes32 result = receiver.onFlashLoan(...);
require(result == CALLBACK_SUCCESS, "Invalid return value from borrower");
漏洞五:侧面入口攻击(Side Entrance)
这是一种更隐蔽的攻击:贷方协议既支持闪电贷又支持存款,但用同一个 balanceOf 检查来验证还款。
攻击步骤(数值示例):
假设贷方合约池子里有 1,000,000 ETH
第一步:攻击者借出 1,000,000 ETH(闪电贷)
第二步:攻击者把 1,000,000 ETH 以"存款"方式存回合约
→ 合约账本上记录:攻击者存入了 1,000,000 ETH
→ 合约的 ETH 余额 = 1,000,000(余额检查通过!)
第三步:闪电贷认为还款成功(因为 balanceOf 没变)
第四步:攻击者再调用"取款"函数,取出 1,000,000 ETH
结果:攻击者白嫖了 1,000,000 ETH
根本原因: 贷方不应该用 balanceOf 作为”还款”的唯一证据,“存款”和”还款”是两个不同的操作,不能混为一谈。
防御: 存款记录和还款验证必须分离;可以记录闪电贷前的余额,要求还款后余额回到原有水平加上手续费,且不能将此次存款记为有效存款余额。
防御措施清单
| 类别 | 防御措施 |
|---|---|
| 重入 | 贷方合约必须使用 nonReentrant 修饰符 |
| 调用者验证 | 借方必须验证 msg.sender == flashLender |
| 发起人验证 | 借方必须验证 initiator == address(this) 或白名单地址 |
| 返回值验证 | 贷方必须验证 onFlashLoan 返回 keccak256("ERC3156FlashBorrower.onFlashLoan") |
| 代币兼容性 | 使用 SafeERC20 处理非标准 ERC20 代币(如不返回 bool 的代币) |
| 价格安全 | 避免用 balanceOf 计算价格,改用 TWAP 预言机 |
| 存款/还款分离 | 贷方协议若支持存款,必须区分存款行为和闪电贷还款行为 |
| 白名单贷方 | 借方合约中贷方地址应硬编码或通过治理设置,不能从函数参数传入 |
总结
闪电贷利用以太坊交易的原子性,提供了无需抵押的”同块还款”借贷服务。ERC-3156 通过标准化接口(借方的 onFlashLoan + 贷方的 flashLoan)让不同协议的闪电贷可以互通。
核心要记住的设计决策:
- 贷方主动拉款,不是借方主动推款——借方只需
approve,贷方自己transferFrom - 必须加重入锁——因为 CEI 模式无法遵守
- 借方必须双重验证——既验证调用者(贷方地址),又验证发起人
- 返回值是安全机制——不是可有可无的约定,而是防止未授权调用的护盾
闪电贷本身是中性工具,理解它的攻防两面,才能写出安全的 DeFi 协议。