Перейти к содержанию
Learning Platform
Глоссарий Troubleshooting
Урок 04.07 · 30 мин
Продвинутый
MultisigWalletSecurityVotingThreshold

Мультисиг кошелёк

Мультисиг-кошелёк — это практический проект, объединяющий все ключевые концепции TON: сообщения, хранение состояния, авторизацию и обработку ошибок. В реальном мире мультисиг-кошельки используются для защиты крупных активов, корпоративных казначейств и управления DAO. Реализация мультисига — отличный способ закрепить навыки и создать полезный инструмент, который можно использовать в продакшене.

Мультисиг (multisig, multi-signature) — это кошелёк, требующий несколько подписей для выполнения операций. Вместо одного приватного ключа используется схема N-of-M: из M участников как минимум N должны одобрить операцию.


Зачем нужен мультисиг?

  • Безопасность — компрометация одного ключа не даёт контроль над средствами
  • Командное управление — несколько участников принимают решения совместно
  • DAO-управление — голосование по расходам казны организации
  • Защита от потери ключей — 2-of-3 позволяет восстановить доступ при потере одного ключа

Примеры конфигураций

КонфигурацияПрименение
2-of-3Личный кошелёк (основной, резервный, хранитель)
3-of-5Командная казна (5 учредителей, 3 для одобрения)
5-of-9DAO-управление (совет из 9 членов)

Архитектура: предложение -> одобрение -> исполнение

Мультисиг на TON работает по паттерну:

1. Участник создаёт Order (предложение)
2. Другие участники голосуют за/против
3. При достижении порога (threshold) ордер исполняется автоматически
Поток Multisig: предложение → одобрение → исполнение
Signer 1
CreateOrder
Signer 2
ApproveOrder
Signer 3
ApproveOrder
MultisigWallet
threshold: 2
signers: 3
orders: map
Execute
send TON

Реализация на Tact

Структуры данных

import "@stdlib/deploy";

// Предложение на отправку средств
message CreateOrder {
    to: Address;
    amount: Int as coins;
    description: Int as uint32;     // ID описания
}

// Одобрение предложения
message ApproveOrder {
    orderId: Int as uint32;
}

// Структура хранения ордера
struct Order {
    to: Address;
    amount: Int as coins;
    approvals: Int as uint8;        // Количество одобрений
    executed: Bool;
    creator: Address;
    createdAt: Int as uint64;
}

Контракт MultisigWallet

contract MultisigWallet with Deployable {
    // Конфигурация
    threshold: Int as uint8;          // Минимум подписей
    signerCount: Int as uint8;        // Общее число подписантов

    // Состояние
    signers: map<Address, Bool>;      // Список подписантов
    orders: map<Int, Order>;          // Активные ордера
    approvals: map<Int, map<Address, Bool>>; // Голоса по ордерам
    nextOrderId: Int as uint32;       // Счётчик ордеров

    init(signer1: Address, signer2: Address, signer3: Address) {
        // 2-of-3 мультисиг
        self.threshold = 2;
        self.signerCount = 3;
        self.nextOrderId = 0;

        // Регистрируем подписантов
        self.signers.set(signer1, true);
        self.signers.set(signer2, true);
        self.signers.set(signer3, true);
    }

    // Создание нового ордера
    receive(msg: CreateOrder) {
        self.requireSigner();

        let orderId: Int = self.nextOrderId;
        self.nextOrderId += 1;

        // Создаём ордер
        self.orders.set(orderId, Order{
            to: msg.to,
            amount: msg.amount,
            approvals: 1,              // Создатель автоматически одобряет
            executed: false,
            creator: sender(),
            createdAt: now(),
        });

        // Записываем голос создателя
        let votes: map<Address, Bool> = emptyMap();
        votes.set(sender(), true);
        self.approvals.set(orderId, votes);

        // Проверяем, не достигнут ли порог сразу
        if (1 >= self.threshold) {
            self.executeOrder(orderId);
        }
    }

    // Одобрение ордера
    receive(msg: ApproveOrder) {
        self.requireSigner();

        let order: Order = self.orders.get(msg.orderId)!!;
        require(!order.executed, "Already executed");

        // Проверяем, что подписант ещё не голосовал
        let votes: map<Address, Bool> = self.approvals.get(msg.orderId)!!;
        require(votes.get(sender()) == null, "Already approved");

        // Записываем голос
        votes.set(sender(), true);
        self.approvals.set(msg.orderId, votes);

        // Увеличиваем счётчик одобрений
        order.approvals += 1;
        self.orders.set(msg.orderId, order);

        // Проверяем порог
        if (order.approvals >= self.threshold) {
            self.executeOrder(msg.orderId);
        }
    }

    // Исполнение ордера (внутренняя функция)
    fun executeOrder(orderId: Int) {
        let order: Order = self.orders.get(orderId)!!;
        require(!order.executed, "Already executed");
        require(order.approvals >= self.threshold, "Not enough approvals");

        // Помечаем как исполненный
        order.executed = true;
        self.orders.set(orderId, order);

        // Отправляем средства
        send(SendParameters{
            to: order.to,
            value: order.amount,
            mode: SendPayGasSeparately,
            bounce: true,
            body: "Multisig transfer".asComment(),
        });
    }

    // Проверка, что отправитель -- подписант
    fun requireSigner() {
        require(self.signers.get(sender()) != null, "Not a signer");
    }

    // Пополнение баланса
    receive("deposit") {
        // Просто принять TON
    }

    // Get-методы
    get fun threshold(): Int {
        return self.threshold;
    }

    get fun signerCount(): Int {
        return self.signerCount;
    }

    get fun nextOrderId(): Int {
        return self.nextOrderId;
    }

    get fun balance(): Int {
        return myBalance();
    }
}

Тестирование в Sandbox

Полный тест жизненного цикла: создание ордера, одобрение, исполнение.

import { Blockchain, SandboxContract, TreasuryContract } from '@ton/sandbox';
import { toNano } from '@ton/core';
import { MultisigWallet } from '../wrappers/MultisigWallet';

describe('MultisigWallet', () => {
    let blockchain: Blockchain;
    let signer1: SandboxContract<TreasuryContract>;
    let signer2: SandboxContract<TreasuryContract>;
    let signer3: SandboxContract<TreasuryContract>;
    let recipient: SandboxContract<TreasuryContract>;
    let multisig: SandboxContract<MultisigWallet>;

    beforeEach(async () => {
        blockchain = await Blockchain.create();
        signer1 = await blockchain.treasury('signer1');
        signer2 = await blockchain.treasury('signer2');
        signer3 = await blockchain.treasury('signer3');
        recipient = await blockchain.treasury('recipient');

        multisig = blockchain.openContract(
            await MultisigWallet.fromInit(
                signer1.address,
                signer2.address,
                signer3.address
            )
        );

        // Деплой + пополнение
        await multisig.send(
            signer1.getSender(),
            { value: toNano('10') },
            { $$type: 'Deploy', queryId: 0n }
        );
    });

    it('should create order (1 approval)', async () => {
        await multisig.send(
            signer1.getSender(),
            { value: toNano('0.05') },
            {
                $$type: 'CreateOrder',
                to: recipient.address,
                amount: toNano('1'),
                description: 1,
            }
        );

        // Ордер создан, но не исполнен (1 из 2)
        const orderId = await multisig.getNextOrderId();
        expect(orderId).toEqual(1n);
    });

    it('should execute after threshold reached', async () => {
        // Signer1 создаёт ордер (1 одобрение)
        await multisig.send(
            signer1.getSender(),
            { value: toNano('0.05') },
            {
                $$type: 'CreateOrder',
                to: recipient.address,
                amount: toNano('1'),
                description: 1,
            }
        );

        // Signer2 одобряет (2 одобрения = threshold)
        const result = await multisig.send(
            signer2.getSender(),
            { value: toNano('0.05') },
            {
                $$type: 'ApproveOrder',
                orderId: 0,
            }
        );

        // Проверяем, что средства отправлены
        expect(result.transactions).toHaveTransaction({
            from: multisig.address,
            to: recipient.address,
            success: true,
        });
    });

    it('should reject non-signer', async () => {
        const outsider = await blockchain.treasury('outsider');

        const result = await multisig.send(
            outsider.getSender(),
            { value: toNano('0.05') },
            {
                $$type: 'CreateOrder',
                to: recipient.address,
                amount: toNano('1'),
                description: 1,
            }
        );

        expect(result.transactions).toHaveTransaction({
            from: outsider.address,
            to: multisig.address,
            success: false,   // Отклонено -- не подписант
        });
    });
});

Безопасность мультисига

Защита от повторного исполнения (Replay Protection)

В нашей реализации каждый ордер имеет уникальный orderId (автоинкремент) и флаг executed. Повторное исполнение невозможно:

require(!order.executed, "Already executed");

Истечение срока действия (Expiration)

В production-контракте стоит добавить срок действия ордера:

// В executeOrder:
require(now() - order.createdAt < 86400, "Order expired"); // 24 часа

Защита от двойного голосования

require(votes.get(sender()) == null, "Already approved");
NOTE

TON Multisig vs Ethereum Gnosis Safe

В Ethereum мультисиг (Safe) работает синхронно: все подписи собираются off-chain и отправляются одной транзакцией. В TON каждое одобрение — отдельная транзакция (отдельное сообщение). Это следствие модели акторов: контракт обрабатывает сообщения по одному. Преимущество: не нужно координировать подписантов для одновременной подписи.


Частые ошибки

  1. Хранят список подписантов в массиве вместо словаря (map): поиск по массиву стоит O(n) газа, а по словарю O(log n).
  2. Не предусматривают механизм ротации ключей (смены подписантов), хотя в реальности ключи могут быть скомпрометированы и нужна процедура замены.
  3. Забывают о replay protection для запросов на подпись: без проверки seqno или expiration злоумышленник может повторить уже одобренный запрос.
  4. Не ограничивают время жизни неподтверждённых запросов, из-за чего устаревшие pending-запросы накапливаются и занимают хранилище, увеличивая storage fees.

Проверка знанийKnowledge check
Почему в TON-мультисиге каждое одобрение -- отдельная транзакция, в отличие от Ethereum Gnosis Safe, где все подписи отправляются разом?
ОтветAnswer
Это следствие модели акторов TON: контракт -- изолированный актор, который обрабатывает сообщения по одному. Каждое одобрение -- это отдельное сообщение от подписанта к контракту, обрабатываемое в отдельной транзакции. В Ethereum синхронная модель позволяет собрать подписи off-chain и проверить их все в одном вызове. В TON это невозможно, зато не нужно координировать подписантов -- каждый голосует в удобное время.

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 10