
Sybil Detection
- 200 installs
- 257 repo stars
- Updated June 24, 2026
- agiprolabs/claude-trading-skills
sybil-detection is a Claude Code skill for detecting coordinated wallet clusters, wash trading, and fake activity on Solana tokens.
About
sybil-detection is a Claude Code skill for detecting coordinated wallet clusters, wash trading, bundled transactions, and fake holder inflation on Solana tokens. It traces holder funding sources, groups co-trading wallets by slot window, computes bundle ratios, finds circular wash-trade cycles, and analyzes creator networks. A developer uses it to judge whether a token's holder and volume metrics reflect real demand or manufactured activity before trading.
- Funding-source tracing to find coordinated wallet clusters
- Co-trading, bundle-ratio, and wash-trading detection
- Separates real token demand from manufactured signals
Sybil Detection by the numbers
- 200 all-time installs (skills.sh)
- Ranked #469 of 1,106 Finance & Trading skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
sybil-detection capabilities & compatibility
Free skill; requires a Helius API key for transaction tracing (free tier available).
- Capabilities
- sybil detection · wash trading detection · bundle detection · funding analysis · token vetting
- Use cases
- trading · data analysis
- Pricing
- Bring your own API key
- Requires keys
- HELIUSAPIKEYPARSEDTRANSACTIONS
What sybil-detection says it does
Sybil attacks in Solana token markets involve a single entity operating many wallets to create the illusion of organic activity.
A token showing 1,000 holders with 80% funded from 3 wallets is fundamentally different from one with 1,000 independently-funded holders.
npx skills add https://github.com/agiprolabs/claude-trading-skills --skill sybil-detectionAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 200 |
|---|---|
| repo stars | ★ 257 |
| Last updated | June 24, 2026 |
| Repository | agiprolabs/claude-trading-skills ↗ |
What it does
Detect coordinated wallets, wash trading, and fake activity on Solana tokens before trading.
Who is it for?
Judging whether a Solana token's holder count and volume reflect real demand or manufactured signals.
Skip if: Non-Solana chains or on-chain data sources without funding-history and per-slot buy events.
When should I use this skill?
You need to vet a token for sybil activity, wash trading, or bundled launches before trading it.
What you get
A sybil assessment separating real holders and volume from coordinated, wash-traded, or bundled activity.
- Detected wallet clusters and wash-trade pairs
- A bundle-ratio and fake-activity assessment for a token
By the numbers
- Traces funding sources up to 2 hops
- Cluster threshold >=3 wallets from one source
- 5 detection categories
Files
Sybil Detection — Coordinated Wallet & Fake Activity Analysis
Sybil attacks in Solana token markets involve a single entity operating many wallets to create the illusion of organic activity. This skill covers detecting coordinated wallet clusters, wash trading, bundled transactions, and fake holder inflation — critical for evaluating whether a token's metrics reflect real demand or manufactured signals.
Why Sybil Detection Matters
Token markets on Solana are rife with manufactured signals:
- Inflated holder counts: 500 "holders" that are really 10 entities with 50 wallets each
- Fake volume: Wash trading between self-controlled wallets to simulate demand
- Artificial social proof: Many wallets holding small amounts to appear broadly distributed
- Rug preparation: Creator distributes supply across many wallets, then sells coordinated
- Bundled launches: PumpFun tokens where creator buys via Jito bundle in first slot
A token showing 1,000 holders with 80% funded from 3 wallets is fundamentally different from one with 1,000 independently-funded holders. Sybil detection separates real demand from theater.
Detection Categories
1. Funding Source Analysis
Trace each holder wallet back 1-2 hops to find who sent them SOL:
import httpx
def trace_funding_source(wallet: str, api_key: str, max_hops: int = 2) -> list[str]:
"""Trace SOL funding sources for a wallet via Helius parsed transactions."""
url = f"https://api.helius.xyz/v0/addresses/{wallet}/transactions"
resp = httpx.get(url, params={"api-key": api_key, "type": "TRANSFER", "limit": 50})
transfers = resp.json()
funders = []
for tx in transfers:
for transfer in tx.get("nativeTransfers", []):
if transfer["toUserAccount"] == wallet and transfer["amount"] > 0.001 * 1e9:
funders.append(transfer["fromUserAccount"])
return fundersKey signals:
- 3+ holder wallets funded from the same source = cluster
- Funding within 24h of token creation = high suspicion
- Funding amounts are identical (e.g., 0.05 SOL to each) = automated distribution
2. Co-Trading Patterns
Wallets that buy the same token at nearly the same time are likely coordinated:
def detect_co_trades(buy_events: list[dict], slot_window: int = 3) -> list[list[str]]:
"""Group wallets that bought within the same slot window."""
buy_events.sort(key=lambda x: x["slot"])
clusters = []
current_cluster = [buy_events[0]]
for i in range(1, len(buy_events)):
if buy_events[i]["slot"] - current_cluster[0]["slot"] <= slot_window:
current_cluster.append(buy_events[i])
else:
if len(current_cluster) >= 3:
clusters.append([b["wallet"] for b in current_cluster])
current_cluster = [buy_events[i]]
if len(current_cluster) >= 3:
clusters.append([b["wallet"] for b in current_cluster])
return clustersInterpretation:
- Same slot, different transactions = coordinated (bot-driven)
- Same transaction = bundled (definite sybil)
- First 3 slots after token creation = launch sniping cluster
3. Bundled Transactions
Multiple buys packed into a single Solana transaction or Jito bundle:
def check_bundle_ratio(early_buys: list[dict], bundle_window_slots: int = 5) -> dict:
"""Calculate the ratio of bundled vs independent early buys."""
bundled = [b for b in early_buys if b.get("is_bundled", False)]
first_slot = min(b["slot"] for b in early_buys) if early_buys else 0
early = [b for b in early_buys if b["slot"] - first_slot <= bundle_window_slots]
return {
"total_early_buys": len(early),
"bundled_buys": len(bundled),
"bundle_ratio": len(bundled) / max(len(early), 1),
"bundled_supply_pct": sum(b["amount"] for b in bundled) / max(sum(b["amount"] for b in early), 1),
}See references/bundler_detection.md for PumpFun-specific patterns and Jito bundle mechanics.
4. Wash Trading Detection
Same entity buying and selling through multiple wallets to inflate volume:
Signals:
- Wallet A buys token, transfers to Wallet B, Wallet B sells — circular flow
- Multiple wallets trading back and forth with no net position change
- Volume concentrated in wallet pairs with funding links
def detect_wash_cycles(transfers: list[dict], holder_set: set[str]) -> list[tuple]:
"""Find circular transfer patterns among known holders."""
# Build directed graph of transfers between holders
edges: dict[tuple, float] = {}
for t in transfers:
if t["from"] in holder_set and t["to"] in holder_set:
key = (t["from"], t["to"])
edges[key] = edges.get(key, 0) + t["amount"]
# Find reciprocal pairs (A->B and B->A both exist)
wash_pairs = []
for (a, b), vol_ab in edges.items():
vol_ba = edges.get((b, a), 0)
if vol_ba > 0:
wash_pairs.append((a, b, vol_ab, vol_ba))
return wash_pairs5. Creator Network Analysis
Identify wallets controlled by the token creator:
- Creator wallet's funding history reveals other wallets it funded
- Those wallets holding token supply = insider distribution
- Creator selling from "different" wallets = disguised dump
Key Metrics
| Metric | Formula | Healthy | Suspicious | Critical |
|---|---|---|---|---|
| Unique funder ratio | unique_funders / total_holders | > 0.8 | 0.4-0.8 | < 0.4 |
| Funding cluster size | max(cluster_sizes) | < 5 | 5-20 | > 20 |
| Co-trade score | wallets_in_first_3_slots / total_holders | < 0.1 | 0.1-0.3 | > 0.3 |
| Bundle ratio | bundled_buys / total_early_buys | < 0.1 | 0.1-0.4 | > 0.4 |
| Bundled supply % | bundled_token_amount / total_supply_sold | < 5% | 5-20% | > 20% |
| Transfer density | internal_transfers / total_transfers | < 0.1 | 0.1-0.3 | > 0.3 |
| Wash trade pairs | reciprocal_pairs / total_holder_pairs | 0 | 1-3 pairs | > 3 pairs |
Composite Risk Score
Combine individual signals into a single sybil risk score (0-100):
def compute_sybil_score(metrics: dict) -> dict:
"""Compute composite sybil risk score from individual metrics."""
weights = {
"funding_cluster": 25, # Wallets from same funder
"co_trade": 20, # Coordinated buy timing
"bundle_ratio": 20, # Bundled early transactions
"unique_funder": 15, # Diversity of funding sources
"transfer_density": 10, # Internal transfers between holders
"wash_trade": 10, # Circular trading patterns
}
scores = {}
# Each sub-score normalized to 0-1, then weighted
scores["funding_cluster"] = min(metrics.get("max_cluster_size", 0) / 20, 1.0)
scores["co_trade"] = min(metrics.get("co_trade_pct", 0) / 0.3, 1.0)
scores["bundle_ratio"] = min(metrics.get("bundle_ratio", 0) / 0.5, 1.0)
scores["unique_funder"] = 1.0 - min(metrics.get("unique_funder_ratio", 1.0), 1.0)
scores["transfer_density"] = min(metrics.get("transfer_density", 0) / 0.3, 1.0)
scores["wash_trade"] = min(metrics.get("wash_pairs", 0) / 5, 1.0)
composite = sum(scores[k] * weights[k] for k in weights)
risk_level = "LOW" if composite < 30 else "MEDIUM" if composite < 60 else "HIGH"
return {"score": round(composite, 1), "risk_level": risk_level, "components": scores}Data Sources
| Source | What It Provides | Auth Required |
|---|---|---|
| Helius parsed transactions | Funding history, transfer details, parsed instruction data | API key (free tier: 30 req/s) |
| SolanaTracker API | Bundler detection, holder lists, token metadata | API key |
| Solana RPC (getSignaturesForAddress) | Raw transaction signatures for any wallet | RPC URL |
| Solana RPC (getTokenLargestAccounts) | Top holders by balance | RPC URL |
| DexScreener | Basic token/pair data for cross-referencing | None |
Workflow: Evaluate a Token
# 1. Get top holders
holders = get_top_holders(token_mint, rpc_url)
# 2. Trace funding sources for each holder
funding_map = {}
for wallet in holders[:30]: # Top 30 is usually sufficient
funding_map[wallet] = trace_funding_source(wallet, helius_key)
# 3. Cluster by common funder
clusters = cluster_by_funder(funding_map)
# 4. Check co-trade timing
early_buys = get_early_buy_events(token_mint, helius_key)
co_trade_groups = detect_co_trades(early_buys)
# 5. Check for bundles
bundle_stats = check_bundle_ratio(early_buys)
# 6. Check wash trading
transfers = get_token_transfers(token_mint, helius_key)
wash_pairs = detect_wash_cycles(transfers, set(holders))
# 7. Compute composite score
metrics = {
"max_cluster_size": max(len(c) for c in clusters) if clusters else 0,
"co_trade_pct": sum(len(g) for g in co_trade_groups) / len(holders),
"bundle_ratio": bundle_stats["bundle_ratio"],
"unique_funder_ratio": len(set(f for fs in funding_map.values() for f in fs)) / len(holders),
"transfer_density": len(wash_pairs) / max(len(holders), 1),
"wash_pairs": len(wash_pairs),
}
result = compute_sybil_score(metrics)
print(f"Sybil Risk: {result['risk_level']} ({result['score']}/100)")Integration with Other Skills
- token-holder-analysis: Use holder list as input; sybil detection adds cluster context
- helius-api: Primary data source for parsed transaction history
- jito-bundles: Detailed bundle detection and MEV context
- liquidity-analysis: Combine with sybil score — low liquidity + high sybil = extreme risk
- whale-tracking: Distinguish real whales from sybil cluster aggregates
Files
| File | Description |
|---|---|
references/clustering_methods.md | Funding source clustering, co-trade timing analysis, graph-based detection methods |
references/bundler_detection.md | Bundled transaction detection, PumpFun patterns, Jito bundle mechanics |
scripts/detect_sybils.py | Full sybil detection pipeline: holders -> funding -> clusters -> risk score |
scripts/funding_tracer.py | Trace funding sources for a set of wallets, group by common ancestor |
Sybil Detection — Bundler Detection
How to detect bundled transactions on Solana, with focus on PumpFun token launches and Jito bundle mechanics.
What Are Bundled Transactions?
On Solana, bundled transactions take two forms:
1. Single-Transaction Bundles
Multiple swap instructions packed into one Solana transaction. One signer executes buys for multiple destination wallets in a single atomic operation.
Detection signals:
- Transaction has multiple token transfer instructions to different wallets
- Single fee payer for all instructions
- All transfers are for the same token mint
2. Jito Bundles
Multiple separate transactions submitted together to a Jito validator. They execute sequentially in the same slot with guaranteed ordering.
Detection signals:
- Multiple transactions in the same slot buying the same token
- Transactions appear sequential (consecutive within the slot)
- Often include a Jito tip transaction to the tip program
Detecting Bundled Buys from Transaction Data
Via Helius Parsed Transactions
import httpx
def detect_single_tx_bundles(
token_mint: str, helius_key: str, limit: int = 100
) -> list[dict]:
"""Find transactions that contain multiple buys of the same token.
These are single transactions where one signer buys tokens
and distributes to multiple wallets.
"""
url = f"https://api.helius.xyz/v0/addresses/{token_mint}/transactions"
resp = httpx.get(url, params={"api-key": helius_key, "limit": limit}, timeout=15)
if resp.status_code != 200:
return []
bundled_txs = []
for tx in resp.json():
token_transfers = [
t for t in tx.get("tokenTransfers", [])
if t.get("mint") == token_mint
]
unique_recipients = set(t["toUserAccount"] for t in token_transfers)
if len(unique_recipients) >= 2:
bundled_txs.append({
"signature": tx.get("signature", ""),
"slot": tx.get("slot", 0),
"recipient_count": len(unique_recipients),
"recipients": list(unique_recipients),
"total_amount": sum(t.get("tokenAmount", 0) for t in token_transfers),
"fee_payer": tx.get("feePayer", ""),
})
return bundled_txsVia Slot-Based Jito Bundle Detection
from collections import defaultdict
def detect_jito_bundles(
buy_events: list[dict], max_slot_gap: int = 0
) -> list[dict]:
"""Detect likely Jito bundles by grouping buys in the same slot.
Jito bundles execute multiple transactions in the same slot with
guaranteed sequential ordering.
Args:
buy_events: List of {"wallet": str, "slot": int, "tx_sig": str, "amount": float}.
max_slot_gap: Maximum slot gap to consider as same bundle (0 = same slot only).
Returns:
List of detected bundle groups.
"""
slot_groups: dict[int, list[dict]] = defaultdict(list)
for event in buy_events:
slot_groups[event["slot"]].append(event)
bundles = []
for slot, events in sorted(slot_groups.items()):
if len(events) >= 2:
unique_wallets = set(e["wallet"] for e in events)
unique_sigs = set(e["tx_sig"] for e in events)
bundles.append({
"slot": slot,
"tx_count": len(unique_sigs),
"wallet_count": len(unique_wallets),
"wallets": list(unique_wallets),
"total_amount": sum(e["amount"] for e in events),
"is_single_tx": len(unique_sigs) == 1,
"is_jito_bundle": len(unique_sigs) > 1,
})
return bundlesVia SolanaTracker Bundler API
SolanaTracker provides a dedicated bundler detection endpoint:
def check_bundler_solanatracker(
token_mint: str, api_key: str
) -> dict:
"""Check for bundled transactions via SolanaTracker API."""
url = f"https://data.solanatracker.io/tokens/{token_mint}/bundled"
resp = httpx.get(url, headers={"x-api-key": api_key}, timeout=15)
if resp.status_code != 200:
return {"error": f"HTTP {resp.status_code}"}
return resp.json()PumpFun-Specific Patterns
PumpFun tokens are the most common target for sybil attacks. Common patterns:
Creator Self-Buy Bundle
1. Creator calls PumpFun create instruction 2. In the same transaction (or same slot via Jito), creator buys tokens through multiple wallets 3. These wallets now appear as "early holders"
Detection:
- Check if any early buyer wallets were funded by the token creator
- Check if buys in slots 0-2 (relative to creation) came from related wallets
- First-slot buys from multiple wallets = almost always bundled
Supply Concentration from Bundles
def pumpfun_bundle_analysis(
early_buys: list[dict], creation_slot: int
) -> dict:
"""Analyze bundling patterns for a PumpFun token.
Args:
early_buys: Buy events with slot, wallet, amount, is_bundled fields.
creation_slot: The slot where the token was created.
Returns:
Analysis dict with bundle metrics.
"""
first_3_slots = [b for b in early_buys if b["slot"] - creation_slot <= 3]
bundled_early = [b for b in first_3_slots if b.get("is_bundled", False)]
total_early_supply = sum(b["amount"] for b in first_3_slots)
bundled_supply = sum(b["amount"] for b in bundled_early)
unique_early_wallets = set(b["wallet"] for b in first_3_slots)
bundled_wallets = set(b["wallet"] for b in bundled_early)
return {
"creation_slot": creation_slot,
"first_3_slot_buys": len(first_3_slots),
"bundled_buys": len(bundled_early),
"unique_early_wallets": len(unique_early_wallets),
"bundled_wallets": len(bundled_wallets),
"total_early_supply": total_early_supply,
"bundled_supply": bundled_supply,
"bundled_supply_pct": bundled_supply / max(total_early_supply, 1) * 100,
}Metrics and Risk Interpretation
Bundle Ratio
bundle_ratio = bundled_buys / total_early_buys
| Bundle Ratio | Risk Level | Interpretation |
|---|---|---|
| 0.0 | None | No bundled activity detected |
| 0.01 - 0.10 | Low | Minor bundling, possibly MEV bots |
| 0.10 - 0.25 | Moderate | Significant bundling, review clusters |
| 0.25 - 0.50 | High | Heavy bundling, likely coordinated launch |
| > 0.50 | Critical | Majority of early buys bundled, probable sybil |
Bundled Supply Percentage
bundled_supply_pct = bundled_token_amount / total_circulating_supply * 100
| Bundled Supply % | Risk Level | Interpretation |
|---|---|---|
| < 2% | Low | Negligible bundled holdings |
| 2-10% | Moderate | Notable bundled position |
| 10-30% | High | Large coordinated position |
| > 30% | Critical | Dominant supply held by bundlers |
Bundler Wallet Count
| Bundler Wallets | Interpretation |
|---|---|
| 1-2 | Single actor with modest distribution |
| 3-10 | Organized sybil operation |
| 10-30 | Large-scale fake holder inflation |
| > 30 | Industrial sybil farming |
Combining Bundle Detection with Funding Analysis
Bundle detection alone identifies the mechanism. Combining with funding source analysis identifies the actor:
def correlate_bundles_with_funding(
bundle_wallets: list[str],
funding_clusters: dict[str, list[str]],
) -> list[dict]:
"""Find overlap between bundled wallets and funding clusters.
When bundled wallets also share a funding source, confidence
in sybil detection is very high.
"""
results = []
bundle_set = set(bundle_wallets)
for funder, funded_wallets in funding_clusters.items():
overlap = bundle_set.intersection(set(funded_wallets))
if overlap:
results.append({
"funder": funder,
"bundled_and_funded": list(overlap),
"overlap_count": len(overlap),
"total_funded": len(funded_wallets),
"confidence": "HIGH" if len(overlap) >= 3 else "MODERATE",
})
return resultsEdge Cases
- MEV bots: Legitimate MEV searchers also use bundles — not all bundles are sybil
- Aggregator routers: Jupiter and other aggregators may route through multiple accounts
- Partial detection: Some bundles only detectable via Jito-specific APIs, not standard RPC
- Historical data: Jito bundle metadata may not be available for older transactions
- False negatives: Sophisticated operators use separate wallets with no on-chain link
Sybil Detection — Clustering Methods
Methods for grouping wallets into coordinated clusters based on funding sources, trading timing, and transfer graph structure.
Funding Source Clustering
The most reliable sybil signal: multiple holder wallets funded from the same SOL source.
Algorithm
1. For each holder wallet, fetch recent SOL transfers in (received) 2. Extract the fromUserAccount for each incoming SOL transfer 3. Group holder wallets by their funder address 4. Clusters of 3+ wallets from the same funder = suspicious
Implementation
from collections import defaultdict
import httpx
def cluster_by_funding(
holder_wallets: list[str],
helius_key: str,
min_cluster_size: int = 3,
min_sol_amount: float = 0.001,
) -> dict[str, list[str]]:
"""Group holder wallets by their SOL funding source.
Args:
holder_wallets: List of token holder wallet addresses.
helius_key: Helius API key for transaction lookups.
min_cluster_size: Minimum wallets from same funder to flag.
min_sol_amount: Minimum SOL transfer to consider as funding (in SOL).
Returns:
Dict mapping funder address -> list of funded holder wallets.
"""
funder_to_holders: dict[str, list[str]] = defaultdict(list)
for wallet in holder_wallets:
url = f"https://api.helius.xyz/v0/addresses/{wallet}/transactions"
resp = httpx.get(url, params={
"api-key": helius_key,
"type": "TRANSFER",
"limit": 50,
}, timeout=15)
if resp.status_code != 200:
continue
for tx in resp.json():
for transfer in tx.get("nativeTransfers", []):
if (
transfer["toUserAccount"] == wallet
and transfer["amount"] > min_sol_amount * 1e9
):
funder_to_holders[transfer["fromUserAccount"]].append(wallet)
# Filter to clusters meeting minimum size
return {
funder: wallets
for funder, wallets in funder_to_holders.items()
if len(wallets) >= min_cluster_size
}Multi-Hop Tracing
Sophisticated sybils use intermediary wallets. Trace 2 hops:
Real Funder -> Intermediary A -> Holder Wallet 1
Real Funder -> Intermediary B -> Holder Wallet 2
Real Funder -> Intermediary C -> Holder Wallet 3At hop 1, each holder has a different funder. At hop 2, all trace to the same source.
def trace_funding_chain(
wallet: str, helius_key: str, max_hops: int = 2
) -> list[list[str]]:
"""Trace funding chain back N hops. Returns list of chains.
Each chain is [wallet, funder_hop1, funder_hop2, ...].
"""
chains: list[list[str]] = [[wallet]]
for hop in range(max_hops):
new_chains = []
for chain in chains:
tip = chain[-1]
funders = _get_sol_funders(tip, helius_key)
if funders:
for funder in funders[:3]: # Limit branching
new_chains.append(chain + [funder])
else:
new_chains.append(chain)
chains = new_chains
return chainsTime Window Analysis
Funding timing adds confidence to cluster detection:
| Timing | Interpretation |
|---|---|
| Funded < 1h before token creation | Very likely sybil preparation |
| Funded < 24h before token creation | Suspicious, especially if clustered |
| Funded > 7 days before token creation | Lower suspicion (pre-existing wallet) |
| Funded identical amounts | Automated distribution (high confidence sybil) |
Co-Trade Timing Analysis
Wallets buying the same token within a narrow time window indicates coordination.
Slot-Based Grouping
Solana slots are ~400ms. Buys in the same slot from different wallets are almost certainly coordinated.
from collections import defaultdict
def group_buys_by_slot(
buy_events: list[dict], window: int = 3
) -> list[dict]:
"""Group buy events into slot-based clusters.
Args:
buy_events: List of {"wallet": str, "slot": int, "amount": float, "tx_sig": str}.
window: Number of slots to consider as "same time".
Returns:
List of cluster dicts with wallets, slot range, and total amount.
"""
if not buy_events:
return []
buy_events.sort(key=lambda x: x["slot"])
clusters = []
current = [buy_events[0]]
for event in buy_events[1:]:
if event["slot"] - current[0]["slot"] <= window:
current.append(event)
else:
if len(current) >= 2:
clusters.append({
"wallets": [e["wallet"] for e in current],
"slot_start": current[0]["slot"],
"slot_end": current[-1]["slot"],
"total_amount": sum(e["amount"] for e in current),
"buy_count": len(current),
})
current = [event]
if len(current) >= 2:
clusters.append({
"wallets": [e["wallet"] for e in current],
"slot_start": current[0]["slot"],
"slot_end": current[-1]["slot"],
"total_amount": sum(e["amount"] for e in current),
"buy_count": len(current),
})
return clustersStatistical Baseline
To distinguish coordination from coincidence, compare observed co-occurrence against a random baseline:
- Null hypothesis: Buys are uniformly distributed across slots
- Test: Given N buys over S slots, expected buys per slot = N/S
- Flag: If any slot has > 3x expected density, likely coordinated
def co_trade_significance(
buy_slots: list[int], total_slot_range: int
) -> dict:
"""Test if buy timing is more clustered than random."""
from collections import Counter
slot_counts = Counter(buy_slots)
n_buys = len(buy_slots)
expected_per_slot = n_buys / max(total_slot_range, 1)
max_in_slot = max(slot_counts.values()) if slot_counts else 0
concentration_ratio = max_in_slot / max(expected_per_slot, 0.001)
return {
"max_buys_in_single_slot": max_in_slot,
"expected_per_slot": round(expected_per_slot, 4),
"concentration_ratio": round(concentration_ratio, 2),
"is_suspicious": concentration_ratio > 3.0,
}Graph-Based Methods
Build a transfer graph between token holders to find densely connected subgroups.
Transfer Graph Construction
def build_transfer_graph(
transfers: list[dict], holder_set: set[str]
) -> dict[str, dict[str, float]]:
"""Build directed weighted graph of transfers between holders.
Args:
transfers: List of {"from": str, "to": str, "amount": float}.
holder_set: Set of known holder wallet addresses.
Returns:
Adjacency dict: {from_wallet: {to_wallet: total_volume}}.
"""
graph: dict[str, dict[str, float]] = defaultdict(lambda: defaultdict(float))
for t in transfers:
if t["from"] in holder_set and t["to"] in holder_set:
graph[t["from"]][t["to"]] += t["amount"]
return dict(graph)Connected Components
Find groups of wallets that have transferred tokens among themselves:
def find_connected_components(
graph: dict[str, dict[str, float]]
) -> list[set[str]]:
"""Find connected components in transfer graph (undirected)."""
all_nodes = set(graph.keys())
for neighbors in graph.values():
all_nodes.update(neighbors.keys())
visited: set[str] = set()
components: list[set[str]] = []
for node in all_nodes:
if node in visited:
continue
component: set[str] = set()
stack = [node]
while stack:
current = stack.pop()
if current in visited:
continue
visited.add(current)
component.add(current)
# Add neighbors (both directions)
for neighbor in graph.get(current, {}):
stack.append(neighbor)
for src, neighbors in graph.items():
if current in neighbors:
stack.append(src)
if len(component) >= 2:
components.append(component)
return componentsGraph Density Metric
Dense subgraphs (many edges relative to nodes) indicate tightly coordinated groups:
| Density | Interpretation |
|---|---|
| < 0.1 | Normal — sparse transfers between holders |
| 0.1-0.3 | Moderate — some internal circulation |
| > 0.3 | High — likely coordinated wallet cluster |
def subgraph_density(component: set[str], graph: dict[str, dict[str, float]]) -> float:
"""Calculate density of a subgraph (edges / possible edges)."""
n = len(component)
if n < 2:
return 0.0
possible_edges = n * (n - 1) # Directed graph
actual_edges = sum(
1 for src in component for dst in graph.get(src, {}) if dst in component
)
return actual_edges / possible_edgesLimitations
- Chain-hopping: Sybils can fund wallets from CEX withdrawals (different addresses each time)
- Mixing services: SOL mixers break the funding chain
- Time delays: Patient attackers fund wallets days/weeks in advance
- Intermediary depth: Tracing beyond 2 hops is computationally expensive and noisy
- False positives: Airdrop campaigns legitimately fund many wallets from one source
- RPC rate limits: Tracing 100+ wallets requires significant API quota
Combine multiple signals — the intersection of funding clusters + co-trade timing + bundle detection catches most coordinated activity even when individual methods miss sophisticated sybils.
#!/usr/bin/env python3
"""Sybil detection pipeline for Solana tokens.
Analyzes a token's holder base for coordinated wallet clusters, bundled
transactions, wash trading, and fake holder inflation. Produces a composite
sybil risk score with detailed breakdown.
Usage:
# Live mode (requires API keys):
TOKEN_MINT=So11... HELIUS_API_KEY=xxx python scripts/detect_sybils.py
# Demo mode (synthetic data, no API keys needed):
python scripts/detect_sybils.py --demo
Dependencies:
uv pip install httpx
Environment Variables:
TOKEN_MINT: Solana token mint address to analyze
HELIUS_API_KEY: Helius API key for transaction lookups (optional in demo mode)
SOLANA_RPC_URL: Solana RPC endpoint (default: https://api.mainnet-beta.solana.com)
"""
import json
import os
import sys
import time
from collections import defaultdict
from typing import Optional
try:
import httpx
except ImportError:
print("Missing dependency. Install with: uv pip install httpx")
sys.exit(1)
# ── Configuration ───────────────────────────────────────────────────
TOKEN_MINT = os.getenv("TOKEN_MINT", "")
HELIUS_API_KEY = os.getenv("HELIUS_API_KEY", "")
SOLANA_RPC_URL = os.getenv(
"SOLANA_RPC_URL", "https://api.mainnet-beta.solana.com"
)
# Detection parameters
TOP_HOLDERS_LIMIT = 30
FUNDING_TRACE_LIMIT = 50
CO_TRADE_SLOT_WINDOW = 3
MIN_CLUSTER_SIZE = 3
MIN_SOL_FUNDING = 0.001 # SOL
# ── Data Fetching ──────────────────────────────────────────────────
def get_top_holders(token_mint: str, rpc_url: str, limit: int = 20) -> list[dict]:
"""Fetch top token holders via Solana RPC getTokenLargestAccounts.
Args:
token_mint: Token mint address.
rpc_url: Solana RPC endpoint URL.
limit: Maximum holders to return (API max is 20).
Returns:
List of dicts with 'address' (token account) and 'amount' fields.
"""
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "getTokenLargestAccounts",
"params": [token_mint],
}
resp = httpx.post(rpc_url, json=payload, timeout=15)
resp.raise_for_status()
data = resp.json()
if "error" in data:
print(f"RPC error: {data['error']}")
return []
accounts = data.get("result", {}).get("value", [])
return [
{
"address": acc["address"],
"amount": float(acc.get("uiAmount", 0) or 0),
"amount_raw": acc.get("amount", "0"),
}
for acc in accounts[:limit]
]
def get_token_account_owner(token_account: str, rpc_url: str) -> Optional[str]:
"""Resolve a token account address to its owner wallet address.
Args:
token_account: SPL token account address.
rpc_url: Solana RPC endpoint URL.
Returns:
Owner wallet address, or None if lookup fails.
"""
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "getAccountInfo",
"params": [token_account, {"encoding": "jsonParsed"}],
}
resp = httpx.post(rpc_url, json=payload, timeout=15)
if resp.status_code != 200:
return None
data = resp.json()
try:
parsed = data["result"]["value"]["data"]["parsed"]["info"]
return parsed.get("owner")
except (KeyError, TypeError):
return None
def trace_funding_source(
wallet: str, helius_key: str, limit: int = 50
) -> list[dict]:
"""Trace SOL funding sources for a wallet via Helius parsed transactions.
Args:
wallet: Wallet address to trace.
helius_key: Helius API key.
limit: Maximum transactions to scan.
Returns:
List of dicts with 'funder', 'amount_sol', and 'timestamp' fields.
"""
url = f"https://api.helius.xyz/v0/addresses/{wallet}/transactions"
try:
resp = httpx.get(
url,
params={"api-key": helius_key, "type": "TRANSFER", "limit": limit},
timeout=15,
)
if resp.status_code != 200:
return []
except httpx.RequestError:
return []
funders = []
for tx in resp.json():
for transfer in tx.get("nativeTransfers", []):
if (
transfer["toUserAccount"] == wallet
and transfer["amount"] > MIN_SOL_FUNDING * 1e9
):
funders.append({
"funder": transfer["fromUserAccount"],
"amount_sol": transfer["amount"] / 1e9,
"timestamp": tx.get("timestamp", 0),
})
return funders
def get_early_buy_events(
token_mint: str, helius_key: str, limit: int = 100
) -> list[dict]:
"""Fetch early buy events for a token via Helius.
Args:
token_mint: Token mint address.
helius_key: Helius API key.
limit: Maximum transactions to fetch.
Returns:
List of buy event dicts with wallet, slot, amount, tx_sig, is_bundled.
"""
url = f"https://api.helius.xyz/v0/addresses/{token_mint}/transactions"
try:
resp = httpx.get(
url,
params={"api-key": helius_key, "limit": limit},
timeout=15,
)
if resp.status_code != 200:
return []
except httpx.RequestError:
return []
buy_events = []
for tx in resp.json():
token_transfers = [
t for t in tx.get("tokenTransfers", [])
if t.get("mint") == token_mint and t.get("tokenAmount", 0) > 0
]
if not token_transfers:
continue
recipients = set(t["toUserAccount"] for t in token_transfers)
is_bundled = len(recipients) >= 2
for t in token_transfers:
buy_events.append({
"wallet": t["toUserAccount"],
"slot": tx.get("slot", 0),
"amount": t.get("tokenAmount", 0),
"tx_sig": tx.get("signature", ""),
"is_bundled": is_bundled,
"timestamp": tx.get("timestamp", 0),
})
buy_events.sort(key=lambda x: x["slot"])
return buy_events
# ── Analysis Functions ─────────────────────────────────────────────
def cluster_by_funder(
funding_map: dict[str, list[dict]], min_size: int = 3
) -> dict[str, list[str]]:
"""Group wallets by common funding source.
Args:
funding_map: Dict mapping wallet -> list of funder records.
min_size: Minimum cluster size to report.
Returns:
Dict mapping funder address -> list of funded holder wallets.
"""
funder_to_holders: dict[str, list[str]] = defaultdict(list)
for wallet, funders in funding_map.items():
for f in funders:
funder_to_holders[f["funder"]].append(wallet)
# Deduplicate and filter
return {
funder: list(set(wallets))
for funder, wallets in funder_to_holders.items()
if len(set(wallets)) >= min_size
}
def detect_co_trades(
buy_events: list[dict], slot_window: int = 3
) -> list[dict]:
"""Detect co-trading clusters — wallets buying in the same slot window.
Args:
buy_events: Sorted list of buy events with 'wallet' and 'slot'.
slot_window: Maximum slot gap to consider as coordinated.
Returns:
List of cluster dicts with wallets, slot range, and buy count.
"""
if not buy_events:
return []
clusters = []
current = [buy_events[0]]
for event in buy_events[1:]:
if event["slot"] - current[0]["slot"] <= slot_window:
current.append(event)
else:
if len(current) >= 2:
wallets = list(set(e["wallet"] for e in current))
if len(wallets) >= 2:
clusters.append({
"wallets": wallets,
"slot_start": current[0]["slot"],
"slot_end": current[-1]["slot"],
"buy_count": len(current),
"total_amount": sum(e["amount"] for e in current),
})
current = [event]
if len(current) >= 2:
wallets = list(set(e["wallet"] for e in current))
if len(wallets) >= 2:
clusters.append({
"wallets": wallets,
"slot_start": current[0]["slot"],
"slot_end": current[-1]["slot"],
"buy_count": len(current),
"total_amount": sum(e["amount"] for e in current),
})
return clusters
def check_bundle_ratio(early_buys: list[dict], window_slots: int = 5) -> dict:
"""Calculate bundled vs independent buy ratio for early transactions.
Args:
early_buys: List of buy events with 'is_bundled' and 'slot' fields.
window_slots: Number of slots from first buy to consider as "early".
Returns:
Dict with bundle metrics.
"""
if not early_buys:
return {
"total_early_buys": 0,
"bundled_buys": 0,
"bundle_ratio": 0.0,
"bundled_supply_pct": 0.0,
}
first_slot = min(b["slot"] for b in early_buys)
early = [b for b in early_buys if b["slot"] - first_slot <= window_slots]
bundled = [b for b in early if b.get("is_bundled", False)]
total_amount = sum(b["amount"] for b in early) or 1
bundled_amount = sum(b["amount"] for b in bundled)
return {
"total_early_buys": len(early),
"bundled_buys": len(bundled),
"bundle_ratio": len(bundled) / max(len(early), 1),
"bundled_supply_pct": bundled_amount / total_amount,
"bundled_wallets": list(set(b["wallet"] for b in bundled)),
}
def detect_wash_cycles(
transfers: list[dict], holder_set: set[str]
) -> list[tuple[str, str, float, float]]:
"""Find reciprocal transfer patterns suggesting wash trading.
Args:
transfers: List of {"from": str, "to": str, "amount": float}.
holder_set: Set of known holder wallet addresses.
Returns:
List of (wallet_a, wallet_b, vol_a_to_b, vol_b_to_a) tuples.
"""
edges: dict[tuple[str, str], float] = defaultdict(float)
for t in transfers:
if t["from"] in holder_set and t["to"] in holder_set:
edges[(t["from"], t["to"])] += t["amount"]
wash_pairs = []
seen: set[tuple[str, str]] = set()
for (a, b), vol_ab in edges.items():
if (b, a) in seen:
continue
vol_ba = edges.get((b, a), 0)
if vol_ba > 0:
wash_pairs.append((a, b, vol_ab, vol_ba))
seen.add((a, b))
return wash_pairs
def compute_sybil_score(metrics: dict) -> dict:
"""Compute composite sybil risk score from individual metrics.
Args:
metrics: Dict with keys: max_cluster_size, co_trade_pct,
bundle_ratio, unique_funder_ratio, transfer_density,
wash_pairs.
Returns:
Dict with 'score' (0-100), 'risk_level', and 'components'.
"""
weights = {
"funding_cluster": 25,
"co_trade": 20,
"bundle_ratio": 20,
"unique_funder": 15,
"transfer_density": 10,
"wash_trade": 10,
}
scores = {
"funding_cluster": min(metrics.get("max_cluster_size", 0) / 20, 1.0),
"co_trade": min(metrics.get("co_trade_pct", 0) / 0.3, 1.0),
"bundle_ratio": min(metrics.get("bundle_ratio", 0) / 0.5, 1.0),
"unique_funder": 1.0 - min(metrics.get("unique_funder_ratio", 1.0), 1.0),
"transfer_density": min(metrics.get("transfer_density", 0) / 0.3, 1.0),
"wash_trade": min(metrics.get("wash_pairs", 0) / 5, 1.0),
}
composite = sum(scores[k] * weights[k] for k in weights)
if composite < 30:
risk_level = "LOW"
elif composite < 60:
risk_level = "MEDIUM"
else:
risk_level = "HIGH"
return {
"score": round(composite, 1),
"risk_level": risk_level,
"components": {k: round(v, 3) for k, v in scores.items()},
}
# ── Demo Mode ──────────────────────────────────────────────────────
def generate_demo_data() -> dict:
"""Generate synthetic data showing a typical sybil pattern.
Returns:
Dict with holders, funding_map, buy_events, and transfers.
"""
# Simulated sybil scenario: 15 holders, 8 funded from same source
sybil_funder = "SybilFunder1111111111111111111111111111111"
creator = "TokenCreator1111111111111111111111111111111"
# Sybil cluster: 8 wallets funded by same entity
sybil_wallets = [f"SybilWallet{i:04d}1111111111111111111111111111" for i in range(8)]
# Organic wallets: 7 wallets with independent funding
organic_wallets = [f"OrganicWlt{i:04d}1111111111111111111111111111" for i in range(7)]
organic_funders = [f"IndepFunder{i:04d}1111111111111111111111111111" for i in range(7)]
all_wallets = sybil_wallets + organic_wallets
creation_slot = 250_000_000
# Funding map
funding_map: dict[str, list[dict]] = {}
for w in sybil_wallets:
funding_map[w] = [{"funder": sybil_funder, "amount_sol": 0.05, "timestamp": 1700000000}]
for i, w in enumerate(organic_wallets):
funding_map[w] = [{"funder": organic_funders[i], "amount_sol": 0.5 + i * 0.1, "timestamp": 1699000000 + i * 86400}]
# Buy events: sybil wallets buy in first 2 slots, organic spread over 100 slots
buy_events = []
for i, w in enumerate(sybil_wallets):
buy_events.append({
"wallet": w,
"slot": creation_slot + (i % 2), # Slots 0-1
"amount": 1_000_000 + i * 50_000,
"tx_sig": f"sybiltx{i:04d}",
"is_bundled": i < 4, # First 4 are bundled
"timestamp": 1700000100 + i,
})
for i, w in enumerate(organic_wallets):
buy_events.append({
"wallet": w,
"slot": creation_slot + 10 + i * 15, # Spread across many slots
"amount": 200_000 + i * 30_000,
"tx_sig": f"organictx{i:04d}",
"is_bundled": False,
"timestamp": 1700000200 + i * 60,
})
buy_events.sort(key=lambda x: x["slot"])
# Internal transfers among sybil wallets (wash trading)
transfers = []
for i in range(0, len(sybil_wallets) - 1, 2):
transfers.append({"from": sybil_wallets[i], "to": sybil_wallets[i + 1], "amount": 500_000})
transfers.append({"from": sybil_wallets[i + 1], "to": sybil_wallets[i], "amount": 450_000})
return {
"holders": all_wallets,
"funding_map": funding_map,
"buy_events": buy_events,
"transfers": transfers,
"creation_slot": creation_slot,
"token_mint": "DemoMint1111111111111111111111111111111111111",
}
# ── Report Printing ────────────────────────────────────────────────
def print_report(
token_mint: str,
holders: list[str],
clusters: dict[str, list[str]],
co_trade_groups: list[dict],
bundle_stats: dict,
wash_pairs: list[tuple],
score_result: dict,
) -> None:
"""Print formatted sybil detection report.
Args:
token_mint: Token mint address.
holders: List of holder wallet addresses.
clusters: Funding clusters (funder -> wallets).
co_trade_groups: Co-trade timing clusters.
bundle_stats: Bundle ratio metrics.
wash_pairs: Detected wash trading pairs.
score_result: Composite sybil score result.
"""
print("=" * 70)
print("SYBIL DETECTION REPORT")
print("=" * 70)
print(f"Token: {token_mint}")
print(f"Holders analyzed: {len(holders)}")
print()
# Overall score
risk = score_result["risk_level"]
score = score_result["score"]
bar = "#" * int(score) + "-" * (100 - int(score))
print(f" SYBIL RISK: {risk} ({score}/100)")
print(f" [{bar[:50]}] {score}%")
print()
# Component breakdown
print(" Score Components:")
for component, value in score_result["components"].items():
label = component.replace("_", " ").title()
print(f" {label:<22} {value:.3f}")
print()
# Funding clusters
print("-" * 70)
print("FUNDING CLUSTERS")
print("-" * 70)
if clusters:
for funder, wallets in clusters.items():
print(f" Funder: {funder[:16]}...")
print(f" Funded wallets: {len(wallets)}")
for w in wallets[:5]:
print(f" - {w[:16]}...")
if len(wallets) > 5:
print(f" ... and {len(wallets) - 5} more")
print()
else:
print(" No funding clusters detected (min size: 3)")
print()
# Co-trade groups
print("-" * 70)
print("CO-TRADE TIMING CLUSTERS")
print("-" * 70)
if co_trade_groups:
for i, group in enumerate(co_trade_groups):
print(f" Cluster {i + 1}: {group['buy_count']} buys in slots {group['slot_start']}-{group['slot_end']}")
print(f" Wallets: {len(group['wallets'])}")
print(f" Total amount: {group['total_amount']:,.0f}")
for w in group["wallets"][:3]:
print(f" - {w[:16]}...")
if len(group["wallets"]) > 3:
print(f" ... and {len(group['wallets']) - 3} more")
print()
else:
print(" No co-trade clusters detected")
print()
# Bundle analysis
print("-" * 70)
print("BUNDLE ANALYSIS")
print("-" * 70)
print(f" Total early buys: {bundle_stats['total_early_buys']}")
print(f" Bundled buys: {bundle_stats['bundled_buys']}")
print(f" Bundle ratio: {bundle_stats['bundle_ratio']:.2%}")
print(f" Bundled supply %: {bundle_stats['bundled_supply_pct']:.2%}")
bundled_wallets = bundle_stats.get("bundled_wallets", [])
if bundled_wallets:
print(f" Bundled wallets ({len(bundled_wallets)}):")
for w in bundled_wallets[:5]:
print(f" - {w[:16]}...")
print()
# Wash trading
print("-" * 70)
print("WASH TRADING")
print("-" * 70)
if wash_pairs:
print(f" Reciprocal transfer pairs found: {len(wash_pairs)}")
for a, b, vol_ab, vol_ba in wash_pairs[:5]:
print(f" {a[:12]}... <-> {b[:12]}...")
print(f" A->B: {vol_ab:,.0f} B->A: {vol_ba:,.0f}")
if len(wash_pairs) > 5:
print(f" ... and {len(wash_pairs) - 5} more pairs")
else:
print(" No wash trading patterns detected")
print()
print("=" * 70)
print("NOTE: This analysis is informational only. Not financial advice.")
print("=" * 70)
# ── Main ────────────────────────────────────────────────────────────
def run_live(token_mint: str, helius_key: str) -> None:
"""Run live sybil detection against a real token.
Args:
token_mint: Solana token mint address.
helius_key: Helius API key.
"""
print(f"Analyzing token: {token_mint}")
print(f"Fetching top holders...")
# Step 1: Get holders
holder_accounts = get_top_holders(token_mint, SOLANA_RPC_URL)
if not holder_accounts:
print("ERROR: Could not fetch holders. Check token mint address.")
sys.exit(1)
# Resolve token accounts to owner wallets
print(f"Resolving {len(holder_accounts)} token accounts to owner wallets...")
holders = []
for acc in holder_accounts:
owner = get_token_account_owner(acc["address"], SOLANA_RPC_URL)
if owner:
holders.append(owner)
time.sleep(0.1) # Rate limit courtesy
if not holders:
print("ERROR: Could not resolve any holder wallets.")
sys.exit(1)
print(f"Resolved {len(holders)} holder wallets.")
# Step 2: Trace funding sources
print("Tracing funding sources...")
funding_map: dict[str, list[dict]] = {}
for wallet in holders:
funding_map[wallet] = trace_funding_source(wallet, helius_key)
time.sleep(0.2) # Rate limit
# Step 3: Cluster by funder
clusters = cluster_by_funder(funding_map, min_size=MIN_CLUSTER_SIZE)
# Step 4: Get early buy events
print("Fetching early buy events...")
buy_events = get_early_buy_events(token_mint, helius_key)
# Step 5: Detect co-trades
co_trade_groups = detect_co_trades(buy_events, CO_TRADE_SLOT_WINDOW)
# Step 6: Check bundles
bundle_stats = check_bundle_ratio(buy_events)
# Step 7: Check wash trading (using buy events as proxy for transfers)
holder_set = set(holders)
# Approximate: treat sequential buys/sells as transfers
transfers = [
{"from": e["wallet"], "to": holders[(i + 1) % len(holders)], "amount": e["amount"]}
for i, e in enumerate(buy_events)
if e["wallet"] in holder_set
]
wash_pairs = detect_wash_cycles(transfers, holder_set)
# Step 8: Compute metrics and score
all_funders = set()
for funders in funding_map.values():
for f in funders:
all_funders.add(f["funder"])
metrics = {
"max_cluster_size": max((len(w) for w in clusters.values()), default=0),
"co_trade_pct": sum(len(g["wallets"]) for g in co_trade_groups) / max(len(holders), 1),
"bundle_ratio": bundle_stats["bundle_ratio"],
"unique_funder_ratio": len(all_funders) / max(len(holders), 1),
"transfer_density": len(wash_pairs) / max(len(holders), 1),
"wash_pairs": len(wash_pairs),
}
score_result = compute_sybil_score(metrics)
print_report(
token_mint, holders, clusters, co_trade_groups,
bundle_stats, wash_pairs, score_result,
)
def run_demo() -> None:
"""Run sybil detection on synthetic demo data."""
print("Running in DEMO mode with synthetic data...")
print()
demo = generate_demo_data()
holders = demo["holders"]
funding_map = demo["funding_map"]
buy_events = demo["buy_events"]
transfers = demo["transfers"]
# Cluster by funder
clusters = cluster_by_funder(funding_map, min_size=MIN_CLUSTER_SIZE)
# Detect co-trades
co_trade_groups = detect_co_trades(buy_events, CO_TRADE_SLOT_WINDOW)
# Check bundles
bundle_stats = check_bundle_ratio(buy_events)
# Wash trading
holder_set = set(holders)
wash_pairs = detect_wash_cycles(transfers, holder_set)
# Compute metrics
all_funders = set()
for funders in funding_map.values():
for f in funders:
all_funders.add(f["funder"])
metrics = {
"max_cluster_size": max((len(w) for w in clusters.values()), default=0),
"co_trade_pct": sum(len(g["wallets"]) for g in co_trade_groups) / max(len(holders), 1),
"bundle_ratio": bundle_stats["bundle_ratio"],
"unique_funder_ratio": len(all_funders) / max(len(holders), 1),
"transfer_density": len(wash_pairs) / max(len(holders), 1),
"wash_pairs": len(wash_pairs),
}
score_result = compute_sybil_score(metrics)
print_report(
demo["token_mint"], holders, clusters, co_trade_groups,
bundle_stats, wash_pairs, score_result,
)
if __name__ == "__main__":
if "--demo" in sys.argv:
run_demo()
elif TOKEN_MINT and HELIUS_API_KEY:
run_live(TOKEN_MINT, HELIUS_API_KEY)
elif TOKEN_MINT and not HELIUS_API_KEY:
print("HELIUS_API_KEY required for live analysis.")
print("Run with --demo flag for synthetic data demo.")
sys.exit(1)
else:
print("Usage:")
print(" Live: TOKEN_MINT=... HELIUS_API_KEY=... python scripts/detect_sybils.py")
print(" Demo: python scripts/detect_sybils.py --demo")
sys.exit(1)
#!/usr/bin/env python3
"""Funding source tracer for Solana wallets.
Takes a list of wallet addresses and traces back their SOL funding sources
(1-2 hops). Groups wallets by common ancestor funder to identify coordinated
wallet clusters.
Usage:
# Live mode:
WALLET_ADDRESSES=addr1,addr2,addr3 HELIUS_API_KEY=xxx python scripts/funding_tracer.py
# Demo mode:
python scripts/funding_tracer.py --demo
Dependencies:
uv pip install httpx
Environment Variables:
WALLET_ADDRESSES: Comma-separated list of Solana wallet addresses
HELIUS_API_KEY: Helius API key for transaction lookups (optional in demo mode)
SOLANA_RPC_URL: Solana RPC endpoint (default: https://api.mainnet-beta.solana.com)
"""
import os
import sys
import time
from collections import defaultdict
from typing import Optional
try:
import httpx
except ImportError:
print("Missing dependency. Install with: uv pip install httpx")
sys.exit(1)
# ── Configuration ───────────────────────────────────────────────────
WALLET_ADDRESSES = os.getenv("WALLET_ADDRESSES", "")
HELIUS_API_KEY = os.getenv("HELIUS_API_KEY", "")
SOLANA_RPC_URL = os.getenv(
"SOLANA_RPC_URL", "https://api.mainnet-beta.solana.com"
)
# Tracing parameters
MAX_HOPS = 2
MIN_SOL_AMOUNT = 0.001 # Minimum SOL transfer to consider as funding
TX_FETCH_LIMIT = 50
REQUEST_DELAY = 0.2 # Seconds between API calls
# ── Data Types ─────────────────────────────────────────────────────
class FundingRecord:
"""Record of a single funding transfer."""
def __init__(
self,
funder: str,
recipient: str,
amount_sol: float,
timestamp: int,
tx_sig: str = "",
hop: int = 1,
):
self.funder = funder
self.recipient = recipient
self.amount_sol = amount_sol
self.timestamp = timestamp
self.tx_sig = tx_sig
self.hop = hop
def __repr__(self) -> str:
return (
f"FundingRecord(funder={self.funder[:12]}..., "
f"recipient={self.recipient[:12]}..., "
f"amount={self.amount_sol:.4f} SOL, hop={self.hop})"
)
# ── Core Tracing ───────────────────────────────────────────────────
def get_sol_funders(
wallet: str,
helius_key: str,
limit: int = TX_FETCH_LIMIT,
min_amount_sol: float = MIN_SOL_AMOUNT,
) -> list[FundingRecord]:
"""Fetch SOL funding sources for a wallet via Helius parsed transactions.
Args:
wallet: Wallet address to trace.
helius_key: Helius API key.
limit: Maximum transactions to scan.
min_amount_sol: Minimum SOL amount to consider as funding.
Returns:
List of FundingRecord objects for incoming SOL transfers.
"""
url = f"https://api.helius.xyz/v0/addresses/{wallet}/transactions"
try:
resp = httpx.get(
url,
params={"api-key": helius_key, "type": "TRANSFER", "limit": limit},
timeout=15,
)
if resp.status_code != 200:
return []
except httpx.RequestError:
return []
records = []
for tx in resp.json():
for transfer in tx.get("nativeTransfers", []):
if (
transfer["toUserAccount"] == wallet
and transfer["amount"] > min_amount_sol * 1e9
):
records.append(
FundingRecord(
funder=transfer["fromUserAccount"],
recipient=wallet,
amount_sol=transfer["amount"] / 1e9,
timestamp=tx.get("timestamp", 0),
tx_sig=tx.get("signature", ""),
hop=1,
)
)
return records
def trace_funding_chain(
wallet: str,
helius_key: str,
max_hops: int = MAX_HOPS,
delay: float = REQUEST_DELAY,
) -> list[list[FundingRecord]]:
"""Trace the full funding chain for a wallet up to N hops.
Args:
wallet: Starting wallet address.
helius_key: Helius API key.
max_hops: Maximum number of hops to trace back.
delay: Delay between API calls in seconds.
Returns:
List of funding chains. Each chain is a list of FundingRecord
objects from the wallet back to the earliest traced funder.
"""
chains: list[list[FundingRecord]] = []
# Hop 1: direct funders of the wallet
hop1_records = get_sol_funders(wallet, helius_key)
if not hop1_records:
return []
# Take top funders by amount (limit branching)
hop1_records.sort(key=lambda r: r.amount_sol, reverse=True)
hop1_top = hop1_records[:3]
for record in hop1_top:
chain = [record]
if max_hops >= 2:
time.sleep(delay)
hop2_records = get_sol_funders(record.funder, helius_key)
if hop2_records:
hop2_records.sort(key=lambda r: r.amount_sol, reverse=True)
for h2 in hop2_records[:2]:
h2.hop = 2
chains.append(chain + [h2])
else:
chains.append(chain)
else:
chains.append(chain)
return chains
def trace_all_wallets(
wallets: list[str],
helius_key: str,
max_hops: int = MAX_HOPS,
) -> dict[str, list[list[FundingRecord]]]:
"""Trace funding chains for multiple wallets.
Args:
wallets: List of wallet addresses to trace.
helius_key: Helius API key.
max_hops: Maximum hops per wallet.
Returns:
Dict mapping wallet address -> list of funding chains.
"""
results: dict[str, list[list[FundingRecord]]] = {}
for i, wallet in enumerate(wallets):
print(f" Tracing wallet {i + 1}/{len(wallets)}: {wallet[:16]}...")
results[wallet] = trace_funding_chain(wallet, helius_key, max_hops)
time.sleep(REQUEST_DELAY)
return results
# ── Clustering ─────────────────────────────────────────────────────
def cluster_by_common_ancestor(
trace_results: dict[str, list[list[FundingRecord]]],
min_cluster_size: int = 2,
) -> dict[str, list[dict]]:
"""Group wallets by their common funding ancestors.
Checks both hop-1 (direct funder) and hop-2 (funder's funder) for
common addresses.
Args:
trace_results: Output from trace_all_wallets.
min_cluster_size: Minimum wallets sharing a funder to report.
Returns:
Dict mapping ancestor address -> list of funded wallet info dicts.
"""
ancestor_map: dict[str, list[dict]] = defaultdict(list)
for wallet, chains in trace_results.items():
seen_ancestors: set[str] = set()
for chain in chains:
for record in chain:
ancestor = record.funder
if ancestor not in seen_ancestors:
seen_ancestors.add(ancestor)
ancestor_map[ancestor].append({
"wallet": wallet,
"hop": record.hop,
"amount_sol": record.amount_sol,
"timestamp": record.timestamp,
})
# Filter to clusters meeting minimum size
return {
ancestor: entries
for ancestor, entries in ancestor_map.items()
if len(entries) >= min_cluster_size
}
def compute_cluster_stats(
clusters: dict[str, list[dict]],
) -> list[dict]:
"""Compute statistics for each funding cluster.
Args:
clusters: Output from cluster_by_common_ancestor.
Returns:
List of cluster stat dicts, sorted by size descending.
"""
stats = []
for ancestor, entries in clusters.items():
amounts = [e["amount_sol"] for e in entries]
timestamps = [e["timestamp"] for e in entries if e["timestamp"] > 0]
time_spread = 0
if len(timestamps) >= 2:
time_spread = max(timestamps) - min(timestamps)
stats.append({
"ancestor": ancestor,
"cluster_size": len(entries),
"wallets": [e["wallet"] for e in entries],
"hops": [e["hop"] for e in entries],
"total_funded_sol": sum(amounts),
"avg_funded_sol": sum(amounts) / len(amounts),
"min_funded_sol": min(amounts),
"max_funded_sol": max(amounts),
"funding_time_spread_sec": time_spread,
"all_same_amount": len(set(round(a, 4) for a in amounts)) == 1,
})
stats.sort(key=lambda s: s["cluster_size"], reverse=True)
return stats
# ── Demo Mode ──────────────────────────────────────────────────────
def generate_demo_data() -> dict[str, list[list[FundingRecord]]]:
"""Generate synthetic funding trace data.
Simulates a scenario with:
- 5 wallets funded by the same sybil operator (2 hops)
- 3 wallets with independent funding
- 2 wallets funded by same CEX withdrawal address (benign cluster)
Returns:
Trace results dict matching trace_all_wallets output format.
"""
results: dict[str, list[list[FundingRecord]]] = {}
sybil_master = "SybilMaster11111111111111111111111111111111"
intermediaries = [f"Intermediary{i:02d}111111111111111111111111111111" for i in range(5)]
sybil_wallets = [f"SybilHolder{i:02d}1111111111111111111111111111111" for i in range(5)]
# Sybil cluster: all trace back to same master via intermediary
for i, wallet in enumerate(sybil_wallets):
hop1 = FundingRecord(
funder=intermediaries[i],
recipient=wallet,
amount_sol=0.05,
timestamp=1700000000 + i * 60,
tx_sig=f"sybiltx1_{i:02d}",
hop=1,
)
hop2 = FundingRecord(
funder=sybil_master,
recipient=intermediaries[i],
amount_sol=0.1,
timestamp=1700000000 + i * 60 - 300,
tx_sig=f"sybiltx2_{i:02d}",
hop=2,
)
results[wallet] = [[hop1, hop2]]
# Independent wallets
indie_funders = [f"IndieFunder{i:02d}11111111111111111111111111111111" for i in range(3)]
indie_wallets = [f"IndieHolder{i:02d}11111111111111111111111111111111" for i in range(3)]
for i, wallet in enumerate(indie_wallets):
hop1 = FundingRecord(
funder=indie_funders[i],
recipient=wallet,
amount_sol=0.5 + i * 0.3,
timestamp=1699500000 + i * 86400,
tx_sig=f"indietx_{i:02d}",
hop=1,
)
results[wallet] = [[hop1]]
# CEX cluster (benign): 2 wallets funded by same Binance hot wallet
cex_wallet = "BinanceHotWallet111111111111111111111111111"
cex_funded = [f"CexFundedWlt{i:02d}1111111111111111111111111111" for i in range(2)]
for i, wallet in enumerate(cex_funded):
hop1 = FundingRecord(
funder=cex_wallet,
recipient=wallet,
amount_sol=2.0 + i * 0.5,
timestamp=1699000000 + i * 7200,
tx_sig=f"cextx_{i:02d}",
hop=1,
)
results[wallet] = [[hop1]]
return results
# ── Report Printing ────────────────────────────────────────────────
def print_funding_report(
trace_results: dict[str, list[list[FundingRecord]]],
cluster_stats: list[dict],
) -> None:
"""Print formatted funding trace and cluster report.
Args:
trace_results: Raw trace results from trace_all_wallets.
cluster_stats: Computed cluster statistics.
"""
print("=" * 70)
print("FUNDING SOURCE TRACE REPORT")
print("=" * 70)
print(f"Wallets analyzed: {len(trace_results)}")
print()
# Individual wallet traces
print("-" * 70)
print("INDIVIDUAL WALLET TRACES")
print("-" * 70)
for wallet, chains in trace_results.items():
print(f"\n Wallet: {wallet[:20]}...")
if not chains:
print(" No funding sources found")
continue
for j, chain in enumerate(chains):
path_parts = [wallet[:12] + "..."]
for record in chain:
path_parts.append(
f"{record.funder[:12]}... ({record.amount_sol:.4f} SOL, hop {record.hop})"
)
path_str = " <- ".join(path_parts)
print(f" Chain {j + 1}: {path_str}")
print()
# Cluster analysis
print("-" * 70)
print("FUNDING CLUSTERS")
print("-" * 70)
if not cluster_stats:
print(" No clusters detected (all wallets have independent funding)")
else:
for i, stat in enumerate(cluster_stats):
suspicion = "HIGH" if stat["cluster_size"] >= 3 else "MODERATE"
if stat["all_same_amount"]:
suspicion = "VERY HIGH (identical amounts)"
print(f"\n Cluster {i + 1}:")
print(f" Common ancestor: {stat['ancestor'][:20]}...")
print(f" Cluster size: {stat['cluster_size']} wallets")
print(f" Suspicion level: {suspicion}")
print(f" Total funded: {stat['total_funded_sol']:.4f} SOL")
print(f" Avg per wallet: {stat['avg_funded_sol']:.4f} SOL")
print(f" Amount range: {stat['min_funded_sol']:.4f} - {stat['max_funded_sol']:.4f} SOL")
print(f" Same amounts: {'Yes' if stat['all_same_amount'] else 'No'}")
if stat["funding_time_spread_sec"] > 0:
hours = stat["funding_time_spread_sec"] / 3600
print(f" Funding time spread: {hours:.1f} hours")
print(f" Hops from wallets: {stat['hops']}")
print(f" Wallets:")
for w in stat["wallets"][:5]:
print(f" - {w[:20]}...")
if len(stat["wallets"]) > 5:
print(f" ... and {len(stat['wallets']) - 5} more")
print()
# Summary
print("-" * 70)
print("SUMMARY")
print("-" * 70)
total_wallets = len(trace_results)
clustered_wallets = set()
for stat in cluster_stats:
clustered_wallets.update(stat["wallets"])
independent = total_wallets - len(clustered_wallets)
print(f" Total wallets: {total_wallets}")
print(f" In clusters: {len(clustered_wallets)}")
print(f" Independent: {independent}")
print(f" Unique funder ratio: {independent / max(total_wallets, 1):.2%}")
print(f" Clusters found: {len(cluster_stats)}")
if cluster_stats:
print(f" Largest cluster: {cluster_stats[0]['cluster_size']} wallets")
print()
print("=" * 70)
print("NOTE: This analysis is informational only. Not financial advice.")
print("=" * 70)
# ── Main ────────────────────────────────────────────────────────────
def run_live(wallets: list[str], helius_key: str) -> None:
"""Run live funding trace against real wallets.
Args:
wallets: List of wallet addresses to trace.
helius_key: Helius API key.
"""
print(f"Tracing funding sources for {len(wallets)} wallets...")
print(f"Max hops: {MAX_HOPS}")
print()
trace_results = trace_all_wallets(wallets, helius_key, MAX_HOPS)
clusters = cluster_by_common_ancestor(trace_results, min_cluster_size=2)
stats = compute_cluster_stats(clusters)
print_funding_report(trace_results, stats)
def run_demo() -> None:
"""Run funding trace on synthetic demo data."""
print("Running in DEMO mode with synthetic data...")
print()
trace_results = generate_demo_data()
clusters = cluster_by_common_ancestor(trace_results, min_cluster_size=2)
stats = compute_cluster_stats(clusters)
print_funding_report(trace_results, stats)
if __name__ == "__main__":
if "--demo" in sys.argv:
run_demo()
elif WALLET_ADDRESSES and HELIUS_API_KEY:
wallet_list = [w.strip() for w in WALLET_ADDRESSES.split(",") if w.strip()]
if len(wallet_list) < 2:
print("Provide at least 2 wallet addresses (comma-separated).")
sys.exit(1)
run_live(wallet_list, HELIUS_API_KEY)
elif WALLET_ADDRESSES and not HELIUS_API_KEY:
print("HELIUS_API_KEY required for live analysis.")
print("Run with --demo flag for synthetic data demo.")
sys.exit(1)
else:
print("Usage:")
print(" Live: WALLET_ADDRESSES=a,b,c HELIUS_API_KEY=... python scripts/funding_tracer.py")
print(" Demo: python scripts/funding_tracer.py --demo")
sys.exit(1)
Related skills
FAQ
How does funding-source analysis find clusters?
It traces each holder wallet back one to two hops to find who sent it SOL; three or more holders funded from the same source, especially within 24h of creation or in identical amounts, indicate a cluster.
What signals wash trading?
Circular transfer flows between self-controlled wallets, reciprocal buy/sell pairs with no net position change, and volume concentrated in funding-linked wallet pairs.