The Solana wallet portfolio API is GET /wallet/{owner} on the Solana Tracker Data API, exposed as client.getWallet(owner) in @solana-tracker/data-api 0.5.0. One call returns every token a wallet holds with its balance, USD value, token metadata, pools, price-change events and risk score, plus the wallet total in USD and SOL. For live updates without polling, the Datastream wallet:{address}:balance room pushes the new balance whenever a token amount changes.
This page covers current holdings and their value. Trading profit is a different question with a different endpoint: see the Solana wallet PnL API for realized and unrealized PnL, ranking wallets by PnL for leaderboards, and wallet identity enrichment for labeling who a wallet is.
Choosing a Solana wallet portfolio API endpoint
The Solana Data API has several wallet routes. They differ in payload size, caching and what they are for.
| Need | Method | Endpoint | Notes |
|---|---|---|---|
| Full holdings with metadata, pools, risk | getWallet(owner) | GET /wallet/{owner} | Default choice for a portfolio view |
| Lightweight balances and prices | getWalletBasic(owner) | GET /wallet/{owner}/basic | Non-cached; flat rows with address, balance, value, price, marketCap, liquidity |
| Very large wallets | getWalletPage(owner, page) | GET /wallet/{owner}/page/{page} | 250 tokens per request, same shape as getWallet |
| Historical portfolio value | getWalletChart(owner) | GET /wallet/{owner}/chart | Legacy value series with 24h and 30d change; may return HTTP 400 when no chart can be built |
| Live balance changes | subscribe.wallet(w).balance() | Datastream room | Premium plan or higher; amounts only, no prices |
| Trading profit | getPnlV2WalletOverview(w) | GET /v2/pnl/wallets/{w} | Cost basis and realized PnL, not holdings |
If you only need raw token accounts and amounts, standard Solana RPC methods return them, but you then have to resolve mints, decimals, prices and liquidity yourself. The wallet endpoint does that join server-side, which is the point of using it.
Use getWallet when a user opens a wallet page. Use getWalletBasic when you poll many wallets and only need balances and prices, because it skips metadata and risk. Use the chart route only for a portfolio-value sparkline: the docs state it is not equivalent to the PnL v2 trade charts.
Response shape
The SDK types the response as WalletResponse. Trimmed to the fields this guide uses:
interface WalletResponse {
tokens: WalletTokenDetail[];
total: number; // wallet value in USD
totalSol: number; // the same value expressed in SOL
timestamp: string; // typed in the SDK; treat as optional at runtime
}
interface WalletTokenDetail {
token: { name: string; symbol: string; mint: string; decimals: number; image?: string };
pools?: Array<{
poolId: string;
market: string;
liquidity: { quote: number; usd: number };
price: { quote: number; usd: number };
marketCap: { quote: number; usd: number };
lastUpdated: number;
}>;
events?: Record<'1m' | '5m' | '15m' | '30m' | '1h' | '24h' /* and more */, { priceChangePercentage: number }>;
risk?: { score: number };
balance: number; // UI amount, decimals applied
value: number; // USD value of the balance
}
Trimmed from the documented example response (one of four tokens, one of several pools):
{
"tokens": [
{
"token": { "name": "Native Solana", "symbol": "SOL", "mint": "So11111111111111111111111111111111111111111", "decimals": 9 },
"pools": [
{
"poolId": "3ucNos4NbumPLZNWztqGHNFFgkHeRMBQAVemeeomsUxv",
"market": "raydium-clmm",
"liquidity": { "quote": 14187388.72, "usd": 14187388.72 },
"price": { "quote": 181.49, "usd": 181.49 },
"lastUpdated": 1760274181116
}
],
"events": { "24h": { "priceChangePercentage": -0.72 } },
"risk": { "score": 0 },
"balance": 0.019911506,
"value": 3.6138035170672844
}
],
"total": 3.7951372323268373,
"totalSol": 0.02091100438964804
}
There is no per-row "share of portfolio" field. Compute it as value / total. There is also no unit price on the row itself; either read pools[].price.usd or derive the price the endpoint used as value / balance. The example below uses the derived price so a live balance change is revalued exactly the way the snapshot valued it.
Build a wallet tracker in TypeScript
Create a project and install the SDK:
npm init -y && npm pkg set type=module
npm install @solana-tracker/data-api@^0.5.0
npm install --save-dev tsx typescript @types/node
node --env-file=.env --import tsx index.ts
Your .env needs a Data API key from the Data API dashboard. The Datastream key is optional and only enables live mode.
# .env.example
ST_API_KEY=
ST_DATASTREAM_KEY=
WALLET_ADDRESS=FbMxP3GVq8TQ36nbYgx4NP9iygMpwAwFWJwW81ioCiSF
MIN_VALUE_USD=1
The REST client has no retry and no timeout of its own, so the script wraps the call. It validates the address before spending a request, prints the snapshot, and only opens a WebSocket when a Datastream key is present.
import { Client, Datastream, DataApiError, RateLimitError, ValidationError } from '@solana-tracker/data-api';
import type { WalletResponse } from '@solana-tracker/data-api';
const apiKey = process.env.ST_API_KEY?.trim();
if (!apiKey) { console.error('Missing ST_API_KEY'); process.exit(1); }
const wallet = process.env.WALLET_ADDRESS?.trim() || 'FbMxP3GVq8TQ36nbYgx4NP9iygMpwAwFWJwW81ioCiSF';
if (!/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(wallet)) { console.error('WALLET_ADDRESS is not base58'); process.exit(1); }
const minValue = Number(process.env.MIN_VALUE_USD ?? 1);
const NATIVE_SOL = 'So11111111111111111111111111111111111111111';
const WRAPPED_SOL = 'So11111111111111111111111111111111111111112';
const client = new Client({ apiKey });
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function fetchWallet(attempts = 4): Promise<WalletResponse> {
for (let i = 1; ; i++) {
let timer: NodeJS.Timeout | undefined;
try {
const timeout = new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new Error('timeout')), 20_000); });
return await Promise.race([client.getWallet(wallet), timeout]);
} catch (err) {
const retryable = err instanceof RateLimitError
|| (err instanceof DataApiError && !(err instanceof ValidationError) && (err.status ?? 500) >= 500)
|| !(err instanceof DataApiError);
if (!retryable || i >= attempts) throw err;
const retryAfterMs = err instanceof RateLimitError && err.retryAfter ? err.retryAfter * 1000 : 0;
await sleep(Math.max(retryAfterMs, Math.min(8_000, 500 * 2 ** (i - 1))));
} finally {
clearTimeout(timer);
}
}
}
type Holding = { symbol: string; balance: number; value: number; unitPrice: number | null };
let holdings = new Map<string, Holding>();
function load(snapshot: WalletResponse) {
holdings = new Map(snapshot.tokens.map((row) => [row.token.mint, {
symbol: row.token.symbol || row.token.mint.slice(0, 4),
balance: row.balance,
value: row.value,
unitPrice: row.balance > 0 ? row.value / row.balance : null,
}]));
}
try {
const snapshot = await fetchWallet();
load(snapshot);
for (const row of [...snapshot.tokens].sort((a, b) => b.value - a.value)) {
if (row.value < minValue) continue;
const share = snapshot.total > 0 ? (row.value / snapshot.total) * 100 : 0;
console.log(row.token.symbol.padEnd(10), row.balance.toFixed(4).padStart(18), `$${row.value.toFixed(2)}`.padStart(14), `${share.toFixed(1)}%`);
}
console.log(`Total $${snapshot.total.toFixed(2)} (${snapshot.totalSol.toFixed(3)} SOL), ${snapshot.tokens.length} tokens`);
} catch (err) {
console.error('Wallet request failed:', err instanceof DataApiError ? `HTTP ${err.status}` : (err as Error).message);
process.exit(1);
}
const dsKey = process.env.ST_DATASTREAM_KEY?.trim();
if (dsKey) {
const ds = new Datastream({
wsUrl: dsKey.startsWith('wss://') ? dsKey : `wss://datastream.solanatracker.io/${dsKey}`,
autoReconnect: true, reconnectDelay: 1_000, reconnectDelayMax: 30_000, randomizationFactor: 0.5,
});
let connectedOnce = false;
ds.on('connected', () => {
// After a reconnect, updates sent while offline are gone: re-read the snapshot.
if (connectedOnce) fetchWallet().then(load).catch((e) => console.warn('refresh failed', (e as Error).message));
connectedOnce = true;
});
ds.on('error', () => console.error('Datastream error (check key and plan)'));
const listener = ds.subscribe.wallet(wallet).balance().on((update) => {
const mint = update.token === WRAPPED_SOL && !holdings.has(WRAPPED_SOL) ? NATIVE_SOL : update.token;
const h = holdings.get(mint);
if (!h) { console.log(`new mint ${mint}: ${update.amount} (re-fetch snapshot to price it)`); return; }
h.balance = update.amount;
if (h.unitPrice !== null) h.value = update.amount * h.unitPrice;
console.log(new Date().toISOString().slice(11, 19), h.symbol, '->', update.amount, `$${h.value.toFixed(2)}`);
});
process.once('SIGINT', () => { listener.unsubscribe(); ds.disconnect(); process.exit(0); });
}
The companion project splits the same flow into a reusable retry helper and a formatted table with 24h change, top-pool liquidity and risk score, and it re-fetches the snapshot on new mints and on a timer.
Live balances over Datastream
subscribe.wallet(address).balance() joins wallet:{address}:balance. Each message is { wallet, token, amount }. Three details decide whether your numbers stay correct:
amountis the balance after the change, as a UI amount. It is not a delta and not raw base units. Replace the stored balance; do not add to it. Compute the delta yourself if you want to display one.- SOL arrives under the wrapped SOL mint. The balance rooms report native SOL as
So11111111111111111111111111111111111111112, while the REST wallet response lists native SOL asSo11111111111111111111111111111111111111111. Map one to the other, or the SOL row never updates. - There is no price in the message. A balance stream tells you amounts, not value. Revalue with the last known price and refresh the snapshot periodically, or pair it with a price stream as described in the Solana price WebSocket guide.
For a single token, subscribe.wallet(address).tokenBalance(mint) joins the narrower wallet:{address}:{mint}:balance room with the same payload. The SDK reconnects with capped, jittered backoff and rejoins rooms on its own, but it cannot replay messages sent while the socket was down. Treat every connected event after the first as a signal to re-read REST.
If you need raw on-chain writes instead of indexed balances, with the slot and signature of every change, stream the wallet's token accounts directly with a Yellowstone gRPC account subscribe.
| Approach | Freshness | Cost | Best for |
|---|---|---|---|
Poll getWallet | Your interval | One request per poll | Occasional views, many wallets |
Poll getWalletBasic | Your interval, non-cached | Lighter payload | Balance checks at scale |
| Balance room plus periodic snapshot | Push for amounts, interval for prices | One socket plus occasional requests | A wallet someone is watching right now |
Production pitfalls
Do not treat value as profit. total is a mark-to-market of current holdings. A wallet that received an airdrop has value without any trade. For cost basis and realized gains use the Solana wallet PnL API, and keep the two panels on separate loading and error states.
Key on the mint, never the symbol. Symbols collide and can be empty. The example falls back to a short mint when symbol is blank.
Filter dust before you render. Wallets collect spam tokens with tiny or zero value. A MIN_VALUE_USD threshold keeps the table readable without dropping data you still have in memory.
Value is not liquidation value. value multiplies the balance by a price. A large position in a thin pool cannot be sold at that price. Show pools[].liquidity.usd next to large positions so the reader can judge.
Handle optional fields. pools, events and risk are optional in the SDK types, and timestamp is typed but missing from the published example response. Guard every nested read.
Rate limits and timeouts are yours to manage. The SDK throws RateLimitError with retryAfter in seconds when it is available, DataApiError with status for HTTP failures, and ValidationError for bad input. Retry 429 and 5xx with backoff, do not retry 4xx, and set a per-attempt timeout.
Never log the Datastream URL. It contains your key. Log connection state, not configuration.
FAQ
Which endpoint returns the total value of a Solana wallet?
GET /wallet/{owner} (getWallet) returns total in USD and totalSol in SOL alongside every token row. getWalletBasic returns the same totals with a lighter, non-cached token list.
Is the balance in raw units or decimals?
Both the REST balance and the Datastream amount are UI amounts with decimals applied. Convert with the mint's decimals only if you need raw integer units.
Why does SOL not update in my live view?
The balance room uses the wrapped SOL mint for SOL, while the REST response uses the native SOL id ending in 1111. Map the wrapped mint to your native SOL row.
Can I get historical portfolio value?
getWalletChart returns a legacy portfolio-value series with 24h and 30d change, and may return HTTP 400 when it cannot build a chart. For trade-based PnL over time, use the PnL v2 history and performance routes.
Does live balance streaming need a higher plan?
Yes. Datastream rooms, including wallet balance, are available on Premium, Business and Enterprise plans. The REST wallet endpoints work with a Data API key.
How do I show who a wallet belongs to?
Holdings do not include identity. Use identity enrichment on trade history and holders, covered in the wallet identity guide.
References
- Get wallet tokens
- Get basic wallet information
- Get wallet tokens with pagination
- Get wallet portfolio chart
- Datastream wallet balance room
- Wallet tracking guide
- @solana-tracker/data-api on npm
Companion project
The solana-wallet-portfolio-api example prints a formatted holdings table and, with ST_DATASTREAM_KEY set, follows balance changes live with reconnect-aware snapshot refreshes.
cp .env.example .env && npm install && npm start