一、链上随机数为啥经常“不靠谱”

很多做链上应用的开发者,不管是做抽奖、NFT mint还是游戏对战,都会用到随机数,但新手很容易踩坑——直接用链上当前的块哈希、时间戳来当随机数,结果最后发现随机数是“可被操控”的。举个真实例子:某早期链上抽奖项目,用block.timestamp(当前块时间戳)生成中奖号码,结果矿工提前调整了块的时间戳,把中奖号改成了自己想要的数字,最后项目亏了十几万。本质上,链上的公开数据,只要是被区块生产者(比如以太坊矿工)能掌控或篡改的,都不能当可信随机数。

比如块哈希,它是每个区块的唯一哈希值,看似随机,但矿工可以选择“挖不挖这个块”——如果这个块的哈希用来生成的随机数,会让矿工亏损,他就会放弃挖这个块,等下一个块生成,这样就跳过了那个“坏”随机数,甚至人为控制出奖结果。时间戳更夸张,矿工可以把块的时间戳调整到下一分钟,差几分钟就能改一次随机数,完全没法用在需要公平的场景。

二、靠谱的链上随机数:VRF和块哈希的适用场景

2.1 可验证随机函数(VRF):高安全需求的首选

可验证随机函数,说白了就是“带公证的扔骰子”:你找一个大家都信任的第三方(比如Chainlink的节点),这个第三方有一个只有自己知道的“秘密种子”,他扔骰子的时候,不仅给你结果,还给你一个“验证证书”。这个证书是用他的秘密种子算出来的,链上所有人都能公开验证——既证明了结果没被篡改,也证明公证人没法抵赖自己的操作。

VRF的核心优势是三个:不可预测(生成前没人知道结果)、可公开验证(任何人都能查证书真伪)、不可篡改(一旦生成就没法改)。所以它的适用场景都是高安全要求的:比如高价值的NFT白名单、百万级别的链上抽奖、游戏里的稀有装备掉落、DeFi里的随机清算(比如随机选择要清算的用户),这些场景容不得半点作弊。

不过VRF也有缺点:它需要依赖第三方节点提供服务(比如Chainlink VRF),会消耗更多的Gas费,比用块哈希贵一点,但对于高价值应用来说,这点Gas费完全值得。下面用Solidity写一个简单的VRF示例,技术栈是Solidity 0.8.20 + Chainlink VRF v2.5:

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

// 导入Chainlink VRF核心合约和接口
import "@chainlink/contracts/src/v0.8/vrf/VRFConsumerBaseV2.sol";
import "@chainlink/contracts/src/v0.8/vrf/interfaces/VRFCoordinatorV2Interface.sol";

// 继承VRF消费者基类,实现随机数获取逻辑
contract HighValueRaffle is VRFConsumerBaseV2 {
    // VRF协调者地址(以太坊主网用这个,测试网换对应地址即可)
    VRFCoordinatorV2Interface public immutable COORDINATOR;
    // Chainlink官网创建的订阅ID,替换成自己的
    uint64 public immutable subscriptionId;
    // Gas上限(支付给节点的Gas费用)
    uint32 public constant CALLBACK_GAS_LIMIT = 100000;
    // 等待区块确认数,越少越快但风险略高,3个是常用值
    uint16 public constant REQUEST_CONFIRMATIONS = 3;
    // 需要生成的随机数个数,这里只需要1个
    uint32 public constant NUM_WORDS = 1;

    // 存储请求ID和对应的发起者,防止重复请求
    mapping(uint256 => address) public requestToUser;
    // 存储生成的可信随机数
    uint256 public lotteryRandom;
    // 标记是否已经生成随机数,防止重复开奖
    bool public isRandomReady;

    // 构造函数初始化VRF配置
    constructor(address _vrfCoordinator, uint64 _subId) VRFConsumerBaseV2(_vrfCoordinator) {
        COORDINATOR = VRFCoordinatorV2Interface(_vrfCoordinator);
        subscriptionId = _subId;
    }

    // 用户发起随机数请求的函数,任何人都能调用
    function requestRandom() external returns (uint256 reqId) {
        require(!isRandomReady, "Lottery already drawn");
        // 向Chainlink节点请求随机数,传入参数:Gas价格、订阅ID、确认数、Gas上限、随机数个数
        reqId = COORDINATOR.requestRandomWords(
            0x47e179ec197488593b187f80a00eb0da91f1b9d0b8a7b1d8f4c3e6d8a2b3c4d,
            subscriptionId,
            REQUEST_CONFIRMATIONS,
            CALLBACK_GAS_LIMIT,
            NUM_WORDS
        );
        requestToUser[reqId] = msg.sender;
    }

    // VRF节点返回随机数后的回调函数,只能由协调者调用
    function fulfillRandomWords(uint256, uint256[] memory randomWords) internal override {
        require(!isRandomReady, "Random already set");
        lotteryRandom = randomWords[0];
        isRandomReady = true;
    }

    // 获取中奖号码,这里取随机数模100,得到0-99的公平号码
    function getWinningNumber() public view returns (uint256) {
        require(isRandomReady, "Wait for random number");
        return lotteryRandom % 100;
    }
}

2.2 块哈希方案:低安全需求的轻量选择

如果你的应用是低价值的,比如小程序里的积分抽奖、测试网的Demo、游戏里的非核心随机操作(比如谁先开局),那用块哈希就够了——它不需要依赖第三方,Gas费极低,几乎不用额外成本。

不过块哈希的缺点很明显:安全性差,只能用最近256个块的哈希(超过256个块的哈希会被链上清除,返回空),而且前1-2个块的哈希,矿工还是有小概率操控(比如放弃坏块等下一个),所以只能用在“就算作弊也不会有大损失”的场景。下面是Solidity写的块哈希示例:

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

// 低价值抽奖用块哈希生成随机数,无需第三方依赖
contract LowValueRaffle {
    // 存储前一个块的高度,取它的哈希来生成随机数
    uint256 public previousBlock;
    // 存储生成的随机数
    uint256 public raffleRandom;
    // 标记是否开奖
    bool public isDrawed;

    constructor() {
        // 用前一个块的哈希,避免当前块哈希未生成的问题
        previousBlock = block.number - 1;
    }

    // 开奖函数,只能调用一次
    function draw() external {
        require(!isDrawed, "Already drew");
        // 获取块哈希,注意:只有最近256个块的哈希有效
        bytes32 hash = blockhash(previousBlock);
        require(hash != bytes32(0), "Block too old, can't get hash");
        // 取模100得到0-99的中奖号
        raffleRandom = uint256(hash) % 100;
        isDrawed = true;
    }

    // 获取中奖号码
    function getWinNum() public view returns (uint256) {
        require(isDrawed, "Not drawn yet");
        return raffleRandom;
    }
}

三、预言机随机数被提前预测的规避方法

很多开发者会用预言机(比如Chainlink)获取随机数,但有个容易忽略的风险:预言机生成的随机数,在“生成”和“你使用”之间的这段时间,会被链上的其他人提前看到,然后操控交易套利——比如你用预言机的随机数发NFT,有人看到随机数后立刻 mint 所有稀有NFT,这就是“提前预测”的问题。

3.1 延迟揭示机制

最简单的规避方法:设置“延迟使用随机数”。比如你发起请求的时候,告诉预言机“3个块之后再把随机数给我”,预言机就会把随机数存3个块,过了时间再返回给你,没人能提前拿到这个数,自然没法套利。

3.2 用VRF替代普通预言机随机数

VRF的随机数是不可预测的,因为只有公证节点有秘密种子,别人没法提前算出结果,从根源上避免了提前预测的问题,比延迟揭示更彻底,适合高价值场景。

3.3 阈值签名预言机

如果对安全性要求极高,可以用多个节点一起生成随机数(比如Chainlink的阈值VRF),多个节点的秘密种子组合成最终结果,单个节点没法控制随机数,彻底杜绝了单点操控的风险。

四、总结

做链上应用选随机数方案,核心看你的场景价值:如果是高价值的NFT、抽奖、DeFi,必须用VRF,哪怕Gas费高一点;如果是低价值的小功能,用块哈希就够了,成本极低;如果用预言机的话,一定要注意“提前预测”的风险,要么用延迟揭示,要么直接换VRF。另外,所有用随机数的场景,一定要避开时间戳、当前块哈希这些可被操控的链上数据,避免像早期项目一样踩坑。