Разработка контрактов лотерей на блокчейне
Самая частая ошибка при разработке блокчейн-лотереи — использовать block.timestamp, block.prevrandao (бывший block.difficulty) или хэш блока как источник случайности. Майнер/валидатор контролирует эти значения в пределах разумного диапазона. Для ставки в $100 атака нерентабельна, для джекпота в $500K — вполне.
Почему on-chain randomness не работает без VRF
block.prevrandao в Ethereum после The Merge предоставляет 1 бит влияния валидатора (include or not include the block). Это лучше, чем block.difficulty, но всё равно не годится для лотереи. RANDAO — агрегированная entropy от всех валидаторов в эпохе, последний reveal имеет 1 бит влияния на финальное значение. Для лотереи с пулом >1M$ это экономически атакуемо: валидатор может скрыть reveal и не финализировать блок, если случайное число его не устраивает.
Chainlink VRF v2 решает проблему криптографически: случайное число генерируется off-chain с доказательством корректности (VRF proof), которое верифицируется on-chain перед использованием. Подделать random невозможно — даже для Chainlink. Это не «доверяем оракулу», это верифицируемая случайность.
Архитектура лотерейного контракта с VRF
Базовая структура: контракт принимает участников, накапливает пул, в момент розыгрыша запрашивает случайное число у Chainlink VRF, получает его в коллбэке, выбирает победителя.
// 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 Lottery is VRFConsumerBaseV2Plus {
uint256 public subscriptionId;
bytes32 public keyHash;
uint32 public callbackGasLimit = 100000;
uint16 public requestConfirmations = 3;
address[] public participants;
uint256 public pendingRequestId;
LotteryState public state;
enum LotteryState { OPEN, DRAWING, CLOSED }
function drawWinner() external onlyOwner {
require(state == LotteryState.OPEN, "Not open");
require(participants.length > 0, "No participants");
state = LotteryState.DRAWING;
pendingRequestId = s_vrfCoordinator.requestRandomWords(
VRFV2PlusClient.RandomWordsRequest({
keyHash: keyHash,
subId: subscriptionId,
requestConfirmations: requestConfirmations,
callbackGasLimit: callbackGasLimit,
numWords: 1,
extraArgs: VRFV2PlusClient._argsToBytes(
VRFV2PlusClient.ExtraArgsV1({nativePayment: false})
)
})
);
}
function fulfillRandomWords(
uint256 requestId,
uint256[] calldata randomWords
) internal override {
require(requestId == pendingRequestId, "Wrong requestId");
uint256 winnerIndex = randomWords[0] % participants.length;
address winner = participants[winnerIndex];
state = LotteryState.CLOSED;
// выплата победителю
payable(winner).transfer(address(this).balance);
}
}
Критичные детали реализации
requestConfirmations — сколько блоков ждать перед генерацией random. Минимум 3, рекомендованное для mainnet — от 5. Меньше — быстрее, но в теории открывает окно для атаки через chain reorg (хотя на Ethereum с finality ~12.8 минут это практически исключено).
callbackGasLimit — лимит газа для fulfillRandomWords. Если внутри коллбэка логика тратит больше газа — транзакция от Chainlink упадёт, random будет утерян, контракт зависнет в состоянии DRAWING. Считайте газ: итерация по 1000 участников внутри коллбэка потребует ~21K gas только на чтение адресов из storage. Лучше хранить только winnerIndex в коллбэке и позволить победителю самому claim приз.
Подписка vs Direct Funding. Subscription model (рекомендована): предварительный депозит LINK, несколько контрактов используют один баланс. Direct Funding: каждый запрос VRF оплачивается из баланса самого контракта. Для лотерей с регулярными розыгрышами — subscription дешевле операционно.
Уязвимости лотерейных контрактов
Front-running розыгрыша
Если момент розыгрыша известен заранее (например, timestamp), MEV-боты могут купить последний билет в одном блоке с транзакцией drawWinner. При малом числе участников это изменяет вероятности. Решение: commit-reveal для покупки билетов, или закрытие продаж за N блоков до розыгрыша.
Reentrancy при выплате
Классика. Паттерн push (transfer ETH победителю в fulfillRandomWords) + внешний код победителя = reentrancy. Используем pull-паттерн: победитель сам вызывает claimPrize(), в котором обновляем состояние до перевода средств.
Централизация управления
onlyOwner на drawWinner — это централизация. Владелец контракта может тянуть с розыгрышем, ждать выгодного момента (хотя randomness от VRF он не контролирует). Лучший вариант: автоматизированный розыгрыш через Chainlink Automation или Gelato, без ручного вызова.
Интеграция с Chainlink Automation
Розыгрыш по расписанию или по условию (накопилось N участников) без ручного вызова:
function checkUpkeep(bytes calldata)
external view override returns (bool upkeepNeeded, bytes memory) {
upkeepNeeded = (
state == LotteryState.OPEN &&
participants.length >= minParticipants &&
block.timestamp >= nextDrawTime
);
}
function performUpkeep(bytes calldata) external override {
// вызывается Chainlink Automation когда checkUpkeep == true
drawWinner();
}
Это устраняет единую точку отказа и централизацию управления.
Тестирование и аудит
Тесты на Foundry с mock VRF Coordinator — обязательны перед деплоем. Chainlink предоставляет VRFCoordinatorV2_5Mock для локального тестирования. Fuzzing на параметры numParticipants, randomWord — проверяем, что winner всегда в bounds [0, participants.length).
Для тестнета — Sepolia с реальным VRF Coordinator. Нужен LINK на тестнете (faucet.chain.link) и подписка на vrf.chain.link.
Сроки
Базовый лотерейный контракт с VRF и Automation: 3-5 дней разработки + 1-2 дня тестирования. Контракт с расширенной токеномикой (несколько пулов, NFT-билеты, реферальная система) — 2-3 недели. Аудит рекомендован для любого контракта с пулом >$50K.
Стоимость рассчитывается индивидуально после уточнения механики розыгрыша и требований к токеномике.







