Решение проблем System Design для TON Developer
Частые ошибки проектирования TON-приложений — симптомы, причины и пошаговые решения.
Область
Тип ошибки
Симптомы
- Контракт упирается в лимит газа при росте числа пользователей
- Невозможно параллельно обрабатывать транзакции — все идут через один аккаунт
- Стоимость одной операции растёт пропорционально размеру хранилища
Причина
Перенос монолитного паттерна из EVM: вся логика и состояние в одном контракте. В TON это создаёт единую точку сериализации — один аккаунт обрабатывает все транзакции последовательно, теряя преимущества шардирования.
Решение
- Декомпозируйте контракт по паттерну master–child: master хранит только глобальную конфигурацию
- Каждый пользователь или сущность получает собственный child-контракт с отдельным состоянием
- Используйте deterministic addressing (hash(master_address, user_id)) для вычисления адресов child-контрактов
- Проверьте, что child-контракты попадают в разные шарды для параллелизма
Связанные уроки:
Симптомы
- Средства отправлены, но не зачислены получателю — застряли в промежуточном контракте
- Состояние отправителя обновлено, но получатель не существует или отклонил сообщение
- Невозможно восстановить средства после неуспешной отправки
Причина
В TON сообщения — асинхронные. Если получатель не существует или выбрасывает исключение, сообщение возвращается (bounce). Без обработки bounce-сообщений контракт теряет средства и остаётся в несогласованном состоянии.
Решение
- Реализуйте on_bounce handler в каждом контракте, отправляющем сообщения с TON
- В bounce handler откатывайте состояние: верните баланс отправителю, сбросьте флаги
- Используйте op-коды для идентификации, какое именно исходящее сообщение вернулось
- Тестируйте bounce-сценарии: отправка на несуществующий адрес, отправка с недостаточным gas
Связанные уроки:
Симптомы
- Race conditions: два сообщения читают одно состояние до того, как первое завершит обновление
- Контракт ожидает ответ, которого может не быть — зависает в промежуточном состоянии
- Непредсказуемый порядок обработки сообщений при высокой нагрузке
Причина
Разработчик мыслит в терминах синхронных вызовов (как в Solidity), но в TON каждое сообщение — отдельная транзакция. Между отправкой и получением ответа состояние контракта может измениться другими сообщениями.
Решение
- Проектируйте контракты как state machines: каждое состояние явно определяет допустимые переходы
- Используйте lock-паттерн: помечайте операцию как in_progress, блокируя конкурентные изменения
- Не храните промежуточное состояние, зависящее от ответа — передавайте контекст в payload сообщения
- Проектируйте каждую операцию как идемпотентную: повторная отправка даёт тот же результат
Связанные уроки:
Симптомы
- Контракт начинает выполнение, но завершается с out-of-gas посередине операции
- Частичное обновление состояния: одна часть данных записана, другая нет
- Пользователь теряет TON — газ потрачен, но операция не завершена
Причина
Контракт не проверяет msg_value при получении сообщения. Если пользователь отправил недостаточно TON для покрытия всей цепочки вычислений и исходящих сообщений, выполнение обрывается на полпути.
Решение
- Добавьте проверку msg_value >= estimated_gas в самом начале recv_internal
- Рассчитайте минимальный газ с запасом: вычисления + storage fee + forward fee для исходящих сообщений
- При недостаточном газе — немедленно верните средства отправителю вместо начала операции
- Документируйте минимальный msg_value для каждого op-кода в контракте
Связанные уроки:
Симптомы
- Gas-стоимость операций растёт логарифмически с числом записей в Dictionary
- Один контракт хранит Dictionary на миллионы ключей — storage fee становится неподъёмным
- Невозможно масштабировать: все операции сериализуются через один аккаунт
Причина
Использование одного большого HashMap/Dictionary в контракте для хранения всех пользовательских данных. В TON каждая операция с Dictionary имеет стоимость O(log n) по глубине дерева Cell, и весь контракт — единая точка сериализации.
Решение
- Перенесите per-user данные в отдельные child-контракты (sharding по пользователям)
- Используйте Jetton-паттерн: master хранит метаданные, wallet-контракты хранят балансы
- Если Dictionary необходим, ограничьте его размер и используйте пагинацию
- Оцените storage fee заранее: dict_size × cell_cost × time
Связанные уроки:
Симптомы
- Аномальные свопы на DEX с мгновенным возвратом ликвидности
- Oracle-цена манипулирована в рамках одной транзакционной цепочки
- Потеря средств пула ликвидности за одну транзакцию
Причина
В TON асинхронные сообщения затрудняют классические flash-loan атаки, но аналогичные атаки возможны через цепочки сообщений в одном блоке. Протокол полагается только на текущую цену без TWAP или проверки временного окна.
Решение
- Используйте TWAP (Time-Weighted Average Price) вместо spot price для critical операций
- Добавьте минимальную задержку между deposit и withdraw в пулах ликвидности
- Ограничьте максимальный размер свопа относительно глубины пула
- Реализуйте circuit breaker: приостановка при аномальном движении цены
Связанные уроки:
Симптомы
- После обновления контракта все интеграции ломаются — адрес изменился
- Невозможно мигрировать на новую версию без координации со всеми клиентами
- Хардкод адресов в нескольких контрактах создаёт хрупкую связность
Причина
Контракты ссылаются друг на друга через захардкоженные адреса вместо registry-паттерна или deterministic addressing. При обновлении одного контракта вся цепочка зависимостей ломается.
Решение
- Используйте registry-контракт: единая точка разрешения адресов по именам/ключам
- Применяйте deterministic addressing: адрес вычисляется из init_state, а не хранится
- Реализуйте proxy-паттерн: стабильный адрес прокси, обновляемая логика за ним
- При необходимости хардкода — выносите адреса в configurable storage, а не в код
Связанные уроки:
Симптомы
- Контракт обработал 3 из 5 переводов в цикле, потом закончился газ
- Данные в Dictionary частично обновлены — часть записей в новом формате, часть в старом
- Пользователь видит inconsistent state после неудачной транзакции
Причина
Операции, требующие итерации по коллекции (batch-переводы, массовые обновления), не учитывают, что газ может закончиться на любой итерации. TVM не откатывает storage-изменения при out-of-gas.
Решение
- Проектируйте batch-операции с continuation: обработайте N элементов, отправьте себе сообщение с оставшимися
- Храните cursor/offset для возобновления обработки с точки остановки
- Рассчитайте газ на одну итерацию и проверяйте остаток газа перед каждой итерацией
- Предпочитайте sharded-подход: каждый элемент обрабатывается отдельным контрактом
Связанные уроки:
Симптомы
- Все переводы токенов проходят через один minter-контракт
- Gas растёт с числом держателей — Dictionary балансов увеличивается
- Переводы между пользователями невозможно параллелизировать
Причина
Реализация токена по ERC-20 паттерну: один контракт хранит mapping(address => balance). В TON это уничтожает шардирование — все операции сериализуются через один аккаунт вместо распределения по шардам.
Решение
- Следуйте стандарту TEP-74 (Jetton): minter + per-user wallet контракты
- Каждый wallet-контракт хранит баланс своего владельца и обрабатывает переводы
- Transfer: wallet_A отправляет сообщение wallet_B напрямую — без участия minter
- Minter используется только для mint/burn и хранения метаданных токена
Связанные уроки:
Симптомы
- Одна и та же транзакция может быть отправлена повторно с тем же эффектом
- Пользователь получает двойное начисление при повторной отправке сообщения
- Атакующий перехватывает и переигрывает успешные транзакции
Причина
Контракт не использует seqno, query_id или другой механизм для отслеживания уже обработанных сообщений. Каждое входящее сообщение с одинаковым payload обрабатывается как новое.
Решение
- Для external messages: используйте seqno (sequence number) с инкрементом при каждой обработке
- Для internal messages: используйте уникальный query_id и храните set обработанных ID
- Устанавливайте msg_valid_until для ограничения времени жизни сообщения
- Регулярно очищайте set обработанных query_id, удаляя записи старше TTL
Связанные уроки:
Симптомы
- Контракт принимает поддельные сообщения от произвольных адресов
- Атакующий имитирует child-контракт и вызывает привилегированные операции на master
- Средства выведены через фальшивый wallet-контракт
Причина
Master-контракт проверяет sender_address, но не верифицирует, что отправитель — настоящий child-контракт с правильным init_state. В TON адрес вычисляется из init_state — проверка адреса без проверки кода недостаточна.
Решение
- Вычисляйте ожидаемый адрес отправителя из init_state: hash(state_init) должен совпадать с sender
- Храните code hash child-контрактов в master и проверяйте при каждом входящем сообщении
- Не доверяйте sender_address без верификации — любой может задеплоить контракт с произвольным адресом
- Используйте паттерн из TEP-74: master знает wallet_code и вычисляет адреса детерминистически
Связанные уроки:
Симптомы
- Storage fee непропорционально высок для объёма хранимых данных
- Операции чтения/записи тратят больше газа, чем ожидалось
- Cell overflow: данные не помещаются в 1023 бита одной Cell
Причина
Данные упакованы без учёта структуры Cell tree в TVM. Неправильный порядок полей, избыточные Cell reference, неиспользование bits для маленьких значений — всё увеличивает число Cell и стоимость хранения.
Решение
- Группируйте часто читаемые поля в root Cell — минимизируйте traversal depth
- Используйте bit-packing: bool = 1 bit, small int = минимальное число бит вместо полных 256
- Размещайте Optional-поля в отдельных ref-Cell — не тратьте биты root Cell на Maybe-обёртки
- Профилируйте storage fee: подсчитайте cells × bits и сравните с оптимальной упаковкой
Связанные уроки:
Симптомы
- Проблемы обнаруживаются, когда пользователи жалуются, а не по метрикам
- Невозможно определить, когда контракт перестал обрабатывать сообщения
- Нет visibility в баланс газа контрактов — контракт замерзает неожиданно
Причина
Разработчик сфокусирован на функциональности контрактов, но не выстраивает off-chain мониторинг. Без наблюдения за балансами, throughput и error rate проблемы обнаруживаются слишком поздно.
Решение
- Настройте индексер (TON Index, custom) для отслеживания транзакций ваших контрактов
- Мониторьте баланс контрактов — alert при приближении к rent-due threshold
- Отслеживайте bounce rate: рост % bounced сообщений указывает на системную проблему
- Настройте дашборд с метриками: TPS, average gas per tx, error rate, message latency
Связанные уроки:
Симптомы
- Контракт считает операцию успешной, хотя вызванный контракт вернул ошибку
- Средства списаны, но целевая операция не выполнена
- Сложно отлаживать: нет информации о причине неудачи в цепочке сообщений
Причина
При получении ответного сообщения контракт не проверяет exit_code или op-код ответа. Успешное получение сообщения не означает успешное выполнение операции на стороне получателя.
Решение
- Определите протокол ответных сообщений: отдельные op-коды для success и error
- Проверяйте exit_code в bounced messages: code = 0xFFFF означает ошибку выполнения
- Логируйте результаты в emit-event для off-chain мониторинга
- Реализуйте timeout: если ответ не получен за N секунд, запускайте recovery procedure
Связанные уроки:
Симптомы
- Сериализация/десериализация съедает значительную часть газа каждой транзакции
- Новые разработчики не могут разобраться в формате данных контракта
- Ошибки при обновлении схемы — новые поля ломают парсинг старых данных
Причина
Схема данных спроектирована без учёта частоты доступа к полям и cost модели TVM. Глубокая вложенность Cell, неиспользуемые поля в hot path, отсутствие версионирования.
Решение
- Следуйте принципу: hot fields в root Cell, cold fields в глубоких ref
- Добавьте version byte в начало root Cell для совместимости при обновлениях
- Минимизируйте глубину Cell tree: каждый ref — дополнительный gas за cell load
- Используйте TL-B компилятор для генерации сериализаторов вместо ручного побитового парсинга
Связанные уроки:
Симптомы
- Обнаружен баг в production-контракте — невозможно исправить без миграции всех пользователей
- Средства заблокированы в контракте с неисправимой ошибкой
- Обновление требует координации с каждым пользователем для миграции на новый адрес
Причина
Контракт задеплоен без set_code/set_data механизма или governance для обновлений. В отличие от Ethereum proxy pattern, TON позволяет менять код контракта через SETCODE — но эту возможность нужно заложить при проектировании.
Решение
- Реализуйте admin-op для set_code с проверкой авторизации (governance multisig)
- Предусмотрите data migration: при смене кода новая версия должна уметь парсить старый формат данных
- Для decentralized протоколов используйте governance voting перед обновлением
- Добавьте timelock: между объявлением обновления и его применением должна быть задержка
Связанные уроки:
Симптомы
- Последнее сообщение в цепочке не доставляется — недостаточно TON для forward_fee
- Пользователь отправляет 1 TON, но до конечного получателя доходит 0.01 TON
- Часть цепочки выполняется, часть нет — inconsistent state между контрактами
Причина
При проектировании цепочки A→B→C→D каждое звено потребляет gas + forward_fee. Разработчик не учёл кумулятивные затраты и не зарезервировал достаточно TON на старте цепочки.
Решение
- Рассчитайте total_cost = sum(gas_i + forward_fee_i) для всей цепочки перед отправкой
- Используйте RAWRESERVE для гарантии сохранения minimum balance на каждом этапе
- Передавайте оставшийся баланс через flag 64 (carry remaining value) где возможно
- Добавьте buffer 20-30% к расчётному gas на случай увеличения storage fee
Связанные уроки:
Симптомы
- DeFi-протокол использует цену из одного источника — при его компрометации все операции под угрозой
- Oracle не обновлялся 24 часа — протокол работает с устаревшей ценой
- Манипуляция ценой одного oracle позволяет провести арбитражную атаку
Причина
Протокол полагается на единственный oracle-контракт для ценовых данных. Отсутствие агрегации, проверки freshness и fallback-механизма создаёт single point of failure.
Решение
- Агрегируйте данные из нескольких oracle источников: median(price_1, price_2, price_3)
- Проверяйте freshness: отклоняйте цену, если timestamp > max_staleness
- Установите price deviation threshold: если новая цена отличается от предыдущей более чем на X% — pause protocol
- Реализуйте fallback oracle: при недоступности основного — переключение на резервный
Связанные уроки:
Симптомы
- Приложение показывает белый экран при потере соединения с TON RPC
- Пользователь нажимает кнопку оплаты, ничего не происходит — нет feedback
- Crash при таймауте запроса к блокчейну — приложение не обрабатывает network errors
Причина
Mini App разработан для happy path: все запросы к TON API и RPC успешны. Отсутствует обработка таймаутов, retry-логика и UI-индикация ошибок. Telegram Mini App работает в мобильном контексте с нестабильным интернетом.
Решение
- Реализуйте retry с exponential backoff для всех blockchain-запросов
- Показывайте loading state и error state с кнопкой retry для каждой blockchain-операции
- Кэшируйте последнее известное состояние — показывайте stale data с пометкой вместо пустого экрана
- Используйте optimistic UI: показывайте ожидаемый результат, откатывайте при ошибке
Связанные уроки:
Симптомы
- Mint 10 000 NFT требует 10 000 транзакций через один контракт — bottleneck
- Метаданные всех NFT хранятся в одном Dictionary — storage fee растёт квадратично
- Невозможно transfer NFT без участия collection-контракта
Причина
Вся коллекция реализована как один контракт с Dictionary of items. Каждая операция (mint, transfer, burn) проходит через единственный аккаунт. TEP-62 NFT Standard предполагает отдельные контракты для каждого item.
Решение
- Следуйте TEP-62: collection-контракт создаёт отдельный NFT Item контракт при каждом mint
- Каждый NFT Item — независимый контракт со своим owner, content и transfer логикой
- Transfer выполняется напрямую между NFT Item и новым owner — collection не участвует
- Для batch mint используйте параллельные сообщения из collection к нескольким NFT Items