<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Symond</title>
    <description>The latest articles on DEV Community by Symond (@disburz).</description>
    <link>https://dev.to/disburz</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4245920%2F5606b274-5f15-45d4-94a0-7ec5e0ec14a0.png</url>
      <title>DEV Community: Symond</title>
      <link>https://dev.to/disburz</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/disburz"/>
    <language>en</language>
    <item>
      <title>What we learned detecting USDT payments on TRON (and the outage that left our scanner 5 days behind)</title>
      <dc:creator>Symond</dc:creator>
      <pubDate>Sun, 15 Nov 2026 12:27:24 +0000</pubDate>
      <link>https://dev.to/disburz/what-we-learned-detecting-usdt-payments-on-tron-and-the-outage-that-left-our-scanner-5-days-behind-2jon</link>
      <guid>https://dev.to/disburz/what-we-learned-detecting-usdt-payments-on-tron-and-the-outage-that-left-our-scanner-5-days-behind-2jon</guid>
      <description>&lt;p&gt;We run &lt;a href="https://disburz.money" rel="noopener noreferrer"&gt;Disburz&lt;/a&gt;, a small service in India that lets people sell USDT and receive rupees in their bank account. The crypto side is simple on paper: a customer sends USDT on TRON (TRC20) to an address we give them, we see the transfer, and our payout partner sends INR by IMPS.&lt;/p&gt;

&lt;p&gt;"We see the transfer" turned out to be the part worth writing about. Here's how our payment scanner works, the numbers that matter on TRON, and what broke when our database went away for five days.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup in one paragraph
&lt;/h2&gt;

&lt;p&gt;Each order gets a deposit address from a pool. A scanner walks TRON blocks in order, picks out TRC20 &lt;code&gt;Transfer&lt;/code&gt; events for the USDT contract, and matches the &lt;code&gt;to&lt;/code&gt; address and amount against open orders. When a match is final, the order is marked paid, the funds are swept to a main wallet, and the payout is triggered. The scanner keeps a single cursor in the database: the last block it fully processed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Numbers that matter on TRON
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Block time is about 3 seconds.&lt;/strong&gt; A scanner that falls behind by a day has roughly 28,800 blocks to catch up on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Finality is about 19 blocks.&lt;/strong&gt; TRON has 27 super representatives, and a block is treated as solidified once more than two thirds of them have built on top of it. In practice that's about 19 confirmations, or a little under a minute.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;USDT transfers are just contract events.&lt;/strong&gt; You're not watching native TRX transfers; you're decoding &lt;code&gt;Transfer(from, to, value)&lt;/code&gt; logs from the USDT TRC20 contract, with 6 decimals.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We show customers a live "payment detected · x/19 confirmations" counter on the payment page. It's display only, but it removed a whole class of "did you get my money?" support tickets, because people can watch it count up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 1: The cursor is the product
&lt;/h2&gt;

&lt;p&gt;Everything hangs off one number: the last processed block. Two rules we'd give anyone building this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Only advance the cursor after the block's effects are committed.&lt;/strong&gt; Matching, marking orders paid and moving the cursor belong in one transaction. If the process dies halfway, you re-scan the block instead of skipping it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make re-scanning idempotent.&lt;/strong&gt; Key each detected transfer by transaction hash plus log index. Seeing the same transfer twice must be a no-op, not a double payout.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Get those two right and most crashes become boring: restart, re-scan a few blocks, carry on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 2: Orders should not expire while you're blind
&lt;/h2&gt;

&lt;p&gt;Our orders have a payment window. Early on, a scanner that fell behind could have let a paid order expire simply because we hadn't looked at the block yet. The fix: expiry checks compare against the &lt;strong&gt;scanner's&lt;/strong&gt; position, not the wall clock. If the scanner hasn't reached the block range covering the order's window, the order stays open.&lt;/p&gt;

&lt;p&gt;This mattered a lot during the outage below. Customers who paid during it saw their order sit unpaid, but none of them were expired and asked to pay again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 3: Rate limits bite hardest when you're already behind
&lt;/h2&gt;

&lt;p&gt;We read blocks through a hosted TRON API with a per-key rate limit. During normal operation the scanner sits near the chain head, handles a block every few seconds, and never gets near the limit.&lt;/p&gt;

&lt;p&gt;Then our database became unreachable for about five days. The scanner couldn't commit anything, so its cursor stopped. When the database came back, it was roughly &lt;strong&gt;143,000 blocks behind&lt;/strong&gt;, and it tried to catch up by fetching several blocks in parallel.&lt;/p&gt;

&lt;p&gt;That's exactly when the provider's limit kicked in: too many requests per second, the key gets suspended for a short window, every call returns HTTP 429, retries pile on, and effective throughput collapses. We measured about &lt;strong&gt;0.6 blocks per second&lt;/strong&gt; while catching up. The chain itself adds a block every 3 seconds (0.33/s), so we were gaining only about 0.27 blocks per second: closing a 143,000-block gap would have taken around six days.&lt;/p&gt;

&lt;p&gt;What we're changing, and what we'd recommend to anyone:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Parallelism should be a knob, and catch-up should use less of it, not more.&lt;/strong&gt; A burst that trips the limiter is slower than a steady stream under it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Respect the suspension window.&lt;/strong&gt; When you get the "suspended for N seconds" response, sleep for N seconds instead of retrying immediately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Have a second RPC endpoint&lt;/strong&gt; for catch-up, so one key's limit isn't your ceiling.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Lesson 4: Sometimes you jump the cursor, but you verify the gap
&lt;/h2&gt;

&lt;p&gt;Catching up 143k blocks at that speed would have taken days, and meanwhile real customers who had just paid were waiting. So we did something that sounds scary: we moved the cursor forward to near the chain head, then &lt;strong&gt;separately verified the skipped range&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;For the verification we didn't replay blocks. We queried the transfer history of every address that could have received a payment (all pool addresses and the main wallet) for the skipped time window, using the API's account-transactions endpoint. That's a few hundred calls instead of 143,000 block fetches. The window came back with zero relevant transfers, so nothing was missed, and the one order that had been paid after the outage was picked up within minutes of the jump.&lt;/p&gt;

&lt;p&gt;The rule we follow now: &lt;strong&gt;a cursor jump is only allowed together with an account-level check of the skipped window&lt;/strong&gt;, written down in the incident log.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 5: Watch the scanner, not just the API
&lt;/h2&gt;

&lt;p&gt;Our health checks were green during the outage in the sense that the web app was up. What we actually needed was an alert on &lt;strong&gt;scanner lag&lt;/strong&gt;: chain head minus cursor. If that number grows for more than a few minutes, something is wrong, whether it's the database, the RPC provider or the scanner process itself. That alert is what we're adding, and in the meantime an order that's paid on chain but still shows unpaid is checked against the scanner position first.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means for people sending USDT
&lt;/h2&gt;

&lt;p&gt;A few things we tell customers, which fall straight out of the above:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Network matters.&lt;/strong&gt; A TRC20 address starts with &lt;code&gt;T&lt;/code&gt;. USDT sent on another network to that address isn't something the scanner will ever see.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;About a minute&lt;/strong&gt; after you send, the transfer is final on TRON. If your wallet shows it confirmed and the service doesn't, ask them to check their scanner, not to "resend".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the transaction hash.&lt;/strong&gt; It's the one reference both sides can look up.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're building on TRON and want to compare notes on scanners, sweeping or rate limits, I'm happy to talk in the comments. And if you're in India and want to see the flow from the user's side, the live quote is on &lt;a href="https://disburz.money/usdt-to-inr" rel="noopener noreferrer"&gt;disburz.money&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>blockchain</category>
      <category>backend</category>
      <category>postgres</category>
      <category>tron</category>
    </item>
  </channel>
</rss>
