Усі статті
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 зап/хв, картка не потрібна.