前言
本文将采用 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)开销进行了底层重写。
- 显式所有权初始化 :V5 版本的
-
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 自动化裁判)。
测试覆盖:市场创建、下注扣款、比例领奖、权限拦截、风控退款五大核心场景全部通过。