24 min read
Overview
Popular assets (like Bitcoin or xStocks) on Solana exist as multiple competing mints with different issuers, liquidity pools, and trust levels. This guide walks through using the Tokens API to find the best mint for any token, compare liquidity and risk across every onchain variant, and check live market conditions before committing.
Note: This guide is for educational purposes only. Quicknode does not provide financial advice or endorse any trading strategies. Always do your own research before making any investment decisions.
- Build a TypeScript CLI that researches any Solana asset using the Assets API, accepting either a ticker symbol or a raw mint address
- Resolve an asset to its canonical ID, then list every onchain variant with its top decentralized exchange (DEX) market in a single call to
variant-top-markets - Score and rank variants, surfacing actively traded pools and pulling a risk grade for each
What You Will Do
- Set up a TypeScript project and authenticate against the Tokens.xyz Assets API
- Resolve a symbol or mint address to a canonical asset
- List and compare every onchain variant of that asset by liquidity, volume, and risk
- Rank variants with an execution-quality score and recommend the best mint
- Read live market data to produce a GO or WAIT timing signal
What You Will Need
This guide assumes a basic understanding of TypeScript, REST APIs, and Solana tokens (mint addresses, SPL tokens, and DEX liquidity pools).
You will also need:
- Node.js v22+
- A Tokens API Key. Sign in at (https://app.tokens.xyz/) to create one
- (Optional) A Quicknode account with a Solana mainnet endpoint to extend this tool to execute trades
This guide uses the following dependencies:
| Dependency | Version |
|---|---|
| chalk | 5.3.0 |
| tsx | 4.19.0 |
| typescript | 5.5.0 |
What is the Tokens API?
Tokens.xyz ("Tokens on Solana") is a token search and liquidity aggregator for onchain assets on Solana, built and hosted as a public good by the Solana Foundation. It covers roughly 300 canonical assets spanning wrapped tokens, tokenized stocks, ETFs, metals, and commodities, and exposes real-time price, volume, liquidity, and risk data for every onchain representation of each one.
The core idea to understand before writing any code is the difference between a canonical asset and a variant:
- A canonical asset has a clean slug like
bitcoin,usd, ortesla. It represents the underlying concept, regardless of how it is held onchain. - A variant is a specific onchain mint, identified by a Solana token address. One canonical asset can have many variants. For example,
usdresolves to USDC, USDT, and PYUSD, each tagged with itskind(such asnative,wrapped,bridged, orstablecoin), aliquidityTier(tier1is the most liquid, down totier3), atrustTier, and anissuer.
This model is what makes the comparison in this guide possible. Instead of treating WBTC and cbBTC as two unrelated addresses, you ask for Bitcoin once and get back every way to hold it on Solana, already grouped and classified.
Set Up the Project
Create a project directory and initialize it:
mkdir tokens-research && cd tokens-research
npm init -y
npm install chalk
npm install -D tsx typescript @types/node
Set "type": "module" in package.json so the ES Module imports resolve correctly:
npm pkg set type=module
Create a .env file in the project root with your API key:
TOKENS_API_KEY=your_api_key_here
Add a .gitignore file so the API key in .env is never accidentally committed:
node_modules/
.env
Create a file named research.ts. Start with the imports, the key and argument checks, and a small get helper that attaches the x-api-key header to every request. The script accepts a single argument, either a ticker symbol like BTC or a raw Solana mint address.
import chalk from 'chalk';
const API_KEY = process.env.TOKENS_API_KEY;
if (!API_KEY) { console.error(chalk.red('TOKENS_API_KEY is not set')); process.exit(1); }
const SYMBOL = process.argv[2];
if (!SYMBOL) { console.error(chalk.red('Usage: tsx research.ts <symbol> e.g. tsx --env-file=.env research.ts BTC')); process.exit(1); }
const BASE = 'https://api.tokens.xyz/v1';
async function get<T = any>(path: string): Promise<T> {
const res = await fetch(`${BASE}${path}`, { headers: { 'x-api-key': API_KEY! } });
if (!res.ok) throw new Error(`${res.status} ${path}`);
return res.json() as T;
}
Next, add a few formatting helpers below the get function. These keep the terminal output readable: usd abbreviates dollar amounts, ago turns a Unix timestamp into a relative time, section prints a section header, and tierColor and gradeColor color values by quality.
// helpers
function usd(value: number | undefined | null): string {
if (value == null) return 'n/a';
if (value >= 1e9) return `$${(value / 1e9).toFixed(2)}B`;
if (value >= 1e6) return `$${(value / 1e6).toFixed(2)}M`;
if (value >= 1e3) return `$${(value / 1e3).toFixed(1)}K`;
return `$${value.toFixed(2)}`;
}
function ago(timestamp: number | undefined | null): string {
if (!timestamp) return 'unknown';
const seconds = Math.floor(Date.now() / 1000) - timestamp;
return seconds < 60 ? `${seconds}s ago` : seconds < 3600 ? `${Math.floor(seconds / 60)}m ago` : `${Math.floor(seconds / 3600)}h ago`;
}
function section(title: string) {
console.log('\n' + chalk.white.bold('━'.repeat(62)));
console.log(chalk.white.bold(` ${title}`));
console.log(chalk.white.bold('━'.repeat(62)) + '\n');
}
function tierColor(tier: string | undefined, text: string): string {
if (tier === 'tier1') return chalk.green(text);
if (tier === 'tier2') return chalk.yellow(text);
return chalk.red(text);
}
function gradeColor(grade: string | undefined): string {
if (!grade) return chalk.gray('n/a');
if (['A+', 'A', 'B+'].includes(grade)) return chalk.green(grade);
if (grade === 'B') return chalk.yellow(grade);
return chalk.red(grade);
}
The rest of the script lives inside one main function. The following sections each add a block of code to the body of main, in order. Start the function now and fill it in as you go:
// main
async function main() {
// the code blocks in the next sections go here, in order
}
main().catch(err => { console.error(chalk.red(err.message)); process.exit(1); });
Resolve Canonical Asset
Everything starts from a canonical assetId. The script accepts either input, so it first checks whether the argument looks like a base58 Solana mint address. If it does, it calls resolve to map the mint to its canonical asset. Otherwise it calls search to look up the asset by name or ticker.
GET /assets/resolve?mint={address}maps a raw mint to its canonicalassetIdand returns the asset'snameandcategory.GET /assets/search?q={symbol}resolves a name or ticker to a canonical asset and returns the top matches.
Add this as the first block inside main:
// Resolve canonical asset (accepts a symbol/name or a raw Solana mint address)
const isMint = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(SYMBOL);
let assetId: string, name: string, category: string;
if (isMint) {
const resolved = await get<any>(`/assets/resolve?mint=${SYMBOL}`);
if (!resolved?.assetId) { console.error(chalk.red(`Could not resolve mint "${SYMBOL}"`)); process.exit(1); }
assetId = resolved.assetId;
name = resolved.asset?.name ?? assetId;
category = resolved.asset?.category ?? 'crypto';
} else {
const search = await get(`/assets/search?q=${encodeURIComponent(SYMBOL)}&limit=3`);
const hit = search.results?.[0];
if (!hit) { console.error(chalk.red(`No results for "${SYMBOL}"`)); process.exit(1); }
({ assetId, name, category } = hit);
}
console.log(chalk.bold.white(`\n Tokens API — ${name} on Solana\n`));
After this block runs, you have an assetId (the canonical slug), a human-readable name, and a category (such as crypto, stablecoin, or equity) that later steps reuse.
Discover Onchain Options
This is the step that exposes the fragmentation problem. A single call to variant-top-markets returns every onchain variant of the canonical asset, and attaches each variant's top DEX market in the same response. That one call replaces what would otherwise be three or four separate requests for variant lists, market data, and pool addresses.
GET /assets/{assetId}/variant-top-markets returns, per variant:
mint: the Solana token addresssymbol,name,label: identity fieldskind,liquidityTier,trustTier,issuer: classificationtopMarket.liquidity,topMarket.volume24h: pool depth and 24h volumetopMarket.source: DEX nametopMarket.name: trading pairtopMarket.address: pool addresstopMarket.trade24h,topMarket.uniqueWallet24h: activity counts
Add the next block inside main. It fetches the variants, then fetches a risk grade for up to 10 of them up front (used in the next section), and finally sorts the list by liquidity tier and then by liquidity within each tier:
// Fetch variants and risk scores before displaying so the sort has all data
const topMarketsRes = await get(`/assets/${assetId}/variant-top-markets?limit=50`);
const rawVariants: any[] = topMarketsRes.variants ?? [];
if (!rawVariants.length) { console.error(chalk.red('No onchain variants found')); process.exit(1); }
type Risk = { score: number; grade: string; label: string; isTrustedLaunch: boolean; caps: any[] };
const riskMap = new Map<string, Risk>();
for (const variant of rawVariants.slice(0, 10)) {
try {
const risk = await get<any>(`/assets/risk-summary?mint=${variant.mint}`);
riskMap.set(variant.mint, risk);
} catch { /* no risk data for this variant */ }
}
const TIER_ORDER: Record<string, number> = { tier1: 0, tier2: 1, tier3: 2 };
const variants: any[] = [...rawVariants].sort((a, b) => {
const tierDiff = (TIER_ORDER[a.liquidityTier] ?? 3) - (TIER_ORDER[b.liquidityTier] ?? 3);
if (tierDiff !== 0) return tierDiff;
return (b.topMarket?.liquidity ?? 0) - (a.topMarket?.liquidity ?? 0);
});
Now print the variants as a table. The turnover column (24h volume divided by liquidity) is the first hint of which pools are actually being used. A high tier with near-zero turnover is a warning sign you will act on in the next step.
// STEP 1
section('STEP 1 — Discover Onchain Options');
console.log(` ${chalk.white(variants.length + ' onchain representation' + (variants.length !== 1 ? 's' : '') + ' of ' + name)}\n`);
const COL = [4, 10, 22, 8, 12, 12, 10, 0];
const head = ['#', 'Symbol', 'Issuer', 'Tier', 'Liquidity', 'Vol 24h', 'Turnover', 'Top DEX'];
console.log(' ' + head.map((h, i) => chalk.cyan(COL[i] ? h.padEnd(COL[i]) : h)).join(''));
console.log(' ' + '─'.repeat(92));
variants.forEach((variant, index) => {
const topMarket = variant.topMarket;
const liquidity = topMarket?.liquidity ?? 0;
const volume24h = topMarket?.volume24h ?? 0;
const turnover = liquidity > 0 ? volume24h / liquidity : 0;
const turnoverColor = (text: string) => turnover > 5 ? chalk.red(text) : turnover > 3 ? chalk.yellow(text) : chalk.green(text);
const tier = (variant.liquidityTier ?? 'n/a').padEnd(8);
console.log(
' ' +
String(index + 1).padEnd(4) +
(variant.symbol ?? 'n/a').padEnd(10) +
(variant.label ?? variant.issuer ?? 'unknown').slice(0, 20).padEnd(22) +
tierColor(variant.liquidityTier, tier) +
usd(liquidity).padEnd(12) +
usd(volume24h).padEnd(12) +
turnoverColor(`${turnover.toFixed(1)}x`.padEnd(10)) +
(topMarket?.source ?? 'n/a')
);
});
Compare Variants
This is where the API's combined data becomes uniquely valuable. The script prints a full breakdown of every variant, including its risk grade, then ranks them and recommends one.
Three endpoints feed this step:
GET /assets/risk-summary?mint={mint}(already fetched above) returns ascore(0 to 100, higher is safer), a lettergrade, a humanlabel, anisTrustedLaunchflag, and acapsarray of named risk factors, each with atoneofsuccess,warning, ordanger.GET /assets/{assetId}/risk-details?mint={mint}adds a per-factor breakdown with each factor'svalueandbenchmark, so you can explain why a token earned its grade rather than just reporting the grade.GET /assets/{assetId}/profilereturns the canonical asset's external profile (website,twitter,marketCap,supply).
First, print the per-variant detail. Add this block inside main:
// STEP 2
section('STEP 2 — Compare & Select');
for (const variant of variants) {
const topMarket = variant.topMarket;
const risk = riskMap.get(variant.mint);
const liquidity = topMarket?.liquidity ?? 0;
const volume24h = topMarket?.volume24h ?? 0;
const turnover = liquidity > 0 ? volume24h / liquidity : 0;
const turnoverColor = (text: string) => turnover > 5 ? chalk.red(text) : turnover > 3 ? chalk.yellow(text) : chalk.green(text);
const tierLabel = variant.liquidityTier ?? 'n/a';
console.log(` ${chalk.white.bold(variant.symbol ?? variant.mint.slice(0, 8))} ${chalk.gray(variant.label ?? '')} ${tierColor(variant.liquidityTier, tierLabel)}`);
console.log(` ${'Issuer:'.padEnd(18)} ${variant.issuer ?? variant.label ?? 'unknown'}`);
console.log(` ${'Mint:'.padEnd(18)} ${chalk.gray(variant.mint)}`);
console.log(` ${'Liquidity:'.padEnd(18)} ${usd(liquidity)}`);
console.log(` ${'Vol 24h:'.padEnd(18)} ${usd(volume24h)}`);
console.log(` ${'Turnover:'.padEnd(18)} ${turnoverColor(`${turnover.toFixed(1)}x`)} ${turnover > 5 ? chalk.red('pool under stress') : turnover < 0.1 ? chalk.yellow('very low activity') : chalk.green('healthy')}`);
if (topMarket?.trade24h != null) console.log(` ${'Trades 24h:'.padEnd(18)} ${topMarket.trade24h.toLocaleString()}`);
if (topMarket?.uniqueWallet24h != null) console.log(` ${'Wallets 24h:'.padEnd(18)} ${topMarket.uniqueWallet24h.toLocaleString()}`);
if (risk) {
console.log(` ${'Risk:'.padEnd(18)} ${gradeColor(risk.grade)} score ${risk.score}/100 ${risk.isTrustedLaunch ? chalk.green('✓ trusted launch') : chalk.yellow('unverified')}`);
for (const cap of risk.caps ?? []) {
const capColor = cap.tone === 'success' ? chalk.green : cap.tone === 'warning' ? chalk.yellow : chalk.red;
console.log(` ${''.padEnd(20)}${capColor('▸')} ${cap.name}: ${capColor(cap.value)}`);
}
} else {
console.log(` ${'Risk:'.padEnd(18)} ${chalk.gray('n/a')}`);
}
console.log(` ${'Top DEX:'.padEnd(18)} ${topMarket?.source ?? 'n/a'} ${topMarket?.name ? chalk.gray(`(${topMarket.name})`) : ''}`);
console.log();
}
Now select the best option. Raw liquidity alone is misleading. The script computes an exec score of liquidity × min(turnover, 3), where turnover = volume24h / liquidity. Capping turnover at 3 prevents a tiny, frantically traded pool from outranking a genuinely deep one, while still rewarding pools that actually see volume.
Why this matters, with real example numbers from the API:
- WBTC on Solana: 75.91M dollars in liquidity, but only 0.44 dollars of 24h volume (one trade, one wallet). Exec score is about 0.44.
- cbBTC on Solana: 5.45M dollars in liquidity, with 17.63M dollars of 24h volume (3.2x turnover, 767 wallets). Exec score is about 16.35M.
cbBTC wins by roughly 37 million times despite having 14x less raw liquidity. In this case, WBTC has liquidity on paper, but nobody trades it, which means stale pricing and wide spreads in practice. A headline liquidity number alone would have pointed to the worse mint. Because the API returns liquidity, volume24h, trade24h, and uniqueWallet24h together, you can see straight through it.
Add the recommendation block, which ranks by exec score and links the winning pool to DexScreener:
// Recommendation: highest exec score (liquidity weighted by real turnover)
const execScore = (variant: any) => {
const liquidity = variant.topMarket?.liquidity ?? 0;
const volume24h = variant.topMarket?.volume24h ?? 0;
const turnover = liquidity > 0 ? volume24h / liquidity : 0;
return liquidity * Math.min(turnover, 3);
};
const ranked = [...variants].sort((a, b) => execScore(b) - execScore(a));
const best = ranked[0];
const runner = ranked[1];
const bestRisk = riskMap.get(best.mint);
const liqRatio = runner && (runner.topMarket?.liquidity ?? 0) > 0
? Math.round((best.topMarket?.liquidity ?? 0) / (runner.topMarket?.liquidity ?? 0))
: null;
console.log(chalk.green.bold(` ✓ Recommendation: ${best.symbol ?? best.mint.slice(0, 8)}`));
if (liqRatio && liqRatio > 1) {
const riskNote = bestRisk ? ` and ${bestRisk.grade ?? 'n/a'} risk grade` : '';
console.log(` Reason: ${liqRatio}x more liquidity than the next option${riskNote}`);
}
console.log(` DEX: ${best.topMarket?.source ?? 'n/a'}`);
if (best.topMarket?.address) {
console.log(` Pool: ${chalk.cyan(`https://dexscreener.com/solana/${best.topMarket.address}`)}`);
}
const chosenMint: string = best.mint;
Finally, pull the detailed risk breakdown and the asset profile for the recommended mint. The risk details explain the grade factor by factor, and the profile adds external context.
// Risk details for the recommended variant (explains why it got its grade)
try {
const detailsRes = await get<any>(`/assets/${assetId}/risk-details?mint=${chosenMint}`);
const factors: any[] = detailsRes.factors ?? detailsRes.data?.factors ?? [];
if (factors.length) {
console.log(`\n ${chalk.yellow('Risk factor breakdown')} ${chalk.gray('(why it got this grade)')}`);
for (const factor of factors) {
const factorColor = (factor.score ?? 0) >= 70 ? chalk.green : (factor.score ?? 0) >= 40 ? chalk.yellow : chalk.red;
const benchmark = factor.benchmark != null ? chalk.gray(` · benchmark: ${factor.benchmark}`) : '';
const value = factor.value != null ? chalk.gray(` · value: ${factor.value}`) : '';
console.log(` ${factorColor((factor.name ?? '').padEnd(22))} score: ${factorColor(String(factor.score ?? 'n/a'))}${value}${benchmark}`);
if (factor.description) console.log(` ${''.padEnd(22)} ${chalk.gray(factor.description)}`);
}
}
} catch { console.log(chalk.yellow(' Risk factor breakdown not available')); }
// Profile (canonical asset). Description is intentionally skipped: for tokenized
// equities it is issuer copy, not a description of the underlying company.
try {
const profileRes = await get<any>(`/assets/${assetId}/profile`);
const profile = profileRes.profile?.ok !== false ? (profileRes.profile?.data ?? null) : null;
if (profile) {
console.log(`\n ${chalk.yellow('Asset profile')}`);
if (profile.website) console.log(` ${'Website:'.padEnd(14)} ${profile.website}`);
if (profile.twitter) console.log(` ${'Twitter:'.padEnd(14)} ${profile.twitter}`);
if (profile.marketCap) console.log(` ${'Market cap:'.padEnd(14)} ${usd(profile.marketCap)}`);
if (profile.supply) console.log(` ${'Supply:'.padEnd(14)} ${Number(profile.supply).toLocaleString()}`);
}
} catch { console.log(chalk.yellow(' Asset profile not available')); }
Check Market Conditions
The recommendation tells you which mint to use. This final step answers whether now is a good time to use it. It reads a live snapshot of the chosen mint, gates on freshness, measures volume acceleration, checks participation, and cross-references the trending list before returning a verdict.
Two endpoints drive it:
GET /assets/{assetId}/variant-market?mint={mint}returns a live cached snapshot:price,priceChange1hPercent,priceChange24hPercent,lastTradeAt(Unix seconds), and ametricsSource. WhenmetricsSourceisclickhouse_trades, you also get windowed volumes and counts (volume5mUSD,volume1hUSD,volume24hUSD,trade24h,uniqueWallet24h, and more). When the source is something else, you only have 1h and 24h granularity, so the script falls back accordingly.GET /assets/trending?category={category}&limit=50is a pre-built momentum composite ranking mints by short-window volume acceleration and trade recency. It serves as a cross-check.
Start by fetching the snapshot and applying the freshness gate. If the last trade was more than 10 minutes ago, the pool is stale and the verdict is WAIT, since every timing signal below would be computed on dead data. Add this block inside main:
// STEP 3
section('STEP 3 — Check Market Conditions');
let snapshot: any;
try {
const snapshotRes = await get<any>(`/assets/${assetId}/variant-market?mint=${chosenMint}`);
snapshot = snapshotRes.market ?? snapshotRes;
} catch (err: any) {
console.log(chalk.red(` Could not fetch snapshot: ${err.message}`));
return;
}
const ageSeconds = snapshot.lastTradeAt ? Math.floor(Date.now() / 1000) - snapshot.lastTradeAt : Infinity;
const fresh = ageSeconds < 600;
console.log(` ${chalk.white.bold(best.symbol ?? chosenMint.slice(0, 8))} ${chalk.gray(`${best.topMarket?.source ?? 'n/a'} · ${best.topMarket?.name ?? ''} · last trade ${ago(snapshot.lastTradeAt)}`)}\n`);
console.log(` ${'Price:'.padEnd(18)} $${snapshot.price?.toFixed(4) ?? 'n/a'}`);
if (snapshot.priceChange1hPercent != null) {
const change = snapshot.priceChange1hPercent;
console.log(` ${'1h change:'.padEnd(18)} ${change >= 0 ? chalk.green(`+${change.toFixed(2)}%`) : chalk.red(`${change.toFixed(2)}%`)}`);
}
if (snapshot.priceChange24hPercent != null) {
const change = snapshot.priceChange24hPercent;
console.log(` ${'24h change:'.padEnd(18)} ${change >= 0 ? chalk.green(`+${change.toFixed(2)}%`) : chalk.red(`${change.toFixed(2)}%`)}`);
}
console.log();
if (!fresh) {
console.log(chalk.red(` Pool is stale (last trade ${ago(snapshot.lastTradeAt)}) — timing signals unreliable\n`));
console.log(' ' + '─'.repeat(56));
console.log(` ${chalk.red.bold('Verdict: WAIT')} — pool is inactive`);
console.log(' ' + '─'.repeat(56) + '\n');
return;
}
Next, measure volume acceleration. The idea is to normalize each window to an hourly rate and compare. If the last 5 minutes, projected to an hour (volume5mUSD × 12), is running well above the trailing 1h volume, activity is accelerating. A ratio of 1.5x or more reads as accelerating, below 0.5x as fading, and anything in between as steady. When the richer clickhouse_trades windows are unavailable, the script falls back to comparing the 1h pace against the 24h baseline.
// Volume acceleration: normalize windows to an hourly rate and compare
let timingSignal = 'unknown';
const hasWindowed = snapshot.metricsSource === 'clickhouse_trades' && snapshot.volume5mUSD != null;
if (hasWindowed) {
const pace5m = snapshot.volume1hUSD > 0 ? (snapshot.volume5mUSD * 12) / snapshot.volume1hUSD : 0;
const pace1h = snapshot.volume24hUSD > 0 ? (snapshot.volume1hUSD * 24) / snapshot.volume24hUSD : 0;
const trendColor = pace5m >= 1.5 ? chalk.green : pace5m < 0.5 ? chalk.red : chalk.yellow;
const label = pace5m >= 1.5 ? '▲ Accelerating' : pace5m < 0.5 ? '▼ Fading' : '→ Steady';
console.log(` ${'Volume trend:'.padEnd(18)} ${trendColor(label)} ${chalk.gray(`(5m pace ${pace5m.toFixed(1)}x 1h avg · 1h pace ${pace1h.toFixed(1)}x daily avg)`)}`);
timingSignal = pace5m >= 1.5 ? 'accelerating' : pace5m < 0.5 ? 'fading' : 'steady';
} else if (snapshot.volume1hUSD != null && snapshot.volume24hUSD > 0) {
const pace1h = (snapshot.volume1hUSD * 24) / snapshot.volume24hUSD;
const trendColor = pace1h >= 1.5 ? chalk.green : pace1h < 0.5 ? chalk.red : chalk.yellow;
const label = pace1h >= 1.5 ? '▲ Accelerating' : pace1h < 0.5 ? '▼ Fading' : '→ Steady';
console.log(` ${'Volume trend:'.padEnd(18)} ${trendColor(label)} ${chalk.gray(`(1h pace ${pace1h.toFixed(1)}x daily avg)`)}`);
timingSignal = pace1h >= 1.5 ? 'accelerating' : pace1h < 0.5 ? 'fading' : 'steady';
} else {
console.log(` ${'Volume trend:'.padEnd(18)} ${chalk.gray('n/a')}`);
}
// Participation: trades per wallet hints at organic vs concentrated activity
let organicSignal = 'unknown';
if (snapshot.trade24h && snapshot.uniqueWallet24h) {
const tradesPerWallet = snapshot.trade24h / snapshot.uniqueWallet24h;
const participationColor = tradesPerWallet <= 5 ? chalk.green : tradesPerWallet <= 15 ? chalk.yellow : chalk.red;
const participationLabel = tradesPerWallet <= 5 ? 'broad' : tradesPerWallet <= 15 ? 'mixed' : 'concentrated';
console.log(` ${'Participation:'.padEnd(18)} ${snapshot.uniqueWallet24h.toLocaleString()} wallets · ${snapshot.trade24h.toLocaleString()} trades ${participationColor(`(${tradesPerWallet.toFixed(1)} trades/wallet, ${participationLabel})`)}`);
organicSignal = participationLabel;
} else if (snapshot.uniqueWallet24h) {
console.log(` ${'Participation:'.padEnd(18)} ${snapshot.uniqueWallet24h.toLocaleString()} unique wallets (24h)`);
}
The participation check divides 24h trades by 24h unique wallets. A low ratio (5 or fewer trades per wallet) suggests broad, organic activity. A high ratio (more than 15) suggests a handful of addresses, possibly bots, doing most of the trading.
Finally, cross-check against the trending list and combine everything into a verdict. The result is GO when the pool is fresh, not fading, and not concentrated, and WAIT otherwise, always with a specific reason.
// Trending cross-check: is the asset in the top 50 for its category?
let trendRank: number | null = null;
try {
const trendRes = await get<any>(`/assets/trending?category=${category}&limit=50`);
const found = trendRes.trending?.find((item: any) => item.assetId === assetId || item.mint === chosenMint);
if (found) {
trendRank = found.rank;
const rankColor = found.rank <= 10 ? chalk.green : found.rank <= 25 ? chalk.yellow : chalk.white;
console.log(` ${'Trending:'.padEnd(18)} ${rankColor(`#${found.rank}`)} ${chalk.gray(`score ${found.trending?.score?.toFixed(1) ?? 'n/a'}`)}`);
} else {
console.log(` ${'Trending:'.padEnd(18)} ${chalk.gray('not in top 50')}`);
}
} catch { console.log(` ${'Trending:'.padEnd(18)} ${chalk.yellow('not available')}`); }
// Verdict: GO if fresh + not fading + not concentrated
const go = fresh && timingSignal !== 'fading' && organicSignal !== 'concentrated';
const reason =
timingSignal === 'accelerating' && organicSignal === 'broad' ? 'strong momentum with broad participation' :
timingSignal === 'accelerating' ? 'volume accelerating; verify participation before entering' :
timingSignal === 'fading' ? 'activity is declining; wait for a better window' :
organicSignal === 'concentrated' ? 'trade activity concentrated; possible bots' :
trendRank != null && trendRank <= 10 ? 'trending token, active market' :
'market is active with no red flags';
console.log('\n ' + '─'.repeat(56));
console.log(` ${go ? chalk.green.bold('Verdict: GO') : chalk.red.bold('Verdict: WAIT')} — ${reason}`);
console.log(' ' + '─'.repeat(56) + '\n');
That closes the body of main. Make sure the final main().catch(...) line from the setup section is still at the bottom of the file.
Run the Script
Run the tool with a ticker symbol. The --env-file flag loads your .env without any additional packages:
npx tsx --env-file=.env research.ts BTC
Or pass a raw mint address instead, and the script will resolve it to its canonical asset first:
npx tsx --env-file=.env research.ts cbbtcf3aa214zXHbiAZQwf4122FBYbraNdFqgw4iMij
You will see all three steps print in sequence: the list of onchain options, the per-variant comparison with a recommendation and a DexScreener pool link, and the timing analysis ending in a GO or WAIT verdict.
Tokens API — Bitcoin on Solana
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
STEP 1 — Discover Onchain Options
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
8 onchain representations of Bitcoin
# Symbol Issuer Tier Liquidity Vol 24h Turnover Top DEX
────────────────────────────────────────────────────────────────────────────────────────────
1 cbBTC cbBTC tier1 $5.54M $17.06M 3.1x Orca
2 WBTC unknown tier2 $75.91M $0.44 0.0x Orca
3 xBTC unknown tier3 $2.22M $504.7K 0.2x Orca
4 zenBTC unknown tier3 $963.3K $39.0K 0.0x Orca
5 zBTC zBTC tier3 $763.2K $63.2K 0.1x Saber
6 tBTC unknown tier3 $143.5K $0.00 0.0x Orca
7 WBTC unknown tier3 $48.2K $6.4K 0.1x Raydium Clamm
8 21BTC unknown tier3 $1.9K $0.00 0.0x Cropper
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
STEP 2 — Compare & Select
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
cbBTC cbBTC tier1
Issuer: cbBTC
Mint: cbbtcf3aa273Sn1dMGVU3ZtLv9yWSyUAanBni19YWDaznnkn
Liquidity: $5.54M
Vol 24h: $17.06M
Turnover: 3.1x healthy
Trades 24h: 4,811
Wallets 24h: 767
Risk: A score 86/100 unverified
Top DEX: Orca (SOL-cbBTC)
... (detail for remaining 7 variants) ...
✓ Recommendation: cbBTC
Reason: 3x more liquidity than the next option and A risk grade
DEX: Orca
Pool: https://dexscreener.com/solana/CeaZcxBNLpJWtxzt58qQmfMBtJY8pQLvursXTJYGQpbN
Asset profile
Market cap: $1251.60B
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
STEP 3 — Check Market Conditions
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
cbBTC Orca · SOL-cbBTC · last trade 17m ago
Price: $63854.1696
1h change: -0.09%
24h change: -3.32%
Pool is stale (last trade 17m ago) — timing signals unreliable
────────────────────────────────────────────────────────
Verdict: WAIT — pool is inactive
────────────────────────────────────────────────────────
Wrapping Up
You have built a research tool that turns a single ticker or mint address into a real decision. It resolves an asset to its canonical ID, lists every onchain variant with its market in one call, ranks them by an execution-quality score that sees past misleading liquidity numbers, grades their risk, and reads live activity to tell you whether now is a good moment to act.
The hardest part of trading a fragmented asset on Solana is knowing which of several mints to actually use, and you now have a repeatable, API-backed workflow for researching Solana token trades before committing to any mint. From here, you can wire these signals into a dashboard, a portfolio tracker, or the front end of a trading bot.
Next Steps
The sample script uses five endpoints, but the Assets API has more to offer. Here are some ideas to extend it:
- Add price charts. Use
GET /assets/{assetId}/ohlcv?mint={mint}&interval={interval}(intervals from1mto1W) to render candlestick history for the recommended variant, orGET /assets/{assetId}/price-chartfor a canonical price view that automatically picks the best available source. - Price a wallet's holdings in bulk. Pair a Quicknode Solana endpoint (to read a wallet's token accounts) with
POST /assets/market-snapshots(up to 250 mints per call) to Get All Token Accounts Held by a Solana Wallet and map each mint to its canonical asset in one round trip. - Build a watchlist. Seed one with
GET /assets/curated(sorted by 24h volume, withgroupBy=assetorgroupBy=mint), then refresh prices withGET /assets/variant-markets?mints={csv}(up to 50 mints), which also resolves each mint to its canonicalassetId. - Compare centralized and decentralized pricing. Use
GET /assets/{assetId}/tickersto pull exchange-level tickers (exchange, pair, last price, volume, bid, ask) and surface slippage or arbitrage context next to the onchain data. - Add retry logic. The
gethelper in the script does not retry on failure. For production, wrap thefetchcall in a retry loop using exponential backoff with jitter (base = 250 * 2 ** attemptms, up to 150ms jitter, four attempts) and cache responses aggressively since most fields only change every few seconds.
Frequently Asked Questions
What is the difference between a canonical asset and a variant in Tokens API?
A canonical asset is the underlying concept identified by a clean slug like bitcoin, usd, or tesla. A variant is a specific onchain mint (a Solana token address) that represents that asset, such as WBTC or cbBTC for Bitcoin. One canonical asset can have many variants, each with its own issuer, liquidity tier, trust tier, and kind. The canonical model lets you ask for an asset once and get every way to hold it onchain, already grouped.
Why does the script rank variants by an exec score instead of by liquidity?
Raw liquidity can be misleading. A pool can hold tens of millions of dollars in liquidity yet see almost no trades, which means stale pricing and wide spreads in practice. The exec score (liquidity multiplied by min(turnover, 3), where turnover is 24h volume divided by liquidity) rewards pools that are actually being used. In testing, cbBTC outranked WBTC by roughly 37 million times despite having 14x less raw liquidity, because WBTC's deep pool had only a single trade in 24 hours.
Does Tokens.xyz execute trades or hold custody of funds?
No. Tokens.xyz is read-only. It handles discovery, pricing, and risk data only. To act on a recommendation (read a wallet's balances, build a swap, or send a transaction) you use a Solana RPC endpoint such as one from Quicknode. The API gives you the research; execution happens onchain through separate infrastructure.
Why do some tokens return richer timing data than others?
The detailed 5-minute and 15-minute windowed volume and wallet counts are only present when the variant-market snapshot reports a metricsSource of clickhouse_trades. For assets sourced from a provider like Birdeye, you only get 1h and 24h granularity. The script detects this and falls back to comparing the 1h pace against the 24h baseline so the timing verdict still works.
Resources
We ❤️ Feedback!
Let us know if you have any feedback or requests for new topics. We'd love to hear from you.