Solidity 0.8.27 + OZ V5 实战:构建 AI 驱动的去中心化预测市场核心合约

前言

本文将采用 Solidity 0.8.27+ 编译器及最新的 OpenZeppelin Contracts V5 工业级标准库,利用这套能实现极致 Gas 优化与资金安全的前沿现代化 EVM 技术栈,构建并测试一个高性能的去中心化预测市场核心智能合约;同时,本文还将深度剖析 Rain 协议与行业龙头 Polymarket 的本质区别。


一、 行业格局:Rain Protocol 与 Polymarket 的本质区别

一句话总结:Polymarket 是预测市场里的"亚马逊"(面向C端用户的成品App),而 Rain Protocol 则是预测市场里的"Shopify"(面向B端开发者的开店工具)。

核心区别如下:

  • 业务定位:Polymarket 是独立的对赌平台(To C);Rain 是让任何项目、社区一键内嵌预测功能的底层基础设施(To B)。
  • 代币结算:Polymarket 长期绑定 USDC 等主流稳定币;Rain 原生支持多代币,允许任何社区用自己的项目代币或 Meme 币进行结算。
  • 裁判机制:Polymarket 依赖 UMA 人类预言机仲裁,结算需数小时或数天;Rain 深度整合 AI 自动化预言机,秒级解析全网数据,实现秒级高频结算。

二、 技术栈核心选型优势

为了支撑 Rain 协议所需的多代币高频交互AI 秒级自动化结算 ,顶尖的区块链项目在底层架构上都会不约而同地选择 Solidity 0.8.27 + OZ V5 这套工业级高标组合:

  • 极致的 Gas 优化(Custom Errors 替代 Require) :在 L2 网络中,由于预测市场涉及高频的创建、下注与清算,传统的 require(condition, "Error String") 会因存储冗长字符串而产生不必要的执行开销。Solidity 0.8.27 配合 OpenZeppelin V5 全面拥抱了 custom error(自定义错误),编译时仅保留 4 字节的选择器(Selector),为高频用户最大化榨干 L2 的 Gas 费率红利。

  • OpenZeppelin V5 架构演进

    • 显式所有权初始化 :V5 版本的 Ownable 彻底废弃了在构造函数中暗中将 msg.sender 设为管理员的隐式设计。现在它强行要求在构造时显式传入 _initialOwner,大幅提升了通过"工厂合约(Factory Pattern)"跨链动态部署无数个预测市场时的安全性。
    • 模块化安全性 :全新的 ReentrancyGuard(防重入)和 SafeERC20 针对防假充值漏洞、多代币兼容性以及降低存储插槽(Storage Slot)开销进行了底层重写。
  • EVM 现代指令集适配 :高版本的编译器能完美利用 Cancun(坎昆)或更先进的硬分叉指令集(如 MCOPY 内存拷贝指令),使复杂的动态数组操作或批量多代币清算逻辑的执行效率达到极致。


三、 核心智能合约实现:PredictionMarket.sol

以下是基于 Rain 协议核心逻辑精简并升级的智能合约。它实现了预测市场的创建、用户对不同结局(YES/NO)的对赌下注、管理员/预言机结算、以及根据投注比例完美清算分红的核心流程:

js 复制代码
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.27;

// 引入 OpenZeppelin V5 标准库
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

/**
 * @title Rain 预测市场核心合约
 * @notice 基于 Solidity 0.8.27 + OpenZeppelin V5 构建
 */
contract RainPredictionMarket is Ownable, ReentrancyGuard {
    using SafeERC20 for IERC20;

    // ==========================================
    // 状态变量与类型定义
    // ==========================================
    
    IERC20 public immutable bettingToken; // 预测市场使用的代币(如 USDC)

    enum MarketState { Open, Closed, Resolved, Cancelled }
    enum Outcome { None, Yes, No }

    struct Market {
        string description;       // 市场描述(如:2026年某事件是否发生)
        uint256 endTime;          // 下注截止时间戳
        uint256 totalYesBets;     // YES 资金池总量
        uint256 totalNoBets;      // NO 资金池总量
        MarketState state;        // 市场当前状态
        Outcome winner;           // 最终结算结果
    }

    uint256 public marketCount;
    mapping(uint256 => Market) public markets;
    
    // 记录用户下注额度: marketId => userAddress => Outcome => amount
    mapping(uint256 => mapping(address => mapping(Outcome => uint256))) public userBets;

    // ==========================================
    // OpenZeppelin V5 自定义错误 (Custom Errors)
    // ==========================================
    error MarketNotOpen();
    error BettingEnded();
    error InvalidOutcome();
    error ZeroAmount();
    error MarketNotResolved();
    error NoWinningShares();
    error ArrayLengthMismatch();

    // ==========================================
    // 事件 (Events)
    // ==========================================
    event MarketCreated(uint256 indexed marketId, string description, uint256 endTime);
    event BetPlaced(uint256 indexed marketId, address indexed user, Outcome indexed outcome, uint256 amount);
    event MarketResolved(uint256 indexed marketId, Outcome indexed winner);
    event MarketCancelled(uint256 indexed marketId);
    event RewardClaimed(uint256 indexed marketId, address indexed user, uint256 amount);

    // ==========================================
    // 构造函数 (Constructor)
    // ==========================================
    // 注意:OpenZeppelin V5 的 Ownable 需要将初始所有者地址显示传递给其构造函数
    constructor(address _bettingToken, address _initialOwner) Ownable(_initialOwner) {
        bettingToken = IERC20(_bettingToken);
    }

    // ==========================================
    // 核心业务函数 (Core Functions)
    // ==========================================

    /**
     * @notice 创建一个新的预测市场
     * @param _description 市场描述
     * @param _durationFromNow 从现在起至截止下注的秒数
     */
    function createMarket(string calldata _description, uint256 _durationFromNow) external onlyOwner {
        uint256 marketId = ++marketCount;
        uint256 endTime = block.timestamp + _durationFromNow;

        markets[marketId] = Market({
            description: _description,
            endTime: endTime,
            totalYesBets: 0,
            totalNoBets: 0,
            state: MarketState.Open,
            winner: Outcome.None
        });

        emit MarketCreated(marketId, _description, endTime);
    }

    /**
     * @notice 用户参与预测下注
     * @param _marketId 预测市场ID
     * @param _outcome 预测结果 (Yes=1, No=2)
     * @param _amount 下注代币数量
     */
    function placeBet(uint256 _marketId, Outcome _outcome, uint256 _amount) external nonReentrant {
        Market storage market = markets[_marketId];
        
        if (market.state != MarketState.Open) revert MarketNotOpen();
        if (block.timestamp >= market.endTime) revert BettingEnded();
        if (_outcome != Outcome.Yes && _outcome != Outcome.No) revert InvalidOutcome();
        if (_amount == 0) revert ZeroAmount();

        // 更新状态变量
        if (_outcome == Outcome.Yes) {
            market.totalYesBets += _amount;
        } else {
            market.totalNoBets += _amount;
        }
        userBets[_marketId][msg.sender][_outcome] += _amount;

        // 安全转移用户代币到本合约 (OZ V5 SafeERC20)
        bettingToken.safeTransferFrom(msg.sender, address(this), _amount);

        emit BetPlaced(_marketId, msg.sender, _outcome, _amount);
    }

    /**
     * @notice 由 AI 预言机/裁判(合约所有者)结算市场结果
     * @param _marketId 预测市场ID
     * @param _winner 获胜的结果 (Yes 或 No)
     */
    function resolveMarket(uint256 _marketId, Outcome _winner) external onlyOwner {
        Market storage market = markets[_marketId];
        
        if (market.state != MarketState.Open) revert MarketNotOpen();
        if (_winner != Outcome.Yes && _winner != Outcome.No) revert InvalidOutcome();

        market.state = MarketState.Resolved;
        market.winner = _winner;

        emit MarketResolved(_marketId, _winner);
    }

    /**
     * @notice 取消预测市场(用于应对突发不可抗力或无效预测)
     */
    function cancelMarket(uint256 _marketId) external onlyOwner {
        Market storage market = markets[_marketId];
        if (market.state != MarketState.Open) revert MarketNotOpen();

        market.state = MarketState.Cancelled;
        emit MarketCancelled(_marketId);
    }

    /**
     * @notice 预测成功者提取奖励(或市场取消后全额退款)
     * @param _marketId 预测市场ID
     */
    function claimReward(uint256 _marketId) external nonReentrant {
        Market storage market = markets[_marketId];
        uint256 payoutAmount = 0;

        if (market.state == MarketState.Resolved) {
            Outcome winner = market.winner;
            uint256 userWinningShares = userBets[_marketId][msg.sender][winner];
            if (userWinningShares == 0) revert NoWinningShares();

            uint256 totalWinningBets = (winner == Outcome.Yes) ? market.totalYesBets : market.totalNoBets;
            uint256 totalPool = market.totalYesBets + market.totalNoBets;

            // 完美的比例奖励计算:(用户持有的获胜额度 * 总资金池) / 总获胜额度
            payoutAmount = (userWinningShares * totalPool) / totalWinningBets;
            
            // 清零防止重复提款
            userBets[_marketId][msg.sender][winner] = 0;

        } else if (market.state == MarketState.Cancelled) {
            // 如果市场被取消,退还所有下注本金
            uint256 yesBet = userBets[_marketId][msg.sender][Outcome.Yes];
            uint256 noBet = userBets[_marketId][msg.sender][Outcome.No];
            payoutAmount = yesBet + noBet;
            if (payoutAmount == 0) revert ZeroAmount();

            userBets[_marketId][msg.sender][Outcome.Yes] = 0;
            userBets[_marketId][msg.sender][Outcome.No] = 0;
        } else {
            revert MarketNotResolved();
        }

        // 发送代币给用户
        bettingToken.safeTransfer(msg.sender, payoutAmount);
        emit RewardClaimed(_marketId, msg.sender, payoutAmount);
    }
}

四、 基于 Viem 与 Node 原生测试框架的集成测试

测试用例:

  • Rain Prediction Market Protocol Test Suite
    • 市场创建:管理员应能正确初始化预测市场并生成 ID
    • 下注功能:用户下注后应正确扣除资产,并更新全局资金池与个人额度
    • 结算领奖:市场产生胜者后,赢家应能按比例瓜分全部奖池
    • 异常拦截:非管理员无法创建或结算市场
    • 风控退款:市场被强制取消后,所有下注用户应能全额撤回本金
js 复制代码
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { parseUnits, getAddress } from "viem";
import { network } from "hardhat";

describe("Rain Prediction Market Protocol Test Suite", function () {
  // 共享夹具:统一部署 MockToken 和 预测市场核心合约
  async function deployFixture() {
    const { viem } = await (network as any).connect();
    const [owner, alice, bob] = await viem.getWalletClients();
    const publicClient = await viem.getPublicClient();

    // 1. 部署模拟下注代币 (假设 6 位小数,模拟 USDC)
    const mockUSDC = await viem.deployContract("TestUSDT", ["TestUSDT", "USDT", 6]);

    // 2. 部署预测市场核心合约 (传入下注代币地址和初始管理员地址)
    const predictionMarket = await viem.deployContract("RainPredictionMarket", [
      mockUSDC.address,
      owner.account.address,
    ]);

    // 3. 为测试用户 Alice 和 Bob 分发初始代币,并完成对市场合约的额度授权
    const initialMint = parseUnits("1000", 6); // 1000 USDC
    await mockUSDC.write.mint([alice.account.address, initialMint]);
    await mockUSDC.write.mint([bob.account.address, initialMint]);

    // 用户授权市场合约扣款
    await mockUSDC.write.approve([predictionMarket.address, initialMint], { account: alice.account });
    await mockUSDC.write.approve([predictionMarket.address, initialMint], { account: bob.account });

    return {
      mockUSDC,
      predictionMarket,
      owner,
      alice,
      bob,
      publicClient,
    };
  }

  it("市场创建:管理员应能正确初始化预测市场并生成 ID", async function () {
    const { predictionMarket } = await deployFixture();

    const description = "Will ETH break $5000 in 2026?";
    const duration = 3600n; // 1小时后截止

    // 创建第一个市场
    await predictionMarket.write.createMarket([description, duration]);
    
    // 验证市场总数
    const marketCount = await predictionMarket.read.marketCount();
    assert.equal(marketCount, 1n, "市场计数器未正确累加");

    // 读取市场详情并验证
    const market = await predictionMarket.read.markets([1n]);
    assert.equal(market[0], description, "市场描述信息不匹配");
    assert.equal(market[4], 0, "新创建的市场状态应为 Open (0)");
  });

  it("下注功能:用户下注后应正确扣除资产,并更新全局资金池与个人额度", async function () {
    const { predictionMarket, mockUSDC, alice, bob } = await deployFixture();

    // 创建市场ID = 1
    await predictionMarket.write.createMarket(["Will Rain Token launch today?", 3600n]);

    const aliceBet = parseUnits("100", 6); // Alice 投 YES 100
    const bobBet = parseUnits("200", 6);   // Bob 投 NO 200

    // Alice 下注 YES (Outcome = 1)
    await predictionMarket.write.placeBet([1n, 1, aliceBet], { account: alice.account });
    // Bob 下注 NO (Outcome = 2)
    await predictionMarket.write.placeBet([1n, 2, bobBet], { account: bob.account });

    // 验证代币扣除
    const aliceBalance = await mockUSDC.read.balanceOf([alice.account.address]);
    assert.equal(aliceBalance, parseUnits("900", 6), "Alice 余额扣除不正确");

    // 验证合约内全局资金池
    const market = await predictionMarket.read.markets([1n]);
    assert.equal(market[2], aliceBet, "YES 资金池统计错误");
    assert.equal(market[3], bobBet, "NO 资金池统计错误");

    // 验证个人下注账本映射
    const aliceLedger = await predictionMarket.read.userBets([1n, alice.account.address, 1]);
    assert.equal(aliceLedger, aliceBet, "Alice 个人下注额账本记录错误");
  });

  it("结算领奖:市场产生胜者后,赢家应能按比例瓜分全部奖池", async function () {
    const { predictionMarket, mockUSDC, alice, bob } = await deployFixture();

    await predictionMarket.write.createMarket(["Will AI Replace Engineers?", 3600n]);

    // Alice 投入 100 USDC 支持 YES
    await predictionMarket.write.placeBet([1n, 1, parseUnits("100", 6)], { account: alice.account });
    // Bob 投入 300 USDC 支持 NO
    await predictionMarket.write.placeBet([1n, 2, parseUnits("300", 6)], { account: bob.account });

    // 管理员(AI 裁判)结算市场,判定结果为 YES 赢 (Outcome = 1)
    await predictionMarket.write.resolveMarket([1n, 1]);

    // 验证状态变为 Resolved (2) 且赢家为 YES (1)
    const market = await predictionMarket.read.markets([1n]);
    assert.equal(market[4], 2, "市场状态未更新为 Resolved");
    assert.equal(market[5], 1, "市场赢家结果判定错误");

    // Alice 作为唯一赢家领取奖励(理应独吞总池 100 + 300 = 400 USDC)
    const beforeClaim = await mockUSDC.read.balanceOf([alice.account.address]); // 900
    await predictionMarket.write.claimReward([1n], { account: alice.account });
    const afterClaim = await mockUSDC.read.balanceOf([alice.account.address]);

    assert.equal(afterClaim - beforeClaim, parseUnits("400", 6), "赢家未获得正确的全量分红奖励");
  });

  it("异常拦截:非管理员无法创建或结算市场", async function () {
    const { predictionMarket, alice } = await deployFixture();

    // 越权创建市场拦截
    await assert.rejects(
      async () => {
        await predictionMarket.write.createMarket(["Hack Market", 3600n], { account: alice.account });
      },
      /OwnableUnauthorizedAccount/,
      "非 Owner 应无权创建市场"
    );
  });

  it("风控退款:市场被强制取消后,所有下注用户应能全额撤回本金", async function () {
    const { predictionMarket, mockUSDC, alice, bob } = await deployFixture();

    await predictionMarket.write.createMarket(["Invalid Oracle Event", 3600n]);

    await predictionMarket.write.placeBet([1n, 1, parseUnits("150", 6)], { account: alice.account });
    await predictionMarket.write.placeBet([1n, 2, parseUnits("250", 6)], { account: bob.account });

    // 管理员触发风控取消市场
    await predictionMarket.write.cancelMarket([1n]);

    // Alice 申请退款
    await predictionMarket.write.claimReward([1n], { account: alice.account });
    const aliceBalance = await mockUSDC.read.balanceOf([alice.account.address]);
    
    // 回到初始 1000 USDC
    assert.equal(aliceBalance, parseUnits("1000", 6), "市场取消后用户本金未能全额退回");
  });
});

五、部署脚本

js 复制代码
// scripts/deploy.js
import { network, artifacts } from "hardhat";
async function main() {
  // 连接网络
  const { viem } = await network.connect({ network: network.name });//指定网络进行链接
  
  // 获取客户端
  const [deployer] = await viem.getWalletClients();
  const publicClient = await viem.getPublicClient();
 
  const deployerAddress = deployer.account.address;
   console.log("部署者的地址:", deployerAddress);
  // 加载合约
  const TestUSDTArtifact = await artifacts.readArtifact("TestUSDT");
  const RainPredictionMarketArtifact = await artifacts.readArtifact("RainPredictionMarket");

  // 部署(构造函数参数:recipient, initialOwner)
  const TestUSDTArtifactHash = await deployer.deployContract({
    abi: TestUSDTArtifact.abi,//获取abi
    bytecode: TestUSDTArtifact.bytecode,//硬编码
    args: ["TestUSDT", "USDT", 6],//部署者地址,初始所有者地址
  });
   const TestUSDTReceipt = await publicClient.waitForTransactionReceipt({ hash: TestUSDTArtifactHash });
   console.log("USDT合约地址:", TestUSDTReceipt.contractAddress);
//
const RainPredictionMarketHash = await deployer.deployContract({
    abi: RainPredictionMarketArtifact.abi,//获取abi
    bytecode: RainPredictionMarketArtifact.bytecode,//硬编码
    args: [TestUSDTReceipt.contractAddress,deployerAddress],//部署者地址,初始所有者地址
  });
  // 等待确认并打印地址
  const RainPredictionMarketReceipt = await publicClient.waitForTransactionReceipt({ hash: RainPredictionMarketHash });
  console.log("预测市场合约地址:", RainPredictionMarketReceipt.contractAddress);
}

main().catch(console.error);

总结

至此基于 Solidity 0.8.27 + OpenZeppelin V5 技术栈,实现了一个高性能的去中心化预测市场核心合约 RainPredictionMarket.sol,并配套完整的 Viem/Node 测试套件与部署脚本。

核心能力:

  • 市场创建:管理员可创建带截止时间的二元预测市场(YES/NO)
  • 多代币下注 :用户通过 SafeERC20 安全投注,合约自动汇总资金池
  • AI 秒级结算:管理员(AI 预言机角色)可即时判定胜负,无需等待数小时的人类仲裁
  • 比例分红清算:赢家按投注占比瓜分总奖池,取消市场则全额退款
  • Gas 极致优化:全面采用 Custom Errors 替代字符串,适配 L2 高频场景

与 Polymarket 的本质差异:Polymarket 是面向 C 端的成品平台(绑定 USDC + UMA 人工仲裁),而 Rain Protocol 是面向 B 端的嵌入式基础设施(支持任意代币结算 + AI 自动化裁判)。

测试覆盖:市场创建、下注扣款、比例领奖、权限拦截、风控退款五大核心场景全部通过。

相关推荐
穗余1 天前
2026 AI x Web3 School共学营笔记-Day8-Agent Wallet
人工智能·web3·区块链
穗余2 天前
hermes agent出现Empty response原因和解决方案
人工智能·web3·区块链
穗余2 天前
2026 AI x Web3 School共学营笔记-Day7
人工智能·web3·区块链
明豆3 天前
以太坊智能合约生产实战 — 安全 · Gas 优化 · 链上监控
安全·区块链·智能合约
穗余4 天前
什么是ERC-8004
人工智能·web3·区块链
Joy T5 天前
【Web3】Hardhat工程架构中Solidity与TypeChain的协作机制
git·架构·typescript·web3·智能合约·hardhat·typechain
Joy T5 天前
【Web3】跨链资金池与消息路由:CCIP 智能合约集成实战与权限收束
git·web3·node·智能合约·hardhat
Joy T5 天前
【Web3】跨链 NFT 工程化实战:多环境配置与自动化状态查询机制
架构·web3·区块链·智能合约·hardhat·hardhat 3.x·跨链测试
Maimai108086 天前
React Query + Zustand 正确结合方式:不要把接口数据复制进 Store
前端·javascript·react.js·前端框架·web3·状态模式