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