Skip to main content
Axiom’s Holders tab gives traders a real-time view of who holds a token — including balance changes, PnL, buy/sell activity, and LP reserves — all updating live as trades happen. In this guide, you’ll learn how to build a production-ready Holders feature using Mobula’s open-source MTT (Mobula Trader Terminal) codebase.
Live Demo: Check out the working implementation at mtt.gg — navigate to any token pair page to see the holders tab in action.Source Code: The complete codebase is available at github.com/MobulaFi/MTT

What You’ll Build

By the end of this guide, you’ll have a holders tab with:
  • Initial holder data fetched via REST API (positions, PnL, labels)
  • Real-time balance updates via the Multi-Events Stream (swap-enriched events)
  • Authoritative post-balance tracking — no drift from incremental calculations
  • LP reserve sync from Market Details stream (no double-counting)
  • Trade deduplication to prevent over-counting during REST resyncs
  • Periodic REST resync every 30s to catch transfers and missed events

Architecture Overview

The MTT holders feature combines three data sources for accurate, real-time tracking:
┌──────────────────────────────────────────────────────────────────┐
│                      useCombinedHolders Hook                     │
│  (Orchestrates REST + Stream + Resync)                          │
├──────────────────────────────────────────────────────────────────┤
│                                                                  │
│  1. REST API (Initial Load)        2. Multi-Events Stream       │
│  ┌─────────────────────────┐       ┌─────────────────────────┐  │
│  │ fetchTokenHolderPositions│       │ subscribe('stream-svm') │  │
│  │ → Top 100 holders       │       │ events: swap-enriched   │  │
│  │ → PnL, labels, metadata │       │ → Real-time trades      │  │
│  │ → Authoritative balances│       │ → Post-balance fields   │  │
│  └─────────────────────────┘       └─────────────────────────┘  │
│                                                                  │
│  3. Market Details Stream          4. Periodic Resync (30s)     │
│  ┌─────────────────────────┐       ┌─────────────────────────┐  │
│  │ subscribeMarketDetails   │       │ REST re-fetch           │  │
│  │ → pairData.base          │       │ → Catches transfers     │  │
│  │   .approximateReserveToken      │ → Fixes any drift       │  │
│  │ → Authoritative LP bal  │       │ → Full holder reset     │  │
│  └─────────────────────────┘       └─────────────────────────┘  │
│                                                                  │
├──────────────────────────────────────────────────────────────────┤
│                    usePairHoldersStore (Zustand)                  │
│  holders[] · holdersCount · tokenPrice · totalSupply             │
│  _seenHashes (dedup) · upsertFromTrades · updateLpFromReserves  │
├──────────────────────────────────────────────────────────────────┤
│                    applyTradesToPositions Utility                 │
│  Post-balance resolution · Incremental fallback · PnL calc      │
└──────────────────────────────────────────────────────────────────┘

Why Three Data Sources?

SourcePurposeLatency
REST APIFull holder list with historical PnL, labels, funding info~500ms
Multi-Events StreamInstant balance updates from swaps (sender + recipient post-balances)~100ms
Market Details StreamAuthoritative LP pool balance from on-chain reserves~1s

Step 1: Project Setup

Clone the MTT repository or start from scratch:
git clone https://github.com/MobulaFi/MTT.git
cd MTT
bun install
Or add the required packages to an existing project:
npm install @mobula/sdk zustand
Configure the SDK client:
// src/lib/sdkClient.ts
import { MobulaClient } from '@mobula/sdk';

const client = new MobulaClient({
  apiKey: process.env.NEXT_PUBLIC_MOBULA_API_KEY,
});

export const sdk = client;
export const streams = client.streams;
Get your free API key at admin.mobula.io

Step 2: Define the Holder Store

The Zustand store manages holder positions, sorting, filtering, and real-time trade application:
// src/features/pair/store/usePairHolderStore.ts
import { create } from 'zustand';
import type { TokenPositionsOutputResponse } from '@mobula_labs/types';
import { applyTradesToPositions } from '@/utils/applyTradesToPositions';

export interface StreamTradeEvent {
  sender: string;
  swapRecipient?: string | null;
  type: 'buy' | 'sell';
  tokenAmount: number;
  tokenAmountUsd: number;
  tokenPrice: number;
  timestamp: number;
  blockchain: string;
  hash: string;
  labels?: string[];
  walletMetadata?: TokenPositionsOutputResponse['walletMetadata'];
  token?: string;
  // Post-balance fields from the multi-event stream
  postBalanceBaseToken?: string | null;       // Sender's post-balance
  postBalanceRecipientBaseToken?: string | null; // Recipient's post-balance
  tokenAmountRaw?: string;
}

interface PairHoldersState {
  holders: TokenPositionsOutputResponse[];
  holdersCount: number;
  loading: boolean;
  tokenPrice: number;
  totalSupply: number;
  _seenHashes: Set<string>;

  // Actions
  setHolders: (holders: TokenPositionsOutputResponse[]) => void;
  setHoldersCount: (count: number) => void;
  setLoading: (loading: boolean) => void;
  upsertFromTrades: (trades: StreamTradeEvent[]) => void;
  updateLpFromReserves: (reserveToken: number) => void;
  clearHolders: () => void;
}

Key Design Decisions

Hash-based deduplication prevents the same trade from being applied twice. This is critical because the stream subscription starts before the REST fetch — some trades may arrive via both channels:
upsertFromTrades: (trades) => {
  const state = get();
  if (state.holders.length === 0) return;

  // Deduplicate by transaction hash
  const unique = trades.filter((t) => {
    if (!t.hash) return true;
    if (state._seenHashes.has(t.hash)) return false;
    state._seenHashes.add(t.hash);
    // Cap set size to prevent memory leak (evict oldest 500 when > 2000)
    if (state._seenHashes.size > 2000) {
      let del = 0;
      for (const v of state._seenHashes) {
        if (del++ < 500) state._seenHashes.delete(v);
        else break;
      }
    }
    return true;
  });

  if (unique.length === 0) return;

  const { positions, countDelta } = applyTradesToPositions(
    state.holders, unique, { removeZeroBalance: true }
  );

  set({
    holders: positions,
    holdersCount: state.holdersCount + countDelta,
  });
},
LP reserve sync uses the Market Details stream’s approximateReserveToken field as the authoritative LP balance — avoiding double-counting from incremental updates:
updateLpFromReserves: (reserveToken: number) => {
  const state = get();
  const idx = state.holders.findIndex((h) => h.labels?.includes('liquidityPool'));
  if (idx < 0 || reserveToken <= 0) return;
  const lp = state.holders[idx];
  // Skip if unchanged (avoid unnecessary re-renders)
  if (Math.abs(Number(lp.tokenAmount) - reserveToken) < 1) return;
  const updated = [...state.holders];
  updated[idx] = { ...lp, tokenAmount: String(reserveToken) };
  set({ holders: updated });
},
Source: src/features/pair/store/usePairHolderStore.ts in the MTT repository.

Step 3: Map Blockchain to Stream Config

The Multi-Events Stream uses separate endpoints for SVM (Solana) and EVM chains, and requires chainId in a specific format:
// Map blockchain name → multi-event stream config
function getStreamConfig(blockchain: string): {
  streamType: 'stream-svm' | 'stream-evm';
  chainId: string;
} | null {
  const name = blockchain.toLowerCase();
  if (name === 'solana') return { streamType: 'stream-svm', chainId: 'solana:solana' };
  if (name === 'ethereum') return { streamType: 'stream-evm', chainId: 'evm:1' };
  if (name.includes('bnb') || name.includes('bsc'))
    return { streamType: 'stream-evm', chainId: 'evm:56' };
  if (name === 'base') return { streamType: 'stream-evm', chainId: 'evm:8453' };
  if (name === 'arbitrum') return { streamType: 'stream-evm', chainId: 'evm:42161' };
  if (name === 'polygon') return { streamType: 'stream-evm', chainId: 'evm:137' };
  if (name === 'avalanche') return { streamType: 'stream-evm', chainId: 'evm:43114' };
  return null;
}
Important: Solana uses solana:solana as the chainId — not solana:mainnet. EVM chains use the evm:{chainId} format (e.g., evm:1 for Ethereum, evm:56 for BSC).

Step 4: Map Stream Events to Trade Events

The Multi-Events Stream’s swap-enriched events contain raw post-balance fields for both the sender and the swap recipient. You need to resolve which is the base token:
function mapSwapToTradeEvent(raw: Record<string, unknown>): StreamTradeEvent | null {
  const type = raw.type as string;
  if (type !== 'buy' && type !== 'sell') return null;

  const sender = (raw.sender as string) || (raw.transactionSenderAddress as string);
  if (!sender) return null;

  const hash = (raw.hash as string) || (raw.transactionHash as string);
  if (!hash) return null;

  // Determine which token is base to pick the right post-balance fields
  const baseToken = raw.baseToken as string | undefined;
  const addressToken0 = raw.addressToken0 as string | undefined;
  const isToken0Base = baseToken && addressToken0 && baseToken === addressToken0;

  // Pick the correct post-balance: token0 or token1
  const postBalanceBaseToken = isToken0Base
    ? (raw.rawPostBalance0 as string) ?? null
    : (raw.rawPostBalance1 as string) ?? null;
  const postBalanceRecipientBaseToken = isToken0Base
    ? (raw.rawPostBalanceRecipient0 as string) ?? null
    : (raw.rawPostBalanceRecipient1 as string) ?? null;

  return {
    sender,
    swapRecipient: (raw.swapRecipient as string) ?? null,
    type,
    tokenAmount: Number(raw.tokenAmount) || 0,
    tokenAmountUsd: Number(raw.tokenAmountUSD) || 0,
    tokenPrice: Number(raw.tokenPrice) || 0,
    timestamp: raw.date ? new Date(raw.date as string | number).getTime() : Date.now(),
    blockchain: (raw.blockchain as string) || '',
    hash,
    labels: raw.labels as string[] | undefined,
    postBalanceBaseToken,
    postBalanceRecipientBaseToken,
    tokenAmountRaw: raw.tokenAmountRaw?.toString(),
  };
}

Understanding Post-Balance Fields

The multi-event stream provides four raw post-balance fields per swap event:
FieldDescription
rawPostBalance0Sender’s post-balance of token0
rawPostBalance1Sender’s post-balance of token1
rawPostBalanceRecipient0Swap recipient’s post-balance of token0
rawPostBalanceRecipient1Swap recipient’s post-balance of token1
By comparing baseToken with addressToken0, you determine whether the base token is token0 or token1, and select the correct post-balance fields accordingly.
Source: src/features/pair/hooks/useCombinedHolders.ts in the MTT repository.

Step 5: Build the Combined Holders Hook

This is the core hook that orchestrates REST fetching, stream subscription, and periodic resync:
// src/features/pair/hooks/useCombinedHolders.ts
'use client';

import { useEffect, useRef } from 'react';
import { sdk, streams } from '@/lib/sdkClient';
import { usePairHoldersStore } from '@/features/pair/store/usePairHolderStore';
import type { StreamTradeEvent } from '@/features/pair/store/usePairHolderStore';
import { UpdateBatcher } from '@/utils/UpdateBatcher';

export const useCombinedHolders = (tokenAddress: string, blockchain: string) => {
  const {
    setHolders, setHoldersCount, setBlockchain,
    clearHolders, setLoading, upsertFromTrades,
  } = usePairHoldersStore();

  // Stable callback ref — avoids recreating the batcher on each render
  const callbackRef = useRef(upsertFromTrades);
  callbackRef.current = upsertFromTrades;

  // Batch trade updates via requestAnimationFrame (60fps)
  const batcherRef = useRef(
    new UpdateBatcher<StreamTradeEvent>((trades) => {
      if (trades.length === 0) return;
      callbackRef.current(trades);
    }),
  );

  useEffect(() => {
    if (!tokenAddress || !blockchain) return;

    clearHolders();
    setBlockchain(blockchain);
    setLoading(true);

    let subscription: { unsubscribe: () => void } | null = null;
    let cancelled = false;

    const init = async () => {
      // 1. Subscribe FIRST so we don't miss trades during the REST fetch
      const config = getStreamConfig(blockchain);

      if (config) {
        subscription = streams.subscribe(
          config.streamType,
          {
            chainIds: [config.chainId],
            events: ['swap-enriched'],
            filters: {
              or: [
                { eq: { addressToken0: tokenAddress } },
                { eq: { addressToken1: tokenAddress } },
              ],
            },
          },
          (event: unknown) => {
            const envelope = event as Record<string, unknown>;
            const swapData = (envelope.data ?? envelope) as Record<string, unknown>;
            if (!swapData || swapData.event) return;

            const trade = mapSwapToTradeEvent(swapData);
            if (!trade) return;
            batcherRef.current.add(trade);
          },
        );
      }

      // 2. Fetch initial data via REST
      try {
        const response = await sdk.fetchTokenHolderPositions({
          blockchain,
          address: tokenAddress,
          limit: 100,
          useSwapRecipient: true,
          includeFees: true,
        });

        if (!cancelled && response?.data) {
          setHoldersCount(response.totalCount || response.data.length);
          setHolders(response.data);
        }
      } catch (error) {
        console.error('Failed to fetch holders:', error);
      } finally {
        if (!cancelled) setLoading(false);
      }
    };

    init();

    // 3. Periodic resync every 30s to catch transfers and missed trades
    const pollInterval = setInterval(async () => {
      if (cancelled) return;
      try {
        const response = await sdk.fetchTokenHolderPositions({
          blockchain,
          address: tokenAddress,
          limit: 100,
          useSwapRecipient: true,
          includeFees: true,
        });

        if (!cancelled && response?.data) {
          setHoldersCount(response.totalCount || response.data.length);
          setHolders(response.data);
        }
      } catch {
        // Silent fail — stream is still live
      }
    }, 30_000);

    return () => {
      cancelled = true;
      clearInterval(pollInterval);
      subscription?.unsubscribe();
      batcherRef.current.clear();
      clearHolders();
    };
  }, [tokenAddress, blockchain]);

  return usePairHoldersStore();
};

Why Subscribe Before Fetching?

The stream subscription starts before the REST fetch. This ensures zero trade gaps:
  1. Stream subscription begins (trades start queueing)
  2. REST API fetches the current snapshot
  3. Queued trades get deduped against the snapshot via transaction hash
  4. From this point on, every new trade is applied immediately

Step 6: Apply Trades to Positions

The applyTradesToPositions utility is the core engine that updates holder balances from stream trades. It uses authoritative post-balances when available and falls back to incremental calculations:
// src/utils/applyTradesToPositions.ts

/** Convert a raw balance (bigint string) to human-readable number */
function rawToHuman(rawValue: string | null | undefined, trade: StreamTradeEvent): number | null {
  if (!rawValue) return null;
  const postBig = Number(rawValue);
  if (!Number.isFinite(postBig)) return null;

  // Derive decimals factor from raw vs human token amount
  const rawAmt = Number(trade.tokenAmountRaw);
  const humanAmt = trade.tokenAmount;
  if (rawAmt && humanAmt && humanAmt > 0) {
    const factor = rawAmt / humanAmt;
    if (factor >= 1) return postBig / factor;
  }
  return null; // Fallback: use incremental calculation
}

/** Get the correct post-balance for a specific wallet */
function getPostBalanceForWallet(trade: StreamTradeEvent, wallet: string): number | null {
  const sender = trade.sender?.toLowerCase();
  const recipient = trade.swapRecipient?.toLowerCase();

  if (recipient && recipient !== sender && wallet === recipient) {
    // Wallet is the swap recipient — use recipient post-balance
    return rawToHuman(trade.postBalanceRecipientBaseToken, trade);
  }
  // Wallet is the sender — use sender post-balance
  return rawToHuman(trade.postBalanceBaseToken, trade);
}

export function applyTradesToPositions(
  positions: TokenPositionsOutputResponse[],
  trades: StreamTradeEvent[],
  options: { removeZeroBalance?: boolean } = {},
): { positions: TokenPositionsOutputResponse[]; countDelta: number } {
  const items = [...positions];
  const walletIndex = new Map<string, number>();
  items.forEach((h, i) => walletIndex.set(h.walletAddress.toLowerCase(), i));

  let countDelta = 0;

  for (const trade of trades) {
    const wallet = (trade.swapRecipient || trade.sender)?.toLowerCase();
    if (!wallet) continue;

    const idx = walletIndex.get(wallet);
    const isBuy = trade.type === 'buy';
    const tradeAmt = trade.tokenAmount || 0;

    if (idx !== undefined) {
      const h = { ...items[idx] };
      const prevBalance = Number(h.tokenAmount) || 0;

      // Use post-balance from stream when available (authoritative, no drift)
      const postBalanceHuman = getPostBalanceForWallet(trade, wallet);
      if (postBalanceHuman !== null) {
        h.tokenAmount = String(postBalanceHuman);
      } else {
        // Fallback: incremental calculation
        h.tokenAmount = String(
          isBuy ? prevBalance + tradeAmt : Math.max(0, prevBalance - tradeAmt)
        );
      }

      // Update counters & volumes
      if (isBuy) {
        h.buys = (h.buys || 0) + 1;
        h.volumeBuyUSD = String((Number(h.volumeBuyUSD) || 0) + trade.tokenAmountUsd);
      } else {
        h.sells = (h.sells || 0) + 1;
        h.volumeSellUSD = String((Number(h.volumeSellUSD) || 0) + trade.tokenAmountUsd);
      }

      // Recalculate avg prices and PnL
      const totalBuyTokens = Number(h.volumeBuyToken) || 0;
      const totalBuyUSD = Number(h.volumeBuyUSD) || 0;
      if (totalBuyTokens > 0) h.avgBuyPriceUSD = String(totalBuyUSD / totalBuyTokens);

      h.lastActivityAt = new Date(trade.timestamp > 1e12 ? trade.timestamp : trade.timestamp * 1000);
      items[idx] = h;

      // Remove if balance is 0 after a sell
      if (options.removeZeroBalance && !isBuy && Number(h.tokenAmount) <= 0) {
        items.splice(idx, 1);
        walletIndex.clear();
        items.forEach((ho, i) => walletIndex.set(ho.walletAddress.toLowerCase(), i));
        countDelta--;
      }
    } else if (isBuy) {
      // New wallet appeared with a buy — add to the list
      const newEntry = createNewPosition(trade);
      walletIndex.set(wallet, items.length);
      items.push(newEntry);
      countDelta++;
    }

    // NOTE: LP balance is NOT updated incrementally from stream trades.
    // Use updateLpFromReserves() via Market Details stream instead.
  }

  // Global pass: recalculate price-dependent values using latest trade price
  const latestPrice = trades[trades.length - 1]?.tokenPrice;
  if (latestPrice) {
    for (let i = 0; i < items.length; i++) {
      const h = { ...items[i] };
      const balance = Number(h.tokenAmount) || 0;
      h.tokenAmountUSD = String(balance * latestPrice);

      const avgBuy = Number(h.avgBuyPriceUSD) || 0;
      h.unrealizedPnlUSD = String((latestPrice - avgBuy) * balance);
      h.totalPnlUSD = String(Number(h.realizedPnlUSD) + Number(h.unrealizedPnlUSD));
      items[i] = h;
    }
  }

  return { positions: items, countDelta };
}
Source: src/utils/applyTradesToPositions.ts in the MTT repository.

Step 7: Sync LP Balance from Market Details

The liquidity pool balance must come from an authoritative source — not from incrementally adding/subtracting trade amounts. MTT uses the Market Details stream for this:
// src/features/pair/hooks/usePairData.ts
'use client';

import { useEffect, useRef } from 'react';
import { streams } from '@/lib/sdkClient';
import { usePairHoldersStore } from '@/features/pair/store/usePairHolderStore';
import { UpdateBatcher } from '@/utils/UpdateBatcher';

export function usePairData(address: string, blockchain: string) {
  const updateLpFromReserves = usePairHoldersStore((s) => s.updateLpFromReserves);

  const pairBatcherRef = useRef(
    new UpdateBatcher((updates) => {
      if (updates.length > 0) {
        const latestUpdate = updates[updates.length - 1];
        if (latestUpdate) {
          // Sync LP balance from pair reserves (authoritative, real-time)
          const reserve = latestUpdate.base?.approximateReserveToken;
          if (reserve && reserve > 0) {
            updateLpFromReserves(reserve);
          }
        }
      }
    })
  );

  useEffect(() => {
    const subscription = streams.subscribeMarketDetails(
      { pools: [{ blockchain, address }] },
      (update: unknown) => {
        const data = update as { pairData?: { base?: { approximateReserveToken?: number } } };
        if (data?.pairData) {
          pairBatcherRef.current.add(data.pairData);
        }
      }
    );

    return () => {
      subscription?.unsubscribe();
      pairBatcherRef.current.clear();
    };
  }, [address, blockchain]);
}

Why Not Increment LP Balance From Trades?

Incrementally updating the LP balance from trade amounts causes double-counting:
  1. Stream delivers a trade → LP balance incremented by tradeAmount
  2. REST resync fires 30s later → LP balance is set from REST data (which already includes the trade)
  3. Result: LP balance counted twice, showing > 100% of supply
Using pairData.base.approximateReserveToken from the Market Details stream gives you the on-chain reserve directly — no accumulation, no drift.

Step 8: Update Batching for 60fps Performance

High-frequency streams can fire hundreds of events per second. The UpdateBatcher coalesces updates into a single requestAnimationFrame callback:
// src/utils/UpdateBatcher.ts
export class UpdateBatcher<T> {
  private updates: T[] = [];
  private scheduled = false;

  constructor(private callback: (updates: T[]) => void) {}

  add(update: T) {
    this.updates.push(update);
    if (!this.scheduled) {
      this.scheduled = true;
      requestAnimationFrame(() => {
        const batch = this.updates;
        this.updates = [];
        this.scheduled = false;
        this.callback(batch);
      });
    }
  }

  clear() {
    this.updates = [];
    this.scheduled = false;
  }
}
This ensures React re-renders at most once per frame, regardless of how many trades arrive.

Step 9: Multi-Events Stream Subscription Details

The Multi-Events Stream is the key ingredient. Here’s the subscription payload format:

Solana (SVM)

{
  "type": "stream",
  "authorization": "YOUR_API_KEY",
  "payload": {
    "name": "HoldersTracker",
    "chainIds": ["solana:solana"],
    "events": ["swap-enriched"],
    "filters": {
      "or": [
        { "eq": { "addressToken0": "TOKEN_ADDRESS" } },
        { "eq": { "addressToken1": "TOKEN_ADDRESS" } }
      ]
    }
  }
}

EVM (Ethereum, BSC, Base, etc.)

{
  "type": "stream",
  "authorization": "YOUR_API_KEY",
  "payload": {
    "name": "HoldersTracker",
    "chainIds": ["evm:1"],
    "events": ["swap-enriched"],
    "filters": {
      "or": [
        { "eq": { "addressToken0": "TOKEN_ADDRESS" } },
        { "eq": { "addressToken1": "TOKEN_ADDRESS" } }
      ]
    }
  }
}

Stream Endpoints

Chain TypeWebSocket Endpoint
Solanawss://stream-sol-prod.mobula.io/
EVMwss://stream-evm-prod.mobula.io/

Swap-Enriched Event Data Fields

Each swap-enriched event includes:
FieldTypeDescription
typestring"buy" or "sell"
senderstringTransaction sender address
swapRecipientstring?Swap recipient (may differ from sender)
tokenAmountnumberHuman-readable token amount
tokenAmountUSDnumberUSD value of the trade
tokenPricenumberCurrent token price in USD
tokenAmountRawstringRaw token amount (with decimals)
hashstringTransaction hash
baseTokenstringBase token address
addressToken0stringPool token0 address
addressToken1stringPool token1 address
rawPostBalance0stringSender’s post-balance of token0
rawPostBalance1stringSender’s post-balance of token1
rawPostBalanceRecipient0stringRecipient’s post-balance of token0
rawPostBalanceRecipient1stringRecipient’s post-balance of token1
labelsstring[]?Wallet labels (e.g., "sniper", "insider")
walletMetadataobject?Wallet metadata (ENS, tags)
For the complete Multi-Events Stream documentation, see the Multi-Events Stream API reference.

Pitfalls and Lessons Learned

Building real-time holders tracking involves several subtle challenges. Here’s what we learned building MTT:

1. Subscribe Before Fetch

Always start the stream subscription before the REST fetch. Otherwise, trades that happen during the fetch window are lost. The dedup mechanism (hash Set) handles any overlap.

2. Post-Balance Resolution

The stream provides raw post-balances as BigInt strings. You need to convert them to human-readable numbers using the tokenAmountRaw / tokenAmount ratio as a decimals factor. If conversion fails, fall back to incremental calculation.

3. Sender vs Recipient

On many DEXes, sender and swapRecipient are different addresses (e.g., router contracts). The Multi-Events Stream provides separate post-balances for each:
  • Sender: rawPostBalance0 / rawPostBalance1
  • Recipient: rawPostBalanceRecipient0 / rawPostBalanceRecipient1

4. LP Double-Counting

Never increment the LP balance from individual trades. The LP pool receives tokens on sells and sends tokens on buys, but these amounts are already reflected in the periodic REST resync. Use the Market Details stream’s approximateReserveToken as the single source of truth.

5. ChainId Format

  • Solana: solana:solana (not solana:mainnet)
  • EVM: evm:{chainId} (e.g., evm:1, evm:56, evm:8453)

Conclusion

You now have a production-ready, real-time holders tab built with:
  • REST API for initial holder snapshots with PnL and labels
  • Multi-Events Stream for instant balance updates with authoritative post-balances
  • Market Details Stream for accurate LP pool balance tracking
  • Hash-based deduplication to prevent double-counting across data sources
  • rAF batching for smooth 60fps rendering under high trade volume

Relevant API Endpoints

APIPurpose
/api/1/token/holder-positionsInitial holder list with PnL
Multi-Events StreamReal-time swap events
Market Details StreamPair data with LP reserves

Get Started

Create a free Mobula API key and start building your own real-time holders tab!

Live Demo

See the holders tab in action on any pair page

Source Code

Explore the complete MTT codebase

Multi-Events Stream

Full stream API documentation

Holder Positions API

REST API for initial holder data