Розробка контрактів лотерей на блокчейні
Найпоширеніша помилка при розробці блокчейн-лотереї — використовувати block.timestamp, block.prevrandao (раніше block.difficulty) або хеш блока як джерело випадковості. Майнер/валідатор контролює ці значення в межах розумного діапазону. Для ставки в $100 атака нерентабельна, для джекпота в $500K — цілком.
Чому on-chain randomness не працює без VRF
block.prevrandao в Ethereum після The Merge надає 1 біт впливу валідатора (включити або не включати блок). Це краще за 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 (трансфер 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 завжди в межах [0, participants.length).
Для тестнета — Sepolia з реальним VRF Coordinator. Потрібен LINK на тестнете (faucet.chain.link) та підписка на vrf.chain.link.
Сроки
Базовий лотерейний контракт з VRF та Automation: 3-5 днів розробки + 1-2 дні тестування. Контракт з розширеною токеномікою (кілька пулів, NFT-білети, реферальна система) — 2-3 тижні. Аудит рекомендований для будь-якого контракту з пулом >$50K.
Вартість розраховується індивідуально після уточнення механіки розіграшу та вимог до токеноміки.







