Détecter un paiement USDT entrant sur TON de façon fiable
Détection d'un paiement USDT sur TON : parser transfer_notification via get_transactions, decimals via get_jetton_info, contrôle du montant et idempotence.
Tous ceux qui ont un jour construit l'encaissement de paiements sur TON sont tombés dans le même piège : le paiement est arrivé, mais le backend ne l'a « pas vu ». Le client a envoyé 50 USDT, le wallet affiche bien la réception, et la facture reste bloquée au statut « en attente ». Dix minutes plus tard, un ticket furieux au support. Et une heure après, vous découvrez que le paiement a été crédité deux fois, parce que le poller s'est déclenché sur un retry. Détecter de façon fiable un paiement USDT entrant (incoming USDT payment) sur TON, ce n'est pas « vérifier le solde une fois par minute » : c'est décoder des messages internes précis du jetton, avec idempotence et protection contre les contrefaçons. Voyons pas à pas comment le faire correctement — avec les vrais outils du serveur MCP TONNode, utilisables gratuitement en local.
Pourquoi détecter un paiement USDT sur TON n'a rien à voir avec le solde du wallet
L'approche naïve consiste à lire périodiquement le solde du wallet et, s'il a augmenté, à considérer le paiement comme reçu. C'est comme une caisse qui ne sait que regarder le total dans le tiroir : +10 USDT sont arrivés — mais de qui, pour quelle facture, pour une ancienne commande ou une nouvelle ? Et si deux clients paient 10 chacun au même moment, vous ne verrez qu'un seul mouvement de +20.
Sur TON, cette approche s'effondre pour plusieurs raisons à la fois :
- USDT n'est pas une monnaie native, mais un jetton (standard TEP-74). Les fonds n'arrivent pas directement sur le wallet principal, mais sur son jetton wallet — un contrat séparé, lié à un master-contrat de jetton précis. Le solde du wallet principal en GRAM, lui, ne bouge pas du tout.
- Le delta de solde ne dit ni qui a payé, ni pour quoi. Le solde ne stocke aucun lien avec la facture.
- Conditions de course et agrégation. Entre deux sondages, plusieurs paiements arrivent — vous voyez le delta cumulé, pas des événements distincts.
- Doubles crédits. Un retry du polling ou un redémarrage du worker — et un même paiement est compté deux fois.
- Les decimals. USDT en a 6, et non les 9 habituels des jettons. Un seul diviseur erroné — et le client se voit « crédité » d'un montant faux d'un facteur 1000.
La bonne approche consiste à travailler non pas avec le solde, mais avec le flux de transactions du jetton wallet du destinataire, en décodant chaque événement de transfert séparément. Toute la lecture est disponible gratuitement en local :
{ "mcpServers": { "ton": { "command": "npx", "args": ["-y", "@tonnode/mcp"] } } }
Un paiement USDT entrant sous le capot : l'internal_transfer du jetton (op 0x178d4519)
Quand quelqu'un vous transfère de l'USDT, une chaîne d'événements se déclenche :
- Le jetton wallet de l'expéditeur débite le montant et envoie à votre jetton wallet un message interne
internal_transfer(op0x178d4519). - Votre jetton wallet crédite les fonds et — uniquement si l'expéditeur a joint
forward_ton_amount > 0— envoie en plus à votre wallet principal un message internetransfer_notification(op0x7362d09c).
C'est là que se cache la bifurcation principale. transfer_notification est une notification pratique « un jetton vous est arrivé », mais elle part vers le wallet principal du propriétaire et seulement si forward_ton_amount > 0. Si l'expéditeur a mis zéro, le jetton wallet crédite quand même les fonds, mais le propriétaire ne reçoit aucune notification.
En revanche, dans l'historique du jetton wallet lui-même, le crédit figure toujours — sous forme d'internal_transfer entrant, indépendamment de forward_ton_amount. La détection fiable se construit donc sur le sondage de l'historique du jetton wallet du destinataire, et sur le décodage de ces internal_transfer précisément. Leur structure selon TEP-74 :
internal_transfer#178d4519
query_id: uint64
amount: (VarUInteger 16) // unités raw du jetton
from: MsgAddress // adresse du propriétaire-PAYEUR (l'humain)
response_address: MsgAddress
forward_ton_amount: (VarUInteger 16)
forward_payload: (Either Cell ^Cell) // commentaire/memo
Deux points importants :
- Le champ
fromest l'adresse du payeur réel (le propriétaire humain), et non celle de son jetton wallet. C'est lui qu'il faut logger comme expéditeur. Attention : l'adresse au niveau du message (tx.in_msg.source) est ici le jetton wallet du payeur, tandis que le payeur humain se trouve dans le champfromdu corps du message. - Le montant du champ
amountest en unités raw ; nous reviendrons à la conversion plus bas.
D'où la conclusion principale sur l'architecture
La détection fiable ne repose pas sur l'attente d'une notification dans le wallet principal, mais sur le sondage de l'historique du jetton wallet du destinataire lui-même. Dans son historique, le crédit est toujours visible, quel que soit forward_ton_amount. Si vous écoutez en plus le wallet principal, vous y attraperez le transfer_notification (op 0x7362d09c, l'expéditeur dans le champ sender) — mais ce n'est qu'un bonus pour le cas forward_ton_amount > 0, pas l'unique source de vérité.
Lire l'historique via get_transactions et décoder l'internal_transfer
Il faut d'abord l'adresse du jetton wallet du destinataire. Ne la dérivez pas hors ligne et ne faites pas confiance au ticker — demandez au master-contrat USDT lui-même : l'appel get_wallet_address (via run_get_method) sur le master USDT renvoie de façon déterministe l'adresse de votre jetton wallet USDT, liée au vrai master. On sonde ensuite les transactions de ce jetton wallet via get_transactions.
Le prompt pour l'agent dans la boucle de polling :
Appelle run_get_method sur le master USDT
EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs avec la méthode
get_wallet_address et comme argument l'adresse du propriétaire
UQ...myservice. Renvoie l'adresse de notre jetton wallet USDT.
Ensuite, via get_transactions, lis les dernières transactions de ce
jetton wallet. Pour chaque internal_transfer entrant (op 0x178d4519),
renvoie query_id, amount (raw), le champ from (adresse du payeur),
le commentaire texte du forward_payload, ainsi que lt et hash de la
transaction.
Pseudo-code de traitement d'une transaction :
for (const tx of txs) {
const body = tx.in_msg?.decoded; // corps décodé du message entrant
if (body?.op !== 0x178d4519) continue; // pas un internal_transfer — on passe
const rawAmount = BigInt(body.amount); // unités raw, PAS lisibles humainement
const payer = body.from; // adresse du propriétaire-payeur
const memo = parseComment(body.forward_payload);
const key = `${tx.lt}:${tx.hash}`; // identifiant pour la déduplication
// …vérification et crédit ci-dessous
}
Il est aussi utile de vérifier ponctuellement l'état du compte principal via get_account_state (est-il actif, de quand date la dernière transaction) — cela aide à savoir si le poller est « vivant » et s'il n'a pas pris de retard.
Les decimals décident de tout : get_jetton_info et la conversion du montant raw (USDT = 6, pas 9)
Le champ amount du transfert est en unités raw du jetton, un entier sans virgule. Pour le transformer en montant lisible, il faut les decimals. Et c'est précisément là que le plus grand nombre d'intégrations se cassent.
La plupart des jettons ont decimals = 9. USDT (Tether) sur TON a decimals = 6. Autrement dit :
1 USDT = 1 000 000 raw (10^6, et non 10^9)
Si, par réflexe, vous divisez le montant raw d'USDT par 10^9, le paiement client de 50 USDT se transforme chez vous en 0,05 — 1000 fois moins. L'erreur dans l'autre sens créditera tout aussi facilement 1000 fois plus. Ne codez pas le diviseur en dur — prenez les decimals dans les métadonnées on-chain via get_jetton_info :
const info = await getJettonInfo(USDT_MASTER); // name, symbol, decimals, émission
const decimals = info.decimals; // pour USDT = 6
const human = Number(rawAmount) / 10 ** decimals; // 50000000 → 50.0
Gardez les decimals en cache par adresse du master, pas par ticker : un ticker se contrefait, les decimals doivent être liés à un contrat précis. La règle est simple : decimals toujours depuis get_jetton_info, et le montant uniquement en BigInt/raw jusqu'au moment de l'affichage.
Vérifier l'origine, le montant et le commentaire — et écarter les faux jettons
Passons au plus important pour la sécurité. Les vérifications sans lesquelles on ne crédite pas.
1. Le master-contrat : uniquement le vrai USDT. N'importe qui peut émettre un jetton nommé « USDT » avec le symbole « USD₮ » et vous envoyer un transfert de 1000 « USDT ». Si vous matchez le paiement par ticker, vous vous ferez avoir en cinq minutes. Le seul ancrage fiable au vrai Tether, c'est de travailler avec le jetton wallet que le vrai master-contrat USDT a calculé pour votre propriétaire :
Tether USD₮ master: EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs
C'est précisément pour cela que nous prenons l'adresse du jetton wallet via l'appel on-chain get_wallet_address sur ce master (voir plus haut), et que nous ne sondons l'historique que de ce contrat-là — et d'aucun autre. Tout internal_transfer crédité dessus provient, par construction, d'un jetton wallet frère légitime du même master : un faux « USDT » vit sur les wallets d'un autre master et n'atteint tout simplement jamais votre adresse. Avec cette architecture, il n'y a donc rien à comparer avec tx.in_msg.source — la protection découle du choix même du point d'observation. Et notez bien : parse_address n'est qu'une conversion hors ligne entre les formats EQ/UQ/raw, elle ne calcule pas l'adresse du jetton wallet ; pour cela, il faut précisément l'appel on-chain get_wallet_address. Elle est en revanche pratique pour normaliser les adresses avant comparaison, afin de ne pas traiter deux représentations d'une même adresse comme deux adresses différentes.
2. Le commentaire (memo) pour le matching avec la facture. Le commentaire texte se trouve dans forward_payload au format standard : préfixe op 0x00000000 (4 octets nuls) + chaîne UTF-8. C'est par lui que vous reliez le transfert entrant à une commande précise.
3. Le montant et le payeur. Comparez le montant converti au montant attendu de la facture ; au besoin, enregistrez from (l'adresse du payeur) pour l'historique et l'antifraude.
// 1. L'origine est garantie : nous lisons l'historique du jetton
// wallet PRÉCIS dont l'adresse a été renvoyée par get_wallet_address
// du vrai master USDT — donc tout transfert ici est authentique.
// 2. Commentaire → facture
const invoice = invoices.get(memo);
if (!invoice) continue;
// 3. Le montant correspond ?
if (human < invoice.expectedAmount) markUnderpaid(invoice);
// 4. Le payeur — pour les logs / l'antifraude
log({ payer, memo, human, lt: tx.lt, hash: tx.hash });
Idempotence : déduplication par lt + hash pour ne jamais créditer un paiement deux fois
Le polling se recoupe toujours avec lui-même : il se déclenche selon un planning, plante, réessaie, et les fenêtres de sondage se chevauchent. Sans déduplication, vous finirez tôt ou tard par créditer un même paiement deux fois.
Sur TON, chaque transaction est identifiée de façon unique par la paire logical time (lt) + hash. C'est votre clé d'idempotence naturelle :
const key = `${tx.lt}:${tx.hash}`;
if (await seen.has(key)) continue; // déjà traité — on sort
await creditInvoice(invoice, human); // crédit
await seen.add(key); // enregistré dans la même transaction BDD
On peut aussi utiliser comme clé le query_id du transfert, mais (lt, hash) fonctionne pour toute transaction entrante du jetton wallet, y compris le cas forward_ton_amount = 0. Stockez les clés traitées de façon persistante et effectuez le crédit dans une seule transaction de base de données avec contrainte d'unicité — ainsi, des workers parallèles ne créeront pas de doublon.
Pensez aussi à une réconciliation périodique : toutes les N minutes, comparez le solde réel du jetton wallet (via get_jetton_balance — il calcule l'adresse on-chain et renvoie le solde en un seul appel) avec la somme de tous les paiements crédités. Un écart est le signal qu'un transfert a été manqué ou compté en double quelque part, même si un seul cycle de polling a échoué.
Un débit stable sous charge : votre propre clé plutôt que les limites publiques
Le polling de paiements, c'est un flux de requêtes constant et régulier : N wallets × fréquence de sondage. Et c'est là que l'infrastructure publique devient le goulot d'étranglement.
Les liteservers publics de la config globale sont partagés et limités : sous charge, ils répondent not ready ou partent en timeout ADNL. Les API HTTP publiques (toncenter, tonapi.io) sans clé tiennent environ 1 requête par seconde et, au-delà, renvoient un honnête HTTP 429 « Too Many Requests ». Notez-le bien : le vrai code de rate limit, c'est 429, et non le « 228 » mémétique qui circule dans la communauté TON et qui n'est pas un code d'API. Pour une caisse qui sonde l'historique toutes les quelques secondes, ces limites se traduisent en crédits manqués et retardés. Comment choisir un provider avec votre propre clé, c'est le sujet d'une analyse dédiée.
Tous les outils de lecture nécessaires à la détection de paiements — get_transactions, run_get_method, get_jetton_balance, get_jetton_info, parse_address, get_account_state — sont disponibles gratuitement en local : npx -y @tonnode/mcp. Le paquet @tonnode/mcp est open source (MIT) et fonctionne via le protocole natif ADNL de TON, sans couches HTTP intermédiaires. Quand le polling passe en prod et qu'un débit garanti devient nécessaire, on branche l'endpoint hébergé avec sa propre clé :
{
"mcpServers": {
"ton": {
"type": "http",
"url": "https://mcp.tonnode.io/mcp",
"headers": { "Authorization": "Bearer tn_live_…" }
}
}
}
Les plans ne diffèrent que par la limite de requêtes par minute — les 16 outils sont disponibles sur chacun d'eux :
- Hobby — gratuit pour toujours, 60 requêtes/min. La clé est délivrée dès la connexion, sans carte bancaire.
- Pro — 29 $/mois, 300 requêtes/min.
- Scale — 199 $/mois, 1200 requêtes/min.
Pour démarrer un polling de paiements, les 60 requêtes gratuites par minute suffisent à maintenir un sondage stable sans 429. Et si vous construisez là-dessus un agent IA qui décode lui-même les paiements, son fonctionnement est détaillé dans le guide MCP pour les agents IA sur TON.
Commencez avec la clé Hobby gratuite — 60 req/min pour votre polling de paiements, sans carte : tonnode.io/dashboard?plan=hobby. Quand la charge grandira et qu'un seul worker ne suffira plus — comparez les limites Pro et Scale.
Check-list de la détection fiable d'USDT sur TON :
- Sondez l'historique du jetton wallet du destinataire via
get_transactions, pas le solde du wallet principal. - Prenez l'adresse de ce jetton wallet via l'appel on-chain
get_wallet_address(run_get_method) sur le vrai master USD₮ (EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs) — ainsi, tout transfert reçu est garanti être du vrai USDT. - Cherchez les
internal_transferentrants avec l'op0x178d4519— ce sont eux qui figurent toujours dans l'historique du jetton wallet, quel que soitforward_ton_amount. Letransfer_notification(0x7362d09c) part vers le wallet principal et seulement siforward_ton_amount > 0. - Prenez l'adresse du payeur dans le champ
from, le montant dansamount(raw). - Prenez les
decimalsdepuisget_jetton_info. USDT = 6, pas 9. - Matchez la facture par le commentaire du
forward_payload(op0x00000000+ UTF-8). - Dédupliquez par
(lt, hash)— créditez strictement une seule fois. - Maintenez un débit stable avec votre propre clé, pas avec les limites publiques.
Donnez à votre agent l'accès à TON
16 outils MCP : lecture, swaps non-custodial, cross-chain et wallets. Forfait gratuit — 60 req/min, sans carte.