<?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: Даши Данцаранов</title>
    <description>The latest articles on DEV Community by Даши Данцаранов (@d_dashi).</description>
    <link>https://dev.to/d_dashi</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%2F4016651%2F6e77644b-e4e7-49fc-9114-9d1da5ed56e9.jpg</url>
      <title>DEV Community: Даши Данцаранов</title>
      <link>https://dev.to/d_dashi</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/d_dashi"/>
    <language>en</language>
    <item>
      <title>Watching incoming USDC &amp; USDT: NestJS + viem and the potholes of free RPCs</title>
      <dc:creator>Даши Данцаранов</dc:creator>
      <pubDate>Sun, 05 Jul 2026 20:30:49 +0000</pubDate>
      <link>https://dev.to/d_dashi/watching-incoming-usdc-usdt-nestjs-viem-and-the-potholes-of-free-rpcs-1h17</link>
      <guid>https://dev.to/d_dashi/watching-incoming-usdc-usdt-nestjs-viem-and-the-potholes-of-free-rpcs-1h17</guid>
      <description>&lt;p&gt;I had a deceptively simple task: watch a set of addresses and push a Telegram notification the moment a stablecoin lands on one of them. The classic "a freelancer got paid and found out instantly, instead of when they next opened an explorer." I started with USDC on Base, then added USDT (TRC-20) on Tron — and the second chain slotted into the same architecture almost for free, viem included. I'll get to that too.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F46lxsk8h8jwq1jeyp3z8.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F46lxsk8h8jwq1jeyp3z8.gif" alt="StablePing bot in Telegram: /start, then /add with a wallet address, then a " width="400" height="755"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Stack: NestJS + viem + BullMQ. You'd think: subscribe to the ERC-20 &lt;code&gt;Transfer&lt;/code&gt; event and you're done. In practice, a reliable watcher on free-tier RPCs is a handful of separate potholes. There's a lot of code here, and all of it is running in production.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why not a "raw" log subscription
&lt;/h2&gt;

&lt;p&gt;The first thing that comes to mind is &lt;code&gt;watchEvent&lt;/code&gt; / &lt;code&gt;eth_subscribe("logs")&lt;/code&gt;: the node pushes matching logs to you by filter. Two problems:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A dropped WS means lost events.&lt;/strong&gt; While the socket reconnects, transfers slip past you. You need a separate catch-up mechanism — and it turns out to be harder than the subscription itself.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A dynamic set of addresses.&lt;/strong&gt; Users add and remove addresses on the fly; recreating a server-side filter on every change is busywork and extra races.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the scheme is different: &lt;strong&gt;a WS subscription to new blocks (&lt;code&gt;newHeads&lt;/code&gt;) is only a trigger, and the data is pulled with &lt;code&gt;getLogs&lt;/code&gt; over a block range&lt;/strong&gt; starting from the last processed block (&lt;code&gt;lastProcessedBlock&lt;/code&gt;, persisted in the DB). A nice consequence: catch-up after a disconnect or a restart is free — the very next block pulls the entire missed range through the same code path. No separate "recovery mode."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The event is a plain ERC-20 Transfer&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TRANSFER_EVENT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseAbiItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;event Transfer(address indexed from, address indexed to, uint256 value)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two clients: WS for the block subscription, HTTP for &lt;code&gt;getLogs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ws&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createPublicClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;transport&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;webSocket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wssUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;reconnect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;httpClient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createPublicClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;transport&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;http&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;httpUrl&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A small note about viem and Base: the clients are created &lt;strong&gt;without a &lt;code&gt;chain&lt;/code&gt; object&lt;/strong&gt;. &lt;code&gt;getBlockNumber&lt;/code&gt; / &lt;code&gt;getLogs&lt;/code&gt; / &lt;code&gt;watchBlockNumber&lt;/code&gt; don't need it, and Base's OP-stack chain type clashes inside &lt;code&gt;createPublicClient&lt;/code&gt;'s generics — drop &lt;code&gt;chain&lt;/code&gt; and the types line up with no loss of functionality. Spoiler: chain-agnostic clients are also what will later let us point viem at Tron.&lt;/p&gt;

&lt;p&gt;The block subscription is just a processing trigger:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unsubscribe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;watchBlockNumber&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;emitOnBegin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;onBlockNumber&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scheduleProcessUpTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bn&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;onError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`WS newHeads: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; (auto-reconnect)`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus a safety reconcile every 30 seconds over HTTP &lt;code&gt;getBlockNumber&lt;/code&gt; — for the "silent death" of a WS subscription, where the connection is alive but events simply stop arriving.&lt;/p&gt;

&lt;p&gt;The processing itself is serialized (no more than one pass at a time — a promise chain) and runs in chunks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;processUpTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;head&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;head&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;confirmations&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// more on this below&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stopped&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastProcessed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastProcessed&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stopped&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chunkEnd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;backfillChunk&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;chunkEnd&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;chunkEnd&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;addresses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;logs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;httpClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getLogs&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;address&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tokenAddress&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TRANSFER_EVENT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;addresses&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c1"&gt;// filter by the indexed parameter&lt;/span&gt;
        &lt;span class="na"&gt;fromBlock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;toBlock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;log&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;logs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;processLog&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastProcessed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;syncState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// persist progress&lt;/span&gt;
    &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note &lt;code&gt;args: { to: this.addresses }&lt;/code&gt; — viem lets you filter by an array of values for an indexed parameter, so "every transfer to any of the N watched addresses" is a single request, not N.&lt;/p&gt;

&lt;p&gt;Now, the potholes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pothole #1: the free tier caps the getLogs range
&lt;/h2&gt;

&lt;p&gt;On the Alchemy free tier, &lt;code&gt;eth_getLogs&lt;/code&gt; accepts a range of &lt;strong&gt;no more than 10 blocks&lt;/strong&gt;. While the service runs in real time this is invisible — Base blocks come every ~2 seconds, and you process one or two at a time. But after downtime (a deploy, a crashed container, a night with no server) you accumulate a backlog of hundreds of blocks, a request for the whole range gets rejected — and the watcher is stuck forever, because the range never shrinks.&lt;/p&gt;

&lt;p&gt;The fix is trivial: the chunk size (&lt;code&gt;backfillChunk&lt;/code&gt; in the code above) comes from env, default 10. On a paid tier you raise it for fast catch-up; on the free tier, 10 blocks per request is ~300 blocks a minute with room to spare — plenty for Base.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pothole #2: the provider may ban your VPS, not your key
&lt;/h2&gt;

&lt;p&gt;Everything worked from my local machine. After deploying to the VPS, the same Alchemy key started getting &lt;code&gt;403 Forbidden&lt;/code&gt; with &lt;code&gt;server: istio-envoy&lt;/code&gt; in the headers — a perimeter block by datacenter IP, nothing to do with the allowlist in the app settings. The symptom is deceptive: it looks like a key problem, when in fact your host is on a blocklist.&lt;/p&gt;

&lt;p&gt;Trying alternatives turned up something no less interesting: with dRPC, WSS requests went through, but the &lt;code&gt;newHeads&lt;/code&gt; subscription silently delivered nothing — the connection alive, no blocks. Without the safety reconcile this would have looked like "the service just stopped noticing transfers."&lt;/p&gt;

&lt;p&gt;I settled on PublicNode (&lt;code&gt;base-rpc.publicnode.com&lt;/code&gt;): no key, &lt;code&gt;eth_subscribe newHeads&lt;/code&gt; and &lt;code&gt;getLogs&lt;/code&gt; both work. The takeaway for any production system on free RPCs: test the provider &lt;strong&gt;from your production IP&lt;/strong&gt;, and don't trust a live WS connection — only the events coming out of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pothole #3: a node pool behind a load balancer and "block range extends beyond current head block"
&lt;/h2&gt;

&lt;p&gt;The least obvious one. After switching to PublicNode, a roughly-once-per-block error started showing up in the logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;block range extends beyond current head block
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cause: PublicNode is a pool of nodes behind a load balancer. The WS subscription pins to one node; HTTP requests go to others. The node behind the WS can be &lt;strong&gt;one block ahead&lt;/strong&gt; of the node answering &lt;code&gt;getLogs&lt;/code&gt;: the subscription reports "block N exists," while the HTTP node still lives in N−1 — and a getLogs request for block N fails.&lt;/p&gt;

&lt;p&gt;The cure is a lag from the head:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/**
 * Confirmation lag: process blocks no closer than `confirmations` to the head.
 * Removes the "WS node ahead of HTTP node" race in a load-balanced pool,
 * and doubles as protection against reorgs at the tip of the chain.
 */&lt;/span&gt;
&lt;span class="nx"&gt;confirmations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two blocks on Base is ~4 seconds of extra notification delay, imperceptible for payment pushes. Bonus: the same lag guards against small tip reorgs — a log from a block that gets reordered a second later is simply one we never read in time. After this fix, error-level entries in the logs: zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reliability: don't lose it, don't double it
&lt;/h2&gt;

&lt;p&gt;The watcher can crash at any moment, so the order of operations is fixed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;handleEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TransferEvent&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;processed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;txHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;logIndex&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// dedup&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wallets&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;wallets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findByAddress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wallet&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;wallets&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;producer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enqueueTransfer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;processed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mark&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;txHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;logIndex&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// mark AFTER enqueue&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First the event goes into the queue for every recipient, and only then is it marked processed (the key is &lt;code&gt;txHash + logIndex&lt;/code&gt;, because a single transaction can carry several transfers). A crash between the steps leads to reprocessing — and that's safe: BullMQ dedups by &lt;code&gt;jobId&lt;/code&gt;, assembled from the same &lt;code&gt;txHash + logIndex&lt;/code&gt;. You must never drop an event; repeating one is fine — choose at-least-once and dedup at the queue boundary.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;lastProcessedBlock&lt;/code&gt; only advances after the full range is processed — a restart mid-chunk means re-reading the chunk, which, again, is safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  A second chain: USDT on Tron — with the same viem
&lt;/h2&gt;

&lt;p&gt;When I finally got to USDT (TRC-20), I expected a separate SDK, a different log format, and rewriting half the watcher. It turned out more interesting: &lt;strong&gt;java-tron (the Tron node) exposes an EVM-compatible subset of JSON-RPC&lt;/strong&gt; — &lt;code&gt;eth_blockNumber&lt;/code&gt;, &lt;code&gt;eth_getLogs&lt;/code&gt; with the same topics and log formats, filtering by &lt;code&gt;to&lt;/code&gt; works. So viem — which we deliberately never gave a &lt;code&gt;chain&lt;/code&gt; — works against Tron unchanged, and a TRC-20 &lt;code&gt;Transfer&lt;/code&gt; is decoded by the same &lt;code&gt;decodeEventLog&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The whole shared pipeline (chunked catch-up from &lt;code&gt;lastProcessedBlock&lt;/code&gt;, &lt;code&gt;enqueue → mark&lt;/code&gt;, progress persistence) moved into an abstract &lt;code&gt;ChainWatcherBase&lt;/code&gt;, and the chains differ only in config and trigger:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;ChainWatcherConfig&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Chain&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;tokenAddress&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`0x&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;tokenSymbol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;tokenDecimals&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;confirmations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// lag from the head&lt;/span&gt;
  &lt;span class="nl"&gt;backfillChunk&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// getLogs cap&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What had to be solved differently:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trigger — polling instead of WS.&lt;/strong&gt; TronGrid has no subscriptions, so the head is polled every 3 seconds (~Tron's block time). An unexpected upside: polling is sturdier than a subscription — each tick is both a trigger and a reconcile in one, no separate safety net needed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;poll&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stopped&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;head&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;httpClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getBlockNumber&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scheduleProcessUpTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;head&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Head poll failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Addresses — base58 on the outside, hex on the inside.&lt;/strong&gt; Tron addresses (&lt;code&gt;T...&lt;/code&gt;) are base58check over the same 20 bytes as an EVM address. In log topics the addresses are hex anyway — so in the DB and throughout the pipeline the address is stored &lt;strong&gt;as lowercase hex&lt;/strong&gt;, uniformly with Base, and base58 only appears at output (the notification, the Tronscan link). base58check validation is written on &lt;code&gt;node:crypto&lt;/code&gt; (double-sha256 + BigInt for base 58) — zero new dependencies. A bonus of this split: the network is inferred straight from the entered address format — &lt;code&gt;T...&lt;/code&gt; → Tron, &lt;code&gt;0x...&lt;/code&gt; → Base, so the bot needs no separate network selector.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Its own free-RPC potholes, of course.&lt;/strong&gt; PublicNode for Tron serves &lt;code&gt;getLogs&lt;/code&gt; only over the ~50 most recent blocks — beyond that, "Archive requests require a personal token," useless for catch-up after downtime. TronGrid's deep &lt;code&gt;getLogs&lt;/code&gt; works without a key and doesn't block datacenter IPs — it became the primary endpoint. Pothole #2's rule held: test the provider from your production IP and on the exact methods you need.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Confirmations — 2 again, not "by the book."&lt;/strong&gt; Full finality on Tron is 19 blocks (the solid block), ~57 seconds. For crediting funds — mandatory; but a notification isn't crediting: a two-block reorg on Tron is vanishingly rare, and a minute of delay would kill the entire point of an "instant" push. A 2-block lag is a deliberate compromise.&lt;/p&gt;

&lt;p&gt;The result: the second chain is ~100 lines of its own code (trigger + config + address conversion), with a shared pipeline. Live check: on a hot USDT address the watcher caught 11 real transfers in a minute, with dedup and progress persistence working without errors.&lt;/p&gt;

&lt;h2&gt;
  
  
  One more viem detail that can cost you money
&lt;/h2&gt;

&lt;p&gt;Address validation on input: &lt;code&gt;getAddress&lt;/code&gt; in viem v2 &lt;strong&gt;does not throw&lt;/strong&gt; on a bad EIP-55 checksum — it silently returns the corrected form. If, like me, you're used to ethers, where a bad checksum is an error, check by comparison:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// a mixed-case address is valid only if the checksum matches&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isValid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getAddress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;addr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;addr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Otherwise a typo in a manually re-cased address passes validation, and the user ends up watching an address that isn't theirs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;A recipe for a reliable watcher on free RPCs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A new block (WS &lt;code&gt;newHeads&lt;/code&gt; or polling) is only a trigger; the data is &lt;code&gt;getLogs&lt;/code&gt; over a range from a persisted &lt;code&gt;lastProcessedBlock&lt;/code&gt;. Catch-up after any failure is free.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;getLogs&lt;/code&gt; range goes in chunks, size in config (free tiers cap it: Alchemy — up to 10 blocks, PublicNode Tron — only the ~50 latest at all).&lt;/li&gt;
&lt;li&gt;A lag of a couple of blocks from the head: kills node-pool races and small reorgs in one move.&lt;/li&gt;
&lt;li&gt;If the trigger is WS, you need a timer-based head poll as a safety net: WS dies quietly. If it's polling — it's its own safety net.&lt;/li&gt;
&lt;li&gt;at-least-once + dedup by &lt;code&gt;txHash + logIndex&lt;/code&gt; at the queue boundary.&lt;/li&gt;
&lt;li&gt;Test your RPC provider from the production IP and on the methods you actually use, not from a laptop with a healthcheck.&lt;/li&gt;
&lt;li&gt;Keep the pipeline network-agnostic — the second chain then costs ~100 lines. If it's EVM-compatible over JSON-RPC (like Tron), you won't even swap the client.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of this runs in production for a small product — the StablePing Telegram bot (stableping.net), which pushes notifications for incoming USDC on Base and USDT on Tron (free — 1 address). If you build something similar from this recipe, or know a pothole I haven't hit yet — comments welcome.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>typescript</category>
      <category>web3</category>
      <category>node</category>
    </item>
  </channel>
</rss>
