Как мы потеряли $5 000 на TON из-за кода, написанного нейронкой

Николай Носков 10 мин

TL;DR: relayer для TON-проекта писался с помощью LLM. Без документации. Без тестов. Без понимания модели угроз. В результате — потеря ~$5 000 из пула ликвидности на STON.fi. Блокчейн не взломан, DEX работает как надо. Проблема была в нашей архитектуре.

Наконец-таки статья о том, как я облажался. Точнее — как облажалась команда, но ответственность всё равно моя.

Эта статья не про «LLM плохие». Это разбор конкретной ошибки, которая стоила реальных денег. И пояснение, почему скептики всё равно не правы — но по другой причине, чем они думают.

1. Что вообще за проект

Делали сервис для крипто-трейдеров. Суть простая:

  • Пользователь покупает подписку за TON
  • Получает возможность создавать триггеры на валютные пары
  • Когда триггер срабатывает — получает уведомление

Интересная особенность: уведомления приходят в виде… звонка на телефон! Пользователь при регистрации указывает свой номер, а при срабатывании триггера сервис Twilio из США звонит ему. У проекта был свой jetton (токен), пул ликвидности на STON.fi, подписочная модель через смарт-контракт.

Сначала мы делали просто приложение — без ончейн-логики. Работало отлично. Потом заказчик решил: «Давайте сделаем по-настоящему, со смарт-контрактами, чтобы всё было прозрачно и децентрализованно». Идея была такая, что за стоимость подписки покупался токен и тут же сжигался. Таким образом каждая покупка подписки увеличивала ликвидность.

Звучало круто. Мы согласились.

2. Архитектура

Для понимания — как это всё было устроено:

Relayer — это off-chain сервис, который:

  • принимал HTTP-запросы;
  • выполнял on-chain операции (swap, burn);
  • подписывал транзакции hot-wallet ключом;
  • работал как «мост» между контрактами и DEX.

По сути — доверенный посредник с правами на критичные операции.

3. Что вообще произошло

День 0: что-то пошло не так (мы ещё не знаем)

В какой-то момент злоумышленник получил возможность инициировать burn LP-токенов. LP-токены — это токены ликвидности, которые подтверждают нашу долю в пуле на STON.fi.

Что произошло технически:

  • LP-токены сожжены через стандартный StonfiBurnNotification
  • STON.fi вернул активы (USD₮ и наш jetton) — ровно по правилам протокола
  • Активы выведены

Мы это не заметили. Вообще.

День 10: обнаружение

Спустя примерно 10 дней кто-то заметил, что ликвидность в пуле нулевая: необходимо было часть токенов обменять обратно на TON для тестирования контракта в другом сервисе. Начали разбираться.

Первые мысли (типичные):

  • Утек seed?
  • STON.fi взломали? (пффф, и только нашу ликвидность вывели))
  • Баг в LP-контракте?

Спойлер: нет, нет и нет конечно же.

День ~11-14: восстановление (и ошибка)

Заказчик попросил сделать то, что кажется логичным:

  • перевести управляющие токены на его новый кошелёк;
  • он решил сразу пополнить ликвидность заново;
  • решили, что это был разовый инцидент.

Это была ошибка.

День ~15: второй удар

Буквально в тот же день или на следующий злоумышленник увидел, что ликвидность снова появилась. Но теперь у него не было доступа к LP-токенам — мы же перевели всё на новые кошельки. Поэтому он сделал другое:

  • наминтил токены напрямую через MINT-контракт;
  • продал их в пул за свежую ликвидность;
  • вывел.

Ну красавчег, если честно, молодец.

4. Что точно НЕ было причиной

Прежде чем копать дальше — зафиксирую, что мы исключили:

Версия Почему не она
STON.fi взломан DEX работает как задокументировано, никаких аномалий
Баг в LP-контракте Стандартный контракт, используется тысячами проектов
Утечка seed-фразы Не было тотального вывода всех активов со всех кошельков
approve/allowance (как в EVM) В TON другая модель, тут это неприменимо

То есть это не классический взлом. Блокчейн сделал ровно то, что должен был сделать.

5. Где начали копать по-настоящему

На меня нахлынуло просветление: relayer.

Почему:

  • у relayer были права на burn;
  • relayer работал со STON.fi;
  • действия в инциденте — именно те, которые relayer умел делать.

Я выгрузил всю историю инцидента в ChatGPT — последовательность событий, что обнаружили, когда, какие транзакции. Запротоколировал. Потом передал эту информацию вместе с кодовой базой в Codex. Пара часов делов, и ясность пришла полная.

6. Ключевая ошибка: relayer доверял HTTP, а не блокчейну

Вот как была устроена логика:

1. Приходит HTTP-запрос /process-subscription
2. Relayer НЕ проверяет:
   - что реально был ончейн-платёж
   - что txHash существует
   - что сумма совпадает
   - что вызов одноразовый
3. Relayer СРАЗУ делает:
   - swap
   - burn
   - другие on-chain действия

Любой, кто мог дёрнуть relayer API, мог инициировать критичные on-chain операции.

Да, API был защищён (?) токеном, в .env, всё как положено. Но этого оказалось недостаточно.

7. Конкретные дыры в коде

7.1. Доверие HTTP-запросу вместо ончейн-события

// relayer/src/controllers/relayer.controller.ts
@Post("process-subscription")
async processSubscription(@Body() data: ProcessSubscriptionDto) {
  return this.relayerService.processSubscription(data);
}
// relayer/src/services/relayer.service.ts
const transaction = this.transactionRepository.create({
  lt: Date.now().toString(),  // ← вот это проблема
  hash: data.txHash,          // ← и это
  userAddress: data.userAddress,
  fromAddress: data.subscriptionContractAddress,
  toAddress: this.config.relayerWalletAddress,
  amountNanotons: (parseFloat(data.amount) * 1_000_000_000).toString(),
  // ...
});
// Далее сразу swap+burn без проверки txHash/платежа

Relayer запускает критичные операции на основании входного HTTP-запроса. Без проверки, что платёж реально был на блокчейне.

7.2. Фейковая идемпотентность

// relayer/src/entities/transaction.entity.ts
@Index(["lt", "hash"], { unique: true })
// relayer/src/services/relayer.service.ts
lt: Date.now().toString(),
hash: data.txHash,

Уникальность транзакций строилась на lt + hash, где lt = Date.now(). Это не привязано к реальному ончейн-идентификатору. Один и тот же txHash можно обработать многократно — просто в разное время.

7.3. Нет связи user → intent → действие

Контракт подписки отправлял на relayer тело сообщения:

// blockchain/contracts/caller.tact
message(MessageParameters{
  to: self.stonRouter,
  value: tonToSwap,
  body: beginCell()
    .storeUint(0x7361_6d70, 32)  // op
    .storeAddress(sender())      // user
    .storeCoins(incoming)        // amount
    .endCell()
});

Но relayer эту информацию не читал и не верифицировал. Он просто выполнял то, что пришло по HTTP. По сути relayer превратился в универсальный прокси выполнения on-chain действий доверенным ключом.

7.4. Hot-wallet с избыточными правами

// relayer/src/config/relayer.config.ts
relayerPrivateKey: process.env.RELAYER_PRIV_KEY!,
relayerWalletAddress: process.env.RELAYER_WALLET_ADDR!,
// relayer/src/modules/ton/ton.service.ts
await walletContract.sendTransfer({
  seqno,
  secretKey: this.keyPair.secretKey,
  messages: [ internal({ to: destination, value, body }) ],
});

Все операции подписывались одним ключом из env. Этот ключ имел права на:

  • swap
  • burn LP
  • mint токенов
  • owner-level операции

Компрометация relayer-сервиса = компрометация всей trust-модели проекта.

8. Как могла выглядеть атака

Вариант A: доступ к API

  1. Злоумышленник получает возможность вызвать relayer HTTP API (утечка токена, открытый порт, SSRF)
  2. Отправляет process-subscription с произвольными данными
  3. Relayer запускает on-chain swap + burn
  4. Профит

Вариант Б: компрометация сервера или ключей

  1. Доступ к relayer-серверу или env-переменным
  2. Использует hot-wallet ключи для owner/minter операций
  3. Mint токенов, burn LP напрямую
  4. Профит

Оба сценария полностью согласуются с тем, что:

  • LP burn мог быть инициирован только владельцем LP — а relayer имел эти права;
  • mint возможен только при наличии owner/minter прав — а relayer имел и их;
  • компрометация STON.fi или блокчейна не требуется.

Мы до конца не выяснили, какой именно вектор был использован. Но это и не так важно — важно, что архитектура это позволяла.

9. Почему это произошло

Теперь самое интересное — почему relayer был написан с такими дырами.

Relayer писался с помощью LLM. Без документации. Без тестов. Без понимания модели угроз.

Человек, который его писал, торопился. Не заказчик торопил — сам торопился. Хотел быстро, срочно и почти офигенно. И он положился на нейронку полностью, без нашей обычной методики:

  • не было детального ТЗ на компонент;
  • не было документации архитектуры;
  • не было TDD;
  • не было threat-model;
  • не было даже базовых тестов.

LLM отлично справился с задачей. Код работал. Swap работал. Burn работал. Всё функционировало. Но LLM не сделал того, о чём его не просили:

  • не подумал о модели угроз;
  • не проверил trust boundaries;
  • не задавался вопросами (ну а зачем?);
  • не предложил разделение ключей по ролям.

Потому что это должен был сделать разработчик.

10. Главный вывод (и он не про «LLM плохие»)

Ожидаю скептиков: «Вот видите! LLM для серьёзных вещей не годится!» Увы, они не правы. Но не потому, что LLM идеальны.

LLM — это усилитель. Это ключевая мысль, которую я повторяю в каждой статье, и этот инцидент её только подтверждает.

  • Если LLM усиливает понимающего архитектора — получается boost.
  • Если LLM усиливает того, кто не понимает предметную область, — усиливаются дыры.

В нашем случае:

  • разработчик не понимал модель угроз для блокчейн-приложений;
  • разработчик не применил нашу методичку (документация + TDD);
  • разработчик торопился.

LLM сделал ровно то, что его просили: написал работающий код. Код работал — до тех пор, пока кто-то не нашёл дыру.

Проблема была не в инструменте. Проблема была в процессе.

11. Что нужно было сделать

Для истории — как должен был быть устроен безопасный relayer.

11.1. Proof of payment

// Вместо доверия HTTP-запросу
async processSubscription(data: ProcessSubscriptionDto) {
  // 1. Проверить, что txHash существует на блокчейне
  const tx = await this.tonClient.getTransaction(data.txHash);
  if (!tx) throw new Error('Transaction not found');

  // 2. Проверить, что транзакция от subscription contract
  if (tx.from !== SUBSCRIPTION_CONTRACT_ADDRESS) {
    throw new Error('Invalid source');
  }

  // 3. Проверить сумму
  if (tx.value !== expectedAmount) {
    throw new Error('Invalid amount');
  }

  // 4. Только после этого — выполнять действия
  await this.executeSwap(data);
}

11.2. Идемпотентность по реальному tx

// Уникальность по ончейн-идентификатору, а не по Date.now()
@Index(["txHash", "lt"], { unique: true })  // lt из блокчейна, не из Date.now()

11.3. Разделение ключей по ролям

11.4. Domain separation

// Whitelist разрешённых операций
const ALLOWED_OPERATIONS = ['swap', 'callback'];
// burn, mint, owner-calls — НИКОГДА через relayer

12. Как это изменило наш процесс

После инцидента я формализовал несколько правил.

Для любого компонента с доступом к критичным ресурсам:

  1. Threat-model до написания кода
    • Кто может вызвать этот компонент?
    • Что произойдёт, если вызов будет поддельным?
    • Какие права минимально необходимы?
  2. Документация как у всех остальных компонентов
    • Детальное ТЗ
    • Архитектура
    • API
    • Ограничения
  3. TDD обязательно
    • Тесты на happy path
    • Тесты на edge cases
    • Тесты на атаки (replay, forge, overflow)

Для блокчейн-проектов конкретно:

  1. Минимальные права для каждого компонента
    • Relayer не должен иметь owner/minter права
    • Hot-wallet только для операционных нужд
    • Критичные ключи — только в cold storage
  2. Verify on-chain, not off-chain
    • Любое действие — проверяем на блокчейне
    • HTTP-запросу доверяем только после верификации

13. Про реакцию заказчика

Отдельно хочу отметить: заказчик отреагировал удивительно спокойно.

$5000 — для него это оказалось «не много». Он не требовал компенсации, не устраивал скандал. Это редкость, и это позволило мне спокойно провести расследование, задокументировать всё, сделать выводы. Не все заказчики такие. Нам повезло.

14. Про мою реакцию

Когда я понял, что это наша ошибка архитектуры, а не хацкеры, — было немного грустно. Но больше было интересно. Включился исследовательский режим. Хотелось понять:

  • Как именно это произошло?
  • Что мы упустили?
  • Как сделать так, чтобы не повторилось?
  • Что из этого можно вынести для будущих проектов?

Этот инцидент не заставил меня усомниться в LLM-разработке. Наоборот, он подтвердил то, что я говорю всегда:

  • Документация решает
  • Тесты решают
  • Понимание предметной области решает
  • LLM — усилитель, не замена мышлению

15. Про другие блокчейн-проекты

У нас есть другие проекты со смарт-контрактами. Там нет таких проблем. Разница:

  • там была полноценная документация;
  • там были тесты;
  • там контракты изолированы и защищены;
  • там не было компонента типа «relayer с правами на всё».

Этот инцидент — исключение, а не правило. Но исключение, которое стоило денег и из которого нужно было сделать выводы.

16. Мораль (без морализаторства)

Если вы используете LLM для разработки — особенно для:

  • блокчейн-приложений;
  • компонентов с доступом к деньгам;
  • relayer’ов и bridge’ей;
  • чего угодно с owner/minter/admin правами,

сначала нарисуйте модель угроз.

Задайте себе вопросы:

  • Кто может вызвать этот код?
  • Что произойдёт, если входные данные поддельные?
  • Какие минимальные права нужны?
  • Что будет, если этот компонент скомпрометирован?

И только после этого просите LLM написать код — с детальным ТЗ, с ограничениями и, конечно же, с тестами.

Код LLM напишет. Ответственность за архитектуру — на вас.

17. Итог

Мы потеряли ~$5 000 не из-за:

  • багов в STON.fi;
  • плохого блокчейна;
  • хацкеров;
  • ненадёжных LLM.

Мы потеряли их из-за:

  • неправильной trust-модели;
  • слишком доверчивого relayer’а;
  • отсутствия документации и тестов;
  • торопливости;
  • иллюзии, что LLM может заменить архитектурное мышление.

Я продолжаю использовать LLM для 100% кода. Но теперь ещё строже слежу за процессом — особенно когда дело касается денег и безопасности.

Про методологию, которая работает, когда её применяют, я разбирал в статье про документацию и TDD.

Готов к помидорам. Как показывает практика — это только добавляет просмотров.