crypttrace 0.18.0__tar.gz → 0.20.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 (54) hide show
  1. {crypttrace-0.18.0/src/crypttrace.egg-info → crypttrace-0.20.0}/PKG-INFO +19 -3
  2. {crypttrace-0.18.0 → crypttrace-0.20.0}/README.md +18 -2
  3. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/__init__.py +1 -1
  4. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/chains.py +15 -4
  5. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/cli.py +25 -2
  6. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/config.py +16 -1
  7. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/fetchers/etherscan.py +26 -4
  8. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/funder.py +1 -0
  9. crypttrace-0.20.0/src/crypttrace/gascluster.py +87 -0
  10. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/labels/known.json +2 -2
  11. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/labels/tronscan.py +1 -11
  12. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/render.py +36 -0
  13. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/store.py +28 -5
  14. {crypttrace-0.18.0 → crypttrace-0.20.0/src/crypttrace.egg-info}/PKG-INFO +19 -3
  15. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace.egg-info/SOURCES.txt +1 -0
  16. {crypttrace-0.18.0 → crypttrace-0.20.0}/LICENSE +0 -0
  17. {crypttrace-0.18.0 → crypttrace-0.20.0}/pyproject.toml +0 -0
  18. {crypttrace-0.18.0 → crypttrace-0.20.0}/setup.cfg +0 -0
  19. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/addresses.py +0 -0
  20. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/analysis.py +0 -0
  21. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/assess.py +0 -0
  22. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/assets.py +0 -0
  23. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/bridges.py +0 -0
  24. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/drain.py +0 -0
  25. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/evidence.py +0 -0
  26. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/fetchers/__init__.py +0 -0
  27. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/fetchers/bitcoin.py +0 -0
  28. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/fetchers/http.py +0 -0
  29. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/fetchers/solana.py +0 -0
  30. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/fetchers/ton.py +0 -0
  31. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/fetchers/tron.py +0 -0
  32. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/freeze.py +0 -0
  33. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/investigate.py +0 -0
  34. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/labels/__init__.py +0 -0
  35. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/labels/audit.py +0 -0
  36. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/labels/bulk.py +0 -0
  37. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/labels/labels.py +0 -0
  38. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/labels/names.py +0 -0
  39. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/labels/partial.json +0 -0
  40. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/labels/tonapi.py +0 -0
  41. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/offramp.py +0 -0
  42. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/poisoning.py +0 -0
  43. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/prices.py +0 -0
  44. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/report.py +0 -0
  45. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/spam.py +0 -0
  46. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/trace.py +0 -0
  47. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/verify.py +0 -0
  48. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/watch.py +0 -0
  49. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/web/index.html +0 -0
  50. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/webapp.py +0 -0
  51. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace.egg-info/dependency_links.txt +0 -0
  52. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace.egg-info/entry_points.txt +0 -0
  53. {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace.egg-info/requires.txt +0 -0
  54. {crypttrace-0.18.0 → crypttrace-0.20.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.18.0
3
+ Version: 0.20.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
@@ -468,6 +468,24 @@ almost certainly controls all of them. `cluster` surfaces those co-signers,
468
468
  turning a single address into a set of wallets belonging to the same owner. A
469
469
  strong lead, not proof.
470
470
 
471
+ ### EVM clustering (gas funding)
472
+
473
+ ```bash
474
+ crypttrace cluster 0xADDRESS # Ethereum; --chain bsc / polygon / … for others
475
+ ```
476
+
477
+ A new EVM wallet can't send anything until someone gives it coin for gas, so its
478
+ first funder is whoever set it up. `cluster` lists the wallets that share this
479
+ one's first funder (*siblings*) and the wallets this one funded first
480
+ (*children*). Siblings funded the same day with similar amounts — what a script
481
+ spinning up wallets looks like — make a strong link. A first funding from an
482
+ exchange, mixer, bridge or any contract (an internal transaction) links
483
+ nothing: those fund countless unrelated wallets, and the tool says so instead
484
+ of listing strangers.
485
+
486
+ On the Ronin bridge exploiter this finds the 18 laundering wallets it set up
487
+ with 1,000–3,300 ETH each in April 2022; from any one of them, its 17 siblings.
488
+
471
489
  ### Cross-chain tracing (bridges)
472
490
 
473
491
  When funds cross a bridge the trail ends at the bridge contract and reappears on
@@ -689,9 +707,7 @@ cases/ # worked investigations with their data
689
707
  - More exchanges on Bitcoin, Tron, Solana (now: Binance, OKX, HTX, Bybit) and
690
708
  TON (now: Binance),
691
709
  and refreshing these lists as the exchanges republish them
692
- - Internal transactions (completes `funder` and contract-mediated transfers)
693
710
  - More label sources: Chainabuse, CryptoScamDB, exchange deposit-address sets
694
- - Entity clustering on EVM via the gas-funding heuristic
695
711
 
696
712
  ## Contributing
697
713
 
@@ -434,6 +434,24 @@ almost certainly controls all of them. `cluster` surfaces those co-signers,
434
434
  turning a single address into a set of wallets belonging to the same owner. A
435
435
  strong lead, not proof.
436
436
 
437
+ ### EVM clustering (gas funding)
438
+
439
+ ```bash
440
+ crypttrace cluster 0xADDRESS # Ethereum; --chain bsc / polygon / … for others
441
+ ```
442
+
443
+ A new EVM wallet can't send anything until someone gives it coin for gas, so its
444
+ first funder is whoever set it up. `cluster` lists the wallets that share this
445
+ one's first funder (*siblings*) and the wallets this one funded first
446
+ (*children*). Siblings funded the same day with similar amounts — what a script
447
+ spinning up wallets looks like — make a strong link. A first funding from an
448
+ exchange, mixer, bridge or any contract (an internal transaction) links
449
+ nothing: those fund countless unrelated wallets, and the tool says so instead
450
+ of listing strangers.
451
+
452
+ On the Ronin bridge exploiter this finds the 18 laundering wallets it set up
453
+ with 1,000–3,300 ETH each in April 2022; from any one of them, its 17 siblings.
454
+
437
455
  ### Cross-chain tracing (bridges)
438
456
 
439
457
  When funds cross a bridge the trail ends at the bridge contract and reappears on
@@ -655,9 +673,7 @@ cases/ # worked investigations with their data
655
673
  - More exchanges on Bitcoin, Tron, Solana (now: Binance, OKX, HTX, Bybit) and
656
674
  TON (now: Binance),
657
675
  and refreshing these lists as the exchanges republish them
658
- - Internal transactions (completes `funder` and contract-mediated transfers)
659
676
  - More label sources: Chainabuse, CryptoScamDB, exchange deposit-address sets
660
- - Entity clustering on EVM via the gas-funding heuristic
661
677
 
662
678
  ## Contributing
663
679
 
@@ -1,2 +1,2 @@
1
1
  """crypttrace — OSINT crypto investigation CLI."""
2
- __version__ = "0.18.0"
2
+ __version__ = "0.20.0"
@@ -102,15 +102,26 @@ def balance(address: str, chain: str = "eth") -> float:
102
102
  # ---------- normalized row builders ----------
103
103
 
104
104
  def _evm_native(address: str, chain: str, limit: int) -> List[Dict]:
105
+ """The native coin moved: normal transactions plus internal ones (sent by contracts —
106
+ withdrawals from mixers and exchanges, ETH back from swaps). Failed calls moved nothing."""
105
107
  rows = []
106
- for tx in etherscan.get_txs(address, chain, limit=limit):
108
+ calls = [(tx, False) for tx in etherscan.get_txs(address, chain, limit=limit)]
109
+ calls += [(tx, True) for tx in etherscan.get_internal_txs(address, chain, limit=limit)]
110
+ for tx, internal in calls:
111
+ if str(tx.get("isError", "0")) == "1":
112
+ continue
107
113
  try:
108
114
  val = int(tx.get("value", 0)) / config.WEI
109
115
  except (TypeError, ValueError):
110
116
  val = 0.0
111
- rows.append({"from": tx.get("from", "").lower(), "to": tx.get("to", "").lower(),
112
- "value": val, "timestamp": int(tx.get("timeStamp", "0") or 0),
113
- "hash": tx.get("hash", ""), "symbol": symbol(chain)})
117
+ if internal and val <= 0:
118
+ continue
119
+ row = {"from": tx.get("from", "").lower(), "to": (tx.get("to") or "").lower(),
120
+ "value": val, "timestamp": int(tx.get("timeStamp", "0") or 0),
121
+ "hash": tx.get("hash", ""), "symbol": symbol(chain)}
122
+ if internal:
123
+ row["internal"] = True
124
+ rows.append(row)
114
125
  return rows
115
126
 
116
127
 
@@ -775,9 +775,32 @@ def verify(
775
775
 
776
776
  @app.command()
777
777
  def cluster(
778
- address: str = typer.Argument(..., help="Bitcoin address (bc1…/1…/3…)"),
778
+ address: str = typer.Argument(..., help="Bitcoin (bc1…/1…/3…) or EVM (0x…) address"),
779
+ chain: Optional[str] = typer.Option(None, "--chain", "-c",
780
+ help="btc, or an EVM chain (eth, bsc, …); guessed from the address"),
781
+ checks: int = typer.Option(60, "--checks", help="EVM: how many of the funder's wallets to check"),
779
782
  ):
780
- """Bitcoin only: find addresses likely owned by the same person (common-input-ownership)."""
783
+ """Addresses likely owned by the same party.
784
+
785
+ Bitcoin: common-input ownership. EVM: gas funding — wallets whose first funding came
786
+ from the same wallet, and wallets this one funded first."""
787
+ chain = chain or ("btc" if not address.lower().startswith("0x") else "eth")
788
+ if chain in config.CHAINS:
789
+ from crypttrace import gascluster
790
+ try:
791
+ with console.status("Finding who set up this wallet and what else they set up…"):
792
+ res = gascluster.cluster(address, chain, max_checks=checks)
793
+ except (chains_mod.ChainError, etherscan.EtherscanError) as e:
794
+ console.print(f"[red]Error:[/red] {e}")
795
+ raise typer.Exit(1)
796
+ console.print(render.gas_cluster_report(res, chains_mod.symbol(chain)))
797
+ console.print("\n[dim]Heuristic: a new wallet needs gas before it can send anything, so its first "
798
+ "funder set it up. Wallets set up by the same person's wallet are likely one party; a "
799
+ "funding from an exchange, mixer, bridge or any contract links nothing. A lead, not proof.[/dim]")
800
+ return
801
+ if chain != "btc":
802
+ console.print("[red]Error:[/red] cluster works on Bitcoin and EVM chains")
803
+ raise typer.Exit(1)
781
804
  from crypttrace.fetchers import bitcoin
782
805
  try:
783
806
  peers = bitcoin.cluster(address)
@@ -1,7 +1,22 @@
1
1
  """Configuration and shared constants."""
2
2
  import os
3
+ import sys
3
4
  from pathlib import Path
4
5
 
6
+
7
+ def env(name: str, default: str = "") -> str:
8
+ """An environment variable — falling back, on Windows, to the user environment, so a key
9
+ saved with [Environment]::SetEnvironmentVariable(..., "User") works in an already-open shell."""
10
+ value = os.environ.get(name)
11
+ if not value and sys.platform == "win32":
12
+ try:
13
+ import winreg
14
+ with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment") as reg:
15
+ value = winreg.QueryValueEx(reg, name)[0]
16
+ except OSError:
17
+ value = None
18
+ return (value or default).strip()
19
+
5
20
  # Data dir for cache + labels (created on first run)
6
21
  DATA_DIR = Path(os.environ.get("CRYPTTRACE_HOME", Path.home() / ".crypttrace"))
7
22
  DATA_DIR.mkdir(parents=True, exist_ok=True)
@@ -11,7 +26,7 @@ CACHE_DB = DATA_DIR / "cache.sqlite"
11
26
  # Etherscan v2 API. One key works across all EVM chains via chainid param.
12
27
  # Get a free key at https://etherscan.io/myapikey and export it:
13
28
  # export ETHERSCAN_API_KEY=xxxx
14
- ETHERSCAN_API_KEY = os.environ.get("ETHERSCAN_API_KEY", "")
29
+ ETHERSCAN_API_KEY = env("ETHERSCAN_API_KEY")
15
30
  ETHERSCAN_BASE = "https://api.etherscan.io/v2/api"
16
31
 
17
32
  # Supported EVM chains: name -> chainid
@@ -50,7 +50,11 @@ class EtherscanError(RuntimeError):
50
50
  pass
51
51
 
52
52
 
53
- def _call(chain: str, params: Dict[str, str], cache_age: int = 3600) -> dict:
53
+ MIN_INTERVAL = 0.22 # the free plan allows 5 calls a second
54
+ _last_call = 0.0
55
+
56
+
57
+ def _call(chain: str, params: Dict[str, str], cache_age: int = 3600, _tries: int = 0) -> dict:
54
58
  if chain not in config.CHAINS:
55
59
  raise EtherscanError(f"unsupported chain '{chain}'. Options: {list(config.CHAINS)}")
56
60
  if not config.ETHERSCAN_API_KEY:
@@ -69,15 +73,20 @@ def _call(chain: str, params: Dict[str, str], cache_age: int = 3600) -> dict:
69
73
  if cached is not None:
70
74
  return cached
71
75
 
76
+ global _last_call
77
+ wait = MIN_INTERVAL - (time.time() - _last_call)
78
+ if wait > 0:
79
+ time.sleep(wait)
80
+ _last_call = time.time()
72
81
  resp = requests.get(config.ETHERSCAN_BASE, params=q, timeout=30)
73
82
  resp.raise_for_status()
74
83
  data = resp.json()
75
84
  # status "0" with "No transactions found" is a valid empty result, not an error
76
85
  if data.get("status") == "0" and "No transactions" not in str(data.get("message", "")):
77
86
  # rate-limit or bad key etc.
78
- if "rate limit" in str(data.get("result", "")).lower():
79
- time.sleep(1)
80
- return _call(chain, params, cache_age)
87
+ if "rate limit" in str(data.get("result", "")).lower() and _tries < 4:
88
+ time.sleep(1 + _tries)
89
+ return _call(chain, params, cache_age, _tries + 1)
81
90
  # otherwise return as-is; caller handles empty
82
91
  _cache_put(key, data)
83
92
  return data
@@ -104,6 +113,19 @@ def get_txs(address: str, chain: str = "eth", limit: int = 1000,
104
113
  return result if isinstance(result, list) else []
105
114
 
106
115
 
116
+ def get_internal_txs(address: str, chain: str = "eth", limit: int = 1000,
117
+ sort: str = "desc") -> List[dict]:
118
+ """Internal transactions: the native coin a contract sent or received as part of a call
119
+ (a mixer or exchange withdrawal, ETH back from a DEX swap, a multisig payout)."""
120
+ data = _call(chain, {
121
+ "module": "account", "action": "txlistinternal", "address": address,
122
+ "startblock": "0", "endblock": "99999999",
123
+ "page": "1", "offset": str(limit), "sort": sort,
124
+ })
125
+ result = data.get("result")
126
+ return result if isinstance(result, list) else []
127
+
128
+
107
129
  def get_token_txs(address: str, chain: str = "eth", limit: int = 1000) -> List[dict]:
108
130
  """ERC-20 token transfers, newest first."""
109
131
  data = _call(chain, {
@@ -37,6 +37,7 @@ def first_funder(address: str, chain: str = "eth") -> Optional[dict]:
37
37
  "value": r.get("value", 0.0),
38
38
  "timestamp": str(r.get("timestamp", 0)),
39
39
  "hash": r.get("hash", ""),
40
+ "internal": bool(r.get("internal")), # EVM: sent by a contract, not a wallet
40
41
  }
41
42
  return None
42
43
 
@@ -0,0 +1,87 @@
1
+ """EVM clustering by gas funding — wallets started by the same funder.
2
+
3
+ A new EVM wallet cannot send anything until someone gives it coin for gas, so
4
+ the first payment into a wallet says who set it up. An attacker who spins up
5
+ many fresh wallets usually tops them all up from one place. Wallets whose
6
+ *first* funding came from the same address — and wallets this one started —
7
+ are therefore likely one entity.
8
+
9
+ The rule only holds when the funder is a person's wallet. An exchange,
10
+ mixer, bridge or DEX "funds" countless unrelated wallets, and so does any
11
+ contract (a first funding that arrived as an internal transaction came from
12
+ one) — a wallet funded like that is not linked to anything by it, and the
13
+ result says so.
14
+ Same-day funding with similar amounts makes a sibling link stronger: that is
15
+ what a script setting up wallets looks like.
16
+ """
17
+ from typing import Dict, List, Optional
18
+
19
+ from crypttrace import chains, funder
20
+ from crypttrace.labels import labels
21
+
22
+ # a funder of this kind funds the public, not a set of its own wallets
23
+ SERVICE_TYPES = {"exchange", "mixer", "bridge", "dex", "service"}
24
+ DAY = 86400
25
+
26
+
27
+ def _first(addr: str, chain: str) -> Optional[dict]:
28
+ try:
29
+ return funder.first_funder(addr, chain)
30
+ except chains.ChainError:
31
+ return None
32
+
33
+
34
+ def _recipients(addr: str, chain: str, limit: int) -> List[dict]:
35
+ """Distinct addresses `addr` paid in the native coin, first payment each."""
36
+ seen: Dict[str, dict] = {}
37
+ for r in sorted(chains.transfers(addr, chain, 1000, oldest_first=True), key=lambda r: r["timestamp"]):
38
+ to = r.get("to")
39
+ if r.get("from") != addr or not to or to == addr or (r.get("value") or 0) <= 0 or r.get("kind"):
40
+ continue
41
+ seen.setdefault(to, {"address": to, "value": r["value"], "timestamp": r["timestamp"], "hash": r.get("hash", "")})
42
+ return list(seen.values())[:limit]
43
+
44
+
45
+ def cluster(address: str, chain: str, max_checks: int = 60) -> Dict:
46
+ """Siblings (same first funder) and children (wallets this one funded first)."""
47
+ me = chains.norm_addr(address, chain)
48
+ res: Dict = {"address": me, "chain": chain, "funder": None, "siblings": [], "children": [],
49
+ "note": "", "checked": 0}
50
+ mine = _first(me, chain)
51
+ if mine:
52
+ f = mine["funder"]
53
+ labels.enrich([f], chain)
54
+ res["funder"] = {"address": f, "label": labels.label_of(f), "type": labels.type_of(f),
55
+ "value": mine["value"], "timestamp": int(mine["timestamp"]), "hash": mine["hash"]}
56
+ if mine.get("internal"):
57
+ res["note"] = ("first funded by a contract (a withdrawal from a bridge, protocol or mixer), "
58
+ "not by a wallet — that links nothing")
59
+ elif res["funder"]["type"] in SERVICE_TYPES:
60
+ res["note"] = (f"first funded by {res['funder']['label'] or 'a ' + res['funder']['type']} — "
61
+ "a service funds countless unrelated wallets, so this links nothing")
62
+ else:
63
+ cands = [c for c in _recipients(f, chain, 1000) if c["address"] != me]
64
+ # the wallets funded closest in time to this one are the likeliest siblings
65
+ t0 = res["funder"]["timestamp"]
66
+ cands.sort(key=lambda c: abs(c["timestamp"] - t0))
67
+ for c in cands[:max_checks]:
68
+ res["checked"] += 1
69
+ first = _first(c["address"], chain)
70
+ if first and first["funder"] == f and not first.get("internal"):
71
+ near = abs(c["timestamp"] - t0) <= DAY
72
+ similar = mine["value"] > 0 and 1 / 3 <= c["value"] / mine["value"] <= 3
73
+ res["siblings"].append({**c, "same_day": near, "similar_amount": similar,
74
+ "label": labels.label_of(c["address"])})
75
+ else:
76
+ res["note"] = "no funding into this wallet was found in the history read"
77
+
78
+ for c in _recipients(me, chain, max_checks):
79
+ first = _first(c["address"], chain)
80
+ if first and first["funder"] == me and not first.get("internal"):
81
+ res["children"].append({**c, "label": labels.label_of(c["address"])})
82
+
83
+ same_day = [s for s in res["siblings"] if s["same_day"] and s["similar_amount"]]
84
+ similar = [s for s in res["siblings"] if s["similar_amount"]]
85
+ res["strength"] = ("strong" if len(same_day) >= 2 or len(similar) >= 5 else
86
+ "moderate" if res["siblings"] or res["children"] else "none")
87
+ return res
@@ -10,10 +10,10 @@
10
10
  "0x1522900b6dafac587d499a862861c0869be6e428": {"name": "OKX", "type": "exchange"},
11
11
  "0x2faf487a4414fe77e2327f0bf4ae2a264a776ad2": {"name": "FTX (defunct)", "type": "exchange"},
12
12
  "0x722122df12d4e14e13ac3b6895a86e84145b6967": {"name": "Tornado Cash: Router", "type": "mixer"},
13
- "0xd90e2f925da726b50c4ed8d0fb90ad053324f31b": {"name": "Tornado Cash: 0.1 ETH", "type": "mixer"},
13
+ "0xd90e2f925da726b50c4ed8d0fb90ad053324f31b": {"name": "Tornado Cash: router", "type": "mixer", "chain": "eth", "source": "own check of its internal transactions (2026-10-17): pays out withdrawals of the 0.1, 1 and 10 ETH pools, so a router, not the 0.1 ETH pool it was labelled as", "source_kind": "research", "added": "2026-10-17"},
14
14
  "0x910cbd523d972eb0a6f4cae4618ad62622b39dbf": {"name": "Tornado Cash: 10 ETH", "type": "mixer"},
15
15
  "0xa160cdab225685da1d56aa342ad8841c3b53f291": {"name": "Tornado Cash: 100 ETH", "type": "mixer"},
16
- "0x12d66f87a04a9e220743712ce6d9bb1b5616b8fc": {"name": "Tornado Cash: 1 ETH", "type": "mixer"},
16
+ "0x12d66f87a04a9e220743712ce6d9bb1b5616b8fc": {"name": "Tornado Cash: 0.1 ETH", "type": "mixer", "chain": "eth", "source": "own check of its internal transactions (2026-10-17): every withdrawal pays out 0.1 ETH in total (recipient plus relayer fee); was labelled 1 ETH", "source_kind": "research", "added": "2026-10-17"},
17
17
  "0x3ee18b2214aff97000d974cf647e7c347e8fa585": {"name": "Wormhole: Token Bridge", "type": "bridge"},
18
18
  "0x99c9fc46f92e8a1c0dec1b1747d010903e884be1": {"name": "Optimism: Gateway (L1 Bridge)", "type": "bridge"},
19
19
  "0x4dbd4fc535ac27206064b68ffcf827b0a60bab3f": {"name": "Arbitrum: Delayed Inbox", "type": "bridge"},
@@ -12,8 +12,6 @@ Lookups are cached on disk for a week; only `enrich()` uses the network.
12
12
  """
13
13
  import datetime as dt
14
14
  import json
15
- import os
16
- import sys
17
15
  import time
18
16
  from typing import Dict, Iterable, Optional
19
17
 
@@ -31,15 +29,7 @@ _cache: Optional[Dict[str, dict]] = None
31
29
 
32
30
 
33
31
  def _key() -> Optional[str]:
34
- k = os.environ.get("TRONSCAN_API_KEY")
35
- if not k and sys.platform == "win32": # set with [Environment]::SetEnvironmentVariable(..., "User")
36
- try:
37
- import winreg
38
- with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment") as reg:
39
- k = winreg.QueryValueEx(reg, "TRONSCAN_API_KEY")[0]
40
- except OSError:
41
- k = None
42
- return (k or "").strip() or None
32
+ return config.env("TRONSCAN_API_KEY") or None
43
33
 
44
34
 
45
35
  def _load() -> Dict[str, dict]:
@@ -181,6 +181,42 @@ def cluster_table(address: str, peers: list) -> Table:
181
181
  return t
182
182
 
183
183
 
184
+ def gas_cluster_report(res: dict, symbol: str = "ETH"):
185
+ """EVM clustering by gas funding: the funder, siblings and children, with the caveat."""
186
+ from rich.console import Group
187
+ parts = []
188
+ f = res.get("funder")
189
+ if f:
190
+ who = escape(f["address"]) + (f" [{escape(f['label'])}]" if f.get("label") else "")
191
+ parts.append(Text.from_markup(f"First funded by {who} — {f['value']:,.4f} {symbol} on {_ts(str(f['timestamp']))}"))
192
+ if res.get("note"):
193
+ parts.append(Text(res["note"], style="yellow"))
194
+ for title, rows, extra in (("Siblings — wallets this funder also set up (same first funder)", res["siblings"], True),
195
+ ("Children — wallets this one set up (it was their first funder)", res["children"], False)):
196
+ if not rows:
197
+ continue
198
+ t = Table(title=title, header_style="bold")
199
+ t.add_column("Address")
200
+ t.add_column(f"First funding ({symbol})", justify="right")
201
+ t.add_column("When (UTC)")
202
+ if extra:
203
+ t.add_column("Same day")
204
+ t.add_column("Similar amount")
205
+ for r in rows:
206
+ cells = [addr_label(r["address"]), f"{r['value']:,.4f}", _ts(str(r["timestamp"]))]
207
+ if extra:
208
+ cells += ["yes" if r["same_day"] else "", "yes" if r["similar_amount"] else ""]
209
+ t.add_row(*cells)
210
+ parts.append(t)
211
+ verdict = {"strong": "[bold green]Strong link[/bold green]: one funder set up several wallets the same day, "
212
+ "or many with similar amounts — what a script spinning up wallets looks like.",
213
+ "moderate": "[bold yellow]Possible link[/bold yellow]: shared first funder, but not the tell-tale "
214
+ "pattern — check the wallets' activity before acting on it.",
215
+ "none": "[dim]No linked wallets found.[/dim]"}[res.get("strength", "none")]
216
+ parts.append(Text.from_markup(verdict))
217
+ return Group(*parts)
218
+
219
+
184
220
  def crosschain_tree(address: str, chain: str, results: list) -> Tree:
185
221
  """Render bridge-outs and their likely cross-chain continuations."""
186
222
  root = Tree(Text.assemble(addr_label(address), Text(f" (source chain: {chain})")))
@@ -86,6 +86,26 @@ def _conn() -> sqlite3.Connection:
86
86
  c.execute("DELETE FROM fetched WHERE chain='ton'")
87
87
  c.execute("PRAGMA user_version=3")
88
88
  c.commit()
89
+ if c.execute("PRAGMA user_version").fetchone()[0] < 4:
90
+ # 0.19: EVM native history now includes internal transactions — read it again
91
+ evm = tuple(config.CHAINS)
92
+ marks = ",".join("?" * len(evm))
93
+ c.execute(f"DELETE FROM transfers WHERE chain IN ({marks}) AND (contract IS NULL OR contract='')", evm)
94
+ c.execute(f"DELETE FROM fetched WHERE chain IN ({marks}) AND asset=''", evm)
95
+ c.execute("PRAGMA user_version=4")
96
+ c.commit()
97
+ if c.execute("PRAGMA user_version").fetchone()[0] < 5:
98
+ # 0.20: remember which EVM transfers were internal (sent by a contract) — gas-funding
99
+ # clustering must not link wallets a contract paid; read EVM native history again
100
+ cols = [r[1] for r in c.execute("PRAGMA table_info(transfers)")]
101
+ if "internal" not in cols:
102
+ c.execute("ALTER TABLE transfers ADD COLUMN internal INTEGER")
103
+ evm = tuple(config.CHAINS)
104
+ marks = ",".join("?" * len(evm))
105
+ c.execute(f"DELETE FROM transfers WHERE chain IN ({marks}) AND (contract IS NULL OR contract='')", evm)
106
+ c.execute(f"DELETE FROM fetched WHERE chain IN ({marks}) AND asset=''", evm)
107
+ c.execute("PRAGMA user_version=5")
108
+ c.commit()
89
109
  return c
90
110
 
91
111
 
@@ -109,13 +129,13 @@ def save(chain: str, address: str, rows: List[dict], asset_key: str = "",
109
129
  try:
110
130
  c.executemany(
111
131
  "INSERT OR REPLACE INTO transfers"
112
- "(id,chain,tx_hash,from_addr,to_addr,value,symbol,contract,ts,fee_share,memo,kind)"
113
- " VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
132
+ "(id,chain,tx_hash,from_addr,to_addr,value,symbol,contract,ts,fee_share,memo,kind,internal)"
133
+ " VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
114
134
  [(_row_id(chain, r), chain, r.get("hash", ""), r.get("from", ""),
115
135
  r.get("to", ""), float(r.get("value", 0) or 0), r.get("symbol", ""),
116
136
  r.get("contract", ""), int(r.get("timestamp", 0) or 0),
117
137
  float(r.get("fee_share", 0) or 0), r.get("memo") or None,
118
- r.get("kind") or None) for r in rows])
138
+ r.get("kind") or None, 1 if r.get("internal") else None) for r in rows])
119
139
  c.execute("INSERT OR REPLACE INTO fetched(chain,address,asset,fetched_at,rows,complete)"
120
140
  " VALUES (?,?,?,?,?,?)",
121
141
  (chain, address, asset_key, int(time.time()), len(rows), int(complete)))
@@ -145,7 +165,8 @@ def is_fresh(chain: str, address: str, asset_key: str = "",
145
165
  def load(chain: str, address: str, contract: str = "", native_symbol: str = "") -> List[dict]:
146
166
  """Every stored transfer touching this address, newest first."""
147
167
  c = _conn()
148
- cols = "SELECT from_addr,to_addr,value,symbol,contract,ts,tx_hash,fee_share,memo,kind FROM transfers"
168
+ cols = ("SELECT from_addr,to_addr,value,symbol,contract,ts,tx_hash,fee_share,memo,kind,internal"
169
+ " FROM transfers")
149
170
  try:
150
171
  if contract == "*":
151
172
  # every token, whatever its contract (address-poisoning checks need fakes too)
@@ -171,7 +192,7 @@ def load(chain: str, address: str, contract: str = "", native_symbol: str = "")
171
192
  finally:
172
193
  c.close()
173
194
  out = []
174
- for f, t, v, sym, con, ts, h, fee, memo, kind in rows:
195
+ for f, t, v, sym, con, ts, h, fee, memo, kind, internal in rows:
175
196
  r = {"from": f, "to": t, "value": v, "symbol": sym,
176
197
  "timestamp": ts, "hash": h}
177
198
  if con:
@@ -182,6 +203,8 @@ def load(chain: str, address: str, contract: str = "", native_symbol: str = "")
182
203
  r["memo"] = memo
183
204
  if kind:
184
205
  r["kind"] = kind
206
+ if internal:
207
+ r["internal"] = True
185
208
  out.append(r)
186
209
  return out
187
210
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crypttrace
3
- Version: 0.18.0
3
+ Version: 0.20.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
@@ -468,6 +468,24 @@ almost certainly controls all of them. `cluster` surfaces those co-signers,
468
468
  turning a single address into a set of wallets belonging to the same owner. A
469
469
  strong lead, not proof.
470
470
 
471
+ ### EVM clustering (gas funding)
472
+
473
+ ```bash
474
+ crypttrace cluster 0xADDRESS # Ethereum; --chain bsc / polygon / … for others
475
+ ```
476
+
477
+ A new EVM wallet can't send anything until someone gives it coin for gas, so its
478
+ first funder is whoever set it up. `cluster` lists the wallets that share this
479
+ one's first funder (*siblings*) and the wallets this one funded first
480
+ (*children*). Siblings funded the same day with similar amounts — what a script
481
+ spinning up wallets looks like — make a strong link. A first funding from an
482
+ exchange, mixer, bridge or any contract (an internal transaction) links
483
+ nothing: those fund countless unrelated wallets, and the tool says so instead
484
+ of listing strangers.
485
+
486
+ On the Ronin bridge exploiter this finds the 18 laundering wallets it set up
487
+ with 1,000–3,300 ETH each in April 2022; from any one of them, its 17 siblings.
488
+
471
489
  ### Cross-chain tracing (bridges)
472
490
 
473
491
  When funds cross a bridge the trail ends at the bridge contract and reappears on
@@ -689,9 +707,7 @@ cases/ # worked investigations with their data
689
707
  - More exchanges on Bitcoin, Tron, Solana (now: Binance, OKX, HTX, Bybit) and
690
708
  TON (now: Binance),
691
709
  and refreshing these lists as the exchanges republish them
692
- - Internal transactions (completes `funder` and contract-mediated transfers)
693
710
  - More label sources: Chainabuse, CryptoScamDB, exchange deposit-address sets
694
- - Entity clustering on EVM via the gas-funding heuristic
695
711
 
696
712
  ## Contributing
697
713
 
@@ -14,6 +14,7 @@ src/crypttrace/drain.py
14
14
  src/crypttrace/evidence.py
15
15
  src/crypttrace/freeze.py
16
16
  src/crypttrace/funder.py
17
+ src/crypttrace/gascluster.py
17
18
  src/crypttrace/investigate.py
18
19
  src/crypttrace/offramp.py
19
20
  src/crypttrace/poisoning.py
File without changes
File without changes
File without changes