crypttrace 0.12.0__tar.gz → 0.13.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. {crypttrace-0.12.0/src/crypttrace.egg-info → crypttrace-0.13.0}/PKG-INFO +12 -3
  2. {crypttrace-0.12.0 → crypttrace-0.13.0}/README.md +11 -2
  3. {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/__init__.py +1 -1
  4. {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/chains.py +7 -3
  5. {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/cli.py +6 -2
  6. {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/fetchers/ton.py +28 -16
  7. {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/fetchers/tron.py +1 -1
  8. {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/labels/known.json +4 -0
  9. {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/prices.py +12 -2
  10. {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/render.py +25 -8
  11. crypttrace-0.13.0/src/crypttrace/spam.py +72 -0
  12. {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/store.py +11 -0
  13. {crypttrace-0.12.0 → crypttrace-0.13.0/src/crypttrace.egg-info}/PKG-INFO +12 -3
  14. {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace.egg-info/SOURCES.txt +1 -0
  15. {crypttrace-0.12.0 → crypttrace-0.13.0}/LICENSE +0 -0
  16. {crypttrace-0.12.0 → crypttrace-0.13.0}/pyproject.toml +0 -0
  17. {crypttrace-0.12.0 → crypttrace-0.13.0}/setup.cfg +0 -0
  18. {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/addresses.py +0 -0
  19. {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/analysis.py +0 -0
  20. {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/assess.py +0 -0
  21. {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/assets.py +0 -0
  22. {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/bridges.py +0 -0
  23. {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/config.py +0 -0
  24. {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/fetchers/__init__.py +0 -0
  25. {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/fetchers/bitcoin.py +0 -0
  26. {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/fetchers/etherscan.py +0 -0
  27. {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/fetchers/http.py +0 -0
  28. {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/fetchers/solana.py +0 -0
  29. {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/freeze.py +0 -0
  30. {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/funder.py +0 -0
  31. {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/investigate.py +0 -0
  32. {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/labels/__init__.py +0 -0
  33. {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/labels/audit.py +0 -0
  34. {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/labels/bulk.py +0 -0
  35. {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/labels/labels.py +0 -0
  36. {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/labels/partial.json +0 -0
  37. {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/offramp.py +0 -0
  38. {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/poisoning.py +0 -0
  39. {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/report.py +0 -0
  40. {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/trace.py +0 -0
  41. {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/verify.py +0 -0
  42. {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/watch.py +0 -0
  43. {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/web/index.html +0 -0
  44. {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/webapp.py +0 -0
  45. {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace.egg-info/dependency_links.txt +0 -0
  46. {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace.egg-info/entry_points.txt +0 -0
  47. {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace.egg-info/requires.txt +0 -0
  48. {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crypttrace
3
- Version: 0.12.0
3
+ Version: 0.13.0
4
4
  Summary: OSINT toolkit for crypto investigations: trace stolen funds across Ethereum, Bitcoin, Tron, Solana and TON
5
5
  Author: bobslayerX
6
6
  License-Expression: MIT
@@ -400,7 +400,9 @@ crypttrace timeline bc1qADDRESS --chain btc --buckets 24
400
400
  ![The victim list for one branch of the Coldcard sweep in the web UI](https://raw.githubusercontent.com/bobslayerX/crypttrace/main/assets/web-victims.jpg)
401
401
 
402
402
  A worked example — 1,169 swept addresses, 1,082.58 BTC, with the commands to
403
- reproduce it — is in [`cases/coldcard-2026`](cases/coldcard-2026).
403
+ reproduce it — is in [`cases/coldcard-2026`](cases/coldcard-2026). A TON one — 200
404
+ wallets emptied after a seed leak, followed to a KuCoin deposit memo — is in
405
+ [`cases/fragment-api-ton-2026`](cases/fragment-api-ton-2026).
404
406
 
405
407
  **Dust is filtered by default.** Addresses that become publicly known get spammed
406
408
  with tiny transfers, which otherwise bury the transactions that matter and can
@@ -455,6 +457,14 @@ with USDT would be meaningless.
455
457
 
456
458
  Amounts carry approximate USD values (stablecoins pinned to $1, others priced
457
459
  via CoinGecko). If pricing is unavailable, USD shows as `—` rather than a guess.
460
+ Only the real stablecoin contract gets the $1 — a token anyone minted and called
461
+ "USDT" is worth nothing.
462
+
463
+ `tokens` hides spam and counterfeit tokens and leaves them out of the total: a
464
+ "USDT" or "TON" that is not the real contract, a name that is a phishing link
465
+ ("GRAM AT GRAMEVENT.ORG", "USDT - usdtunloсk.соm" with Cyrillic letters in the
466
+ domain), and on TON anything the indexer flags as a scam. `--all` lists them
467
+ with the reason.
458
468
 
459
469
  ### Labels
460
470
 
@@ -602,7 +612,6 @@ cases/ # worked investigations with their data
602
612
  and refreshing these lists as the exchanges republish them
603
613
  - Internal transactions (completes `funder` and contract-mediated transfers)
604
614
  - More label sources: Chainabuse, CryptoScamDB, exchange deposit-address sets
605
- - Spam/dust token filtering in `tokens`
606
615
  - Entity clustering on EVM via the gas-funding heuristic
607
616
 
608
617
  ## Contributing
@@ -366,7 +366,9 @@ crypttrace timeline bc1qADDRESS --chain btc --buckets 24
366
366
  ![The victim list for one branch of the Coldcard sweep in the web UI](https://raw.githubusercontent.com/bobslayerX/crypttrace/main/assets/web-victims.jpg)
367
367
 
368
368
  A worked example — 1,169 swept addresses, 1,082.58 BTC, with the commands to
369
- reproduce it — is in [`cases/coldcard-2026`](cases/coldcard-2026).
369
+ reproduce it — is in [`cases/coldcard-2026`](cases/coldcard-2026). A TON one — 200
370
+ wallets emptied after a seed leak, followed to a KuCoin deposit memo — is in
371
+ [`cases/fragment-api-ton-2026`](cases/fragment-api-ton-2026).
370
372
 
371
373
  **Dust is filtered by default.** Addresses that become publicly known get spammed
372
374
  with tiny transfers, which otherwise bury the transactions that matter and can
@@ -421,6 +423,14 @@ with USDT would be meaningless.
421
423
 
422
424
  Amounts carry approximate USD values (stablecoins pinned to $1, others priced
423
425
  via CoinGecko). If pricing is unavailable, USD shows as `—` rather than a guess.
426
+ Only the real stablecoin contract gets the $1 — a token anyone minted and called
427
+ "USDT" is worth nothing.
428
+
429
+ `tokens` hides spam and counterfeit tokens and leaves them out of the total: a
430
+ "USDT" or "TON" that is not the real contract, a name that is a phishing link
431
+ ("GRAM AT GRAMEVENT.ORG", "USDT - usdtunloсk.соm" with Cyrillic letters in the
432
+ domain), and on TON anything the indexer flags as a scam. `--all` lists them
433
+ with the reason.
424
434
 
425
435
  ### Labels
426
436
 
@@ -568,7 +578,6 @@ cases/ # worked investigations with their data
568
578
  and refreshing these lists as the exchanges republish them
569
579
  - Internal transactions (completes `funder` and contract-mediated transfers)
570
580
  - More label sources: Chainabuse, CryptoScamDB, exchange deposit-address sets
571
- - Spam/dust token filtering in `tokens`
572
581
  - Entity clustering on EVM via the gas-funding heuristic
573
582
 
574
583
  ## Contributing
@@ -1,2 +1,2 @@
1
1
  """crypttrace — OSINT crypto investigation CLI."""
2
- __version__ = "0.12.0"
2
+ __version__ = "0.13.0"
@@ -14,6 +14,7 @@ from typing import List, Dict, Optional
14
14
 
15
15
  from crypttrace import config
16
16
  from crypttrace import addresses as _addresses
17
+ from crypttrace import spam as _spam
17
18
  from crypttrace.fetchers import etherscan, bitcoin, http as _http, tron, solana, ton
18
19
 
19
20
  EVM_CHAINS = set(config.CHAINS)
@@ -127,7 +128,7 @@ def _evm_token(address: str, chain: str, contract: Optional[str], limit: int) ->
127
128
  rows.append({"from": t.get("from", "").lower(), "to": t.get("to", "").lower(),
128
129
  "value": val, "timestamp": int(t.get("timeStamp", "0") or 0),
129
130
  "hash": t.get("hash", ""), "symbol": t.get("tokenSymbol", "?"),
130
- "contract": c})
131
+ "name": t.get("tokenName", ""), "contract": c})
131
132
  return rows
132
133
 
133
134
 
@@ -278,8 +279,11 @@ def token_holdings(address: str, chain: str = "eth", limit: int = 1000) -> List[
278
279
  agg: Dict[str, dict] = {}
279
280
  for r in rows:
280
281
  key = r.get("contract") or r.get("symbol", "?")
281
- rec = agg.setdefault(key, {"symbol": r.get("symbol", "?"), "contract": key,
282
- "net": 0.0, "txs": 0})
282
+ rec = agg.setdefault(key, {"symbol": r.get("symbol", "?"), "name": r.get("name", ""),
283
+ "contract": key, "net": 0.0, "txs": 0,
284
+ "spam": _spam.verdict(chain, r.get("contract") or "",
285
+ r.get("symbol", ""), r.get("name", ""),
286
+ bool(r.get("flagged")))})
283
287
  if r.get("to") == me:
284
288
  rec["net"] += r.get("value", 0.0)
285
289
  if r.get("from") == me:
@@ -199,8 +199,12 @@ def trace(
199
199
  def tokens(
200
200
  address: str = typer.Argument(..., help="Address to inspect (0x…)"),
201
201
  chain: str = CHAIN_OPT,
202
+ show_all: bool = typer.Option(False, "--all", help="Also list spam and counterfeit tokens"),
202
203
  ):
203
- """Show an address's token holdings (approx from transfer history) with USD."""
204
+ """Show an address's token holdings (approx from transfer history) with USD.
205
+
206
+ Spam and counterfeit tokens — a fake "USDT", a name that is a phishing link —
207
+ are hidden and never priced; --all lists them with the reason."""
204
208
  try:
205
209
  holdings = chains_mod.token_holdings(address, chain)
206
210
  except (chains_mod.ChainError, etherscan.EtherscanError) as e:
@@ -209,7 +213,7 @@ def tokens(
209
213
  if not holdings:
210
214
  console.print("[dim]No token transfers found for this address on this chain.[/dim]")
211
215
  return
212
- console.print(render.holdings_table(address, chain, holdings))
216
+ console.print(render.holdings_table(address, chain, holdings, show_all))
213
217
 
214
218
 
215
219
  @app.command()
@@ -28,10 +28,10 @@ TX_PAGE, JETTON_PAGE = 500, 1000
28
28
  _KEY = os.environ.get("TONCENTER_API_KEY")
29
29
  http.MIN_INTERVAL["toncenter.com"] = 0.12 if _KEY else 1.1
30
30
 
31
- # jetton master (raw, lower-case) -> decimals, starting with the ones we know
32
- _DECIMALS: Dict[str, int] = {t["contract"]: t.get("decimals", 9)
33
- for t in assets.tokens_for("ton").values()}
34
- _SYMBOLS: Dict[str, str] = {t["contract"]: t["symbol"] for t in assets.tokens_for("ton").values()}
31
+ # jetton master (raw, lower-case) -> what the token calls itself, starting with the ones we know
32
+ _META: Dict[str, dict] = {t["contract"]: {"decimals": t.get("decimals", 9), "symbol": t["symbol"],
33
+ "name": "", "scam": False}
34
+ for t in assets.tokens_for("ton").values()}
35
35
 
36
36
 
37
37
  class TonError(RuntimeError):
@@ -98,21 +98,32 @@ def transfers(address: str, limit: int = 1000) -> List[Dict]:
98
98
  src, dst = _addr(src), _addr(dst)
99
99
  if me not in (src, dst):
100
100
  continue
101
+ # the sender's and the receiver's transactions have different hashes;
102
+ # the message hash is the same on both sides (the store keys on it)
101
103
  rows.append({"from": src, "to": dst, "value": value, "timestamp": ts,
102
- "hash": h, "symbol": "TON"})
104
+ "hash": h, "symbol": "TON", "msg_hash": _hex(m.get("hash") or "")})
103
105
  return rows
104
106
 
105
107
 
106
- def _decimals(master: str) -> Optional[int]:
107
- if master not in _DECIMALS:
108
+ def _meta(master: str) -> Optional[dict]:
109
+ """Decimals, symbol, name and the indexer's scam flag for a jetton master."""
110
+ if master not in _META:
108
111
  try:
109
- m = (_get("/jetton/masters", {"address": master, "limit": 1}).get("jetton_masters")
110
- or [{}])[0]
111
- content = m.get("jetton_content") or {}
112
- _DECIMALS[master] = int(content.get("decimals") or 9) # TEP-64 default is 9
112
+ d = _get("/jetton/masters", {"address": master, "limit": 1})
113
+ content = ((d.get("jetton_masters") or [{}])[0].get("jetton_content")) or {}
114
+ # off-chain metadata (a URI) is resolved by the indexer into "metadata"
115
+ info = next((i for m in (d.get("metadata") or {}).values()
116
+ for i in (m.get("token_info") or []) if i.get("type") == "jetton_masters"), {})
117
+ _META[master] = {
118
+ "decimals": int(content.get("decimals") or (info.get("extra") or {}).get("decimals")
119
+ or 9), # TEP-64 default is 9
120
+ "symbol": info.get("symbol") or content.get("symbol") or "JETTON",
121
+ "name": info.get("name") or content.get("name") or "",
122
+ "scam": bool(info.get("is_scam")),
123
+ }
113
124
  except (TonError, ValueError, TypeError):
114
125
  return None
115
- return _DECIMALS[master]
126
+ return _META[master]
116
127
 
117
128
 
118
129
  def jetton_transfers(address: str, limit: int = 1000) -> List[Dict]:
@@ -122,15 +133,16 @@ def jetton_transfers(address: str, limit: int = 1000) -> List[Dict]:
122
133
  if t.get("aborted"):
123
134
  continue
124
135
  master = (t.get("jetton_master") or "").lower()
125
- dec = _decimals(master)
126
- if dec is None:
136
+ meta = _meta(master)
137
+ if meta is None:
127
138
  continue # unknown unit: don't guess
128
139
  try:
129
- value = int(t.get("amount") or 0) / (10 ** dec)
140
+ value = int(t.get("amount") or 0) / (10 ** meta["decimals"])
130
141
  except (ValueError, TypeError):
131
142
  continue
132
143
  rows.append({"from": _addr(t.get("source")), "to": _addr(t.get("destination")),
133
144
  "value": value, "timestamp": int(t.get("transaction_now") or 0),
134
145
  "hash": _hex(t.get("transaction_hash", "")),
135
- "symbol": _SYMBOLS.get(master, "JETTON"), "contract": master})
146
+ "symbol": meta["symbol"], "name": meta["name"], "contract": master,
147
+ "flagged": meta["scam"]})
136
148
  return rows
@@ -142,7 +142,7 @@ def token_transfers(address: str, limit: int = 200) -> List[Dict]:
142
142
  rows.append({"from": t.get("from", ""), "to": t.get("to", ""), "value": val,
143
143
  "timestamp": int((t.get("block_timestamp") or 0) / 1000),
144
144
  "hash": t.get("transaction_id", ""),
145
- "symbol": info.get("symbol", "TRC20"),
145
+ "symbol": info.get("symbol", "TRC20"), "name": info.get("name", ""),
146
146
  "contract": (info.get("address") or "").lower()})
147
147
  return rows
148
148
 
@@ -35,6 +35,10 @@
35
35
  "bc1qdaarag7729c2n4l2wnyt3hkhfpcs66n98z7uuh": {"name": "Coldcard Exploiter (Jul 2026) - collector C1", "type": "scam", "chain": "btc", "source": "own tracing: 100 inbound sweeps totalling 88.850 BTC at 01:10 UTC, forwarded to the 89 BTC pile", "source_kind": "research", "added": "2026-08-09"},
36
36
  "bc1qh0l7q0mca3ln7wsl9luwns0jc9jhgrtft025l4": {"name": "Coldcard Exploiter (Jul 2026) - collector C2", "type": "scam", "chain": "btc", "source": "own tracing: 78 inbound sweeps totalling 0.769 BTC at 01:10 UTC; totals reconcile with chain", "source_kind": "research", "added": "2026-08-09"},
37
37
 
38
+ "UQDiqkA78AG6tqpWPP3WeQs4EvvOIXZyXAg4JZwjNq9Tqwnm": {"name": "fragment-api seed leak drainer (Sep 2026) - collector", "type": "scam", "chain": "ton", "source": "own tracing (cases/fragment-api-ton-2026): swept 200 wallets from 2026-09-29 18:49 UTC, 10,080 TON + 29,494 USDT", "source_kind": "research", "added": "2026-09-30"},
39
+ "UQCuquK3ZOsWuGCh21eFC_SrmB6pezbtKP_Mm2kJDactC5Wb": {"name": "fragment-api seed leak drainer (Sep 2026) - second wallet", "type": "scam", "chain": "ton", "source": "own tracing (cases/fragment-api-ton-2026): received the collector's remaining TON, jettons and 15 stolen NFTs on 2026-09-30 16:34 UTC", "source_kind": "research", "added": "2026-09-30"},
40
+ "UQCA1BI4QRZ8qYmskSRDzJmkucGodYRTZCf_b9hckjla6Yug": {"name": "KuCoin hot wallet (memo required)", "type": "exchange", "chain": "ton", "source": "tonapi account name 'Kucoin 1', memo_required, fetched 2026-09-30", "source_kind": "explorer-tag", "added": "2026-09-30"},
41
+
38
42
  "1DEuKerATHWRfyB14s6QCt4LPkfCTx4zaW": {"name": "Binance reserve wallet", "type": "exchange", "chain": "btc", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-09-23; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-09-23"},
39
43
  "1PJiGp2yDLvUgqeBsuZVCBADArNsk6XEiw": {"name": "Binance reserve wallet", "type": "exchange", "chain": "btc", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-09-23; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-09-23"},
40
44
  "1PPWMe42cywnM61nVatkmiggwdRQYLiCyS": {"name": "Binance reserve wallet", "type": "exchange", "chain": "btc", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-09-23; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-09-23"},
@@ -8,6 +8,8 @@ from typing import Optional, Dict
8
8
 
9
9
  import requests
10
10
 
11
+ from crypttrace import assets
12
+
11
13
  # symbols treated as ~$1
12
14
  _STABLE = {"usdt", "usdc", "dai", "busd", "tusd", "usdp", "gusd", "frax", "lusd"}
13
15
 
@@ -54,9 +56,17 @@ def native_price(chain: str = "eth") -> Optional[float]:
54
56
 
55
57
 
56
58
  def token_price(contract: str, chain: str = "eth", symbol: str = "") -> Optional[float]:
57
- """USD price of an ERC-20 token by contract. Stablecoins short-circuit to $1."""
59
+ """USD price of a token by contract. Stablecoins short-circuit to $1 — but only
60
+ the real contract where the registry knows it: a "USDT" anyone can mint is not
61
+ worth a dollar."""
58
62
  if symbol.lower() in _STABLE:
59
- return 1.0
63
+ real = {t["symbol"].lower(): t["contract"].lower()
64
+ for t in assets.tokens_for(chain).values() if t.get("stable")}
65
+ if not assets.tokens_for(chain):
66
+ return 1.0 # no registry for this chain: trust the symbol
67
+ if (contract or "").lower() == real.get(symbol.lower()):
68
+ return 1.0
69
+ return None
60
70
  key = f"{chain}:{contract.lower()}"
61
71
  if key in _cache:
62
72
  return _cache[key]
@@ -1,5 +1,6 @@
1
1
  """Terminal rendering helpers (rich)."""
2
2
  from datetime import datetime, timezone
3
+ from rich.markup import escape
3
4
  from rich.table import Table
4
5
  from rich.tree import Tree
5
6
  from rich.text import Text
@@ -223,20 +224,36 @@ def funding_tree(address: str, hops: list, symbol: str = "ETH") -> Tree:
223
224
  return root
224
225
 
225
226
 
226
- def holdings_table(address: str, chain: str, holdings: list) -> Table:
227
- """Token holdings with per-token and total USD value."""
228
- t = Table(title=f"Token holdings — {address} ({chain})", header_style="bold")
227
+ def holdings_table(address: str, chain: str, holdings: list, show_all: bool = False) -> Table:
228
+ """Token holdings with per-token and total USD value.
229
+
230
+ Spam and counterfeit tokens (see spam.py) are left out unless `show_all`;
231
+ they never count towards the total either way.
232
+ """
233
+ hidden = [h for h in holdings if h.get("spam")]
234
+ shown = holdings if show_all else [h for h in holdings if not h.get("spam")]
235
+ t = Table(title=f"Token holdings — {address} ({chain})", header_style="bold",
236
+ caption=(f"{len(hidden)} spam or counterfeit token(s) hidden — --all shows them"
237
+ if hidden and not show_all else None))
229
238
  t.add_column("Token")
230
239
  t.add_column("Amount", justify="right")
231
240
  t.add_column("USD", justify="right")
232
241
  t.add_column("Txs", justify="right")
242
+ if show_all:
243
+ t.add_column("Note")
233
244
  total = 0.0
234
- for h in holdings:
235
- price = prices.token_price(h["contract"], chain, h["symbol"])
236
- usd = prices.usd(h["net"], price)
245
+ for h in shown:
246
+ if h.get("spam"):
247
+ usd = None
248
+ else:
249
+ usd = prices.usd(h["net"], prices.token_price(h["contract"], chain, h["symbol"]))
237
250
  if usd is not None:
238
251
  total += usd
239
- t.add_row(h["symbol"], f"{h['net']:.4f}", prices.fmt_usd(usd), str(h["txs"]))
252
+ cells = [escape(h["symbol"]), f"{h['net']:.4f}", prices.fmt_usd(usd), str(h["txs"])]
253
+ if show_all:
254
+ cells.append(f"[red]{escape(h['spam'])}[/red]" if h.get("spam") else "")
255
+ t.add_row(*cells)
240
256
  t.add_section()
241
- t.add_row("[bold]Total[/bold]", "", f"[bold]{prices.fmt_usd(total)}[/bold]", "")
257
+ t.add_row("[bold]Total[/bold]", "", f"[bold]{prices.fmt_usd(total)}[/bold]", "",
258
+ *([""] if show_all else []))
242
259
  return t
@@ -0,0 +1,72 @@
1
+ """Spam and counterfeit tokens — what `tokens` hides by default.
2
+
3
+ Anyone can mint a token and send it to any address, so a wallet's token list
4
+ fills up with two kinds of junk:
5
+
6
+ - counterfeits: a token called "USDT" or "TON" that is not the real contract,
7
+ sent so the balance looks bigger or to bait a victim into a fake swap;
8
+ - link bait: the name *is* the advert — "GRAM AT GRAMEVENT.ORG",
9
+ "USDT - usdtunlock.com" — pointing at a phishing site.
10
+
11
+ Neither is worth money, and pricing them as the real thing would put a fake
12
+ million dollars into a case file. A token is judged by its contract against the
13
+ registry in assets.py, and by its name after undoing look-alike letters
14
+ (Cyrillic "с" for Latin "c" is how "usdtunloсk.соm" dodges a naive filter).
15
+ """
16
+ import re
17
+ from typing import Optional
18
+
19
+ from crypttrace import assets
20
+
21
+ # Cyrillic and Greek letters that look like Latin ones, and ₮ for T
22
+ _LOOKALIKE = str.maketrans({
23
+ "а": "a", "в": "b", "е": "e", "к": "k", "м": "m", "н": "h", "о": "o", "р": "p",
24
+ "с": "c", "т": "t", "у": "y", "х": "x", "і": "i", "ј": "j", "ѕ": "s",
25
+ "А": "A", "В": "B", "Е": "E", "К": "K", "М": "M", "Н": "H", "О": "O", "Р": "P",
26
+ "С": "C", "Т": "T", "У": "Y", "Х": "X", "І": "I", "Ј": "J", "Ѕ": "S",
27
+ "ο": "o", "Ο": "O", "α": "a", "Α": "A", "ε": "e", "Ε": "E", "ν": "v", "Τ": "T",
28
+ "₮": "T",
29
+ })
30
+
31
+ _URL = re.compile(
32
+ r"(https?://|www\.|t\.me/|\b[a-z0-9-]{2,}\.(com|org|net|io|xyz|click|app|site|top|info|"
33
+ r"cc|me|gg|pro|live|vip|fun|online|link|claim|finance|exchange|network|tech|store|"
34
+ r"shop|club|world|space|website|biz|us|co)\b)", re.I)
35
+ _HANDLE = re.compile(r"(^|\s)@[a-z0-9_]{4,}", re.I)
36
+ _BAIT = re.compile(r"\b(claim|unlock|airdrop|reward|voucher|visit|bonus|giveaway)\b", re.I)
37
+
38
+ # a token named after the chain's own coin is never that coin
39
+ _NATIVE = {"eth": "ETH", "tron": "TRX", "ton": "TON"}
40
+
41
+
42
+ def plain(text: str) -> str:
43
+ """Text with look-alike letters replaced by the Latin ones they imitate."""
44
+ return (text or "").translate(_LOOKALIKE)
45
+
46
+
47
+ def _ticker(symbol: str) -> str:
48
+ return re.sub(r"[^A-Z0-9]", "", plain(symbol).upper())
49
+
50
+
51
+ def verdict(chain: str, contract: str, symbol: str, name: str = "",
52
+ flagged: bool = False) -> Optional[str]:
53
+ """Why this token is junk, or None if nothing marks it as such."""
54
+ contract = (contract or "").lower()
55
+ known = {t["contract"].lower(): t for t in assets.tokens_for(chain).values()}
56
+ if contract and contract in known:
57
+ return None
58
+ if flagged:
59
+ return "flagged as scam by the indexer"
60
+ text = plain(f"{symbol} {name}")
61
+ link = _URL.search(text) or _HANDLE.search(text)
62
+ if link:
63
+ return f"link in its name ({link.group(0).strip()})"
64
+ if _BAIT.search(text):
65
+ return f"bait word in its name ({_BAIT.search(text).group(0)})"
66
+ tick = _ticker(symbol)
67
+ for t in known.values():
68
+ if tick and tick == _ticker(t["symbol"]):
69
+ return f"counterfeit {t['symbol']} (not the real contract)"
70
+ if tick and tick == _NATIVE.get(chain):
71
+ return f"counterfeit {_NATIVE[chain]} (the coin itself is not a token)"
72
+ return None
@@ -60,11 +60,22 @@ def _conn() -> sqlite3.Connection:
60
60
  PRIMARY KEY (chain, address, asset)
61
61
  );
62
62
  """)
63
+ if c.execute("PRAGMA user_version").fetchone()[0] < 1:
64
+ # 0.12.0 stored a TON transfer once per side (two transaction hashes),
65
+ # so a history fetched from both ends counted it twice. Refetch TON.
66
+ c.execute("DELETE FROM transfers WHERE chain='ton'")
67
+ c.execute("DELETE FROM fetched WHERE chain='ton'")
68
+ c.execute("PRAGMA user_version=1")
69
+ c.commit()
63
70
  return c
64
71
 
65
72
 
66
73
  def _row_id(chain: str, r: dict) -> str:
67
74
  """Content hash — the same transfer seen from either side stores once."""
75
+ if r.get("msg_hash"):
76
+ # TON: sender and receiver each have their own transaction for one transfer
77
+ key = "|".join([chain, "msg", str(r["msg_hash"]), str(r.get("symbol", ""))])
78
+ return hashlib.sha1(key.encode()).hexdigest()
68
79
  key = "|".join([
69
80
  chain, str(r.get("hash", "")), str(r.get("from", "")), str(r.get("to", "")),
70
81
  str(r.get("symbol", "")), f"{float(r.get('value', 0)):.12f}",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crypttrace
3
- Version: 0.12.0
3
+ Version: 0.13.0
4
4
  Summary: OSINT toolkit for crypto investigations: trace stolen funds across Ethereum, Bitcoin, Tron, Solana and TON
5
5
  Author: bobslayerX
6
6
  License-Expression: MIT
@@ -400,7 +400,9 @@ crypttrace timeline bc1qADDRESS --chain btc --buckets 24
400
400
  ![The victim list for one branch of the Coldcard sweep in the web UI](https://raw.githubusercontent.com/bobslayerX/crypttrace/main/assets/web-victims.jpg)
401
401
 
402
402
  A worked example — 1,169 swept addresses, 1,082.58 BTC, with the commands to
403
- reproduce it — is in [`cases/coldcard-2026`](cases/coldcard-2026).
403
+ reproduce it — is in [`cases/coldcard-2026`](cases/coldcard-2026). A TON one — 200
404
+ wallets emptied after a seed leak, followed to a KuCoin deposit memo — is in
405
+ [`cases/fragment-api-ton-2026`](cases/fragment-api-ton-2026).
404
406
 
405
407
  **Dust is filtered by default.** Addresses that become publicly known get spammed
406
408
  with tiny transfers, which otherwise bury the transactions that matter and can
@@ -455,6 +457,14 @@ with USDT would be meaningless.
455
457
 
456
458
  Amounts carry approximate USD values (stablecoins pinned to $1, others priced
457
459
  via CoinGecko). If pricing is unavailable, USD shows as `—` rather than a guess.
460
+ Only the real stablecoin contract gets the $1 — a token anyone minted and called
461
+ "USDT" is worth nothing.
462
+
463
+ `tokens` hides spam and counterfeit tokens and leaves them out of the total: a
464
+ "USDT" or "TON" that is not the real contract, a name that is a phishing link
465
+ ("GRAM AT GRAMEVENT.ORG", "USDT - usdtunloсk.соm" with Cyrillic letters in the
466
+ domain), and on TON anything the indexer flags as a scam. `--all` lists them
467
+ with the reason.
458
468
 
459
469
  ### Labels
460
470
 
@@ -602,7 +612,6 @@ cases/ # worked investigations with their data
602
612
  and refreshing these lists as the exchanges republish them
603
613
  - Internal transactions (completes `funder` and contract-mediated transfers)
604
614
  - More label sources: Chainabuse, CryptoScamDB, exchange deposit-address sets
605
- - Spam/dust token filtering in `tokens`
606
615
  - Entity clustering on EVM via the gas-funding heuristic
607
616
 
608
617
  ## Contributing
@@ -18,6 +18,7 @@ src/crypttrace/poisoning.py
18
18
  src/crypttrace/prices.py
19
19
  src/crypttrace/render.py
20
20
  src/crypttrace/report.py
21
+ src/crypttrace/spam.py
21
22
  src/crypttrace/store.py
22
23
  src/crypttrace/trace.py
23
24
  src/crypttrace/verify.py
File without changes
File without changes
File without changes