> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mobula.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Build a Real-Time Holders Tab Like Axiom

> Learn how to build a production-ready, real-time token holders table with live balance tracking, PnL calculations, and LP reserve sync — just like Axiom's holders view. Built with Mobula's Multi-Events Stream and open-source MTT codebase.

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**.

<Tip>
  **Live Demo**: Check out the working implementation at [mtt.gg](https://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](https://github.com/MobulaFi/MTT)
</Tip>

***

## 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?

| Source                    | Purpose                                                               | Latency |
| ------------------------- | --------------------------------------------------------------------- | ------- |
| **REST API**              | Full holder list with historical PnL, labels, funding info            | \~500ms |
| **Multi-Events Stream**   | Instant balance updates from swaps (sender + recipient post-balances) | \~100ms |
| **Market Details Stream** | Authoritative LP pool balance from on-chain reserves                  | \~1s    |

***

## Step 1: Project Setup

Clone the MTT repository or start from scratch:

```bash theme={null}
git clone https://github.com/MobulaFi/MTT.git
cd MTT
bun install
```

Or add the required packages to an existing project:

```bash theme={null}
npm install @mobula/sdk zustand
```

Configure the SDK client:

```typescript theme={null}
// 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;
```

<Tip>
  Get your free API key at [admin.mobula.io](https://admin.mobula.io/auth/sign-in)
</Tip>

***

## Step 2: Define the Holder Store

The Zustand store manages holder positions, sorting, filtering, and real-time trade application:

```typescript theme={null}
// 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:

```typescript theme={null}
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:

```typescript theme={null}
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 });
},
```

<Note>
  **Source**: [`src/features/pair/store/usePairHolderStore.ts`](https://github.com/MobulaFi/MTT/blob/main/src/features/pair/store/usePairHolderStore.ts) in the MTT repository.
</Note>

***

## 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:

```typescript theme={null}
// 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;
}
```

<Warning>
  **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).
</Warning>

***

## 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:

```typescript theme={null}
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:

| Field                      | Description                             |
| -------------------------- | --------------------------------------- |
| `rawPostBalance0`          | Sender's post-balance of token0         |
| `rawPostBalance1`          | Sender's post-balance of token1         |
| `rawPostBalanceRecipient0` | Swap recipient's post-balance of token0 |
| `rawPostBalanceRecipient1` | Swap 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.

<Note>
  **Source**: [`src/features/pair/hooks/useCombinedHolders.ts`](https://github.com/MobulaFi/MTT/blob/main/src/features/pair/hooks/useCombinedHolders.ts) in the MTT repository.
</Note>

***

## Step 5: Build the Combined Holders Hook

This is the core hook that orchestrates REST fetching, stream subscription, and periodic resync:

```typescript theme={null}
// 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:

```typescript theme={null}
// 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 };
}
```

<Note>
  **Source**: [`src/utils/applyTradesToPositions.ts`](https://github.com/MobulaFi/MTT/blob/main/src/utils/applyTradesToPositions.ts) in the MTT repository.
</Note>

***

## 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:

```typescript theme={null}
// 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:

```typescript theme={null}
// 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)

```json theme={null}
{
  "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.)

```json theme={null}
{
  "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 Type | WebSocket Endpoint                 |
| ---------- | ---------------------------------- |
| Solana     | `wss://stream-sol-prod.mobula.io/` |
| EVM        | `wss://stream-evm-prod.mobula.io/` |

### Swap-Enriched Event Data Fields

Each `swap-enriched` event includes:

| Field                      | Type        | Description                                   |
| -------------------------- | ----------- | --------------------------------------------- |
| `type`                     | `string`    | `"buy"` or `"sell"`                           |
| `sender`                   | `string`    | Transaction sender address                    |
| `swapRecipient`            | `string?`   | Swap recipient (may differ from sender)       |
| `tokenAmount`              | `number`    | Human-readable token amount                   |
| `tokenAmountUSD`           | `number`    | USD value of the trade                        |
| `tokenPrice`               | `number`    | Current token price in USD                    |
| `tokenAmountRaw`           | `string`    | Raw token amount (with decimals)              |
| `hash`                     | `string`    | Transaction hash                              |
| `baseToken`                | `string`    | Base token address                            |
| `addressToken0`            | `string`    | Pool token0 address                           |
| `addressToken1`            | `string`    | Pool token1 address                           |
| `rawPostBalance0`          | `string`    | Sender's post-balance of token0               |
| `rawPostBalance1`          | `string`    | Sender's post-balance of token1               |
| `rawPostBalanceRecipient0` | `string`    | Recipient's post-balance of token0            |
| `rawPostBalanceRecipient1` | `string`    | Recipient's post-balance of token1            |
| `labels`                   | `string[]?` | Wallet labels (e.g., `"sniper"`, `"insider"`) |
| `walletMetadata`           | `object?`   | Wallet metadata (ENS, tags)                   |

<Tip>
  For the complete Multi-Events Stream documentation, see the [Multi-Events Stream API reference](https://docs.mobula.io/indexing-stream/stream/websocket/multi-events-stream).
</Tip>

***

## 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

| API                                                                                                          | Purpose                      |
| ------------------------------------------------------------------------------------------------------------ | ---------------------------- |
| [`/api/1/token/holder-positions`](https://docs.mobula.io/rest-api-reference/endpoint/token-holder-positions) | Initial holder list with PnL |
| [Multi-Events Stream](https://docs.mobula.io/indexing-stream/stream/websocket/multi-events-stream)           | Real-time swap events        |
| [Market Details Stream](https://docs.mobula.io/indexing-stream/stream/websocket/wss-market-details)          | Pair data with LP reserves   |

***

## Get Started

**Create a [free Mobula API key](https://admin.mobula.io/auth/sign-in) and start building your own real-time holders tab!**

<CardGroup cols={2}>
  <Card title="Live Demo" icon="rocket" href="https://mtt.gg">
    See the holders tab in action on any pair page
  </Card>

  <Card title="Source Code" icon="github" href="https://github.com/MobulaFi/MTT">
    Explore the complete MTT codebase
  </Card>

  <Card title="Multi-Events Stream" icon="bolt" href="/indexing-stream/stream/websocket/multi-events-stream">
    Full stream API documentation
  </Card>

  <Card title="Holder Positions API" icon="users" href="/rest-api-reference/endpoint/token-holder-positions">
    REST API for initial holder data
  </Card>
</CardGroup>
