Race Conditions и асинхронность
Race conditions (состояния гонки) — это класс уязвимостей, уникальный для асинхронных блокчейнов вроде TON. В Ethereum все вызовы в одной транзакции атомарны, а в TON каждое сообщение — отдельная транзакция. Между отправкой запроса и получением ответа состояние контракта может быть изменено другим сообщением.
В модуле M02 мы узнали, что в TON нет атомарных транзакций между контрактами. Каждое сообщение обрабатывается в отдельной транзакции, и между отправкой и получением ответа состояние системы может измениться. Это фундаментально отличается от Ethereum, где вызовы между контрактами (кроме re-entrancy) выполняются в одной транзакции.
Транзакции в TON НЕ атомарны между контрактами
Если контракт A отправляет сообщение контракту B и ждёт ответа, любой другой контракт может отправить сообщение контракту A в промежутке. Состояние A может измениться до получения ответа от B.
TOCTOU: Time of Check, Time of Use
TOCTOU (Time of Check, Time of Use) — классическая уязвимость, когда проверка условия и использование результата разнесены во времени.
Уязвимый сценарий
Контракт A (DEX) Контракт B (Пул ликвидности)
| |
| 1. Проверяем: price = 100 |
| |
| 2. Отправляем: buy(price=100) |
|------------------------------>|
| |
| [В ЭТОТ МОМЕНТ кто-то |
| меняет цену на 200] |
| |
| 3. Получаем ответ: куплено |
|<------------------------------|
| |
| Проблема: купили по старой |
| цене или по новой? |
Принцип: проверка и действие в одной транзакции
В TON единственный способ гарантировать атомарность — выполнить проверку и действие в одном handler, в одной транзакции:
// ПРАВИЛЬНО: проверка и действие в одной транзакции
receive(msg: Swap) {
let currentPrice: Int = self.price; // Читаем актуальное состояние
require(currentPrice <= msg.maxPrice, "Price too high"); // Проверяем
// Выполняем обмен с текущей ценой -- всё в одной транзакции
self.executeSwap(msg.amount, currentPrice);
}
// НЕПРАВИЛЬНО: проверяем в одном сообщении, действуем в другом
receive(msg: CheckPrice) {
// Отправляем цену обратно -- к моменту получения она может измениться
send(SendParameters{
to: sender(),
value: 0,
mode: SendRemainingValue,
body: PriceInfo{price: self.price}.toCell(),
});
}
Параллельные потоки сообщений
В TON контракт может отправить несколько сообщений одновременно. Они будут обработаны в разных транзакциях, возможно в разном порядке.
Проблема: разделяемое состояние
Контракт A
|
|--- Сообщение 1 ---> Контракт B
| |
|--- Сообщение 2 ---> Контракт C
| |
| B и C оба модифицируют |
| данные, связанные с A |
Если B и C оба отправят ответы, которые модифицируют состояние A, порядок обработки непредсказуем. Результат зависит от того, какой ответ придёт первым.
Решение: шардирование состояния (паттерн Jetton Wallet)
Jetton стандарт решает эту проблему элегантно — каждый владелец имеет свой контракт-кошелёк:
Jetton Master (глобальный)
|
|--- Jetton Wallet Alice (баланс Alice)
|--- Jetton Wallet Bob (баланс Bob)
|--- Jetton Wallet Charlie (баланс Charlie)
Каждый кошелёк хранит только свой баланс. При переводе от Alice к Bob:
- Wallet Alice уменьшает свой баланс (одна транзакция)
- Wallet Bob увеличивает свой баланс (другая транзакция)
Нет общего “глобального баланса”, который могли бы модифицировать параллельно. Это принцип проектирования TON — распределять состояние по отдельным контрактам.
Проектируйте контракты так, чтобы каждый контракт владел своими данными
Избегайте общего состояния, которое модифицируется из нескольких источников. Шардируйте данные: один контракт — одно ответственное лицо за конкретные данные.
Частые ошибки
- Доверяют состоянию, прочитанному до отправки сообщения: между чтением и обработкой ответа другой пользователь может изменить это состояние.
- Не используют паттерн «блокировка перед операцией»: без блокировки два параллельных сообщения могут дважды вывести одни и те же средства.
- Забывают о порядке обработки сообщений от разных отправителей: TON гарантирует порядок только для пары (отправитель и получатель), но не между разными отправителями.
- Не тестируют сценарии с одновременными действиями нескольких пользователей: race conditions проявляются только при конкурентном доступе.
Проверка знанийA DEX contract reads the current pool price, sends a swap message to the pool, then waits for a confirmation response to update the user's balance. Explain the TOCTOU vulnerability and how to fix it.
Replay-атаки и защита через seqno
Replay-атака — повторная отправка ранее валидного сообщения. Особенно опасна для внешних сообщений (external messages), которые приходят извне блокчейна.
Почему external messages уязвимы
Внешнее сообщение содержит подпись владельца. Если не защитить контракт, атакующий может перехватить подписанное сообщение и отправить его повторно:
1. Владелец подписывает: "Отправить 100 TON на адрес X"
2. Сообщение обработано, 100 TON отправлены
3. Атакующий берёт то же подписанное сообщение
4. Отправляет его снова -- ещё 100 TON уходят на адрес X!
Защита: seqno (sequence number)
Стандартный паттерн — порядковый номер в каждом сообщении:
contract SecureWallet {
seqno: Int as uint32;
publicKey: Int as uint256;
external(msg: SignedMessage) {
// 1. Проверяем подпись
require(checkSignature(msg.hash, msg.signature, self.publicKey),
"Invalid signature");
// 2. Проверяем seqno -- сообщение должно содержать
// СЛЕДУЮЩИЙ ожидаемый номер
require(msg.seqno == self.seqno, "Invalid seqno");
// 3. Принимаем сообщение (оплачиваем газ из баланса контракта)
acceptMessage();
// 4. Увеличиваем seqno -- повторная отправка будет отклонена
self.seqno += 1;
// 5. Выполняем операцию
self.processMessage(msg.payload);
}
}
После обработки seqno увеличивается на 1. Повторная отправка того же сообщения будет отклонена: msg.seqno (42) != self.seqno (43).
Валидация состояния после async-вызовов
Когда контракт получает ответ на отправленное ранее сообщение, нельзя полагаться на состояние, которое было на момент отправки:
// НЕПРАВИЛЬНО: полагаемся на сохранённое состояние
contract UnsafeAuction {
highestBid: Int as coins;
highestBidder: Address;
pendingRefund: Bool;
receive(msg: NewBid) {
require(msg.amount > self.highestBid, "Bid too low");
// Сохраняем состояние для "будущего" возврата
self.pendingRefund = true;
// Возвращаем предыдущую ставку
send(SendParameters{
to: self.highestBidder,
value: self.highestBid,
mode: 0,
bounce: true,
});
// Обновляем -- но что если до получения bounced
// придёт ещё одна NewBid?
self.highestBid = msg.amount;
self.highestBidder = sender();
}
}
Проблема: если между обработкой двух NewBid придёт bounce от возврата средств, состояние может стать несогласованным.
Правильный подход: идемпотентные операции
// ПРАВИЛЬНО: каждая операция самодостаточна
contract SafeAuction {
highestBid: Int as coins;
highestBidder: Address;
receive(msg: NewBid) {
// Перечитываем актуальное состояние в каждом handler
require(msg.amount > self.highestBid, "Bid too low");
let prevBidder: Address = self.highestBidder;
let prevBid: Int = self.highestBid;
// Обновляем состояние ПЕРЕД отправкой
self.highestBid = msg.amount;
self.highestBidder = sender();
// Возвращаем предыдущую ставку
send(SendParameters{
to: prevBidder,
value: prevBid,
mode: 0,
bounce: true,
});
}
// Bounced handler восстанавливает средства
bounced(src: bounced<EmptyMessage>) {
// Если возврат не удался, средства остаются на контракте
// Администратор может вернуть их вручную
}
}
Ключевые правила
- Проверка и действие — в одной транзакции. Никогда не проверяйте состояние в одном сообщении, а действуйте в другом
- Шардируйте состояние. Каждый контракт владеет своими данными — по паттерну Jetton Wallet
- Используйте seqno для защиты от replay-атак на внешние сообщения
- Не полагайтесь на “старое” состояние — после получения async-ответа всегда перечитывайте текущие значения
- Проектируйте идемпотентные handler-ы — каждый handler должен быть самодостаточным
В следующем уроке мы проанализируем три реальных продакшен-контракта и увидим, как эти паттерны безопасности применяются на практике.
Проверьте понимание
Закончили урок?
Отметьте его как пройденный, чтобы отслеживать свой прогресс
Войдите чтобы оценить урок