Tracking Stellar's $2B RWA Milestone On-Chain: Build a Tokenized-Asset Analytics Dashboard with LumenQuery APIs
Stellar crossed the $2 billion mark in tokenized real-world assets in 2026, with payment volume up 72% year-over-year and developer participation increasing 86%. These are not speculative DeFi tokens. They are government bonds, fund shares, and institutional stablecoins issued by regulated entities like Franklin Templeton, WisdomTree, and Circle.
This guide walks you through building a live RWA analytics dashboard that tracks tokenized asset issuance, holder distribution, and transfer activity on Stellar using the Horizon API and LumenQuery endpoints.
Understanding the RWA Landscape on Stellar
Major Tokenized Assets
| Asset | Issuer | Category | Approx. Value |
|---|---|---|---|
| USDC | Circle | Stablecoin | $1B+ |
| BENJI (FOBXX) | Franklin Templeton | Money market fund | $400M+ |
| WisdomTree Prime tokens | WisdomTree | Fund shares | $100M+ |
| SHX | Stronghold | Tokenized USD | $50M+ |
Why Stellar for RWAs
| Requirement | How Stellar Delivers |
|---|---|
| Regulatory compliance | Native auth flags (required, revocable, clawback) |
| Low transaction costs | ~0.00001 XLM per transaction |
| Fast settlement | 5-7 second finality |
| Programmable logic | Soroban smart contracts |
| Institutional adoption | SDF partnerships with Franklin Templeton, DTCC, Visa |
Project Architecture
We will build a dashboard with four sections:
Step 1: Define Your RWA Asset Registry
// lib/rwa-registry.ts
interface RWAAsset {
code: string;
issuer: string;
name: string;
category: 'stablecoin' | 'fund' | 'bond' | 'commodity' | 'other';
}
const RWA_ASSETS: RWAAsset[] = [
{
code: 'USDC',
issuer: 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN',
name: 'USD Coin',
category: 'stablecoin',
},
{
code: 'BENJI',
issuer: 'GBHNGLLIE3KWGKCHIKMHJ5HVZHYFF32DLXTUENE3AFMUQT2SGHSPSA2A',
name: 'Franklin OnChain US Govt Money Fund',
category: 'fund',
},
// Add additional RWA assets as they launch
];Step 2: Fetch Asset Metrics from Horizon
// lib/rwa-fetcher.ts
const HORIZON = 'https://horizon.stellar.org';
async function fetchAssetMetrics(asset: RWAAsset) {
const res = await fetch(
`${HORIZON}/assets?asset_code=${asset.code}&asset_issuer=${asset.issuer}`
);
const data = await res.json();
if (!data._embedded?.records?.length) {
return { ...asset, totalSupply: 0, authorizedAccounts: 0 };
}
const record = data._embedded.records[0];
return {
code: asset.code,
issuer: asset.issuer,
name: asset.name,
category: asset.category,
totalSupply: parseFloat(record.amount),
authorizedAccounts: record.accounts.authorized,
pendingAccounts: record.accounts.authorized_to_maintain_liabilities,
authRequired: record.flags.auth_required,
authRevocable: record.flags.auth_revocable,
clawbackEnabled: record.flags.auth_clawback_enabled,
};
}
async function fetchAllAssetMetrics() {
const results = await Promise.all(
RWA_ASSETS.map(asset => fetchAssetMetrics(asset))
);
return results.filter(r => r.totalSupply > 0);
}Step 3: Track Holder Distribution
async function getTopHolders(assetCode: string, issuer: string, limit = 10) {
const res = await fetch(
`${HORIZON}/accounts?asset=${assetCode}:${issuer}&limit=200&order=desc`
);
const data = await res.json();
// Get total supply for percentage calculation
const assetRes = await fetch(
`${HORIZON}/assets?asset_code=${assetCode}&asset_issuer=${issuer}`
);
const assetData = await assetRes.json();
const totalSupply = parseFloat(assetData._embedded.records[0]?.amount || '0');
return data._embedded.records
.map((account: any) => {
const trustline = account.balances.find(
(b: any) => b.asset_code === assetCode && b.asset_issuer === issuer
);
if (!trustline) return null;
const balance = parseFloat(trustline.balance);
return {
accountId: account.id,
balance,
percentOfSupply: totalSupply > 0 ? (balance / totalSupply) * 100 : 0,
authorized: trustline.is_authorized,
};
})
.filter(Boolean)
.sort((a: any, b: any) => b.balance - a.balance)
.slice(0, limit);
}Step 4: Track Transfer Activity
async function getTransferActivity(assetCode: string, issuer: string, hours = 24) {
let url = `${HORIZON}/payments?limit=200&order=desc`;
const cutoff = new Date(Date.now() - hours * 60 * 60 * 1000);
const transfers: { timestamp: Date; amount: number }[] = [];
let pages = 0;
while (url && pages < 10) {
const res = await fetch(url);
const data = await res.json();
let done = false;
for (const record of data._embedded.records) {
if (new Date(record.created_at) < cutoff) { done = true; break; }
if (record.type === 'payment' &&
record.asset_code === assetCode &&
record.asset_issuer === issuer) {
transfers.push({
timestamp: new Date(record.created_at),
amount: parseFloat(record.amount),
});
}
}
if (done || data._embedded.records.length < 200) break;
url = data._links?.next?.href;
pages++;
}
// Bucket into hourly intervals
const buckets = new Map();
for (const transfer of transfers) {
const hour = new Date(transfer.timestamp);
hour.setMinutes(0, 0, 0);
const key = hour.toISOString();
if (!buckets.has(key)) {
buckets.set(key, { timestamp: key, volume: 0, count: 0 });
}
const bucket = buckets.get(key);
bucket.volume += transfer.amount;
bucket.count++;
}
return Array.from(buckets.values()).sort(
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
}Step 5: Build the API Route
// app/api/rwa/route.ts
import { NextResponse } from 'next/server';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const section = searchParams.get('section') || 'overview';
try {
switch (section) {
case 'overview': {
const metrics = await fetchAllAssetMetrics();
const totalValue = metrics.reduce((sum, m) => sum + m.totalSupply, 0);
const totalHolders = metrics.reduce(
(sum, m) => sum + m.authorizedAccounts, 0
);
return NextResponse.json({
totalValue,
totalHolders,
assetCount: metrics.length,
assets: metrics,
});
}
case 'holders': {
const assetCode = searchParams.get('asset') || 'USDC';
const issuer = RWA_ASSETS.find(a => a.code === assetCode)?.issuer;
if (!issuer) return NextResponse.json({ error: 'Unknown asset' }, { status: 400 });
const holders = await getTopHolders(assetCode, issuer);
return NextResponse.json({ holders });
}
default:
return NextResponse.json({ error: 'Invalid section' }, { status: 400 });
}
} catch (error) {
return NextResponse.json({ error: 'Failed to fetch RWA data' }, { status: 500 });
}
}Step 6: Add Caching for Performance
RWA data does not change every second. Cache aggressively:
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');
async function getCachedOrFetch<T>(
key: string,
ttlSeconds: number,
fetcher: () => Promise<T>
): Promise<T> {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const data = await fetcher();
await redis.setex(key, ttlSeconds, JSON.stringify(data));
return data;
}
// Usage:
const overview = await getCachedOrFetch(
'rwa:overview',
300, // 5-minute cache
() => fetchAllAssetMetrics()
);Recommended Cache TTLs
| Data Type | TTL | Reason |
|---|---|---|
| Asset supply | 5 minutes | Changes infrequently |
| Holder counts | 10 minutes | Relatively stable |
| Transfer activity | 1 minute | More dynamic |
| Top holders | 15 minutes | Changes slowly |
Step 7: Add Real-Time Updates with SSE
For live transfer monitoring, use LumenQuery's SSE endpoint:
'use client';
import { useEffect, useState } from 'react';
export function useRWAStream(assetCode: string) {
const [transfers, setTransfers] = useState([]);
useEffect(() => {
const es = new EventSource('/api/transactions/stream');
es.onmessage = (event) => {
try {
const tx = JSON.parse(event.data);
const rwaOps = tx.operations?.filter(
(op: any) => op.type === 'payment' && op.asset_code === assetCode
);
if (rwaOps?.length) {
for (const op of rwaOps) {
setTransfers(prev => [{
hash: tx.hash,
asset: assetCode,
amount: op.amount,
from: op.from,
to: op.to,
timestamp: tx.created_at,
}, ...prev].slice(0, 50));
}
}
} catch (err) { /* skip malformed events */ }
};
return () => es.close();
}, [assetCode]);
return transfers;
}Key Metrics to Track
| Category | Metric | What It Tells You |
|---|---|---|
| Supply | Total supply | Market size of the tokenized asset |
| Supply | Supply change (24h) | Issuance/redemption activity |
| Holders | Unique holders | Adoption breadth |
| Holders | Top 10 concentration | Risk concentration |
| Transfers | Volume (24h) | Capital movement |
| Transfers | Average transfer size | Retail vs institutional split |
| Compliance | Pending authorizations | KYC pipeline |
| Compliance | Frozen accounts | Regulatory activity |
Using LumenQuery's Existing Endpoints
You do not need to build everything from scratch. LumenQuery already provides endpoints that support RWA analytics:
// Network-level metrics
const network = await fetch('https://lumenquery.io/api/analytics/network');
// Token-level metrics
const tokens = await fetch('https://lumenquery.io/api/analytics/tokens');
// Live transaction stream (filter for RWA assets client-side)
const stream = new EventSource('https://lumenquery.io/api/transactions/stream');
// Natural language queries
const query = await fetch('https://lumenquery.io/api/query', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: 'recent USDC payments larger than 100000' }),
});How LumenQuery Helps
LumenQuery provides the complete infrastructure stack for RWA analytics:
*Build real-world asset analytics on reliable infrastructure. LumenQuery provides managed Horizon API and Soroban RPC with caching, analytics, and real-time streaming. Start free.*