Which holders are the same entity?
Open-source holder clustering for Solana tokens. Feed it a mint, get back which top holders are linked: shared funding sources, direct transfer history, creator outflow, known-entity labels.
Website · Try it live · Docs · X · Telegram
A holder list tells you who holds. It does not tell you which holders are one entity. On Solana that difference is the whole game: a launch where the top 40 wallets were funded by one private address looks perfectly distributed until the coordinated dump.
The products that detect this today are closed and paywalled. Airdrop teams, DAOs and researchers keep re-implementing the same sybil filtering from scratch. This repo ends that: the clustering engine that powers three production tools on j.tools, extracted into a standalone MIT library that runs against any RPC endpoint you bring.
What's here: shared-funder clustering, distribution metrics, wallet labeling and age analysis, a transparent risk scorecard, holder-set comparison, graph primitives, and CSV/JSON/Markdown output. All over standard RPC, all runnable today. The deeper transfer-link graph (direct edges, connector fan-in, 1-hop relay detection) is the next tier and lands with our Solana Foundation Turkey Grant milestones. See the roadmap.
Docs: Methodology (every formula written out) · API reference · Recipes · Contributing
npm install solana-holder-clustersimport { scanHolders } from "solana-holder-clusters";
const scan = await scanHolders({
mint: "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263",
top: 500,
rpcUrl: process.env.RPC_URL, // any endpoint that allows getProgramAccounts
});
for (const c of scan.clusters) {
console.log(`${c.members.length} wallets share funder ${c.funderLabel ?? c.funder}`);
}# or from the CLI
npx solana-holder-clusters scan <MINT> --top 500 --rpc $RPC_URL --out clusters.jsonThe exact pipeline this repo will publish already serves three free tools in production:
| Tool | What it answers | Live |
|---|---|---|
| J Map | Which holders are the same entity? Top 500 holders as linked, colored clusters; creator marked red, outflow traced | j.tools/en/tools/j-map |
| Holder Snapshot | Who holds what, on record? Ranked holders to top 10,000 with labels, balances and a safety scorecard | j.tools/en/tools/token-snapshot |
| Wallet Scope | How do those wallets behave? Up to 500 wallets bulk-analyzed with a shared-funder cluster map | j.tools/en/tools/wallet-scope |
All three are free and need no account. Paste any mint and watch the engine work before reading another line here.
The design principle, straight from the source: facts first, inference minimal. Every link the engine draws is verifiable on any explorer. Grouping wallets that share a private funder is the only derived step. No confidence scores pulled from thin air, no black box.
flowchart LR
A["mint address"] --> B["holder fetch<br/>getProgramAccounts, owner-resolved"]
B --> C["first-funder resolution<br/>earliest SOL sender per wallet"]
C --> E["noise engineering<br/>dust · cex bucket · share cap"]
E --> F["cluster assembly<br/>+ known-entity labels"]
F --> G["JSON output<br/>schema/cluster.schema.json"]
B -.->|next tier| D["transfer-link graph<br/>edges · connector fan-in · relays"]
D -.-> E
1. Holder fetch · src/holders.ts
Top N holders of any SPL or Token-2022 mint, over standard RPC via getProgramAccounts. No provider lock-in: the library takes whatever endpoint you give it. Token accounts resolve to owner wallets and balances split across accounts are summed, so the graph is built on the identity that actually matters and a whale is counted once.
2. First-funder resolution · src/funders.ts
For each wallet the engine reads its oldest signature, opens that first transaction, and takes the account that lost the most SOL: the payer that brought the wallet to life. Wallets sharing one private funder form a cluster. That funding transfer sits on-chain forever; anyone can click through and confirm it. A wallet too established to reach its first tx in one page is left unresolved, never guessed.
3. Cluster assembly · src/clusters.ts
Pure and synchronous (no network, which is what makes it unit-testable): group by shared funder, split the known-exchange bucket out, drop dust-only and over-share funders. The shipped core clusters on funding. The transfer-link graph below is the next tier.
Naive clustering "discovers" that everyone is connected, because everyone touches the same DEX pools and exchange wallets. Most of the engineering in this engine is in what it refuses to count:
- Swaps don't link wallets. Only plain native-SOL transfers count as fund flow. Two wallets trading on the same pool is not a relationship.
- Dust is not funding. Sub-threshold transfers, the kind used to manufacture fake links between strangers, are dropped.
- Stablecoin seeding counts. A source that funded holders with USDC or USDT is as real a coordination signal as SOL, and is treated as one.
- A source that funded most of the set is infrastructure. A real distributor funds a minority subset. Sources above a hard share cap are common infra and get dropped.
- Big connectors must prove they are private. A source funding many wallets is kept only if a fan-in check confirms it is low-activity, a private distributor rather than a CEX, bridge or faucet. Coordinated launches seeding 15 to 100 wallets stay visible; exchange hot wallets never produce fake clusters.
- Fresh-mule chains collapse. A funder that routes through a freshly created intermediary before reaching holders is folded back into a single relay connector, so one hop of indirection buys nothing.
- CEX-funded is its own bucket. Wallets first funded by a known exchange are grouped separately and never presented as coordination. Thousands of unrelated people withdraw from the same hot wallet.
Every number the engine uses lives in src/config.ts, documented and overridable. The defaults below are sensible starting values that run and cluster correctly out of the box; j.tools tunes its own in production. Pass a partial override to scanHolders({ config }) to set your own.
| Config | What it guards |
|---|---|
minFundSol |
A funding transfer below this is dust used to fake links, not a real relationship |
minFundUsd |
Stablecoin (USDC/USDT) seeding floor, counted the same as SOL |
connectorHardShare |
A funder touching more than this share of the set is common infra, never a private bundle |
safeConnectorSize |
Larger shared funders must pass a fan-in check before they can cluster |
activityCap |
A source with more signatures than this is infrastructure, not a private funder |
funderPageLimit |
A wallet with a full page of history is too established to reach its first tx; left unresolved |
// stricter dust floor, looser share cap
const scan = await scanHolders({
mint, rpcUrl,
config: { minFundSol: 0.1, connectorHardShare: 0.8 },
});Nothing is hidden behind a service. If you disagree with a threshold, change it and re-run.
One JSON document per scan, matching schema/cluster.schema.json:
Scans are time-budgeted: a large set returns a graceful partial with pending counts instead of a timeout. Progress is resumable, never wedged.
| Module | What it gives you |
|---|---|
scan |
scanHolders(): holders → funders → clusters in one call |
report |
generateReport(): the whole pipeline under one time budget, with warnings when it degrades |
holders |
Top-N owner-resolved holders (SPL + Token-2022), balances summed per wallet |
funders |
First-funder resolution over standard RPC |
clusters |
Pure grouping: shared-funder clusters, CEX bucket, dust and share-cap gates |
metrics |
Gini, HHI, effective holders, Nakamoto, entropy, Lorenz curve, dust ratio |
labels |
Burn / exchange / pool / creator / wallet, size tiers, age tiers, float share |
age |
Wallet age, cohorts, and birth-time bursts (wallets created together) |
risk |
Transparent scorecard: every point traceable to a signal and a threshold |
similarity |
Holder overlap between tokens, snapshot diffs, recurring operators |
graph |
Union-find, components, hub detection, clustering coefficient |
format |
CSV, JSON, Markdown and terminal rendering |
rpc |
Retry with jitter, bounded concurrency, deadlines, token-bucket limiting |
supply |
Mint state, authorities, precision-safe amount math, coverage |
errors |
Typed errors with codes, so you branch on err.code not message strings |
statistics |
Percentiles, IQR outliers, histograms (incl. log-scale), correlation, uniformity |
charts |
Dependency-free SVG: Lorenz curve, bars, histogram, donut, gauge, sparkline |
cluster-map |
The cluster map as SVG, plus Graphviz DOT and Mermaid export |
cluster-layout |
Deterministic force-directed layout with collision resolution |
html-report |
One self-contained HTML file: inline charts, no scripts, no external requests |
cache |
LRU + TTL, request coalescing, negative caching |
validate |
Address parsing, config range checks, schema and invariant validation |
Full signatures in the API reference.
solana-holder-clusters scan <MINT> # cluster the top holders
solana-holder-clusters report <MINT> # full distribution report
solana-holder-clusters metrics <MINT> # concentration metrics only
solana-holder-clusters compare <MINT_A> <MINT_B> # holder overlap between two tokens
solana-holder-clusters html <MINT> # standalone HTML report with charts
solana-holder-clusters mint <MINT> # authorities and supplyAdd --format json|csv|markdown to pipe it somewhere. Progress goes to stderr, data to stdout.
git clone https://github.com/jayrabol/solana-holder-clusters
cd solana-holder-clusters
npm install
npm test # 398 tests, no network needed
RPC_URL=https://your-endpoint npm run example -- <MINT>The network-touching code is tested offline against an in-memory fake chain (test/helpers/mock-connection.ts), so the owner-dedup rule, the full-page bail-out and the largest-payer rule are all verified without an endpoint.
Examples: basic · full-report · html-report · export-csv · compare-tokens
data/known-entities.json is the seed of an open registry of labeled addresses: exchanges now, bridges and liquidity pools next. Every entry is verified against Solscan's public account label before inclusion. A wrong label would mislabel a wallet in every downstream scan, so unverified entries are rejected, no exceptions.
- Shared-funder clustering engine: holders, funder resolution, cluster assembly, documented thresholds
- Distribution metrics, wallet labeling, age cohorts, risk scorecard, token comparison, graph primitives
- CLI with six commands, CSV/JSON/Markdown/HTML output, 398 offline tests, published methodology
- Visualization: SVG cluster map with deterministic force layout, charts, self-contained HTML reports
- Weeks 1-4 · publish on npm, add the transfer-link graph (direct edges, connector fan-in, 1-hop relay detection)
- Weeks 5-8 · snapshot-at-slot, CSV/JSON exports, stored scan history with diffs
- Weeks 9-12 · sybil-score v1 with published methodology, expanded entity registry, worked examples
The transfer-link tier is the deeper engine behind J Map's connector detection; extracting it cleanly from the serverless job runtime is grant milestone 1.
j.tools keeps running on this same package after extraction. If the library rots, our own product rots. That is the maintenance guarantee.
J Tools is a Solana toolkit built from Istanbul: 35 live tools across the token lifecycle, bilingual (EN/TR), fully non-custodial. Every transaction is signed in the user's own wallet; the platform never sees a key. The three transparency tools above are free, and this library is how their core becomes everyone's.
j.tools · docs.j.tools · @jtools_official · Telegram · info@j.tools
MIT licensed. Built in Istanbul. 🇹🇷
{ "mint": "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263", "scannedAt": 1786060800, "depth": 500, "clusters": [ { "funder": "3nY7...k2Fq", "funderLabel": null, "members": [ { "address": "8dQz...mPa1", "amountSol": 1.5, "ts": 1785970000 }, { "address": "B4tr...9xWc", "amountSol": 1.5, "ts": 1785970012 } ] } ], "cexFunded": [ { "funder": "GJRs...7npE", "funderLabel": "Coinbase", "members": [ ] } ], "independent": ["9aKe...t3Lm"], "resolved": 496, "pending": 4 }