Все статьи
7 мин чтения

Как надёжно детектить входящий USDT-платёж на TON

Детект платежа USDT на TON: разбор transfer_notification жетона через get_transactions, decimals из get_jetton_info, сверка суммы и идемпотентность.

USDT на TONдетект платежаtransfer_notificationджеттоны TEP-74идемпотентностьget_transactions

Каждый, кто хоть раз строил приём платежей на TON, натыкался на одну и ту же ловушку: платёж пришёл, а бэкенд его «не увидел». Клиент отправил 50 USDT, кошелёк показывает поступление, а инвойс всё ещё висит в статусе «ожидание». Через десять минут — гневный тикет в поддержку. А ещё через час выясняется, что вы зачислили платёж дважды, потому что поллер сработал на ретрае. Надёжный детект входящего USDT-платежа (incoming USDT payment) на TON — это не «проверить баланс раз в минуту», а разбор конкретных внутренних сообщений джеттона с идемпотентностью и защитой от подделок. Разберём по шагам, как сделать это правильно — на реальных инструментах MCP-сервера TONNode, которые можно вызывать бесплатно локально.

Почему детект USDT-платежа на TON — это не про баланс кошелька

Наивный подход — периодически читать баланс кошелька и, если он вырос, считать платёж принятым. Это как касса, которая умеет только смотреть на общую сумму в ящике: пришло +10 USDT — но от кого, по какому счёту, за старый заказ или новый? А если два клиента заплатили по 10 одновременно, вы увидите только +20 одним движением.

На TON такой подход разваливается сразу по нескольким причинам:

  • USDT — это не нативная монета, а джеттон (стандарт TEP-74). Средства приходят не на основной кошелёк напрямую, а на его джеттон-кошелёк — отдельный контракт, привязанный к конкретному мастер-контракту жетона. Баланс основного кошелька в GRAM при этом вообще не меняется.
  • Дельта баланса не говорит, кто и за что заплатил. Баланс не хранит привязку к инвойсу.
  • Гонки и агрегация. Между двумя опросами приходит несколько платежей — вы видите суммарную дельту, а не отдельные события.
  • Двойные зачисления. Ретрай поллинга или рестарт воркера — и один платёж засчитан дважды.
  • Decimals. У USDT их 6, а не привычные джеттонам 9. Один неверный делитель — и клиенту «начислено» в 1000 раз мимо.

Правильный путь — работать не с балансом, а с потоком транзакций джеттон-кошелька получателя и разбирать каждое событие перевода отдельно. Всё чтение доступно бесплатно локально:

{ "mcpServers": { "ton": { "command": "npx", "args": ["-y", "@tonnode/mcp"] } } }

Как выглядит входящий USDT-платёж внутри: internal_transfer жетона (op 0x178d4519)

Когда кто-то переводит вам USDT, происходит цепочка:

  1. Джеттон-кошелёк отправителя списывает сумму и шлёт вашему джеттон-кошельку internal-сообщение internal_transfer (op 0x178d4519).
  2. Ваш джеттон-кошелёк зачисляет средства и — только если отправитель приложил forward_ton_amount > 0 — дополнительно шлёт вашему основному кошельку внутреннее сообщение transfer_notification (op 0x7362d09c).

Здесь и кроется главная развилка. transfer_notification — удобное уведомление «вам пришёл жетон», но оно уходит основному кошельку-владельцу и только при forward_ton_amount > 0. Если отправитель поставил ноль — джеттон-кошелёк всё равно зачислит средства, но уведомления владельцу не будет.

А вот в истории самого джеттон-кошелька зачисление лежит всегда — как входящий internal_transfer, независимо от forward_ton_amount. Поэтому надёжный детект строится на опросе истории джеттон-кошелька получателя и разборе именно internal_transfer. Его структура по TEP-74:

internal_transfer#178d4519
  query_id:           uint64
  amount:             (VarUInteger 16)     // raw-единицы джеттона
  from:               MsgAddress           // адрес владельца-ПЛАТЕЛЬЩИКА (человек)
  response_address:   MsgAddress
  forward_ton_amount: (VarUInteger 16)
  forward_payload:    (Either Cell ^Cell)  // комментарий/memo

Два важных момента:

  • Поле from — это адрес реального плательщика (владельца-человека), а не адрес его джеттон-кошелька. Именно его нужно логировать как отправителя. Обратите внимание: адрес на уровне сообщения (tx.in_msg.source) здесь — это джеттон-кошелёк плательщика, а человек-плательщик лежит в поле from тела сообщения.
  • Сумма в поле amount — в raw-единицах, к пересчёту вернёмся ниже.

Отсюда главный вывод об архитектуре

Надёжный детект строится не на ожидании notification в основном кошельке, а на опросе истории самого джеттон-кошелька получателя. В его истории зачисление видно всегда, независимо от forward_ton_amount. Если вы дополнительно слушаете основной кошелёк, то там ловите transfer_notification (op 0x7362d09c, отправитель — в поле sender) — но это лишь бонус для случая forward_ton_amount > 0, а не единственный источник правды.

Читаем историю через get_transactions и разбираем internal_transfer

Сначала нужен адрес джеттон-кошелька получателя. Не выводите его офлайн и не доверяйте тикеру — спросите сам мастер-контракт USDT: вызов get_wallet_address (через run_get_method) на мастере USDT детерминированно возвращает адрес вашего USDT-джеттон-кошелька, привязанный к настоящему мастеру. Затем опрашиваем транзакции этого джеттон-кошелька через get_transactions.

Промпт агенту в цикле поллинга:

Вызови run_get_method на мастере USDT
EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs с методом
get_wallet_address и аргументом — адресом владельца UQ...myservice.
Верни адрес нашего USDT-джеттон-кошелька. Затем через get_transactions
прочитай последние транзакции этого джеттон-кошелька. Для каждого
входящего internal_transfer (op 0x178d4519) верни query_id,
amount (raw), поле from (адрес плательщика), текстовый комментарий
из forward_payload, а также lt и hash транзакции.

Псевдокод обработки одной транзакции:

for (const tx of txs) {
  const body = tx.in_msg?.decoded;          // разобранное тело входящего сообщения
  if (body?.op !== 0x178d4519) continue;    // не internal_transfer — пропускаем

  const rawAmount = BigInt(body.amount);    // raw-единицы, НЕ человекочитаемо
  const payer     = body.from;              // адрес владельца-плательщика
  const memo      = parseComment(body.forward_payload);
  const key       = `${tx.lt}:${tx.hash}`;  // идентификатор для дедупа
  // …сверка и зачисление ниже
}

Полезно также разово свериться со статусом основного аккаунта через get_account_state (активен ли, когда была последняя транзакция) — это помогает понять, «жив» ли поллер и не отстал ли он.

decimals решают всё: get_jetton_info и пересчёт raw-суммы (USDT = 6, а не 9)

Поле amount в переводе — в raw-единицах джеттона, целое число без запятой. Чтобы превратить его в человекочитаемую сумму, нужны decimals. И вот здесь ломается больше всего интеграций.

Большинство джеттонов имеют decimals = 9. У USDT (Tether) на TON — decimals = 6. То есть:

1 USDT = 1 000 000 raw   (10^6, а не 10^9)

Если по инерции поделить raw-сумму USDT на 10^9, клиентский платёж в 50 USDT превратится у вас в 0.05 — в 1000 раз меньше. Ошибка в другую сторону так же легко зачислит в 1000 раз больше. Не хардкодьте делитель — берите decimals из он-чейн-метаданных через get_jetton_info:

const info = await getJettonInfo(USDT_MASTER); // name, symbol, decimals, эмиссия
const decimals = info.decimals;                // для USDT = 6
const human = Number(rawAmount) / 10 ** decimals; // 50000000 → 50.0

Держите decimals в кэше по адресу мастера, а не по тикеру: тикер подделывается, decimals нужно связывать с конкретным контрактом. Правило простое: decimals всегда из get_jetton_info, сумма — только в BigInt/raw до момента отображения.

Сверяем происхождение, сумму и комментарий — и отсекаем поддельные жетоны

Теперь самое важное для безопасности. Проверки, без которых зачислять нельзя.

1. Мастер-контракт — только настоящий USDT. Кто угодно может выпустить джеттон с именем «USDT» и символом «USD₮» и прислать вам перевод на 1000 «USDT». Если вы матчите платёж по тикеру — вас обманут за пять минут. Единственная надёжная привязка к настоящему Tether — работать с тем джеттон-кошельком, который настоящий мастер-контракт USDT вычислил для вашего владельца:

Tether USD₮ master: EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs

Именно поэтому адрес джеттон-кошелька мы берём из он-чейн-вызова get_wallet_address на этом мастере (см. выше), а историю опрашиваем только у этого — и никакого другого — контракта. Любой internal_transfer, зачисленный на него, гарантированно приходит от легитимного собрата-джеттон-кошелька того же мастера: поддельный «USDT» живёт на кошельках другого мастера и до вашего адреса просто не долетает. Поэтому при этой архитектуре сверять tx.in_msg.source с чем-либо не нужно — защита обеспечена самим выбором точки наблюдения. И учтите: parse_address — это только офлайн-конвертация форматов EQ/UQ/raw, адрес джеттон-кошелька она не вычисляет; для этого нужен именно он-чейн-вызов get_wallet_address. Её удобно применять для нормализации адресов перед сравнением, чтобы не считать разные представления одного адреса разными.

2. Комментарий (memo) для матчинга с инвойсом. Текстовый комментарий лежит в forward_payload в стандартном формате: префикс op 0x00000000 (4 нулевых байта) + UTF-8 строка. По нему вы связываете входящий перевод с конкретным заказом.

3. Сумма и плательщик. Сравните пересчитанную сумму с ожидаемой по инвойсу; при необходимости зафиксируйте from (адрес плательщика) для истории и антифрода.

// 1. Происхождение гарантировано: мы читаем историю ИМЕННО того
//    джеттон-кошелька, чей адрес вернул get_wallet_address
//    настоящего мастера USDT — значит, любой перевод здесь настоящий.

// 2. Комментарий → инвойс
const invoice = invoices.get(memo);
if (!invoice) continue;

// 3. Сумма сходится?
if (human < invoice.expectedAmount) markUnderpaid(invoice);

// 4. Плательщик — для логов/антифрода
log({ payer, memo, human, lt: tx.lt, hash: tx.hash });

Идемпотентность: дедуп по lt + hash, чтобы не зачислить платёж дважды

Поллинг всегда пересекается сам с собой: запускается по расписанию, падает, ретраится, окна опроса перекрываются. Без дедупа вы рано или поздно зачислите один платёж дважды.

В TON каждая транзакция уникально идентифицируется парой logical time (lt) + hash. Это ваш естественный ключ идемпотентности:

const key = `${tx.lt}:${tx.hash}`;
if (await seen.has(key)) continue;   // уже обработали — выходим
await creditInvoice(invoice, human); // зачисление
await seen.add(key);                 // фиксируем в той же транзакции БД

Как ключ можно использовать и query_id из перевода, но (lt, hash) работает для любой входящей транзакции джеттон-кошелька, включая случай forward_ton_amount = 0. Храните обработанные ключи персистентно и делайте зачисление в одной транзакции БД с проверкой уникальности — тогда параллельные воркеры не создадут дубль.

Периодически стоит делать реконсиляцию: раз в N минут сравнить фактический баланс джеттон-кошелька (через get_jetton_balance — он вычисляет адрес он-чейн и отдаёт баланс одним вызовом) с суммой всех зачисленных платежей. Расхождение — сигнал, что где-то пропущен или задвоен перевод, даже если один поллинг-цикл дал сбой.

Стабильный throughput под нагрузкой: свой ключ вместо публичных лимитов

Платёжный поллинг — это постоянный, ровный поток запросов: N кошельков × частота опроса. И тут публичная инфраструктура становится узким местом.

Публичные лайтсерверы из глобального конфига — общие и лимитированные: под нагрузкой отвечают not ready или уходят в ADNL-таймаут. Публичные HTTP-API (toncenter, tonapi.io) без ключа держат порядка 1 запроса в секунду и при превышении отдают честный HTTP 429 «Too Many Requests». Заметьте: реальный код лимита — именно 429, а не мемное «228», которое гуляет по TON-комьюнити и не является кодом API. Для кассы, которая опрашивает историю каждые несколько секунд, эти лимиты означают пропущенные и задержанные зачисления. Как выбрать провайдера с собственным ключом — в отдельном разборе.

Все инструменты чтения, нужные для детекта платежей — get_transactions, run_get_method, get_jetton_balance, get_jetton_info, parse_address, get_account_state — доступны бесплатно локально: npx -y @tonnode/mcp. Пакет @tonnode/mcp — open source (MIT) и работает по нативному ADNL-протоколу TON без HTTP-прослоек. Когда поллинг переходит на прод и нужен гарантированный throughput, подключается hosted-эндпоинт со своим ключом:

{
  "mcpServers": {
    "ton": {
      "type": "http",
      "url": "https://mcp.tonnode.io/mcp",
      "headers": { "Authorization": "Bearer tn_live_…" }
    }
  }
}

Тарифы отличаются только лимитом запросов в минуту — все 16 инструментов доступны на каждом:

  • Hobby — бесплатно навсегда, 60 запросов/мин. Ключ выдаётся сразу после входа, без карты.
  • Pro — $29/мес, 300 запросов/мин.
  • Scale — $199/мес, 1200 запросов/мин.

Для старта платёжного поллинга бесплатных 60 запросов в минуту достаточно, чтобы держать стабильный опрос без 429. Если вы строите на этом ИИ-агента, который сам разбирает платежи, — как это устроено, разобрано в гайде по MCP для ИИ-агентов на TON.

Начните с бесплатного ключа Hobby — 60 req/min под платёжный поллинг, без карты: tonnode.io/dashboard?plan=hobby. Когда нагрузка вырастет и одного воркера мало — сравните лимиты Pro и Scale.


Чек-лист надёжного детекта USDT на TON:

  1. Опрашивайте историю джеттон-кошелька получателя через get_transactions, а не баланс основного кошелька.
  2. Адрес этого джеттон-кошелька берите из он-чейн-вызова get_wallet_address (run_get_method) на настоящем мастере USD₮ (EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs) — тогда любой перевод на него гарантированно настоящий USDT.
  3. Ищите входящие internal_transfer с op 0x178d4519 — именно они всегда лежат в истории джеттон-кошелька, независимо от forward_ton_amount. transfer_notification (0x7362d09c) уходит основному кошельку и только при forward_ton_amount > 0.
  4. Адрес плательщика берите из поля from, сумму — из amount (raw).
  5. Берите decimals из get_jetton_info. USDT = 6, не 9.
  6. Матчите инвойс по комментарию из forward_payload (op 0x00000000 + UTF-8).
  7. Дедуп по (lt, hash) — зачисляйте строго один раз.
  8. Держите стабильный throughput своим ключом, а не публичными лимитами.

Дайте вашему агенту доступ к TON

16 MCP-инструментов: чтение, некастодиальные свапы, кроссчейн и кошельки. Бесплатный тариф — 60 запр/мин, карта не нужна.