Як надійно детектувати вхідний 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 зап/хв, картка не потрібна.