Разработка игры Roulette на блокчейне
Рулетка на блокчейне — классический use case для provably fair гемблинга. Главная инженерная задача: обеспечить непредсказуемость результата, которую нельзя манипулировать ни казино, ни игроком. Решение — верифицируемая случайность через Chainlink VRF или commit-reveal схему. Разберём оба подхода и полную архитектуру.
Случайность: главная проблема
Нельзя использовать block.timestamp, block.prevrandao, или хэши блоков как источник случайности. Miner/validator может влиять на эти значения — это называется miner extractable value (MEV) атака на randomness. Для рулетки это означает, что нода может выбирать, включать или нет транзакцию в зависимости от того, выигрышный ли блок.
Chainlink VRF v2.5
Cryptographically secure, verifiable random numbers от decentralized oracle network:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import { VRFConsumerBaseV2Plus } from "@chainlink/contracts/src/v0.8/vrf/dev/VRFConsumerBaseV2Plus.sol";
import { VRFV2PlusClient } from "@chainlink/contracts/src/v0.8/vrf/dev/libraries/VRFV2PlusClient.sol";
contract RouletteGame is VRFConsumerBaseV2Plus {
uint256 public subscriptionId;
bytes32 public keyHash; // зависит от сети
enum BetType { Number, Red, Black, Even, Odd, Low, High }
struct Bet {
address player;
BetType betType;
uint8 number; // для Number ставки (0-36)
uint256 amount;
bool settled;
}
struct Round {
uint256 requestId;
uint8 result; // 0-36
bool fulfilled;
mapping(uint256 => Bet) bets;
uint256 betCount;
}
mapping(uint256 => Round) public rounds; // requestId → Round
mapping(uint256 => uint256) public requestToRound;
uint256 public currentRoundId;
uint256 public constant MAX_BET = 1 ether;
uint256 public constant HOUSE_EDGE = 270; // 2.7% (европейская рулетка)
event BetPlaced(uint256 roundId, address player, BetType betType, uint8 number, uint256 amount);
event RoundStarted(uint256 roundId, uint256 requestId);
event RoundSettled(uint256 roundId, uint8 result);
event WinningPaid(address player, uint256 amount);
constructor(
address vrfCoordinator,
uint256 _subscriptionId,
bytes32 _keyHash
) VRFConsumerBaseV2Plus(vrfCoordinator) {
subscriptionId = _subscriptionId;
keyHash = _keyHash;
currentRoundId = 1;
}
function placeBet(
BetType betType,
uint8 number
) external payable {
require(msg.value > 0 && msg.value <= MAX_BET, "Invalid bet amount");
if (betType == BetType.Number) {
require(number <= 36, "Invalid number");
}
Round storage round = rounds[currentRoundId];
uint256 betId = round.betCount++;
round.bets[betId] = Bet({
player: msg.sender,
betType: betType,
number: number,
amount: msg.value,
settled: false
});
emit BetPlaced(currentRoundId, msg.sender, betType, number, msg.value);
}
// Закрыть раунд и запросить случайность
function spinWheel() external returns (uint256 requestId) {
requestId = s_vrfCoordinator.requestRandomWords(
VRFV2PlusClient.RandomWordsRequest({
keyHash: keyHash,
subId: subscriptionId,
requestConfirmations: 3,
callbackGasLimit: 300_000,
numWords: 1,
extraArgs: VRFV2PlusClient._argsToBytes(
VRFV2PlusClient.ExtraArgsV1({ nativePayment: false })
)
})
);
requestToRound[requestId] = currentRoundId;
rounds[currentRoundId].requestId = requestId;
currentRoundId++;
emit RoundStarted(currentRoundId - 1, requestId);
}
// Callback от Chainlink VRF
function fulfillRandomWords(
uint256 requestId,
uint256[] calldata randomWords
) internal override {
uint256 roundId = requestToRound[requestId];
Round storage round = rounds[roundId];
// 0-36 включительно = 37 значений
uint8 result = uint8(randomWords[0] % 37);
round.result = result;
round.fulfilled = true;
emit RoundSettled(roundId, result);
_settleAllBets(roundId);
}
function _settleAllBets(uint256 roundId) internal {
Round storage round = rounds[roundId];
uint8 result = round.result;
for (uint256 i = 0; i < round.betCount; i++) {
Bet storage bet = round.bets[i];
if (bet.settled) continue;
bet.settled = true;
uint256 payout = _calculatePayout(bet, result);
if (payout > 0) {
payable(bet.player).transfer(payout);
emit WinningPaid(bet.player, payout);
}
}
}
function _calculatePayout(
Bet memory bet,
uint8 result
) internal pure returns (uint256) {
bool win = false;
uint256 multiplier = 0;
if (bet.betType == BetType.Number) {
win = (bet.number == result);
multiplier = 35; // 35:1
} else if (bet.betType == BetType.Red) {
win = _isRed(result);
multiplier = 1; // 1:1
} else if (bet.betType == BetType.Black) {
win = (!_isRed(result) && result != 0);
multiplier = 1;
} else if (bet.betType == BetType.Even) {
win = (result != 0 && result % 2 == 0);
multiplier = 1;
} else if (bet.betType == BetType.Odd) {
win = (result % 2 == 1);
multiplier = 1;
} else if (bet.betType == BetType.Low) {
win = (result >= 1 && result <= 18);
multiplier = 1;
} else if (bet.betType == BetType.High) {
win = (result >= 19 && result <= 36);
multiplier = 1;
}
if (!win) return 0;
return bet.amount + (bet.amount * multiplier); // stake + profit
}
// Красные числа европейской рулетки
function _isRed(uint8 n) internal pure returns (bool) {
uint256 redNumbers = 0x3A4A5251412C2B1A191009080706;
return (redNumbers >> n) & 1 == 1;
}
// Пополнение пула призов
receive() external payable {}
// Emergency: вывод призового пула (timelock + multisig в production)
function withdrawHouseBalance(uint256 amount) external onlyOwner {
payable(owner()).transfer(amount);
}
}
Комиссия и ликвидность
Казино должно иметь достаточный резерв для выплаты крупных выигрышей. Распространённые подходы:
Fixed house reserve — контракт держит фиксированный резерв, максимальная ставка ограничена процентом от резерва.
Liquidity pool модель — LP провайдеры депонируют ETH, получают долю house edge как доходность. Игроки играют против пула. Смарт-контракт вроде Liquidity.lp token. Это модель Rollbit, Stake Casino и других крупных on-chain казино.
Фронтенд и анимация
Сама рулетка — DOM canvas (React + Pixi.js или Three.js). Ключевые моменты:
- Анимация колеса запускается при вызове
spinWheel(), ещё до получения результата от VRF - Реальный результат приходит через ~30–60 секунд (время VRF callback)
- Колесо "случайно вращается" несколько полных оборотов, затем плавно тормозит на нужном секторе
- Event
RoundSettledот контракта — триггер для финальной позиции колеса
// Подписка на событие результата
const unwatch = publicClient.watchContractEvent({
address: ROULETTE_ADDRESS,
abi: rouletteAbi,
eventName: "RoundSettled",
args: { roundId: currentRoundId },
onLogs: (logs) => {
const result = logs[0].args.result;
rouletteWheel.stopAt(result); // анимация финального положения
showResult(result);
checkWinnings(result);
},
});
Сети и стоимость
Основной параметр выбора сети — стоимость VRF callback. На Ethereum mainnet это дорого. Рекомендуемые сети:
| Сеть | VRF стоимость | Время ответа | Рекомендация |
|---|---|---|---|
| Arbitrum | ~$0.30–0.80 | ~30–60 сек | Оптимально |
| Polygon | ~$0.01–0.05 | ~30–60 сек | Бюджетный вариант |
| Avalanche | ~$0.10–0.30 | ~30–60 сек | Хорошо |
| Base | ~$0.05–0.20 | ~30–60 сек | Растущая аудитория |
Для high-frequency вращений (несколько в минуту) — рассматриваем alternative VRF: Pyth Entropy (значительно дешевле Chainlink), или commit-reveal с server seed (менее decentralized, но мгновенно).







