Интеграция Chainlink VRF для генерации случайных чисел в казино
Блокчейн-казино с честным рандомом — это не маркетинговое заявление, а техническая архитектура. Chainlink VRF (Verifiable Random Function) генерирует случайное число с криптографическим доказательством его корректности. On-chain верификация доказательства происходит до того, как число используется в логике игры — манипуляция исключена математически, а не организационно.
VRF v2.5: подписка vs Direct Funding
Актуальная версия — VRF v2.5. Два режима оплаты:
Subscription model. Создаётся подписка на vrf.chain.link, пополняется LINK. Несколько контрактов-казино используют один баланс. Подходит для продуктов с регулярными запросами — рулетка, слоты, карточные игры.
Direct Funding (VRFV2PlusWrapper). Контракт сам оплачивает каждый запрос LINK или нативным токеном (ETH, MATIC). Проще для запуска, нет нужды управлять отдельной подпиской. Но каждый запрос чуть дороже.
Для казино с высокой нагрузкой — subscription. Для NFT mint с рандомными атрибутами или разовых турниров — Direct Funding проще операционно.
Интеграция в контракт казино
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
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 immutable subscriptionId;
bytes32 public immutable keyHash;
struct Bet {
address player;
uint256 amount;
uint8 betType; // 0=red, 1=black, 2=number
uint8 number;
}
mapping(uint256 requestId => Bet) public pendingBets;
event BetPlaced(uint256 indexed requestId, address indexed player);
event GameResult(uint256 indexed requestId, uint8 result, bool won);
function placeBet(uint8 betType, uint8 number) external payable {
require(msg.value >= MIN_BET, "Below minimum");
require(msg.value <= MAX_BET, "Above maximum");
uint256 requestId = s_vrfCoordinator.requestRandomWords(
VRFV2PlusClient.RandomWordsRequest({
keyHash: keyHash,
subId: subscriptionId,
requestConfirmations: 3,
callbackGasLimit: 150_000,
numWords: 1,
extraArgs: VRFV2PlusClient._argsToBytes(
VRFV2PlusClient.ExtraArgsV1({nativePayment: false})
)
})
);
pendingBets[requestId] = Bet({
player: msg.sender,
amount: msg.value,
betType: betType,
number: number
});
emit BetPlaced(requestId, msg.sender);
}
function fulfillRandomWords(uint256 requestId, uint256[] calldata randomWords)
internal override {
Bet memory bet = pendingBets[requestId];
delete pendingBets[requestId];
uint8 result = uint8(randomWords[0] % 37); // 0-36
bool won = _checkWin(bet, result);
if (won) {
uint256 payout = _calculatePayout(bet);
payable(bet.player).transfer(payout);
}
emit GameResult(requestId, result, won);
}
}
Критичные детали
callbackGasLimit должен быть с запасом. Если газа не хватает в fulfillRandomWords — Chainlink не повторит вызов автоматически. Запрос будет потерян, ставка зависнет. Считайте реальный газ: forge test --gas-report. Для рулетки с выплатой и событиями — 150K газа достаточно. Для сложной логики с multiple bets — увеличивайте.
requestConfirmations: 3 — минимум. На Ethereum при реорге в 2 блока Chainlink может получить другой seed для random. 3 подтверждения — разумный компромисс между скоростью и безопасностью. Для jackpot ставок ставьте 5-10.
Не храните ставки в массиве. Mapping requestId => Bet правильный подход. Массив ставок с поиском по requestId — O(n) в коллбэке, gas griefing при большом числе pending ставок.
keyHash — выбор lane
Chainlink предоставляет несколько keyHash для одной сети — они отличаются максимальным gas price, который Chainlink готов потратить на deliver random. На Ethereum mainnet:
-
0x8af...— 200 gwei lane: Chainlink задержит delivery если gas price выше -
0x9fe...— 500 gwei lane: доставка даже при перегрузке сети (дороже)
Для казино с мгновенными играми берите 500 gwei lane — игрок не должен ждать часами при пиковой нагрузке.
Защита от злоупотреблений
Ставка размещена, random в пути. Что если игрок размещает ставку и ждёт результата на другом устройстве, потом отменяет если результат ему не понравится? В текущей архитектуре отмена невозможна — средства заблокированы в контракте до fulfillRandomWords. Это правильно.
Но что если random не пришёл в течение 24 часов (Chainlink недоступен, баланс подписки исчерпан)? Нужна функция возврата ставки с проверкой timeout:
function refundExpiredBet(uint256 requestId) external {
Bet memory bet = pendingBets[requestId];
require(bet.player == msg.sender, "Not your bet");
require(block.timestamp > betTimestamps[requestId] + 24 hours, "Not expired");
delete pendingBets[requestId];
payable(msg.sender).transfer(bet.amount);
}
Тестирование
Chainlink предоставляет VRFCoordinatorV2_5Mock для Foundry тестов — имитирует Coordinator и позволяет вручную вызвать fulfillRandomWords с заданным random:
vrfCoordinator.fulfillRandomWords(requestId, address(game));
Fuzz тест: проверяем корректность выплат при всех значениях randomWords[0] от 0 до 2^256-1. Граничный случай: randomWords[0] % 37 == 0 — зеро на рулетке, редкий edge case.
Сроки
Базовая интеграция VRF в существующий контракт игры: 1-2 дня. Новый контракт казино с VRF, выплатами, и защитой от timeout: 2-3 дня. Тестирование на Sepolia с реальным VRF — включено.
Стоимость рассчитывается после уточнения типа игры и механики ставок.







