Мультисиг кошелёк
Мультисиг-кошелёк — это практический проект, объединяющий все ключевые концепции TON: сообщения, хранение состояния, авторизацию и обработку ошибок. В реальном мире мультисиг-кошельки используются для защиты крупных активов, корпоративных казначейств и управления DAO. Реализация мультисига — отличный способ закрепить навыки и создать полезный инструмент, который можно использовать в продакшене.
Мультисиг (multisig, multi-signature) — это кошелёк, требующий несколько подписей для выполнения операций. Вместо одного приватного ключа используется схема N-of-M: из M участников как минимум N должны одобрить операцию.
Зачем нужен мультисиг?
- Безопасность — компрометация одного ключа не даёт контроль над средствами
- Командное управление — несколько участников принимают решения совместно
- DAO-управление — голосование по расходам казны организации
- Защита от потери ключей — 2-of-3 позволяет восстановить доступ при потере одного ключа
Примеры конфигураций
| Конфигурация | Применение |
|---|---|
| 2-of-3 | Личный кошелёк (основной, резервный, хранитель) |
| 3-of-5 | Командная казна (5 учредителей, 3 для одобрения) |
| 5-of-9 | DAO-управление (совет из 9 членов) |
Архитектура: предложение -> одобрение -> исполнение
Мультисиг на TON работает по паттерну:
1. Участник создаёт Order (предложение)
2. Другие участники голосуют за/против
3. При достижении порога (threshold) ордер исполняется автоматически
Реализация на 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");
TON Multisig vs Ethereum Gnosis Safe
В Ethereum мультисиг (Safe) работает синхронно: все подписи собираются off-chain и отправляются одной транзакцией. В TON каждое одобрение — отдельная транзакция (отдельное сообщение). Это следствие модели акторов: контракт обрабатывает сообщения по одному. Преимущество: не нужно координировать подписантов для одновременной подписи.
Частые ошибки
- Хранят список подписантов в массиве вместо словаря (map): поиск по массиву стоит O(n) газа, а по словарю O(log n).
- Не предусматривают механизм ротации ключей (смены подписантов), хотя в реальности ключи могут быть скомпрометированы и нужна процедура замены.
- Забывают о replay protection для запросов на подпись: без проверки seqno или expiration злоумышленник может повторить уже одобренный запрос.
- Не ограничивают время жизни неподтверждённых запросов, из-за чего устаревшие pending-запросы накапливаются и занимают хранилище, увеличивая storage fees.
Проверка знанийПочему в TON-мультисиге каждое одобрение -- отдельная транзакция, в отличие от Ethereum Gnosis Safe, где все подписи отправляются разом?
Проверьте понимание
Закончили урок?
Отметьте его как пройденный, чтобы отслеживать свой прогресс
Войдите чтобы оценить урок