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.
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
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:
Copy
Ask AI
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:
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).
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:
Copy
Ask AI
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(), };}
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.
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:
Copy
Ask AI
// 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 };}
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:
Copy
Ask AI
// 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]);}
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.
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.
On many DEXes, sender and swapRecipient are different addresses (e.g., router contracts). The Multi-Events Stream provides separate post-balances for each:
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.