Flash Loans and ERC-3156: Complete Overview

深入解析闪电贷原理、ERC-3156标准规范、完整实现方式及关键安全漏洞防御措施

· ☕ 11 分钟阅读
Flash Loans and ERC-3156: Complete Overview

闪电贷与 ERC-3156 完全指南

目录

  1. 什么是闪电贷
  2. 闪电贷的核心原理:原子性
  3. 只有合约能用闪电贷,EOA 不行
  4. 闪电贷的主要使用场景
  5. ERC-3156 标准规范
  6. ERC-3156 完整实现示例
  7. 关键细节:谁来转账还款?
  8. 安全漏洞深度分析
  9. 防御措施清单
  10. 总结

什么是闪电贷

闪电贷(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 发出一笔交易只能调用一个合约函数,没有办法在中途”接收回调 → 执行逻辑 → 还款”。

只有智能合约才能实现这种逻辑,因为合约可以:

  1. 调用贷方的 flashLoan() 函数
  2. 贷方在转账后,回调借方合约的 onFlashLoan() 函数
  3. 借方合约在回调中执行套利等逻辑,然后批准贷方取走还款金额

闪电贷的主要使用场景

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) → 贷方自己   ← 贷方主动取钱

所以借方只需要:

  1. onFlashLoan 中执行业务逻辑
  2. 调用 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
}

攻击者可以:

  1. 借入大量 token(使合约内 token 减少)
  2. 此时 getPrice() 返回虚高的价格
  3. 利用虚高价格进行操纵获利
  4. 还款,整个过程在一笔交易内完成

防御: 使用时间加权平均价格(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)让不同协议的闪电贷可以互通。

核心要记住的设计决策:

  1. 贷方主动拉款,不是借方主动推款——借方只需 approve,贷方自己 transferFrom
  2. 必须加重入锁——因为 CEI 模式无法遵守
  3. 借方必须双重验证——既验证调用者(贷方地址),又验证发起人
  4. 返回值是安全机制——不是可有可无的约定,而是防止未授权调用的护盾

闪电贷本身是中性工具,理解它的攻防两面,才能写出安全的 DeFi 协议。

💬 评论