Как надёжно детектить входящий USDT-платёж на TON
Детект платежа USDT на TON: разбор transfer_notification жетона через get_transactions, decimals из get_jetton_info, сверка суммы и идемпотентность.
Каждый, кто хоть раз строил приём платежей на 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, происходит цепочка:
- Джеттон-кошелёк отправителя списывает сумму и шлёт вашему джеттон-кошельку internal-сообщение
internal_transfer(op0x178d4519). - Ваш джеттон-кошелёк зачисляет средства и — только если отправитель приложил
forward_ton_amount > 0— дополнительно шлёт вашему основному кошельку внутреннее сообщениеtransfer_notification(op0x7362d09c).
Здесь и кроется главная развилка. 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:
- Опрашивайте историю джеттон-кошелька получателя через
get_transactions, а не баланс основного кошелька. - Адрес этого джеттон-кошелька берите из он-чейн-вызова
get_wallet_address(run_get_method) на настоящем мастере USD₮ (EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs) — тогда любой перевод на него гарантированно настоящий USDT. - Ищите входящие
internal_transferс op0x178d4519— именно они всегда лежат в истории джеттон-кошелька, независимо отforward_ton_amount.transfer_notification(0x7362d09c) уходит основному кошельку и только приforward_ton_amount > 0. - Адрес плательщика берите из поля
from, сумму — изamount(raw). - Берите
decimalsизget_jetton_info. USDT = 6, не 9. - Матчите инвойс по комментарию из
forward_payload(op0x00000000+ UTF-8). - Дедуп по
(lt, hash)— зачисляйте строго один раз. - Держите стабильный throughput своим ключом, а не публичными лимитами.
Дайте вашему агенту доступ к TON
16 MCP-инструментов: чтение, некастодиальные свапы, кроссчейн и кошельки. Бесплатный тариф — 60 запр/мин, карта не нужна.