Le 4 novembre 2026, une destruction de jetons déclenchée par une vente a presque vidé le côté TENDRIL d’un pool de liquidité sur BNB Chain, préparant ainsi un swap qui a retiré près de 4,42 millions d’USDT. La sortie nette du pool lors de la transaction était plus faible — 555 670,538752928908475139 USDT — car d’importantes quantités d’USDT y étaient entrées plus tôt au cours de la même transaction.
Points clés à retenir
Le contrôleur de TENDRIL a retiré du pool une quantité équivalant au double du montant initial de la vente.
Le pool ne contenait presque pas de TENDRIL avant de recevoir les jetons restants du vendeur.
La renonciation à la propriété n’a pas désactivé l’autorisation de destruction du contrôleur.
Le montant final reçu lors de l’échange et la sortie nette de la transaction correspondent à des montants différents.
Comment la vente a déclenché la destruction des jetons
La vente a appelé un contrôleur avant que les TENDRIL restants du vendeur n’atteignent le pool. À 16:02:45 UTC, un appel transferFrom d’un routeur a lancé la vente de 39,053,180,124.060957132414461269 TENDRIL, déclenchant la taxe du jeton et la logique du contrôleur.
Les transferts vers le pool principal étaient soumis à une taxe de vente de 30 %, sauf lorsqu’ils provenaient de l’une des deux adresses configurées, _poolControl et _markControl. Le contrat exécutant ne bénéficiait d’aucune de ces exemptions. Le jeton a donc d’abord envoyé 11,715,954,037.218287139724338380 TENDRIL à _poolControl.
Ensuite, le jeton a appelé la fonction sellToken du contrôleur en lui passant le montant initial de la vente, et non uniquement celui de la taxe. Cet appel s’est terminé avant que le jeton ne crédite le pool du solde des 70 %.
La trace historique des appels montre la séquence suivante : transferFrom, sellToken, controlburn et sync, puis l’échange final. La trace des opcodes du contrôleur confirme qu’il a multiplié par deux le montant initial de la vente, obtenant ainsi le montant exact qui a ensuite été retiré du pool.
controlburn a retiré des jetons du pool sans réduire l’offre totale
La fonction controlburn a déplacé des TENDRIL du solde du pool vers une adresse morte. Elle n’a pas réduit l’offre totale enregistrée du jeton ni détruit les jetons des fournisseurs de liquidité.
Son contrôle d’autorisation exigeait que l’appelant corresponde à _poolControl. Une fois autorisée, la fonction a utilisé _basicTransfer pour débiter les jetons du solde du pool et créditer l’adresse morte. Aucune autorisation du pool ni aucun retrait de liquidité n’étaient en jeu : le contrat du jeton gérait lui-même ses registres de soldes.
Le journal 28 enregistre le retrait de 78,106,360,248.121914264828922538 TENDRIL. Il ne restait alors que 6,678 unités brutes dans le pool. Avec les 18 décimales de TENDRIL, le solde restant n’était que de 0.000000000000006678 TENDRIL.
La destruction n’a pas retiré simultanément d’USDT. Presque tout le solde en TENDRIL a disparu, tandis que les USDT du pool sont restés disponibles pour l’échange suivant.
La renonciation à la propriété n’a pas bloqué cette voie. Au bloc 131653426, juste avant l’incident, owner() renvoyait l’adresse zéro. Mais controlburn vérifiait le contrôleur configuré plutôt que owner(), de sorte que cette autorisation distincte restait active. La fonction n’était pas librement accessible au public : un appelant quelconque aurait échoué au contrôle d’autorisation.
La réinitialisation des réserves a permis un retrait presque complet des USDT
Le contrôleur a appelé sync() immédiatement après la destruction, enregistrant le solde réduit en jetons avant l’arrivée des jetons de vente restants. Le journal 29 a fixé les réserves à 4,421,023.357469283419586501 USDT et 0.000000000000006678 TENDRIL.
Ce n’est qu’ensuite que 27,337,226,086.842669992690122889 TENDRIL sont entrés dans le pool. L’échange final a transféré 4,421,023.357469283419586499 USDT hors du pool, n’y laissant que 0.000000000000000002 USDT.
Le calcul du produit constant explique l’ampleur de ce montant. Pour les besoins de l’exemple, en faisant abstraction des frais et des arrondis, le montant d’USDT reçu se calcule en multipliant la réserve d’USDT par le montant de jetons apporté, puis en divisant le résultat par la réserve de jetons augmentée du montant de jetons apporté. Lorsque la réserve de jetons enregistrée est presque nulle, le montant apporté domine le dénominateur et le montant reçu se rapproche de la réserve totale d’USDT.
Ce retrait final ne correspondait pas au montant net extrait du pool sur l’ensemble de la transaction. Les sorties totales d’USDT ont atteint 5,117,517.824288279504509510, tandis que les entrées se sont élevées à 4,561,847.285535350596034371. Leur soustraction donne une sortie nette de 555,670.538752928908475139 USDT.
Le code du contrat et les traces historiques établissent la séquence
La destruction de jetons déclenchée par la vente a été reconstituée à partir du code source vérifié par un explorateur, des appels historiques, des journaux d’événements et d’une trace des opcodes. La recompilation du jeton avec Solidity v0.8.24+commit.e11b9ed9, sans optimisation, a produit les mêmes octets exécutables à l’exécution au bloc 131653426, à l’exclusion des métadonnées du compilateur.
L’argument de destruction décodé correspondait exactement au transfert du pool vers l’adresse morte. Les calculs entiers concordaient également avec la taxe, les jetons de vente restants et les soldes des réserves. L’offre totale est restée inchangée entre les blocs immédiatement avant et après la transaction.
La transaction liée à l’incident, qui a abouti, est apparue dans le bloc 131653427. Les vérifications du reçu via deux points de terminaison RPC publics de BNB Chain concordaient, et les flux d’USDT au niveau des adresses ont été entièrement rapprochés lors d’une nouvelle vérification le 6 novembre 2026.
Article rédigé avec l’aide de l’intelligence artificielle et relu par l’équipe éditoriale.
