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:Why Three Data Sources?
Step 1: Project Setup
Clone the MTT repository or start from scratch:Step 2: Define the Holder Store
The Zustand store manages holder positions, sorting, filtering, and real-time trade application: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:approximateReserveToken field as the authoritative LP balance — avoiding double-counting from incremental updates:
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 requireschainId in a specific format:
Step 4: Map Stream Events to Trade Events
The Multi-Events Stream’sswap-enriched events contain raw post-balance fields for both the sender and the swap recipient. You need to resolve which is the base token:
Understanding Post-Balance Fields
The multi-event stream provides four raw post-balance fields per swap event:
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:Why Subscribe Before Fetching?
The stream subscription starts before the REST fetch. This ensures zero trade gaps:- Stream subscription begins (trades start queueing)
- REST API fetches the current snapshot
- Queued trades get deduped against the snapshot via transaction hash
- From this point on, every new trade is applied immediately
Step 6: Apply Trades to Positions
TheapplyTradesToPositions 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:
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:Why Not Increment LP Balance From Trades?
Incrementally updating the LP balance from trade amounts causes double-counting:- Stream delivers a trade → LP balance incremented by
tradeAmount - REST resync fires 30s later → LP balance is set from REST data (which already includes the trade)
- Result: LP balance counted twice, showing > 100% of supply
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. TheUpdateBatcher coalesces updates into a single requestAnimationFrame callback:
Step 9: Multi-Events Stream Subscription Details
The Multi-Events Stream is the key ingredient. Here’s the subscription payload format:Solana (SVM)
EVM (Ethereum, BSC, Base, etc.)
Stream Endpoints
Swap-Enriched Event Data Fields
Eachswap-enriched event includes:
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 thetokenAmountRaw / 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’sapproximateReserveToken as the single source of truth.
5. ChainId Format
- Solana:
solana:solana(notsolana: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
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