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.
- {crypttrace-0.18.0/src/crypttrace.egg-info → crypttrace-0.20.0}/PKG-INFO +19 -3
- {crypttrace-0.18.0 → crypttrace-0.20.0}/README.md +18 -2
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/__init__.py +1 -1
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/chains.py +15 -4
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/cli.py +25 -2
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/config.py +16 -1
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/fetchers/etherscan.py +26 -4
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/funder.py +1 -0
- crypttrace-0.20.0/src/crypttrace/gascluster.py +87 -0
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/labels/known.json +2 -2
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/labels/tronscan.py +1 -11
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/render.py +36 -0
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/store.py +28 -5
- {crypttrace-0.18.0 → crypttrace-0.20.0/src/crypttrace.egg-info}/PKG-INFO +19 -3
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace.egg-info/SOURCES.txt +1 -0
- {crypttrace-0.18.0 → crypttrace-0.20.0}/LICENSE +0 -0
- {crypttrace-0.18.0 → crypttrace-0.20.0}/pyproject.toml +0 -0
- {crypttrace-0.18.0 → crypttrace-0.20.0}/setup.cfg +0 -0
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/addresses.py +0 -0
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/analysis.py +0 -0
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/assess.py +0 -0
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/assets.py +0 -0
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/bridges.py +0 -0
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/drain.py +0 -0
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/evidence.py +0 -0
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/fetchers/__init__.py +0 -0
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/fetchers/bitcoin.py +0 -0
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/fetchers/http.py +0 -0
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/fetchers/solana.py +0 -0
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/fetchers/ton.py +0 -0
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/fetchers/tron.py +0 -0
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/freeze.py +0 -0
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/investigate.py +0 -0
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/labels/__init__.py +0 -0
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/labels/audit.py +0 -0
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/labels/bulk.py +0 -0
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/labels/labels.py +0 -0
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/labels/names.py +0 -0
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/labels/partial.json +0 -0
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/labels/tonapi.py +0 -0
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/offramp.py +0 -0
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/poisoning.py +0 -0
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/prices.py +0 -0
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/report.py +0 -0
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/spam.py +0 -0
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/trace.py +0 -0
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/verify.py +0 -0
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/watch.py +0 -0
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/web/index.html +0 -0
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace/webapp.py +0 -0
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace.egg-info/dependency_links.txt +0 -0
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace.egg-info/entry_points.txt +0 -0
- {crypttrace-0.18.0 → crypttrace-0.20.0}/src/crypttrace.egg-info/requires.txt +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
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
|
-
"""
|
|
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 =
|
|
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
|
-
|
|
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", "
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|