🎰 TON Lottery — разбор смарт-контракта лотереи на Tact
В этой статье разберём полноценный смарт-контракт лотереи, написанный на Tact, который:
- принимает ставки пользователей
- автоматически завершает раунд при достижении лимита
- случайным образом выбирает победителя
- распределяет призовой фонд
- возвращает остатки средств
- позволяет владельцу обновлять параметры, отменять раунд и выводить баланс
Контракт реализует полностью автономную логику — от покупки билетов до выплаты выигрыша и комиссии разработчика.
📌 Основные параметры лотереи
Контракт использует следующие параметры:
- minEntryAmount — минимальная ставка за один слот
- trigerAmount — сумма для завершения раунда
- devReward — награда разработчику
- devWallet — кошелёк разработчика
- owner — владелец контракта
- maxSlotCount — рассчитанное количество слотов (trigerAmount / minEntryAmount)
- roundSlotCounter — сколько слотов уже куплено
- roundCounter — номер текущего раунда
При запуске контракт вычисляет максимальное количество слотов и определяет forward fee сети TON.
🧩 Как работает лотерея?
1. Пользователь отправляет сообщение "enter_lottery" вместе с TON
processEntry(sender, value)
Она рассчитывает, сколько слотов можно купить на отправленную сумму.
2. Покупка слотов
Каждый слот стоит minEntryAmount.
Пользователь может купить сразу много слотов одной транзакцией.
3. Завершение раунда
Когда куплено достаточно слотов:
roundSlotCounter >= maxSlotCount
4. Возврат остатка
Если пользователь отправил больше, чем нужно — контракт делает refund, удерживая только gas и forward fee:
refund = valueLeft - gasConsumed() - FORWARD_FEE
5. После завершения раунда
Полный процесс полностью автоматизирован.
🎲 Механика выбора победителя
Контракт использует простую и честную систему:
winningSlot = random(1, roundSlotCounter + 1)
Чем больше слотов покупает игрок — тем выше шанс.
💸 Распределение средств
Призовой фонд:
prizePool = min(maxSlotCount * minEntryAmount, myBalance() - storageReserve)
Выплата:
prizeAmount = prizePool - devReward - 2 * forwardFee
Итог:
- победитель получает prizeAmount
- разработчик получает devReward
- контракт сохраняет 0.02 TON резерв на существование
⛔ Админ-функции
Контракт поддерживает административные команды:
🔹 Обновление параметров
UpdateParams{ minEntryAmount, trigerAmount, devReward, devWallet }
Но обновление доступно только если раунд ещё не начался:
require(players.isEmpty())
🔹 Вывод средств владельцем
receive("withdraw")
Выводит весь доступный баланс, кроме обязательного резерва и forwardFee.
🔹 Отмена раунда
receive("cancelRound")
Полностью безопасная отмена раунда.
🧠 Полный код контракта
import "@stdlib/ownable";
// сообщение обновления параметров лотереи
message(0x3a8b046a) UpdateParams {
queryId: Int as uint64;
minEntryAmount: Int as coins; // цена 1 слота
trigerAmount: Int as coins; // сумма для окончания раунда
devReward: Int as coins; // выплата разработчику
devWallet: Address; // кошелек разработчика
}
// сообщение деплоя (ничего не делает)
message(0xf23ea34) Deploy {
queryId: Int as uint64;
}
// обязательный резерв для существования контракта
const STORAGE_RESERVE: Int = ton("0.02");
contract Lottery with Ownable {
id: Int as uint32; // ID лотереи
roundCounter: Int as uint32; // номер раунда
minEntryAmount: Int as coins; // стоимость 1 слота
maxSlotCount: Int as uint32; // слотов всего: triggerAmount / minEntryAmount
owner: Address; // владелец
trigerAmount: Int as coins; // сумма окончания раунда
devWallet: Address; // кошелек разработчика
devReward: Int as coins; // награда разработчику
roundWinners: map<Int, Address>; // история победителей
players: map<Address, Int>; // адрес игрока → сколько слотов купил
roundSlotCounter: Int as uint32; // сколько слотов куплено в текущем раунде
FORWARD_FEE: Int as coins; // комиссия сети на отправку
// ИНИЦИАЛИЗАЦИЯ — расчет слотов, установка параметров
init(id, minEntryAmount, devWallet, owner, trigerAmount, devReward) {
self.id = id;
self.roundCounter = 0;
self.roundSlotCounter = 0;
self.minEntryAmount = minEntryAmount;
self.devWallet = devWallet;
self.owner = owner;
self.trigerAmount = trigerAmount;
self.devReward = devReward;
self.maxSlotCount = trigerAmount / minEntryAmount; // слотов в раунде
self.FORWARD_FEE = getForwardFee(1, 288, false); // комиссия TON
}
receive(deploy: Deploy) {} // игнор события деплоя
receive() {} // игнор обычных TON без текстов
// Покупка слотов пользователем
receive("enter_lottery") {
self.processEntry(context().sender, context().value);
}
// Основная логика покупки слотов
fun processEntry(sender: Address, valueLeftStart: Int) {
let valueLeft = valueLeftStart; // сколько TON осталось использовать
let stopProcessing = false;
// цикл — покупаем слоты, пока хватает TON
while (valueLeft >= self.minEntryAmount && !stopProcessing) {
// сколько слотов осталось до конца раунда
let slotsLeft = self.maxSlotCount - self.roundSlotCounter;
// сколько слотов игрок может купить на свои TON
let possibleSlots = valueLeft / self.minEntryAmount;
// покупаем разрешенное количество
let slotCount = min(possibleSlots, slotsLeft);
if (slotCount > 0) {
// предыдущие покупки игрока
let prev = self.players.exists(sender) ? self.players.get(sender)!! : 0;
// обновляем количество его слотов
self.players.set(sender, prev + slotCount);
// увеличиваем счетчик раунда
self.roundSlotCounter += slotCount;
// списываем деньги
valueLeft -= slotCount * self.minEntryAmount;
// если раунд заполнен — пытаемся завершить
if (self.roundSlotCounter >= self.maxSlotCount) {
// нужно TON на devReward + 2 FORWARD_FEE
let requiredFee = 2 * self.FORWARD_FEE + self.devReward;
// хватает ли TON чтобы завершить раунд
if (myBalance() >= requiredFee) {
self.finishRound();
valueLeft -= self.devReward + 2 * self.FORWARD_FEE;
}
}
} else {
stopProcessing = true; // денег недостаточно → выйти
}
}
// Возврат остатка пользователю
let refund = valueLeft - gasConsumed() - self.FORWARD_FEE;
let balanceAfter = myBalance() - refund;
if (refund > 0 && balanceAfter > 0) {
self.transferTons(sender, refund, "Refund");
}
}
// Завершение раунда — выбор победителя и выплаты
fun finishRound() {
let winner = self.pickWinner(); // выбираем случайного игрока
if (winner != null) {
// сохраняем победителя в истории
self.roundWinners.set(self.roundCounter, winner!!);
// общий пул (не забираем storage reserve)
let prizePool = min(
self.maxSlotCount * self.minEntryAmount,
myBalance() - STORAGE_RESERVE
);
// приз победителю
let prizeAmount = prizePool - self.devReward - 2 * self.FORWARD_FEE;
// комиссия разработчику
self.transferTons(self.devWallet, self.devReward, "DevReward");
// отправляем приз
self.transferTons(winner!!, prizeAmount, "Prize");
}
// очистка раунда
self.players = emptyMap();
self.roundSlotCounter = 0;
self.roundCounter += 1;
}
// Универсальная отправка TON
fun transferTons(to: Address, value: Int, body: String) {
send(SendParameters{
to: to,
value: value,
mode: SendPayGasSeparately, // газ платит контракт
bounce: false,
body: body.asComment() // строка-комментарий для wallet
});
}
// Выбор случайного победителя
fun pickWinner(): Address? {
let winningSlot = random(1, self.roundSlotCounter + 1); // случайный слот
let current = 0;
// перебор игроков
foreach (addr, count in self.players) {
current += count;
if (winningSlot <= current)
return addr; // этот игрок попал в диапазон
}
return null;
}
// Обновление параметров лотереи
receive(msg: UpdateParams){
self.requireOwner();
require(self.players.isEmpty(), "Round already started");
self.minEntryAmount = msg.minEntryAmount;
self.trigerAmount = msg.trigerAmount;
self.devReward = msg.devReward;
self.devWallet = msg.devWallet;
}
// Вывод TON владельцем
receive("withdraw") {
self.requireOwner();
// доступный остаток (без хранения и комиссии)
let available = myBalance() - STORAGE_RESERVE - self.FORWARD_FEE;
require(available > 0, "Nothing to withdraw");
self.transferTons(self.owner, available, "Withdrawal");
}
// Отмена раунда → возврат всем участникам
receive("cancelRound") {
self.requireOwner();
let totalRefund = 0;
let playersCount = 0;
// суммируем возвраты
foreach (addr, count in self.players) {
totalRefund += count * self.minEntryAmount;
playersCount += 1;
}
// комиссия отправки каждому игроку
let totalFee = getForwardFee(1, 288, false) * playersCount;
let needed = totalRefund + totalFee + gasConsumed();
require(myBalance() >= needed, "Not enough balance");
// возвращаем деньги
foreach (addr, count in self.players) {
self.transferTons(addr, count * self.minEntryAmount, "CancelRefund");
}
// сброс
self.players = emptyMap();
self.roundSlotCounter = 0;
}
}