crypttrace 0.19.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.19.0/src/crypttrace.egg-info → crypttrace-0.20.0}/PKG-INFO +19 -2
  2. {crypttrace-0.19.0 → crypttrace-0.20.0}/README.md +18 -1
  3. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace/__init__.py +1 -1
  4. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace/cli.py +25 -2
  5. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace/fetchers/etherscan.py +13 -4
  6. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace/funder.py +1 -0
  7. crypttrace-0.20.0/src/crypttrace/gascluster.py +87 -0
  8. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace/render.py +36 -0
  9. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace/store.py +20 -5
  10. {crypttrace-0.19.0 → crypttrace-0.20.0/src/crypttrace.egg-info}/PKG-INFO +19 -2
  11. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace.egg-info/SOURCES.txt +1 -0
  12. {crypttrace-0.19.0 → crypttrace-0.20.0}/LICENSE +0 -0
  13. {crypttrace-0.19.0 → crypttrace-0.20.0}/pyproject.toml +0 -0
  14. {crypttrace-0.19.0 → crypttrace-0.20.0}/setup.cfg +0 -0
  15. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace/addresses.py +0 -0
  16. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace/analysis.py +0 -0
  17. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace/assess.py +0 -0
  18. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace/assets.py +0 -0
  19. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace/bridges.py +0 -0
  20. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace/chains.py +0 -0
  21. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace/config.py +0 -0
  22. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace/drain.py +0 -0
  23. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace/evidence.py +0 -0
  24. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace/fetchers/__init__.py +0 -0
  25. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace/fetchers/bitcoin.py +0 -0
  26. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace/fetchers/http.py +0 -0
  27. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace/fetchers/solana.py +0 -0
  28. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace/fetchers/ton.py +0 -0
  29. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace/fetchers/tron.py +0 -0
  30. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace/freeze.py +0 -0
  31. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace/investigate.py +0 -0
  32. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace/labels/__init__.py +0 -0
  33. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace/labels/audit.py +0 -0
  34. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace/labels/bulk.py +0 -0
  35. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace/labels/known.json +0 -0
  36. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace/labels/labels.py +0 -0
  37. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace/labels/names.py +0 -0
  38. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace/labels/partial.json +0 -0
  39. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace/labels/tonapi.py +0 -0
  40. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace/labels/tronscan.py +0 -0
  41. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace/offramp.py +0 -0
  42. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace/poisoning.py +0 -0
  43. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace/prices.py +0 -0
  44. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace/report.py +0 -0
  45. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace/spam.py +0 -0
  46. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace/trace.py +0 -0
  47. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace/verify.py +0 -0
  48. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace/watch.py +0 -0
  49. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace/web/index.html +0 -0
  50. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace/webapp.py +0 -0
  51. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace.egg-info/dependency_links.txt +0 -0
  52. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace.egg-info/entry_points.txt +0 -0
  53. {crypttrace-0.19.0 → crypttrace-0.20.0}/src/crypttrace.egg-info/requires.txt +0 -0
  54. {crypttrace-0.19.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.19.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
@@ -690,7 +708,6 @@ cases/ # worked investigations with their data
690
708
  TON (now: Binance),
691
709
  and refreshing these lists as the exchanges republish them
692
710
  - More label sources: Chainabuse, CryptoScamDB, exchange deposit-address sets
693
- - Entity clustering on EVM via the gas-funding heuristic
694
711
 
695
712
  ## Contributing
696
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
@@ -656,7 +674,6 @@ cases/ # worked investigations with their data
656
674
  TON (now: Binance),
657
675
  and refreshing these lists as the exchanges republish them
658
676
  - More label sources: Chainabuse, CryptoScamDB, exchange deposit-address sets
659
- - Entity clustering on EVM via the gas-funding heuristic
660
677
 
661
678
  ## Contributing
662
679
 
@@ -1,2 +1,2 @@
1
1
  """crypttrace — OSINT crypto investigation CLI."""
2
- __version__ = "0.19.0"
2
+ __version__ = "0.20.0"
@@ -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)
@@ -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
@@ -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
@@ -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})")))
@@ -94,6 +94,18 @@ def _conn() -> sqlite3.Connection:
94
94
  c.execute(f"DELETE FROM fetched WHERE chain IN ({marks}) AND asset=''", evm)
95
95
  c.execute("PRAGMA user_version=4")
96
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()
97
109
  return c
98
110
 
99
111
 
@@ -117,13 +129,13 @@ def save(chain: str, address: str, rows: List[dict], asset_key: str = "",
117
129
  try:
118
130
  c.executemany(
119
131
  "INSERT OR REPLACE INTO transfers"
120
- "(id,chain,tx_hash,from_addr,to_addr,value,symbol,contract,ts,fee_share,memo,kind)"
121
- " VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
132
+ "(id,chain,tx_hash,from_addr,to_addr,value,symbol,contract,ts,fee_share,memo,kind,internal)"
133
+ " VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
122
134
  [(_row_id(chain, r), chain, r.get("hash", ""), r.get("from", ""),
123
135
  r.get("to", ""), float(r.get("value", 0) or 0), r.get("symbol", ""),
124
136
  r.get("contract", ""), int(r.get("timestamp", 0) or 0),
125
137
  float(r.get("fee_share", 0) or 0), r.get("memo") or None,
126
- 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])
127
139
  c.execute("INSERT OR REPLACE INTO fetched(chain,address,asset,fetched_at,rows,complete)"
128
140
  " VALUES (?,?,?,?,?,?)",
129
141
  (chain, address, asset_key, int(time.time()), len(rows), int(complete)))
@@ -153,7 +165,8 @@ def is_fresh(chain: str, address: str, asset_key: str = "",
153
165
  def load(chain: str, address: str, contract: str = "", native_symbol: str = "") -> List[dict]:
154
166
  """Every stored transfer touching this address, newest first."""
155
167
  c = _conn()
156
- 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")
157
170
  try:
158
171
  if contract == "*":
159
172
  # every token, whatever its contract (address-poisoning checks need fakes too)
@@ -179,7 +192,7 @@ def load(chain: str, address: str, contract: str = "", native_symbol: str = "")
179
192
  finally:
180
193
  c.close()
181
194
  out = []
182
- 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:
183
196
  r = {"from": f, "to": t, "value": v, "symbol": sym,
184
197
  "timestamp": ts, "hash": h}
185
198
  if con:
@@ -190,6 +203,8 @@ def load(chain: str, address: str, contract: str = "", native_symbol: str = "")
190
203
  r["memo"] = memo
191
204
  if kind:
192
205
  r["kind"] = kind
206
+ if internal:
207
+ r["internal"] = True
193
208
  out.append(r)
194
209
  return out
195
210
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crypttrace
3
- Version: 0.19.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
@@ -690,7 +708,6 @@ cases/ # worked investigations with their data
690
708
  TON (now: Binance),
691
709
  and refreshing these lists as the exchanges republish them
692
710
  - More label sources: Chainabuse, CryptoScamDB, exchange deposit-address sets
693
- - Entity clustering on EVM via the gas-funding heuristic
694
711
 
695
712
  ## Contributing
696
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