December 3, 2025

🎰 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

Контракт:

  1. проверяет, хватает ли TON на сеть + награду разработчику
  2. выбирает победителя
  3. распределяет:
  • devReward → разработчику
  • prizePayment → победителю

4. Возврат остатка

Если пользователь отправил больше, чем нужно — контракт делает refund, удерживая только gas и forward fee:

refund = valueLeft - gasConsumed() - FORWARD_FEE

5. После завершения раунда

Контракт:

  • очищает список игроков
  • обнуляет счётчик
  • увеличивает roundCounter
  • сохраняет победителя

Полный процесс полностью автоматизирован.


🎲 Механика выбора победителя

Контракт использует простую и честную систему:

  1. Генерируется случайное число:
winningSlot = random(1, roundSlotCounter + 1)
  1. Идёт перебор всех игроков и их «слотов»:
  • если сумма слотов ≥ winningSlot → этот игрок и есть победитель

Чем больше слотов покупает игрок — тем выше шанс.


💸 Распределение средств

Призовой фонд:

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")

Контракт:

  1. рассчитывает сумму возвратов
  2. убеждается, что хватает TON
  3. возвращает деньги всем участникам

Полностью безопасная отмена раунда.


🧠 Полный код контракта

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;
    }
}

https://studio.rubeton.app/