DEV Community

Cover image for Watching incoming USDC & USDT: NestJS + viem and the potholes of free RPCs

Watching incoming USDC & USDT: NestJS + viem and the potholes of free RPCs

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.
StablePing bot in Telegram: /start, then /add with a wallet address, then a

Stack: NestJS + viem + BullMQ. You'd think: subscribe to the ERC-20 Transfer 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.

Why not a "raw" log subscription

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

  1. A dropped WS means lost events. 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.

  2. A dynamic set of addresses. Users add and remove addresses on the fly; recreating a server-side filter on every change is busywork and extra races.

So the scheme is different: a WS subscription to new blocks (newHeads) is only a trigger, and the data is pulled with getLogs over a block range starting from the last processed block (lastProcessedBlock, 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."

// The event is a plain ERC-20 Transfer
export const TRANSFER_EVENT = parseAbiItem(
  'event Transfer(address indexed from, address indexed to, uint256 value)',
);
Enter fullscreen mode Exit fullscreen mode

Two clients: WS for the block subscription, HTTP for getLogs:

this.ws = createPublicClient({
  transport: webSocket(wssUrl, { reconnect: true }),
});
this.httpClient = createPublicClient({
  transport: http(httpUrl),
});
Enter fullscreen mode Exit fullscreen mode

A small note about viem and Base: the clients are created without a chain object. getBlockNumber / getLogs / watchBlockNumber don't need it, and Base's OP-stack chain type clashes inside createPublicClient's generics — drop chain 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.

The block subscription is just a processing trigger:

this.unsubscribe = this.ws.watchBlockNumber({
  emitOnBegin: false,
  onBlockNumber: (bn) => this.scheduleProcessUpTo(bn),
  onError: (e) => this.logger.warn(`WS newHeads: ${e.message} (auto-reconnect)`),
});
Enter fullscreen mode Exit fullscreen mode

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

The processing itself is serialized (no more than one pass at a time — a promise chain) and runs in chunks:

private async processUpTo(head: bigint): Promise<void> {
  const target = head - this.cfg.confirmations; // more on this below
  if (this.stopped || target <= this.lastProcessed) return;

  let from = this.lastProcessed + 1n;
  while (from <= target && !this.stopped) {
    const chunkEnd = from + this.cfg.backfillChunk - 1n;
    const to = chunkEnd < target ? chunkEnd : target;

    if (this.addresses.length > 0) {
      const logs = await this.httpClient.getLogs({
        address: this.cfg.tokenAddress,
        event: TRANSFER_EVENT,
        args: { to: this.addresses }, // filter by the indexed parameter
        fromBlock: from,
        toBlock: to,
      });
      for (const log of logs) await this.processLog(log);
    }

    this.lastProcessed = to;
    await this.syncState.set(this.cfg.chain, to); // persist progress
    from = to + 1n;
  }
}
Enter fullscreen mode Exit fullscreen mode

Note args: { to: this.addresses } — 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.

Now, the potholes.

Pothole #1: the free tier caps the getLogs range

On the Alchemy free tier, eth_getLogs accepts a range of no more than 10 blocks. 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.

The fix is trivial: the chunk size (backfillChunk 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.

Pothole #2: the provider may ban your VPS, not your key

Everything worked from my local machine. After deploying to the VPS, the same Alchemy key started getting 403 Forbidden with server: istio-envoy 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.

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

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

Pothole #3: a node pool behind a load balancer and "block range extends beyond current head block"

The least obvious one. After switching to PublicNode, a roughly-once-per-block error started showing up in the logs:

block range extends beyond current head block
Enter fullscreen mode Exit fullscreen mode

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 one block ahead of the node answering getLogs: the subscription reports "block N exists," while the HTTP node still lives in N−1 — and a getLogs request for block N fails.

The cure is a lag from the head:

/**
 * 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.
 */
confirmations: 2n,
Enter fullscreen mode Exit fullscreen mode

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.

Reliability: don't lose it, don't double it

The watcher can crash at any moment, so the order of operations is fixed:

async handleEvent(ev: TransferEvent): Promise<void> {
  if (await this.processed.has(ev.txHash, ev.logIndex)) return; // dedup

  const wallets = await this.wallets.findByAddress(ev.to, this.cfg.chain);
  for (const wallet of wallets) {
    await this.producer.enqueueTransfer({ /* ... */ });
  }

  await this.processed.mark(ev.txHash, ev.logIndex); // mark AFTER enqueue
}
Enter fullscreen mode Exit fullscreen mode

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

lastProcessedBlock only advances after the full range is processed — a restart mid-chunk means re-reading the chunk, which, again, is safe.

A second chain: USDT on Tron — with the same viem

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: java-tron (the Tron node) exposes an EVM-compatible subset of JSON-RPCeth_blockNumber, eth_getLogs with the same topics and log formats, filtering by to works. So viem — which we deliberately never gave a chain — works against Tron unchanged, and a TRC-20 Transfer is decoded by the same decodeEventLog.

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

export interface ChainWatcherConfig {
  chain: Chain;
  tokenAddress: `0x${string}`;
  tokenSymbol: string;
  tokenDecimals: number;
  confirmations: bigint;  // lag from the head
  backfillChunk: bigint;  // getLogs cap
}
Enter fullscreen mode Exit fullscreen mode

What had to be solved differently:

Trigger — polling instead of WS. 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:

private async poll(): Promise<void> {
  if (this.stopped) return;
  try {
    const head = await this.httpClient.getBlockNumber();
    this.scheduleProcessUpTo(head);
  } catch (e) {
    this.logger.warn(`Head poll failed: ${e.message}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Addresses — base58 on the outside, hex on the inside. Tron addresses (T...) 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 as lowercase hex, uniformly with Base, and base58 only appears at output (the notification, the Tronscan link). base58check validation is written on node:crypto (double-sha256 + BigInt for base 58) — zero new dependencies. A bonus of this split: the network is inferred straight from the entered address format — T... → Tron, 0x... → Base, so the bot needs no separate network selector.

Its own free-RPC potholes, of course. PublicNode for Tron serves getLogs only over the ~50 most recent blocks — beyond that, "Archive requests require a personal token," useless for catch-up after downtime. TronGrid's deep getLogs 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.

Confirmations — 2 again, not "by the book." 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.

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.

One more viem detail that can cost you money

Address validation on input: getAddress in viem v2 does not throw 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:

// a mixed-case address is valid only if the checksum matches
const isValid = getAddress(addr) === addr;
Enter fullscreen mode Exit fullscreen mode

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

Wrapping up

A recipe for a reliable watcher on free RPCs:

  • A new block (WS newHeads or polling) is only a trigger; the data is getLogs over a range from a persisted lastProcessedBlock. Catch-up after any failure is free.
  • The getLogs range goes in chunks, size in config (free tiers cap it: Alchemy — up to 10 blocks, PublicNode Tron — only the ~50 latest at all).
  • A lag of a couple of blocks from the head: kills node-pool races and small reorgs in one move.
  • 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.
  • at-least-once + dedup by txHash + logIndex at the queue boundary.
  • Test your RPC provider from the production IP and on the methods you actually use, not from a laptop with a healthcheck.
  • 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.

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.

Top comments (0)