返回 SC 笔记
SC Day 43

Chainlink预言机集成 + Fork主网测试

### 1. 为什么需要预言机?

2026-05-13
第二阶段:框架实战 (41-48)
Chainlink预言机PriceFeedForkTestFoundry

日期: 2026-05-13 方向: Solidity 阶段: 第二阶段:框架实战 (41-48) 标签: #Chainlink #预言机 #PriceFeed #ForkTest #Foundry


今日目标

  1. 理解 Chainlink Price Feed 的架构和工作原理
  2. 实现合约集成 AggregatorV3Interface 获取实时价格
  3. 掌握 Foundry Fork 测试:在本地分叉主网进行测试
  4. 理解预言机攻击向量与防御措施

核心概念

1. 为什么需要预言机?

区块链是一个封闭的确定性系统——每个节点必须对同一输入产生完全相同的输出。这意味着智能合约不能直接访问外部数据(价格、天气、API 等)。

预言机(Oracle)是连接链上世界和链下世界的桥梁。

链下世界                    预言机                    链上世界
┌──────────┐          ┌───────────┐          ┌──────────┐
│ 交易所    │ ──价格──► │ Chainlink │ ──喂价──► │ DeFi 合约 │
│ API      │          │ 节点网络   │          │ 借贷/DEX  │
│ 数据源    │          │ 聚合 + 共识│          │ 清算逻辑  │
└──────────┘          └───────────┘          └──────────┘

Chainlink 的价格数据不是单一来源,而是一个去中心化预言机网络

数据源层:      Binance  Coinbase  Kraken  OKX  ...
                  │        │        │      │
预言机节点:    Node1    Node2    Node3   Node4  ...
                  │        │        │      │
链上聚合:      ┌─────────────────────────────┐
               │   Aggregator Contract       │
               │   取中位数 → 写入新 Round    │
               └─────────────────────────────┘
                              │
消费者合约:              Your Contract
                    (调用 latestRoundData)

核心概念

  • Aggregator: 聚合器合约,存储多轮价格数据
  • Round: 每次价格更新称为一个 Round,有 roundId
  • Heartbeat: 价格最长更新间隔(如 ETH/USD 的 heartbeat 是 3600 秒)
  • Deviation Threshold: 价格偏差超过阈值时触发更新(如 0.5%)

3. AggregatorV3Interface

interface AggregatorV3Interface {
    function decimals() external view returns (uint8);
    function description() external view returns (string memory);
    function version() external view returns (uint256);

    function latestRoundData() external view returns (
        uint80 roundId,         // 当前 round 编号
        int256 answer,          // 价格(需要除以 10^decimals)
        uint256 startedAt,      // round 开始时间
        uint256 updatedAt,      // 最后更新时间
        uint80 answeredInRound  // 回答所在的 round
    );

    function getRoundData(uint80 _roundId) external view returns (
        uint80 roundId,
        int256 answer,
        uint256 startedAt,
        uint256 updatedAt,
        uint80 answeredInRound
    );
}

4. 主网 Price Feed 地址(Ethereum Mainnet)

交易对地址Decimals
ETH/USD0x5f4eC3Df9cbd43714FE2740f5E3616155c5b84198
BTC/USD0xF4030086522a5bEEa4988F8cA5B36dbC97BeE88c8
USDC/USD0x8fFfFfd4AfB6115b954Bd326cbe7B4BA576818f68
DAI/USD0xAed0c38402a5d19df6E4c03F4E2DceD6e29c1ee98
LINK/USD0x2c1d072e956AFFC0D435Cb7AC38EF18d24d9127c8

代码实战

1. 价格消费者合约

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

import "@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol";

/// @title PriceConsumer - Chainlink 价格预言机集成
/// @notice 支持多个 Price Feed,带过期检查和安全验证
contract PriceConsumer {
    // Price Feed 注册表
    mapping(string => AggregatorV3Interface) public priceFeeds;
    mapping(string => uint256) public maxStalePeriods; // 最大过期时间

    address public owner;

    // 默认最大过期时间: 1 小时
    uint256 public constant DEFAULT_MAX_STALE = 3600;

    event PriceFeedAdded(string symbol, address feed, uint256 maxStale);
    event PriceQueried(string symbol, int256 price, uint256 timestamp);

    error StalePrice(string symbol, uint256 updatedAt, uint256 currentTime);
    error InvalidPrice(string symbol, int256 price);
    error FeedNotFound(string symbol);
    error OnlyOwner();

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

    constructor() {
        owner = msg.sender;
    }

    /// @notice 注册 Price Feed
    /// @param symbol 交易对标识符(如 "ETH/USD")
    /// @param feedAddress Chainlink Aggregator 地址
    /// @param maxStale 最大允许过期时间(秒)
    function addPriceFeed(
        string calldata symbol,
        address feedAddress,
        uint256 maxStale
    ) external onlyOwner {
        require(feedAddress != address(0), "Invalid feed address");
        priceFeeds[symbol] = AggregatorV3Interface(feedAddress);
        maxStalePeriods[symbol] = maxStale > 0 ? maxStale : DEFAULT_MAX_STALE;
        emit PriceFeedAdded(symbol, feedAddress, maxStale);
    }

    /// @notice 获取最新价格(带完整安全检查)
    /// @param symbol 交易对标识符
    /// @return price 价格(8 位小数)
    /// @return decimals 小数位数
    /// @return updatedAt 最后更新时间
    function getLatestPrice(
        string calldata symbol
    ) public view returns (int256 price, uint8 decimals, uint256 updatedAt) {
        AggregatorV3Interface feed = priceFeeds[symbol];
        if (address(feed) == address(0)) revert FeedNotFound(symbol);

        (
            uint80 roundId,
            int256 answer,
            ,
            uint256 _updatedAt,
            uint80 answeredInRound
        ) = feed.latestRoundData();

        // 安全检查 1: 价格必须为正
        if (answer <= 0) revert InvalidPrice(symbol, answer);

        // 安全检查 2: Round 完整性
        // answeredInRound >= roundId 确保这个 round 已经完成
        require(answeredInRound >= roundId, "Stale round");

        // 安全检查 3: 过期检查
        uint256 maxStale = maxStalePeriods[symbol];
        if (block.timestamp - _updatedAt > maxStale) {
            revert StalePrice(symbol, _updatedAt, block.timestamp);
        }

        return (answer, feed.decimals(), _updatedAt);
    }

    /// @notice 获取 ETH 数量对应的 USD 价值
    /// @param ethAmount ETH 数量(18 位小数)
    /// @return usdValue USD 价值(18 位小数)
    function getEthUsdValue(
        uint256 ethAmount
    ) external view returns (uint256 usdValue) {
        (int256 price, uint8 decimals, ) = getLatestPrice("ETH/USD");

        // ETH: 18 decimals, Price: 8 decimals
        // usdValue = ethAmount * price / 10^decimals
        // 结果保持 18 位小数
        usdValue = (ethAmount * uint256(price)) / (10 ** decimals);
    }

    /// @notice 使用两个 Price Feed 计算交叉价格
    /// @dev 例如 ETH/BTC = (ETH/USD) / (BTC/USD)
    function getCrossPrice(
        string calldata base,
        string calldata quote
    ) external view returns (uint256 crossPrice) {
        (int256 basePrice, uint8 baseDecimals, ) = getLatestPrice(base);
        (int256 quotePrice, uint8 quoteDecimals, ) = getLatestPrice(quote);

        // crossPrice = basePrice / quotePrice
        // 标准化到 18 位小数
        crossPrice = (uint256(basePrice) * (10 ** 18) * (10 ** quoteDecimals))
            / (uint256(quotePrice) * (10 ** baseDecimals));
    }
}

2. 使用预言机的借贷合约示例

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

import "@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

/// @title SimpleLending - 使用 Chainlink 预言机的简化借贷协议
/// @notice 仅用于教学目的,展示预言机在 DeFi 中的实际应用
contract SimpleLending is ReentrancyGuard {
    AggregatorV3Interface public immutable ethUsdFeed;
    IERC20 public immutable usdc;

    // 抵押率: 150% (需要 $150 的 ETH 才能借 $100 USDC)
    uint256 public constant COLLATERAL_RATIO = 150;
    // 清算阈值: 120%
    uint256 public constant LIQUIDATION_THRESHOLD = 120;
    // 清算奖励: 5%
    uint256 public constant LIQUIDATION_BONUS = 5;

    struct Position {
        uint256 collateralEth;  // 抵押的 ETH(wei)
        uint256 borrowedUsdc;   // 借出的 USDC(6 decimals)
    }

    mapping(address => Position) public positions;

    event Deposited(address indexed user, uint256 ethAmount);
    event Borrowed(address indexed user, uint256 usdcAmount);
    event Repaid(address indexed user, uint256 usdcAmount);
    event Liquidated(address indexed user, address indexed liquidator, uint256 ethAmount);

    uint256 private constant MAX_STALE_PERIOD = 3600; // 1 hour

    constructor(address _ethUsdFeed, address _usdc) {
        ethUsdFeed = AggregatorV3Interface(_ethUsdFeed);
        usdc = IERC20(_usdc);
    }

    /// @notice 获取 ETH/USD 价格(带安全检查)
    function getEthPrice() public view returns (uint256) {
        (
            uint80 roundId,
            int256 answer,
            ,
            uint256 updatedAt,
            uint80 answeredInRound
        ) = ethUsdFeed.latestRoundData();

        require(answer > 0, "Invalid price");
        require(answeredInRound >= roundId, "Stale round");
        require(block.timestamp - updatedAt <= MAX_STALE_PERIOD, "Stale price");

        return uint256(answer); // 8 decimals
    }

    /// @notice 存入 ETH 作为抵押品
    function depositCollateral() external payable nonReentrant {
        require(msg.value > 0, "Zero deposit");
        positions[msg.sender].collateralEth += msg.value;
        emit Deposited(msg.sender, msg.value);
    }

    /// @notice 借出 USDC
    function borrow(uint256 usdcAmount) external nonReentrant {
        Position storage pos = positions[msg.sender];
        require(pos.collateralEth > 0, "No collateral");

        uint256 ethPrice = getEthPrice(); // 8 decimals

        // 计算抵押品 USD 价值
        // collateralUsd = collateralEth * ethPrice / 1e8 (price decimals)
        // 转换为 6 decimals(USDC 精度)
        uint256 collateralUsdValue = (pos.collateralEth * ethPrice) / 1e20;
        // 1e20 = 1e18 (ETH decimals) * 1e8 (price decimals) / 1e6 (USDC decimals)

        uint256 newBorrowed = pos.borrowedUsdc + usdcAmount;

        // 检查抵押率
        // collateralValue >= borrowedValue * COLLATERAL_RATIO / 100
        require(
            collateralUsdValue * 100 >= newBorrowed * COLLATERAL_RATIO,
            "Insufficient collateral"
        );

        pos.borrowedUsdc = newBorrowed;
        require(usdc.transfer(msg.sender, usdcAmount), "Transfer failed");

        emit Borrowed(msg.sender, usdcAmount);
    }

    /// @notice 计算用户的健康因子
    /// @return healthFactor 健康因子 * 100(>100 表示安全)
    function getHealthFactor(address user) public view returns (uint256 healthFactor) {
        Position storage pos = positions[user];
        if (pos.borrowedUsdc == 0) return type(uint256).max;

        uint256 ethPrice = getEthPrice();
        uint256 collateralUsdValue = (pos.collateralEth * ethPrice) / 1e20;

        // healthFactor = (collateralValue / borrowedValue) * 100
        healthFactor = (collateralUsdValue * 100) / pos.borrowedUsdc;
    }

    /// @notice 清算不健康头寸
    function liquidate(address user) external nonReentrant {
        uint256 health = getHealthFactor(user);
        require(health < LIQUIDATION_THRESHOLD, "Position is healthy");

        Position storage pos = positions[user];
        uint256 debtUsdc = pos.borrowedUsdc;
        uint256 collateralEth = pos.collateralEth;

        // 清算人偿还全部债务
        require(
            usdc.transferFrom(msg.sender, address(this), debtUsdc),
            "Transfer failed"
        );

        // 计算清算人获得的 ETH(债务等值 + 5% 奖励)
        uint256 ethPrice = getEthPrice();
        uint256 ethToLiquidator = (debtUsdc * 1e20 * (100 + LIQUIDATION_BONUS))
            / (ethPrice * 100);

        if (ethToLiquidator > collateralEth) {
            ethToLiquidator = collateralEth;
        }

        // 重置头寸
        pos.borrowedUsdc = 0;
        pos.collateralEth = collateralEth - ethToLiquidator;

        // 发送 ETH 给清算人
        (bool success, ) = msg.sender.call{value: ethToLiquidator}("");
        require(success, "ETH transfer failed");

        emit Liquidated(user, msg.sender, ethToLiquidator);
    }
}

3. Foundry Fork 主网测试

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

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

/// @notice Fork 主网测试 - 读取真实 Chainlink 价格
contract PriceConsumerForkTest is Test {
    PriceConsumer public consumer;

    // 主网 Chainlink Price Feed 地址
    address constant ETH_USD_FEED = 0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419;
    address constant BTC_USD_FEED = 0xF4030086522a5bEEa4988F8cA5B36dbC97BeE88c;
    address constant USDC_USD_FEED = 0x8fFfFfd4AfB6115b954Bd326cbe7B4BA576818f6;

    function setUp() public {
        // Fork 以太坊主网
        // 运行命令: forge test --fork-url $ETH_RPC_URL -vvv
        // 或在 foundry.toml 中配置:
        // [profile.default]
        // eth_rpc_url = "https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY"

        consumer = new PriceConsumer();

        // 注册 Price Feeds
        consumer.addPriceFeed("ETH/USD", ETH_USD_FEED, 3600);
        consumer.addPriceFeed("BTC/USD", BTC_USD_FEED, 3600);
        consumer.addPriceFeed("USDC/USD", USDC_USD_FEED, 86400);
    }

    function test_GetEthUsdPrice() public view {
        (int256 price, uint8 decimals, uint256 updatedAt) =
            consumer.getLatestPrice("ETH/USD");

        // ETH 价格应该在合理范围内($100 - $100,000)
        assertGt(price, 100 * int256(10 ** decimals), "ETH price too low");
        assertLt(price, 100_000 * int256(10 ** decimals), "ETH price too high");
        assertEq(decimals, 8, "Should have 8 decimals");

        // 输出价格用于观察
        emit log_named_decimal_int("ETH/USD Price", price, decimals);
        emit log_named_uint("Updated at", updatedAt);
    }

    function test_GetBtcUsdPrice() public view {
        (int256 price, uint8 decimals, ) =
            consumer.getLatestPrice("BTC/USD");

        assertGt(price, 1000 * int256(10 ** decimals), "BTC price too low");
        emit log_named_decimal_int("BTC/USD Price", price, decimals);
    }

    function test_GetEthBtcCrossPrice() public view {
        uint256 ethBtcPrice = consumer.getCrossPrice("ETH/USD", "BTC/USD");

        // ETH/BTC 应该在 0.01 - 1 之间
        assertGt(ethBtcPrice, 0.01e18, "ETH/BTC too low");
        assertLt(ethBtcPrice, 1e18, "ETH/BTC too high");

        emit log_named_decimal_uint("ETH/BTC Price", ethBtcPrice, 18);
    }

    function test_GetEthUsdValue() public view {
        uint256 oneEthValue = consumer.getEthUsdValue(1 ether);

        // 1 ETH 的 USD 价值应该在 $100 - $100,000
        assertGt(oneEthValue, 100e18, "1 ETH value too low");
        assertLt(oneEthValue, 100_000e18, "1 ETH value too high");

        emit log_named_decimal_uint("1 ETH in USD", oneEthValue, 18);
    }

    function test_StalePriceReverts() public {
        // 将时间快进 2 小时,使价格过期
        vm.warp(block.timestamp + 7200);

        vm.expectRevert(
            abi.encodeWithSelector(
                PriceConsumer.StalePrice.selector,
                "ETH/USD",
                // updatedAt 和 currentTime 是动态的,这里只检查 selector
                0, 0
            )
        );
        // 注意: 这个测试在 fork 模式下可能需要调整,
        // 因为 warp 只影响 block.timestamp
        // consumer.getLatestPrice("ETH/USD");
    }

    function test_NonExistentFeedReverts() public {
        vm.expectRevert(
            abi.encodeWithSelector(
                PriceConsumer.FeedNotFound.selector,
                "DOGE/USD"
            )
        );
        consumer.getLatestPrice("DOGE/USD");
    }
}

运行 Fork 测试

# 方法1: 命令行指定 RPC URL
forge test --fork-url https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY -vvv

# 方法2: 在 foundry.toml 配置
# [profile.default]
# eth_rpc_url = "https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY"
forge test -vvv

# 方法3: 使用环境变量
export ETH_RPC_URL=https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY
forge test --fork-url $ETH_RPC_URL -vvv

# 只运行 fork 测试(通常单独标记)
forge test --match-contract ForkTest --fork-url $ETH_RPC_URL -vvv

预言机攻击与防御

攻击类型 1: 闪电贷操纵价格

攻击流程:
1. 闪电贷借入大量 ETH
2. 在 Uniswap 大量卖 ETH → 压低 ETH 价格
3. 调用受害协议(如果它用 Uniswap 即时价格作为预言机)
4. 以低价清算用户或借出超额资金
5. 归还闪电贷

防御: 使用 Chainlink 或 TWAP,不用即时 DEX 价格

攻击类型 2: 预言机过期利用

场景: Chainlink 节点因网络拥堵无法更新价格
       ETH 实际已从 $3000 跌到 $2000
       但 Chainlink 报价仍是 $3000

利用: 用过期的高价抵押借出更多资金

防御: 严格的 staleness 检查
      require(block.timestamp - updatedAt <= MAX_STALE_PERIOD)

攻击类型 3: 反射型预言机攻击(Reflexive Oracle)

场景: 协议 A 使用 Token X 的 Chainlink 价格
       协议 A 本身是 Token X 的最大持有者
       操纵协议 A → 影响 Token X 价格 → 反过来影响协议 A

防御: 使用多个预言机源、设置价格变化上限

防御措施清单

防御实现方式保护范围
过期检查block.timestamp - updatedAt <= maxStale价格过期
正数检查answer > 0异常价格
Round 完整性answeredInRound >= roundId未完成的 round
多预言机Chainlink + TWAP 交叉验证单点故障
价格偏差限制新旧价格偏差 < 10%极端波动/操纵
断路器价格异常时暂停协议黑天鹅事件

关键要点总结

  1. 永远不要信任单一预言机: Chainlink 已经是最可靠的方案,但仍需做过期检查、正数检查等防护
  2. Fork 测试是 DeFi 开发必备技能: 可以在本地用真实主网数据测试,无需部署到测试网
  3. 价格精度处理是常见 bug 来源: ETH 是 18 decimals,Chainlink 是 8 decimals,USDC 是 6 decimals,混用时容易出错
  4. staleness 检查是安全底线: 不同交易对的 heartbeat 不同,maxStale 需要根据实际 feed 配置
  5. 预言机是 DeFi 安全的关键环节: 大量 DeFi 攻击都涉及预言机操纵

常见误区

纠正: Chainlink 有延迟(heartbeat 可能是 1 小时),有偏差阈值(0.5% 才触发更新)。极端市场条件下(如 2022 年 LUNA 崩盘),预言机可能跟不上实际价格。

误区2: "只检查 answer > 0 就够了"

纠正: 还需要检查 updatedAt(过期)、answeredInRound >= roundId(round 完整性)。生产环境建议加上多预言机交叉验证。

误区3: "Fork 测试和主网行为完全一致"

纠正: Fork 测试有局限:

  • 时间戳可以用 vm.warp 调整,但 Chainlink 数据不会跟着更新
  • 无法测试 Mempool 相关行为(MEV、前跑等)
  • Gas 成本可能与实际不同

误区4: "decimals 总是 8"

纠正: 大多数 USD 交易对是 8 decimals,但 ETH 交易对(如 BTC/ETH)可能是 18 decimals。永远调用 feed.decimals() 而不是硬编码


面试关联

Q: "DeFi 协议如何安全地获取外部价格?"

参考回答:

  1. 首选 Chainlink: 去中心化预言机网络,多节点聚合,最成熟
  2. 必须做安全检查: 过期检查、正数检查、round 完整性、价格偏差限制
  3. 考虑 TWAP 作为备选/补充: Uniswap V3 的几何平均 TWAP 难以被单区块操纵
  4. 多预言机策略: 生产环境建议 Chainlink + TWAP 交叉验证,偏差过大时暂停
  5. 断路器机制: 设置价格变化上限,异常时触发暂停,由治理恢复

Q: "你了解哪些预言机攻击案例?如何防范?"

参考回答:

  • Mango Markets(2022): 攻击者操纵 MNGO/USD 价格,借出 $115M。根因:依赖单一低流动性交易对的价格
  • Bonq(2023): TellorFlex 预言机被操纵,导致 $120M 损失
  • 防范: 不使用低流动性代币的即时价格;使用 TWAP;多预言机源;设置借贷上限

参考资源