DEV Community

Cover image for Designing a multichain balance tracker in TypeScript: architecture and key listings
Nica Furs
Nica Furs

Posted on

Designing a multichain balance tracker in TypeScript: architecture and key listings

A typical crypto user today holds USDT on TRON: read events by position, leftover tokens on Arbitrum, some SOL from a mobile app and a little Bitcoin. Every one of those chains has its own address format, its own units, its own API and its own idea of when a transaction is final.

No single call returns “everything this address owns and everything that happened to it”. This post designs a utility that does. It is read-only and takes public addresses only. It adds new chains without touching the core, and it degrades gracefully when public infrastructure starts answering HTTP 429.

The focus is architecture. Listings show only the load-bearing parts. Helpers, error classes and dependency wiring are left out. Stack: TypeScript (ESM, Node 20.3+), ethers v6, @solana/kit, bitcoinjs-lib, bullmq, ioredis, postgres, decimal.js.

The shape of the system

The tracker has two modes that share the same chain code:

  • Lookup. A user pastes an address and gets a snapshot within seconds. This mode is latency-sensitive and stateless.

  • Tracking. A watchlist is kept up to date in the background, with full transfer history and change notifications. This mode is throughput-sensitive and stateful.

Architecture of the multichain balance tracker: the client and API gateway feed a portfolio service and a history query service; the portfolio service reads a hot balance store, a read-through cache and a price service backed by an external price API, and calls chain adapters (EVM, TRON, Bitcoin, Solana) through an adapter registry; adapters reach RPC nodes and indexers only through provider pools with quota, limiter, breaker and failover; a scheduler feeds per-chain queues and sync workers, which write to the transfers store in PostgreSQL, update the hot balance store and emit change events.

Architecture of the multichain balance tracker: the client and API gateway feed a portfolio service and a history query service; the portfolio service reads a hot balance store, a read-through cache and a price service backed by an external price API, and calls chain adapters (EVM, TRON, Bitcoin, Solana) through an adapter registry; adapters reach RPC nodes and indexers only through provider pools with quota, limiter, breaker and failover; a scheduler feeds per-chain queues and sync workers, which write to the transfers store in PostgreSQL, update the hot balance store and emit change events.

Three decisions shape everything else

  1. Chains are plugins. The core knows only a ChainAdapter interface. A new EVM chain is a config entry. A new chain family is one class and one registration call.

  2. Every upstream call goes through a pool. No adapter calls fetch or an RPC client directly. Rate limits, retries, failover and timeouts live in one place.

  3. Every value is tied to a block. A balance without a block height cannot be verified, so it cannot go into a report. On TRON and Bitcoin the provider APIs only allow this approximately, and the tracker says so.

Domain model: bigint and CAIP

Two rules. Amounts are bigint in the chain’s smallest unit (wei, sat, lamport, sun). Identifiers follow CAIP-2 and CAIP-19, so “USDT on TRON” and “USDT on Ethereum” never collide: eip155:1, tron:728126428, solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp.

// core/types.ts

export interface BlockRef {
  height: bigint; // block number, or slot on Solana
  hash?: string;
  timestamp?: number; // unix seconds
}

export interface AssetRef {
  id: string; // CAIP-19, e.g. "eip155:1/erc20:0xdac17f958d2ee523a2206206994597c13d831ec7"
  chainId: string; // CAIP-2, e.g. "eip155:1"
  symbol: string;
  decimals: number;
  contract?: string; // undefined for the native coin
  verified: boolean; // comes from a curated list, never from on-chain symbol()
}

export interface Balance {
  asset: AssetRef;
  raw: bigint;
  pending?: bigint; // signed unconfirmed delta, where the chain exposes a mempool view
  at: BlockRef;
}

export interface Transfer {
  id: string; // deterministic per chain: txHash + log/event/account position
  chainId: string;
  txHash: string;
  block: BlockRef;
  from: string;
  to: string;
  asset: AssetRef;
  raw: bigint;
  status: "success" | "failed";
  finality: "pending" | "confirmed" | "finalized";
  flags: ("unverified_asset" | "zero_value" | "lookalike_counterparty")[];
}
Enter fullscreen mode Exit fullscreen mode

Two fields deserve a word. verified exists because on-chain symbol() is attacker-controlled. The deterministic id is what makes every retry, overlapping page and reorg re-read harmless further down.

Decimal maths needs more precision than decimal.js gives by default. At its default 20 significant digits, an 18-decimal amount above roughly 100 tokens is already rounded. So the codebase shares one configured constructor:

Decimal.clone({ precision: 80 })
Enter fullscreen mode Exit fullscreen mode

Chains as plugins

// core/adapter.ts (excerpt)

export interface ChainAdapter {
  readonly chainId: string;
  readonly family: "evm" | "tron" | "utxo" | "solana";
  readonly capabilities: {
    tokens: boolean;
    history: "native" | "indexer" | "none";
    subscribe: boolean;
  };
  readonly features: Partial<Features>; // optional modules: NFTs and so on

  /** Canonical address, or null if the input is not a valid address on this chain. */
  normalizeAddress(input: string): string | null;

  getHead(): Promise<BlockRef>;

  /** Returns every requested token, zeros included, so stale non-zero values can be overwritten. */
  getBalances(address: string, tokens: AssetRef[]): Promise<Balance[]>;

  getTransfers(address: string, cursor?: string): Promise<Page<Transfer>>;

  getTxStatus(txHash: string): Promise<TxStatus>;
}
Enter fullscreen mode Exit fullscreen mode

normalizeAddress replaces the regex-based detectNetwork() found in many tutorials. Each adapter validates against its own rules: checksum, decoded length, version byte. The registry asks all of them and returns a set of candidate chains. That is the honest answer for an EVM address, which is valid on twenty chains at once.

// core/registry.ts (excerpt)

candidates(input: string): { adapter: ChainAdapter; address: string }[] {
  const out: { adapter: ChainAdapter; address: string }[] = [];

  for (const adapter of this.adapters.values()) {
    const address = adapter.normalizeAddress(input);
    if (address) out.push({ adapter, address });
  }

  return out;
}
Enter fullscreen mode Exit fullscreen mode

Decoding rather than pattern-matching also removes the classic TRON/Solana ambiguity. Both use base58, but a TRON address decodes to 21 bytes plus a checksum, with a 0x41 version byte. A Solana public key decodes to 32 bytes.

// chains/tron/tron-adapter.ts (excerpt)

normalizeAddress(input: string): string | null {
  const s = input.trim();

  try {
    const bytes = bs58check.decode(s);
    return bytes.length === 21 && bytes[0] === 0x41 ? s : null;
  } catch {
    return null;
  }
}
Enter fullscreen mode Exit fullscreen mode

Bootstrapping shows the extension points. Adding Optimism is one more line in CHAINS. Adding TON means writing a TonAdapter class and making one registerFamily call.

// main.ts

const registry = new AdapterRegistry(deps)
  .registerFamily("evm", (cfg, d) => new EvmAdapter(cfg, d))
  .registerFamily("tron", (cfg, d) => new TronAdapter(cfg, d))
  .registerFamily("utxo", (cfg, d) => new EsploraAdapter(cfg, d))
  .registerFamily("solana", (cfg, d) => new SolanaAdapter(cfg, d));

for (const cfg of CHAINS) registry.addChain(cfg);
Enter fullscreen mode Exit fullscreen mode

The provider pool: one place for everything that fails

Public RPC endpoints throttle, time out and return stale data. If each adapter handles that on its own, you end up with N slightly different retry loops. Instead, each chain gets a pool of endpoints, and every request passes four gates:

  • A circuit breaker. A dead endpoint stops receiving traffic. After a cool-down, exactly one probe request decides whether it comes back.

  • A shared token bucket in Redis, keyed by the provider account or API key (quotaKey). The provider counts requests per key, not per pod or per chain, so every pod and every chain on that key draws from the same bucket.

  • An AIMD limiter. Local concurrency grows additively on success and halves on throttling, the same idea as TCP congestion control.

  • Latency tracking per endpoint and operation, used to rank endpoints and to time hedged requests.

Throttling and failure are different signals and get different reactions. HTTP 429 means “slow down” and only feeds the limiter. Timeouts, 5xx responses and connection resets mean “this endpoint is unhealthy” and feed the breaker. A revert or a malformed request means the endpoint did its job.

// infra/provider-pool.ts (excerpt)

private async callOn<R>(
  ep: Endpoint<C>,
  op: string,
  fn: (client: C, signal: AbortSignal) => Promise<R>,
  parent?: AbortSignal,
  onDispatch?: () => void, // fires after queueing, right before the request leaves
): Promise<R> {
  if (!ep.breaker.tryAcquire()) throw new CircuitOpen(ep.name);

  try {
    await this.quota.take(
      `${this.quotaPrefix}:${ep.quotaKey}`,
      ep.rps,
      ep.burst,
      parent
    );
    await ep.limiter.acquire(parent);
  } catch (err) {
    ep.breaker.onNeutral(); // cancelled while queueing: release a half-open probe without a verdict
    throw err;
  }

  onDispatch?.();

  const started = performance.now();
  const timeout = AbortSignal.timeout(this.timeoutMs);
  const signal = parent ? AbortSignal.any([parent, timeout]) : timeout;

  const request = Promise.resolve().then(() => fn(ep.client, signal));

  request.finally(() => ep.limiter.release()).catch(() => {});

  try {
    const result = await raceAbort(request, signal);
    const ms = performance.now() - started;

    ep.breaker.onSuccess();
    ep.limiter.onSuccess();
    ep.latencyMs = 0.8 * ep.latencyMs + 0.2 * ms;
    this.tracker(ep, op).observe(ms);

    return result;
  } catch (err) {
    if (parent?.aborted) {
      ep.breaker.onNeutral();
    } else if (isThrottle(err)) {
      ep.limiter.onThrottle();
      ep.breaker.onNeutral();
    } else if (isRetryable(err)) {
      ep.breaker.onFailure();
    } else {
      ep.breaker.onSuccess(); // the endpoint answered; the request itself was bad
    }

    throw err;
  }
}
Enter fullscreen mode Exit fullscreen mode

Two details are easy to get wrong:

  • Zombie requests. Some clients, ethers among them, cannot be cancelled mid-flight. raceAbort stops waiting for them, but the request keeps running. The limiter slot is therefore released when the request really finishes, not when the caller gives up. Otherwise abandoned requests would escape the concurrency limit.

  • Cancellation is not failure. A request cancelled by its caller is neither a success nor a failure of the endpoint. The cancellation also reaches the queues. The token bucket and the limiter both honour the caller’s AbortSignal, so a cancelled request still waiting for quota leaves the queue without consuming a token.

The token bucket itself is a short Lua script that reads the clock with redis.call("TIME"), so clock skew between pods does not matter.

Four chain families, one contract

EVM: one class for every EVM chain

Multicall3 is deployed at the same address on most EVM chains. A single eth_call to it returns the native balance, every ERC-20 balance, and the block number and timestamp the call executed against. All of this comes from one call on one node, so the snapshot and its block reference cannot disagree. If the token list needs more than one chunk, later chunks are pinned to the block of the first one.

// chains/evm/evm-adapter.ts (excerpt)

async getBalances(owner: string, tokens: AssetRef[]): Promise<Balance[]> {
  const assets: AssetRef[] = [nativeAsset(this.cfg), ...tokens];
  const out: Balance[] = [];
  let at: BlockRef | undefined;

  for (const chunk of chunks(assets, CHUNK)) {
    const calls = [
      {
        target: MULTICALL3,
        allowFailure: false,
        callData: MC.encodeFunctionData("getBlockNumber")
      },
      {
        target: MULTICALL3,
        allowFailure: false,
        callData: MC.encodeFunctionData("getCurrentBlockTimestamp")
      },
      ...chunk.map((a) =>
        a.contract
          ? {
              target: a.contract,
              allowFailure: true,
              callData: ERC20.encodeFunctionData("balanceOf", [owner])
            }
          : {
              target: MULTICALL3,
              allowFailure: true,
              callData: MC.encodeFunctionData("getEthBalance", [owner])
            }
      ),
    ];

    const blockTag = at ? Number(at.height) : "latest";

    const [blockNo, blockTs, ...results]: {
      success: boolean;
      returnData: string;
    }[] = await this.pool.call(
      "multicall",
      (p) =>
        new Contract(
          MULTICALL3,
          MULTICALL3_ABI,
          p
        ).aggregate3(calls, { blockTag }),
    );

    const snapshot: BlockRef = at ?? {
      height: toUint(blockNo.returnData),
      timestamp: Number(toUint(blockTs.returnData)),
    };

    at = snapshot;

    results.forEach((r, i) => {
      if (r.success) {
        out.push({
          asset: chunk[i],
          raw: toUint(r.returnData),
          at: snapshot
        });
      }
    });
  }

  return out;
}
Enter fullscreen mode Exit fullscreen mode

tokens comes from a curated token registry. balanceOf answers “how much of X”, never “which X”, so discovering which tokens an address holds is a separate concern.

History uses eth_getLogs filtered by the Transfer topic, with the owner in the from or to slot. Providers cap the block range, so the adapter halves the range on a “range too large” error instead of hard-coding a limit per provider. Logs have a blind spot: native transfers emit none, and ETH moved by contracts needs traces. So native history and the initial backfill come from an indexer. The deterministic transfer ID deduplicates the two sources.

TRON: read events by position

TronGrid returns TRX, staked TRX and TRC-20 balances in one account response. Staked TRX is reported as a staking position, not as a balance, so it is neither forgotten nor counted twice.

Two traps in history:

  • Event parameters are read by position (result["0"], ["1"], ["2"]). The named keys come from each contract’s ABI and differ between contracts: from in one, _from in another. TRC-721 emits a Transfer event with the same name, and the only way to tell it apart is the signature, where the token ID is indexed.

  • The TRC-20 history list does not expose event positions. Numbering transfers by their order on a page breaks idempotency when a transaction straddles a page boundary. The list is used only to discover transaction IDs. Each transaction’s events are then read once and cached for good, and the transfer ID becomes txid:event_index.

TronGrid’s indexed account data can lag the solidity node’s head by a block or two, so the block reference on TRON balances is approximate.

Bitcoin: a balance is rarely one address

An Esplora-compatible API aggregates outputs per address. Confirmed and mempool stats come separately. The mempool part becomes the signed pending field, which is negative when an unconfirmed transaction spends from the address. The address endpoint does not say which block its numbers reflect, so the tip read alongside it is approximate.

The bigger issue is conceptual. HD wallets use a fresh address for every receive and for change, so the balance of one address is rarely the wallet’s balance. A serious tracker accepts an xpub, derives addresses until 20 consecutive unused ones (the BIP-44 gap limit), and sums them. An xpub cannot spend funds, but it reveals the wallet’s whole history. Never log it, and encrypt it at rest.

Solana: token accounts everywhere

Each token balance lives in its own token account, there are two token programs (SPL Token and Token-2022), and one owner can hold several accounts for the same mint. Balances are summed per mint. Each one is tied to the context.slot its response was served at. A closed token account disappears from the response, so a requested mint that is missing is reported as zero.

History has two traps:

  • The owner alone is not enough. An incoming SPL transfer to an existing token account references only that token account. Polling getSignaturesForAddress(owner) misses it, so the adapter polls the owner and each of its token accounts.

  • Paging can silently lose data. getSignaturesForAddress returns at most 1,000 signatures, newest first. If more accumulated since the last run, moving the cursor to the newest one drops everything in between. Each account’s cursor therefore has two parts. done marks the newest signature processed together with everything older. top and before track a catch-up in progress. A page shorter than the limit ends the catch-up, so an ordinary poll costs one call per account.

Prices: case, precision, and unpriced is not zero

A price API that looks up tokens by contract address removes most of the symbol-to-ID mapping problem, but it has a trap. Lower-case contract addresses only on EVM chains, where case is just a checksum. Base58 addresses on TRON and Solana are case-sensitive, and lower-casing them silently loses every price on those chains.

A quote arrives as a JSON number. Convert it once to the 80-digit decimal and never feed it back into amount arithmetic. An asset without a quote stays null and is reported separately, so it never shows up as $0 in the total.

Tracking: history sync and reorgs

Tracking turns the same adapters into a pipeline:

// services/history-sync.ts

const MAX_PAGES_PER_JOB = 10;

export async function syncAddress(
  adapter: ChainAdapter,
  address: string,
  store: TransferStore
) {
  const cp = await store.checkpoint(adapter.chainId, address);
  let cursor = cp ? rewind(adapter, cp.cursor) : undefined;
  const trust = await store.addressTrust(adapter.chainId, address);

  for (let i = 0; i < MAX_PAGES_PER_JOB; i++) {
    const page = await adapter.getTransfers(address, cursor);

    await fillBlockTimes(
      adapter,
      page.items
    ); // one block read per distinct block, cached forever

    const suspects = markLookalikes(page.items, address, trust);

    if (suspects.length > 0) {
      await store.addSuspects(
        adapter.chainId,
        address,
        suspects
      );
    }

    await store.upsertTransfers(page.items);

    if (i === 0 && cp) {
      await store.pruneOrphans(
        adapter.chainId,
        address,
        cursor,
        page.items
      );
    }

    if (!page.cursor || page.cursor === cursor) {
      return { done: true };
    }

    cursor = page.cursor;

    await store.saveCheckpoint(
      adapter.chainId,
      address,
      cursor
    );
  }

  return { done: false }; // re-enqueue; short jobs keep queues fair
}
Enter fullscreen mode Exit fullscreen mode

Three properties make it robust:

  • Idempotency. Transfer IDs are deterministic and the store upserts by (chainId, id), so retries and overlapping pages are harmless.

  • A reorg window. rewind moves a block cursor back by the chain’s confirmation count on every pass. Non-finalized rows in that window that did not reappear are pruned. Rows become immutable once finalized.

  • Separate backfill. Years of history go through a bulk queue with its own budge

Top comments (0)