Усі статті
Розробка16 липня 2026 р.7 хв читання

Як надійно детектувати вхідний USDT-платіж на TON

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

TNTONNode16 липня 2026 р.
How to Reliably Detect an Incoming USDT Payment on TON

Кожен, хто хоч раз будував приймання платежів на 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 разів не ту суму.

Правильний шлях — працювати не з балансом, а з потоком транзакцій джетон-гаманця отримувача й розбирати кожну подію переказу окремо. Усе читання доступне безкоштовно локально:

JSON
{ "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 транзакції.

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

TypeScript
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:

TypeScript
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 (адресу платника) для історії та антифроду.

TypeScript
// 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. Це ваш природний ключ ідемпотентності:

TypeScript
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-ендпоінт із власним ключем:

JSON
{
  "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, не збираючи інфраструктуру заново.

Оберіть MCP, приватний доступ до нод або зрозумілий API — кожен продукт працює окремо.