crypttrace 0.14.0__tar.gz → 0.16.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.14.0/src/crypttrace.egg-info → crypttrace-0.16.0}/PKG-INFO +32 -1
- {crypttrace-0.14.0 → crypttrace-0.16.0}/README.md +31 -0
- {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/__init__.py +1 -1
- {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/analysis.py +29 -4
- {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/chains.py +1 -1
- {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/cli.py +16 -0
- crypttrace-0.16.0/src/crypttrace/drain.py +197 -0
- {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/fetchers/ton.py +31 -0
- {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/funder.py +1 -1
- {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/render.py +67 -2
- {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/store.py +17 -5
- {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/verify.py +6 -1
- {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/web/index.html +45 -0
- {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/webapp.py +16 -0
- {crypttrace-0.14.0 → crypttrace-0.16.0/src/crypttrace.egg-info}/PKG-INFO +32 -1
- {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace.egg-info/SOURCES.txt +1 -0
- {crypttrace-0.14.0 → crypttrace-0.16.0}/LICENSE +0 -0
- {crypttrace-0.14.0 → crypttrace-0.16.0}/pyproject.toml +0 -0
- {crypttrace-0.14.0 → crypttrace-0.16.0}/setup.cfg +0 -0
- {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/addresses.py +0 -0
- {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/assess.py +0 -0
- {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/assets.py +0 -0
- {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/bridges.py +0 -0
- {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/config.py +0 -0
- {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/fetchers/__init__.py +0 -0
- {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/fetchers/bitcoin.py +0 -0
- {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/fetchers/etherscan.py +0 -0
- {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/fetchers/http.py +0 -0
- {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/fetchers/solana.py +0 -0
- {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/fetchers/tron.py +0 -0
- {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/freeze.py +0 -0
- {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/investigate.py +0 -0
- {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/labels/__init__.py +0 -0
- {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/labels/audit.py +0 -0
- {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/labels/bulk.py +0 -0
- {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/labels/known.json +0 -0
- {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/labels/labels.py +0 -0
- {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/labels/partial.json +0 -0
- {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/labels/tonapi.py +0 -0
- {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/offramp.py +0 -0
- {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/poisoning.py +0 -0
- {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/prices.py +0 -0
- {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/report.py +0 -0
- {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/spam.py +0 -0
- {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/trace.py +0 -0
- {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace/watch.py +0 -0
- {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace.egg-info/dependency_links.txt +0 -0
- {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace.egg-info/entry_points.txt +0 -0
- {crypttrace-0.14.0 → crypttrace-0.16.0}/src/crypttrace.egg-info/requires.txt +0 -0
- {crypttrace-0.14.0 → crypttrace-0.16.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.16.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
|
|
@@ -129,6 +129,30 @@ them (`UQ…`); jetton transfers are reported between the owners' wallets.
|
|
|
129
129
|
|
|
130
130
|
---
|
|
131
131
|
|
|
132
|
+
## Was my wallet drained?
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
crypttrace check YOUR_ADDRESS --chain ton
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
For someone who just found their wallet empty. `check` reads the wallet's
|
|
139
|
+
history and answers in plain language:
|
|
140
|
+
|
|
141
|
+
- **drained or not** — several signed transfers moving everything (tokens, NFTs,
|
|
142
|
+
then the coin) to one new address within minutes, leaving the wallet empty:
|
|
143
|
+
a program holding your key did that, not you;
|
|
144
|
+
- **a mass drain?** — whether the same address emptied other wallets in the same
|
|
145
|
+
hour (leaked keys from a shared service or tool, not a scam aimed at you);
|
|
146
|
+
- **is the key still in use?** — money that arrived after the drain and was
|
|
147
|
+
taken too;
|
|
148
|
+
- **where the money went next** — exchange deposits with the memo that names the
|
|
149
|
+
account (see *Exchange deposits with a memo*);
|
|
150
|
+
- **what to do now** — what is still on the wallet worth moving (on TON: real
|
|
151
|
+
jetton balances and NFTs from trusted collections, not airdropped spam), and
|
|
152
|
+
who to report to.
|
|
153
|
+
|
|
154
|
+
The web UI has the same check in its **Was I drained?** tab.
|
|
155
|
+
|
|
132
156
|
## Web UI
|
|
133
157
|
|
|
134
158
|
```bash
|
|
@@ -412,6 +436,13 @@ flip the timing verdict entirely. Each chain has a dust threshold; override with
|
|
|
412
436
|
paged rather than read one page deep, so an old sweep isn't hidden behind recent
|
|
413
437
|
spam.
|
|
414
438
|
|
|
439
|
+
**On TON, only payments count.** A drainer's collector also receives notes
|
|
440
|
+
people send the attacker, dust from look-alike addresses, and gas refunds and
|
|
441
|
+
notices from jetton wallets and NFT items. None of those senders are victims,
|
|
442
|
+
and `victims` leaves them out. It also counts the NFTs (Telegram usernames,
|
|
443
|
+
numbers, domains) each wallet lost — a victim who lost only NFTs is listed too,
|
|
444
|
+
with an `nfts` column in the CSV. Failed (aborted) NFT transfers are not counted.
|
|
445
|
+
|
|
415
446
|
### Bitcoin clustering (common-input-ownership)
|
|
416
447
|
|
|
417
448
|
Bitcoin's UTXO model enables the strongest clustering heuristic in blockchain
|
|
@@ -95,6 +95,30 @@ them (`UQ…`); jetton transfers are reported between the owners' wallets.
|
|
|
95
95
|
|
|
96
96
|
---
|
|
97
97
|
|
|
98
|
+
## Was my wallet drained?
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
crypttrace check YOUR_ADDRESS --chain ton
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
For someone who just found their wallet empty. `check` reads the wallet's
|
|
105
|
+
history and answers in plain language:
|
|
106
|
+
|
|
107
|
+
- **drained or not** — several signed transfers moving everything (tokens, NFTs,
|
|
108
|
+
then the coin) to one new address within minutes, leaving the wallet empty:
|
|
109
|
+
a program holding your key did that, not you;
|
|
110
|
+
- **a mass drain?** — whether the same address emptied other wallets in the same
|
|
111
|
+
hour (leaked keys from a shared service or tool, not a scam aimed at you);
|
|
112
|
+
- **is the key still in use?** — money that arrived after the drain and was
|
|
113
|
+
taken too;
|
|
114
|
+
- **where the money went next** — exchange deposits with the memo that names the
|
|
115
|
+
account (see *Exchange deposits with a memo*);
|
|
116
|
+
- **what to do now** — what is still on the wallet worth moving (on TON: real
|
|
117
|
+
jetton balances and NFTs from trusted collections, not airdropped spam), and
|
|
118
|
+
who to report to.
|
|
119
|
+
|
|
120
|
+
The web UI has the same check in its **Was I drained?** tab.
|
|
121
|
+
|
|
98
122
|
## Web UI
|
|
99
123
|
|
|
100
124
|
```bash
|
|
@@ -378,6 +402,13 @@ flip the timing verdict entirely. Each chain has a dust threshold; override with
|
|
|
378
402
|
paged rather than read one page deep, so an old sweep isn't hidden behind recent
|
|
379
403
|
spam.
|
|
380
404
|
|
|
405
|
+
**On TON, only payments count.** A drainer's collector also receives notes
|
|
406
|
+
people send the attacker, dust from look-alike addresses, and gas refunds and
|
|
407
|
+
notices from jetton wallets and NFT items. None of those senders are victims,
|
|
408
|
+
and `victims` leaves them out. It also counts the NFTs (Telegram usernames,
|
|
409
|
+
numbers, domains) each wallet lost — a victim who lost only NFTs is listed too,
|
|
410
|
+
with an `nfts` column in the CSV. Failed (aborted) NFT transfers are not counted.
|
|
411
|
+
|
|
381
412
|
### Bitcoin clustering (common-input-ownership)
|
|
382
413
|
|
|
383
414
|
Bitcoin's UTXO model enables the strongest clustering heuristic in blockchain
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""crypttrace — OSINT crypto investigation CLI."""
|
|
2
|
-
__version__ = "0.
|
|
2
|
+
__version__ = "0.16.0"
|
|
@@ -51,8 +51,8 @@ def _direct(address: str, chain: str, asset: Optional[dict], limit: int,
|
|
|
51
51
|
continue
|
|
52
52
|
other = r.get(far)
|
|
53
53
|
val = r.get("value", 0) or 0
|
|
54
|
-
if not other or val <= 0 or val < min_value:
|
|
55
|
-
continue
|
|
54
|
+
if not other or val <= 0 or val < min_value or r.get("kind"):
|
|
55
|
+
continue # TON: gas refunds, notices and notes are not payments
|
|
56
56
|
rec = agg.setdefault(other, {"address": other, "value": 0.0, "txs": 0,
|
|
57
57
|
"first_ts": None, "last_ts": None})
|
|
58
58
|
rec["value"] += val
|
|
@@ -97,6 +97,7 @@ def collect_sources(address: str, chain: str = "btc", depth: int = 1,
|
|
|
97
97
|
found[key]["txs"] += rec["txs"]
|
|
98
98
|
continue
|
|
99
99
|
rec = dict(rec)
|
|
100
|
+
rec.setdefault("nfts", 0)
|
|
100
101
|
rec["hop"] = hop
|
|
101
102
|
rec["into"] = node
|
|
102
103
|
rec["label"] = labels.label_of(addr)
|
|
@@ -105,11 +106,32 @@ def collect_sources(address: str, chain: str = "btc", depth: int = 1,
|
|
|
105
106
|
if key not in seen:
|
|
106
107
|
seen.add(key)
|
|
107
108
|
next_frontier.append(addr)
|
|
109
|
+
if chain == "ton" and asset is None:
|
|
110
|
+
_add_nfts(found, node, hop)
|
|
108
111
|
frontier = next_frontier
|
|
109
112
|
if not frontier or len(found) >= max_addresses:
|
|
110
113
|
break
|
|
111
114
|
|
|
112
|
-
return sorted(found.values(), key=lambda r: r["value"], reverse=True)
|
|
115
|
+
return sorted(found.values(), key=lambda r: (r["value"], r.get("nfts", 0)), reverse=True)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _add_nfts(found: Dict[str, dict], node: str, hop: int) -> None:
|
|
119
|
+
"""Count NFTs each sender handed to `node` — a drained wallet often loses only its NFTs."""
|
|
120
|
+
from crypttrace.fetchers import ton
|
|
121
|
+
try:
|
|
122
|
+
rows = ton.nft_transfers(node, 1000)
|
|
123
|
+
except ton.TonError:
|
|
124
|
+
return
|
|
125
|
+
for r in rows:
|
|
126
|
+
if r["to"] != node or not r["from"] or r["from"] == node:
|
|
127
|
+
continue
|
|
128
|
+
rec = found.setdefault(r["from"], {"address": r["from"], "value": 0.0, "txs": 0, "nfts": 0,
|
|
129
|
+
"first_ts": r["timestamp"], "last_ts": r["timestamp"],
|
|
130
|
+
"hop": hop, "into": node,
|
|
131
|
+
"label": labels.label_of(r["from"]),
|
|
132
|
+
"type": labels.type_of(r["from"])})
|
|
133
|
+
rec["nfts"] = rec.get("nfts", 0) + 1
|
|
134
|
+
rec["first_ts"] = min(rec["first_ts"] or r["timestamp"], r["timestamp"])
|
|
113
135
|
|
|
114
136
|
|
|
115
137
|
def export_csv(rows: List[dict], path: Path, chain: str, symbol: str) -> Path:
|
|
@@ -117,6 +139,9 @@ def export_csv(rows: List[dict], path: Path, chain: str, symbol: str) -> Path:
|
|
|
117
139
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
118
140
|
cols = ["address", "hop", f"amount_sent_{symbol}", "transactions",
|
|
119
141
|
"first_seen_utc", "last_seen_utc", "sent_into", "label", "explorer"]
|
|
142
|
+
nfts = any(r.get("nfts") for r in rows)
|
|
143
|
+
if nfts:
|
|
144
|
+
cols.append("nfts")
|
|
120
145
|
with open(path, "w", newline="", encoding="utf-8") as fh:
|
|
121
146
|
w = csv.writer(fh)
|
|
122
147
|
w.writerow(cols)
|
|
@@ -124,7 +149,7 @@ def export_csv(rows: List[dict], path: Path, chain: str, symbol: str) -> Path:
|
|
|
124
149
|
w.writerow([r["address"], r.get("hop", 1), round(r["value"], 8), r["txs"],
|
|
125
150
|
_ts(r.get("first_ts")), _ts(r.get("last_ts")),
|
|
126
151
|
r.get("into", ""), r.get("label", ""),
|
|
127
|
-
chains.explorer_url(r["address"], chain)])
|
|
152
|
+
chains.explorer_url(r["address"], chain)] + ([r.get("nfts", 0)] if nfts else []))
|
|
128
153
|
return path
|
|
129
154
|
|
|
130
155
|
|
|
@@ -242,7 +242,7 @@ def flows(address: str, chain: str, top: int, direction: str = "out",
|
|
|
242
242
|
if r.get(near) != me:
|
|
243
243
|
continue
|
|
244
244
|
other = r.get(far)
|
|
245
|
-
if not other or r.get("value", 0) <= 0:
|
|
245
|
+
if not other or r.get("value", 0) <= 0 or r.get("kind"):
|
|
246
246
|
continue
|
|
247
247
|
rec = agg.setdefault(other, [0.0, 0])
|
|
248
248
|
rec[0] += r["value"]
|
|
@@ -166,6 +166,22 @@ def profile(
|
|
|
166
166
|
console.print("[dim]No transactions found for this address on this chain.[/dim]")
|
|
167
167
|
|
|
168
168
|
|
|
169
|
+
@app.command()
|
|
170
|
+
def check(
|
|
171
|
+
address: str = typer.Argument(..., help="Your wallet address"),
|
|
172
|
+
chain: str = CHAIN_OPT,
|
|
173
|
+
):
|
|
174
|
+
"""Was this wallet drained? Who took it, whether others were hit too, what to do now."""
|
|
175
|
+
from crypttrace import drain
|
|
176
|
+
try:
|
|
177
|
+
with console.status("Reading the wallet's history…"):
|
|
178
|
+
res = drain.check(address, chain)
|
|
179
|
+
except (chains_mod.ChainError, etherscan.EtherscanError) as e:
|
|
180
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
181
|
+
raise typer.Exit(1)
|
|
182
|
+
console.print(render.drain_report(res))
|
|
183
|
+
|
|
184
|
+
|
|
169
185
|
@app.command()
|
|
170
186
|
def trace(
|
|
171
187
|
address: str = typer.Argument(..., help="Starting address (0x…)"),
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Was this wallet drained? — the check a victim can run on their own address.
|
|
2
|
+
|
|
3
|
+
A drain looks the same on every chain where the attacker holds the key: within
|
|
4
|
+
minutes, several signed transfers move everything — tokens, NFTs, then the coin
|
|
5
|
+
itself — to one address the wallet never paid before, and the wallet is left
|
|
6
|
+
empty. Nobody approves a dozen transfers a minute by hand, so a burst like that
|
|
7
|
+
means the key or seed phrase is in someone else's hands.
|
|
8
|
+
|
|
9
|
+
Three more facts turn "my wallet is empty" into something a victim can act on:
|
|
10
|
+
- the same address emptied many other wallets in the same hour: a mass drain
|
|
11
|
+
(leaked keys from a shared service or tool), not a scam aimed at you;
|
|
12
|
+
- money that arrived after the drain was taken too: the attacker still has
|
|
13
|
+
the key, and anything sent to this wallet will be lost;
|
|
14
|
+
- where the money went next — an exchange, with the deposit memo that names
|
|
15
|
+
the account there.
|
|
16
|
+
"""
|
|
17
|
+
from collections import defaultdict
|
|
18
|
+
from typing import Dict, List, Optional
|
|
19
|
+
|
|
20
|
+
from crypttrace import analysis, chains, spam
|
|
21
|
+
from crypttrace.labels import labels
|
|
22
|
+
|
|
23
|
+
WINDOW = 15 * 60 # a drain empties a wallet within minutes
|
|
24
|
+
BURST = 120 # signed transfers this close together are automated
|
|
25
|
+
MASS_SENDERS = 10 # wallets swept into one address within an hour of yours
|
|
26
|
+
MASS_SPAN = 3600
|
|
27
|
+
_ALL_TOKENS = {"contract": None, "symbol": "*"}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _history(address: str, chain: str, limit: int) -> List[dict]:
|
|
31
|
+
rows = list(chains.transfers(address, chain, limit))
|
|
32
|
+
if chain != "btc":
|
|
33
|
+
try:
|
|
34
|
+
rows += [r for r in chains.transfers(address, chain, limit, asset=_ALL_TOKENS)
|
|
35
|
+
if not spam.verdict(chain, r.get("contract", ""), r.get("symbol", ""),
|
|
36
|
+
r.get("name", ""), bool(r.get("flagged")))]
|
|
37
|
+
except chains.ChainError:
|
|
38
|
+
pass
|
|
39
|
+
if chain == "ton":
|
|
40
|
+
from crypttrace.fetchers import ton
|
|
41
|
+
try:
|
|
42
|
+
rows += ton.nft_transfers(address, limit)
|
|
43
|
+
except ton.TonError:
|
|
44
|
+
pass
|
|
45
|
+
return rows
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _best_burst(rows: List[dict], native: str) -> Optional[dict]:
|
|
49
|
+
"""The WINDOW of outgoing transfers to one destination that took the most:
|
|
50
|
+
most kinds of asset first, then the most of the chain's own coin."""
|
|
51
|
+
by_dest: Dict[str, List[dict]] = defaultdict(list)
|
|
52
|
+
for r in rows:
|
|
53
|
+
by_dest[r["to"]].append(r)
|
|
54
|
+
best = None
|
|
55
|
+
for dest, rs in by_dest.items():
|
|
56
|
+
rs.sort(key=lambda r: r["timestamp"])
|
|
57
|
+
for i, first in enumerate(rs):
|
|
58
|
+
win = [r for r in rs[i:] if r["timestamp"] - first["timestamp"] <= WINDOW]
|
|
59
|
+
assets = {r.get("symbol") for r in win}
|
|
60
|
+
coin = sum(r.get("value", 0) or 0 for r in win if r.get("symbol") == native)
|
|
61
|
+
score = (len(assets), coin, len({r.get("hash") for r in win}))
|
|
62
|
+
if best is None or score > best["score"]:
|
|
63
|
+
best = {"score": score, "to": dest, "rows": win,
|
|
64
|
+
"start": first["timestamp"], "end": win[-1]["timestamp"]}
|
|
65
|
+
return best
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _amounts(rows: List[dict]) -> Dict[str, float]:
|
|
69
|
+
tot: Dict[str, float] = defaultdict(float)
|
|
70
|
+
for r in rows:
|
|
71
|
+
tot[r.get("symbol") or "?"] += r.get("value", 0) or 0
|
|
72
|
+
return dict(tot)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def check(address: str, chain: str, limit: int = 1000) -> Dict:
|
|
76
|
+
"""Verdict on one wallet: drained (and how), suspicious, or no drain found."""
|
|
77
|
+
me = chains.norm_addr(address, chain)
|
|
78
|
+
rows = _history(address, chain, limit)
|
|
79
|
+
out = [r for r in rows if r.get("from") == me and r.get("to") and r.get("to") != me
|
|
80
|
+
and not r.get("kind") and (r.get("value") or 0) > 0]
|
|
81
|
+
res: Dict = {"address": me, "chain": chain, "verdict": "no_drain", "reasons": []}
|
|
82
|
+
try:
|
|
83
|
+
res["balance"] = chains.balance(address, chain)
|
|
84
|
+
except chains.ChainError:
|
|
85
|
+
res["balance"] = None
|
|
86
|
+
if not out:
|
|
87
|
+
res["reasons"].append("this wallet has sent nothing in the history read")
|
|
88
|
+
return res
|
|
89
|
+
|
|
90
|
+
burst = _best_burst(out, chains.symbol(chain))
|
|
91
|
+
dest, win = burst["to"], burst["rows"]
|
|
92
|
+
labels.enrich([dest], chain)
|
|
93
|
+
paid_before = any(r.get("to") == dest and r["timestamp"] < burst["start"] for r in out)
|
|
94
|
+
times = sorted(r["timestamp"] for r in win)
|
|
95
|
+
tight = sum(1 for a, b in zip(times, times[1:]) if b - a <= BURST)
|
|
96
|
+
dust = analysis.dust_threshold(chain)
|
|
97
|
+
emptied = res["balance"] is not None and res["balance"] <= max(dust, 1e-9) * 5
|
|
98
|
+
nfts = sum(1 for r in win if r.get("symbol") == "NFT")
|
|
99
|
+
assets = _amounts([r for r in win if r.get("symbol") != "NFT"])
|
|
100
|
+
res.update({"to": dest, "to_label": labels.label_of(dest), "to_type": labels.type_of(dest),
|
|
101
|
+
"start": burst["start"], "end": burst["end"], "transfers": len({r.get("hash") for r in win}),
|
|
102
|
+
"assets": assets, "nfts": nfts, "emptied": emptied, "new_destination": not paid_before,
|
|
103
|
+
"tight_pairs": tight})
|
|
104
|
+
|
|
105
|
+
# a mass drain: the same address swept many wallets around the same time
|
|
106
|
+
try:
|
|
107
|
+
inflow = _history(dest, chain, limit) if labels.type_of(dest) != "exchange" else []
|
|
108
|
+
except chains.ChainError:
|
|
109
|
+
inflow = []
|
|
110
|
+
senders = {r["from"] for r in inflow
|
|
111
|
+
if r.get("to") == dest and not r.get("kind") and r.get("from") != dest
|
|
112
|
+
and abs(r["timestamp"] - burst["start"]) <= MASS_SPAN}
|
|
113
|
+
res["other_wallets_same_hour"] = max(0, len(senders) - 1)
|
|
114
|
+
mass = res["other_wallets_same_hour"] >= MASS_SENDERS
|
|
115
|
+
|
|
116
|
+
automated = tight >= 2 or (len(assets) + (1 if nfts else 0)) >= 2 or mass
|
|
117
|
+
if labels.type_of(dest) == "exchange":
|
|
118
|
+
res["reasons"].append("the largest outflow went to an exchange — usually your own deposit")
|
|
119
|
+
elif emptied and automated and not paid_before:
|
|
120
|
+
res["verdict"] = "drained"
|
|
121
|
+
elif emptied and not paid_before:
|
|
122
|
+
res["verdict"] = "suspicious"
|
|
123
|
+
if res["verdict"] == "no_drain":
|
|
124
|
+
return res
|
|
125
|
+
|
|
126
|
+
if tight >= 2 or len(assets) + (1 if nfts else 0) >= 2:
|
|
127
|
+
res["reasons"].append(f"{res['transfers']} transfers to one new address within "
|
|
128
|
+
f"{max(1, (burst['end'] - burst['start']) // 60)} min, "
|
|
129
|
+
"several seconds apart — signed by a program holding your key")
|
|
130
|
+
if emptied:
|
|
131
|
+
res["reasons"].append("the wallet was left empty")
|
|
132
|
+
if labels.type_of(dest) == "scam":
|
|
133
|
+
res["reasons"].append(f"the receiving address is a known drainer: {labels.label_of(dest)}")
|
|
134
|
+
|
|
135
|
+
if mass:
|
|
136
|
+
res["reasons"].append(f"the same address took money from {res['other_wallets_same_hour']} other "
|
|
137
|
+
"wallets within an hour — a mass drain from leaked keys, not a scam aimed at you")
|
|
138
|
+
|
|
139
|
+
# taken again after the drain: the attacker still has the key
|
|
140
|
+
later = [r for r in out if r["timestamp"] > burst["end"] + 60 and r.get("to") == dest]
|
|
141
|
+
res["resweeps"] = len({r.get("hash") for r in later})
|
|
142
|
+
if res["resweeps"]:
|
|
143
|
+
res["reasons"].append(f"{res['resweeps']} later transfer(s) to the same address after the wallet "
|
|
144
|
+
"was topped up — the attacker still has the key")
|
|
145
|
+
|
|
146
|
+
# where it went next: exchange deposits (with memo) of the receiver and its next hop
|
|
147
|
+
nxt = []
|
|
148
|
+
try:
|
|
149
|
+
hops = [dest] + [o for o, _, _ in chains.outflows(dest, chain, 3)]
|
|
150
|
+
labels.enrich(hops, chain)
|
|
151
|
+
for h in hops:
|
|
152
|
+
for d in chains.exchange_deposits(h, chain):
|
|
153
|
+
nxt.append({**d, "from": h})
|
|
154
|
+
if h != dest and labels.type_of(h) == "exchange":
|
|
155
|
+
nxt.append({"exchange_address": h, "exchange": labels.label_of(h), "memo": "",
|
|
156
|
+
"value": None, "txs": None, "symbol": "", "from": dest})
|
|
157
|
+
except chains.ChainError:
|
|
158
|
+
pass
|
|
159
|
+
res["next"] = nxt
|
|
160
|
+
|
|
161
|
+
# what can still be saved
|
|
162
|
+
if chain == "ton":
|
|
163
|
+
res["remaining_tokens"], res["remaining_nfts"] = _ton_left(me)
|
|
164
|
+
else:
|
|
165
|
+
keep = []
|
|
166
|
+
try:
|
|
167
|
+
keep = [h for h in chains.token_holdings(address, chain) if not h.get("spam")]
|
|
168
|
+
except chains.ChainError:
|
|
169
|
+
pass
|
|
170
|
+
res["remaining_tokens"] = [{"symbol": h["symbol"], "amount": h["net"]} for h in keep[:10]]
|
|
171
|
+
return res
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _ton_left(address: str):
|
|
175
|
+
"""Jettons the wallet holds now (not spam), and NFTs from collections tonapi trusts —
|
|
176
|
+
airdropped spam NFTs are not worth the gas to move."""
|
|
177
|
+
from crypttrace.fetchers import http, ton
|
|
178
|
+
tokens = []
|
|
179
|
+
try:
|
|
180
|
+
for w in ton._get("/jetton/wallets", {"owner_address": address, "limit": 100}).get("jetton_wallets") or []:
|
|
181
|
+
master = (w.get("jetton") or "").lower()
|
|
182
|
+
meta = ton._meta(master)
|
|
183
|
+
if not meta or not int(w.get("balance") or 0):
|
|
184
|
+
continue
|
|
185
|
+
if spam.verdict("ton", master, meta["symbol"], meta["name"], meta["scam"]):
|
|
186
|
+
continue
|
|
187
|
+
tokens.append({"symbol": meta["symbol"], "amount": int(w["balance"]) / 10 ** meta["decimals"]})
|
|
188
|
+
except (ton.TonError, ValueError, TypeError):
|
|
189
|
+
pass
|
|
190
|
+
nfts = None
|
|
191
|
+
try:
|
|
192
|
+
items = http.request_json(f"https://tonapi.io/v2/accounts/{address}/nfts", {"limit": 100},
|
|
193
|
+
timeout=20).get("nft_items") or []
|
|
194
|
+
nfts = sum(1 for i in items if i.get("trust") == "whitelist" or i.get("approved_by"))
|
|
195
|
+
except Exception: # tonapi down: say nothing rather than guess
|
|
196
|
+
pass
|
|
197
|
+
return tokens[:10], nfts
|
|
@@ -70,6 +70,21 @@ def _addr(raw: Optional[str]) -> str:
|
|
|
70
70
|
return addresses.ton_friendly(raw or "") or (raw or "")
|
|
71
71
|
|
|
72
72
|
|
|
73
|
+
# messages contracts send back as part of a transfer: gas refunds ("excesses"),
|
|
74
|
+
# jetton and NFT arrival notices, bounces. They carry TON, but nobody *sent* it.
|
|
75
|
+
SERVICE_OPS = {"0xd53276db", "0x7362d09c", "0x05138d91", "0xffffffff"}
|
|
76
|
+
MESSAGE_MAX = 0.05 # a comment with less TON than this is a note, not a payment
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def kind_of(opcode, memo: str, value: float) -> str:
|
|
80
|
+
"""'service' for contract plumbing, 'message' for a note sent to an address, else ''."""
|
|
81
|
+
if opcode in SERVICE_OPS:
|
|
82
|
+
return "service"
|
|
83
|
+
if memo and value < MESSAGE_MAX:
|
|
84
|
+
return "message"
|
|
85
|
+
return ""
|
|
86
|
+
|
|
87
|
+
|
|
73
88
|
def _memo(text) -> str:
|
|
74
89
|
"""A transfer comment, cleaned — exchanges read deposit memos from it."""
|
|
75
90
|
return str(text or "").replace("\x00", "").strip()
|
|
@@ -110,6 +125,9 @@ def transfers(address: str, limit: int = 1000) -> List[Dict]:
|
|
|
110
125
|
memo = _memo(((m.get("message_content") or {}).get("decoded") or {}).get("comment"))
|
|
111
126
|
if memo:
|
|
112
127
|
row["memo"] = memo
|
|
128
|
+
kind = kind_of(m.get("opcode"), memo, value)
|
|
129
|
+
if kind:
|
|
130
|
+
row["kind"] = kind
|
|
113
131
|
rows.append(row)
|
|
114
132
|
return rows
|
|
115
133
|
|
|
@@ -135,6 +153,19 @@ def _meta(master: str) -> Optional[dict]:
|
|
|
135
153
|
return _META[master]
|
|
136
154
|
|
|
137
155
|
|
|
156
|
+
def nft_transfers(address: str, limit: int = 1000) -> List[Dict]:
|
|
157
|
+
"""NFTs (Telegram usernames, numbers, domains, collectibles) moved to or from `address`."""
|
|
158
|
+
rows = []
|
|
159
|
+
for t in _pages("/nft/transfers", "nft_transfers", {"owner_address": address}, limit, JETTON_PAGE):
|
|
160
|
+
if t.get("transaction_aborted"):
|
|
161
|
+
continue
|
|
162
|
+
rows.append({"from": _addr(t.get("old_owner")), "to": _addr(t.get("new_owner")),
|
|
163
|
+
"value": 1.0, "symbol": "NFT", "timestamp": int(t.get("transaction_now") or 0),
|
|
164
|
+
"hash": _hex(t.get("transaction_hash", "")), "item": _addr(t.get("nft_address")),
|
|
165
|
+
"contract": (t.get("nft_collection") or t.get("nft_address") or "").lower()})
|
|
166
|
+
return rows
|
|
167
|
+
|
|
168
|
+
|
|
138
169
|
def jetton_transfers(address: str, limit: int = 1000) -> List[Dict]:
|
|
139
170
|
"""Jetton (token) transfers to and from `address`, between owner wallets."""
|
|
140
171
|
rows = []
|
|
@@ -30,7 +30,7 @@ def first_funder(address: str, chain: str = "eth") -> Optional[dict]:
|
|
|
30
30
|
dust = analysis.dust_threshold(chain)
|
|
31
31
|
for r in rows: # ascending => first match is the earliest funding
|
|
32
32
|
# dust is not funding: it is what poisoners and spammers send everyone
|
|
33
|
-
if r.get("to") != me or r.get("value", 0) < max(dust, 1e-18):
|
|
33
|
+
if r.get("to") != me or r.get("value", 0) < max(dust, 1e-18) or r.get("kind"):
|
|
34
34
|
continue
|
|
35
35
|
return {
|
|
36
36
|
"funder": r.get("from", ""),
|
|
@@ -141,10 +141,13 @@ def sources_table(rows: list, symbol: str, top: int = 25) -> Table:
|
|
|
141
141
|
t.add_column(f"Sent ({symbol})", justify="right")
|
|
142
142
|
t.add_column("Txs", justify="right")
|
|
143
143
|
t.add_column("When (UTC)")
|
|
144
|
+
nfts = any(r.get("nfts") for r in rows)
|
|
145
|
+
if nfts:
|
|
146
|
+
t.add_column("NFTs", justify="right")
|
|
144
147
|
for r in rows[:top]:
|
|
145
148
|
t.add_row(addr_label(r["address"]), str(r.get("hop", 1)),
|
|
146
|
-
f"{r['value']:.8f}".rstrip("0").rstrip("."), str(r["txs"]),
|
|
147
|
-
_ts(str(r.get("first_ts") or 0)))
|
|
149
|
+
f"{r['value']:.8f}".rstrip("0").rstrip(".") or "0", str(r["txs"]),
|
|
150
|
+
_ts(str(r.get("first_ts") or 0)), *([str(r.get("nfts") or "")] if nfts else []))
|
|
148
151
|
return t
|
|
149
152
|
|
|
150
153
|
|
|
@@ -224,6 +227,68 @@ def funding_tree(address: str, hops: list, symbol: str = "ETH") -> Tree:
|
|
|
224
227
|
return root
|
|
225
228
|
|
|
226
229
|
|
|
230
|
+
def drain_report(res: dict):
|
|
231
|
+
"""What `crypttrace check` prints: verdict, what was taken, where it went, what to do."""
|
|
232
|
+
from rich.console import Group
|
|
233
|
+
from rich.panel import Panel
|
|
234
|
+
verdict = {
|
|
235
|
+
"drained": ("[bold red]DRAINED[/bold red] — someone who has this wallet's key or seed "
|
|
236
|
+
"phrase emptied it.", "red"),
|
|
237
|
+
"suspicious": ("[bold yellow]POSSIBLY DRAINED[/bold yellow] — the wallet was emptied to a new "
|
|
238
|
+
"address. If you did not send it, treat the key as leaked.", "yellow"),
|
|
239
|
+
"no_drain": ("[bold green]No drain found[/bold green] in the history read.", "green"),
|
|
240
|
+
}[res["verdict"]]
|
|
241
|
+
parts = [Text.from_markup(verdict[0])]
|
|
242
|
+
for r in res.get("reasons", []):
|
|
243
|
+
parts.append(Text(f" • {r}"))
|
|
244
|
+
if res["verdict"] != "no_drain":
|
|
245
|
+
taken = ", ".join(f"{v:,.4f} {k}" for k, v in res.get("assets", {}).items())
|
|
246
|
+
if res.get("nfts"):
|
|
247
|
+
taken += f"{', ' if taken else ''}{res['nfts']} NFT"
|
|
248
|
+
t = Table(show_header=False, box=None, pad_edge=False)
|
|
249
|
+
t.add_column(style="dim")
|
|
250
|
+
t.add_column()
|
|
251
|
+
t.add_row("when (UTC)", _ts(str(res.get("start") or 0)))
|
|
252
|
+
t.add_row("taken", escape(taken or "—"))
|
|
253
|
+
t.add_row("to", escape(res["to"] + (f" [{res['to_label']}]" if res.get("to_label") else "")))
|
|
254
|
+
if res.get("other_wallets_same_hour") is not None:
|
|
255
|
+
t.add_row("same hour", f"this address emptied {res['other_wallets_same_hour']} other wallet(s)")
|
|
256
|
+
parts += [Text(""), t]
|
|
257
|
+
if res.get("next"):
|
|
258
|
+
nx = Table(title="Where the money went next — the memo names one account at the exchange",
|
|
259
|
+
header_style="bold")
|
|
260
|
+
nx.add_column("Exchange")
|
|
261
|
+
nx.add_column("Memo", style="bold green")
|
|
262
|
+
nx.add_column("Amount", justify="right")
|
|
263
|
+
nx.add_column("Deposited from")
|
|
264
|
+
for d in res["next"]:
|
|
265
|
+
amt = f"{d['value']:,.4f} {d['symbol']}" if d.get("value") is not None else "—"
|
|
266
|
+
nx.add_row(escape(d["exchange"]), escape(d.get("memo") or "—"), amt,
|
|
267
|
+
escape(d["from"][:10] + "…" + d["from"][-6:]))
|
|
268
|
+
parts += [Text(""), nx]
|
|
269
|
+
steps = ["This seed phrase is burned. Make a new wallet with a new seed phrase; never type it "
|
|
270
|
+
"into a site, bot or \"support\" chat again.",
|
|
271
|
+
"Send nothing to this wallet — " + ("the attacker took money that arrived after the "
|
|
272
|
+
"drain, too." if res.get("resweeps") else
|
|
273
|
+
"anything that arrives can be taken."),
|
|
274
|
+
"Other wallets made from the same seed phrase (other versions, other networks) are "
|
|
275
|
+
"exposed too: move them first."]
|
|
276
|
+
left = [f"{x['amount']:,.4f} {x['symbol']}" for x in res.get("remaining_tokens", [])]
|
|
277
|
+
if res.get("remaining_nfts"):
|
|
278
|
+
left.append(f"{res['remaining_nfts']} NFT")
|
|
279
|
+
if left:
|
|
280
|
+
steps.insert(1, "Move what is still here to the new wallet right away: " + ", ".join(left) + ".")
|
|
281
|
+
if any(d.get("memo") for d in res.get("next", [])):
|
|
282
|
+
steps.append("Report it to the police and to the exchange above, quoting the memo and the "
|
|
283
|
+
"transaction: the exchange can freeze the account and identify its owner.")
|
|
284
|
+
else:
|
|
285
|
+
steps.append("Report it to the police with this wallet, the receiving address and the time.")
|
|
286
|
+
parts += [Text(""), Text("What to do now", style="bold")]
|
|
287
|
+
parts += [Text(f" {i}. {s}") for i, s in enumerate(steps, 1)]
|
|
288
|
+
return Panel(Group(*parts), title=f"Drain check — {res['address']} ({res['chain']})",
|
|
289
|
+
border_style=verdict[1])
|
|
290
|
+
|
|
291
|
+
|
|
227
292
|
def deposits_table(deposits: list) -> Table:
|
|
228
293
|
"""Exchange deposits made with a memo — each memo names one account at the exchange."""
|
|
229
294
|
t = Table(title="Exchange deposits — the memo names one account at the exchange (KYC)",
|
|
@@ -77,6 +77,15 @@ def _conn() -> sqlite3.Connection:
|
|
|
77
77
|
c.execute("DELETE FROM fetched WHERE chain='ton'")
|
|
78
78
|
c.execute("PRAGMA user_version=2")
|
|
79
79
|
c.commit()
|
|
80
|
+
if c.execute("PRAGMA user_version").fetchone()[0] < 3:
|
|
81
|
+
# 0.15: TON rows say whether they are contract plumbing or a note
|
|
82
|
+
cols = [r[1] for r in c.execute("PRAGMA table_info(transfers)")]
|
|
83
|
+
if "kind" not in cols:
|
|
84
|
+
c.execute("ALTER TABLE transfers ADD COLUMN kind TEXT")
|
|
85
|
+
c.execute("DELETE FROM transfers WHERE chain='ton'")
|
|
86
|
+
c.execute("DELETE FROM fetched WHERE chain='ton'")
|
|
87
|
+
c.execute("PRAGMA user_version=3")
|
|
88
|
+
c.commit()
|
|
80
89
|
return c
|
|
81
90
|
|
|
82
91
|
|
|
@@ -100,12 +109,13 @@ def save(chain: str, address: str, rows: List[dict], asset_key: str = "",
|
|
|
100
109
|
try:
|
|
101
110
|
c.executemany(
|
|
102
111
|
"INSERT OR REPLACE INTO transfers"
|
|
103
|
-
"(id,chain,tx_hash,from_addr,to_addr,value,symbol,contract,ts,fee_share,memo)"
|
|
104
|
-
" VALUES (
|
|
112
|
+
"(id,chain,tx_hash,from_addr,to_addr,value,symbol,contract,ts,fee_share,memo,kind)"
|
|
113
|
+
" VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
105
114
|
[(_row_id(chain, r), chain, r.get("hash", ""), r.get("from", ""),
|
|
106
115
|
r.get("to", ""), float(r.get("value", 0) or 0), r.get("symbol", ""),
|
|
107
116
|
r.get("contract", ""), int(r.get("timestamp", 0) or 0),
|
|
108
|
-
float(r.get("fee_share", 0) or 0), r.get("memo") or None
|
|
117
|
+
float(r.get("fee_share", 0) or 0), r.get("memo") or None,
|
|
118
|
+
r.get("kind") or None) for r in rows])
|
|
109
119
|
c.execute("INSERT OR REPLACE INTO fetched(chain,address,asset,fetched_at,rows,complete)"
|
|
110
120
|
" VALUES (?,?,?,?,?,?)",
|
|
111
121
|
(chain, address, asset_key, int(time.time()), len(rows), int(complete)))
|
|
@@ -135,7 +145,7 @@ def is_fresh(chain: str, address: str, asset_key: str = "",
|
|
|
135
145
|
def load(chain: str, address: str, contract: str = "", native_symbol: str = "") -> List[dict]:
|
|
136
146
|
"""Every stored transfer touching this address, newest first."""
|
|
137
147
|
c = _conn()
|
|
138
|
-
cols = "SELECT from_addr,to_addr,value,symbol,contract,ts,tx_hash,fee_share,memo FROM transfers"
|
|
148
|
+
cols = "SELECT from_addr,to_addr,value,symbol,contract,ts,tx_hash,fee_share,memo,kind FROM transfers"
|
|
139
149
|
try:
|
|
140
150
|
if contract == "*":
|
|
141
151
|
# every token, whatever its contract (address-poisoning checks need fakes too)
|
|
@@ -161,7 +171,7 @@ def load(chain: str, address: str, contract: str = "", native_symbol: str = "")
|
|
|
161
171
|
finally:
|
|
162
172
|
c.close()
|
|
163
173
|
out = []
|
|
164
|
-
for f, t, v, sym, con, ts, h, fee, memo in rows:
|
|
174
|
+
for f, t, v, sym, con, ts, h, fee, memo, kind in rows:
|
|
165
175
|
r = {"from": f, "to": t, "value": v, "symbol": sym,
|
|
166
176
|
"timestamp": ts, "hash": h}
|
|
167
177
|
if con:
|
|
@@ -170,6 +180,8 @@ def load(chain: str, address: str, contract: str = "", native_symbol: str = "")
|
|
|
170
180
|
r["fee_share"] = fee
|
|
171
181
|
if memo:
|
|
172
182
|
r["memo"] = memo
|
|
183
|
+
if kind:
|
|
184
|
+
r["kind"] = kind
|
|
173
185
|
out.append(r)
|
|
174
186
|
return out
|
|
175
187
|
|
|
@@ -21,6 +21,9 @@ from crypttrace.fetchers import bitcoin
|
|
|
21
21
|
# how far computed and reported totals may drift before we complain
|
|
22
22
|
REL_TOLERANCE = 0.005 # 0.5%
|
|
23
23
|
ABS_TOLERANCE = 1e-6
|
|
24
|
+
# fee an account-based chain may charge per outgoing transaction, which never
|
|
25
|
+
# shows up as a transfer: the net-vs-balance check allows this much per tx
|
|
26
|
+
GAS_PER_TX = {"ton": 0.02}
|
|
24
27
|
|
|
25
28
|
|
|
26
29
|
def _within(a: float, b: float) -> bool:
|
|
@@ -99,7 +102,9 @@ def reconcile(address: str, chain: str = "btc", asset: Optional[dict] = None,
|
|
|
99
102
|
if ref_in is None and ref_balance is not None and asset is None and complete:
|
|
100
103
|
# no aggregate feed: fall back to net vs balance (fees make it inexact)
|
|
101
104
|
net = got_in - got_out
|
|
102
|
-
|
|
105
|
+
n_out = len({r.get("hash") for r in rows if r.get("from") == me})
|
|
106
|
+
ok = abs(net - ref_balance) <= max(0.01, abs(ref_balance) * 0.02,
|
|
107
|
+
GAS_PER_TX.get(chain, 0) * n_out)
|
|
103
108
|
checks.append(("net vs balance", net, ref_balance, ok))
|
|
104
109
|
if not ok:
|
|
105
110
|
notes.append("net flow differs from the current balance; on account-based "
|
|
@@ -192,6 +192,7 @@
|
|
|
192
192
|
.t-unknown,.t-offramp{color:var(--dim);background:rgba(147,161,181,.12)}
|
|
193
193
|
.muted{color:var(--dim);font-size:13px;line-height:1.55}
|
|
194
194
|
.err{color:var(--red);font-size:13px}
|
|
195
|
+
.warn{color:#ffc857;font-size:13px}
|
|
195
196
|
.alert{border-color:rgba(255,107,96,.4);background:rgba(255,107,96,.08)}
|
|
196
197
|
a.btnlink{display:inline-block;margin-top:9px;font-size:12.5px;color:var(--accent);text-decoration:none;
|
|
197
198
|
padding:7px 12px;border-radius:10px;background:rgba(77,157,255,.10);border:1px solid rgba(77,157,255,.28);transition:.25s}
|
|
@@ -248,6 +249,7 @@
|
|
|
248
249
|
<button class="tab" data-view="timeline">Timeline</button>
|
|
249
250
|
<button class="tab" data-view="victims">Who sent funds here</button>
|
|
250
251
|
<button class="tab" data-view="poison">Poisoning</button>
|
|
252
|
+
<button class="tab" data-view="check">Was I drained?</button>
|
|
251
253
|
</div>
|
|
252
254
|
<div class="stage">
|
|
253
255
|
<div class="loader" id="loader"></div>
|
|
@@ -258,6 +260,7 @@
|
|
|
258
260
|
<div class="view" id="v-timeline"></div>
|
|
259
261
|
<div class="view" id="v-victims"></div>
|
|
260
262
|
<div class="view" id="v-poison"></div>
|
|
263
|
+
<div class="view" id="v-check"></div>
|
|
261
264
|
<div class="scaleview" id="scaleview" style="display:none">100%</div>
|
|
262
265
|
<div class="zoomctl" id="zoomctl">
|
|
263
266
|
<button id="zin" title="Zoom in">+</button>
|
|
@@ -536,6 +539,7 @@ function showView(v){
|
|
|
536
539
|
$('#v-timeline').classList.toggle('on',v==='timeline');
|
|
537
540
|
$('#v-victims').classList.toggle('on',v==='victims');
|
|
538
541
|
$('#v-poison').classList.toggle('on',v==='poison');
|
|
542
|
+
$('#v-check').classList.toggle('on',v==='check');
|
|
539
543
|
const a=$('#addr').value.trim();
|
|
540
544
|
if(a && !loaded[v+a]) {
|
|
541
545
|
loaded[v+a]=true;
|
|
@@ -543,6 +547,7 @@ function showView(v){
|
|
|
543
547
|
if(v==='victims') loadVictims(a);
|
|
544
548
|
if(v==='assess') loadAssessment(a);
|
|
545
549
|
if(v==='poison') loadPoisoning(a);
|
|
550
|
+
if(v==='check') loadCheck(a);
|
|
546
551
|
}
|
|
547
552
|
}
|
|
548
553
|
document.querySelectorAll('.tab').forEach(t=>t.onclick=()=>showView(t.dataset.view));
|
|
@@ -610,6 +615,46 @@ async function loadAssessment(a){
|
|
|
610
615
|
}
|
|
611
616
|
|
|
612
617
|
let victimRows=[], victimSym='';
|
|
618
|
+
async function loadCheck(a){
|
|
619
|
+
const el=$('#v-check');
|
|
620
|
+
el.innerHTML='<div class="muted"><span class="spin"></span>Reading the wallet\'s history…</div>';
|
|
621
|
+
try{
|
|
622
|
+
const r=await api(`/api/check?address=${q(a)}&chain=${chain()}`);
|
|
623
|
+
const head={drained:['err','DRAINED — someone who has this wallet\'s key or seed phrase emptied it.'],
|
|
624
|
+
suspicious:['warn','POSSIBLY DRAINED — the wallet was emptied to a new address. If you did not send it, treat the key as leaked.'],
|
|
625
|
+
no_drain:['muted','No drain found in the history read.']}[r.verdict];
|
|
626
|
+
let h=`<div class="${head[0]}" style="font-weight:600;margin-bottom:10px">${esc(head[1])}</div>`;
|
|
627
|
+
h+=(r.reasons||[]).map(x=>`<div>• ${esc(x)}</div>`).join('');
|
|
628
|
+
if(r.verdict!=='no_drain'){
|
|
629
|
+
const taken=Object.entries(r.assets||{}).map(([k,v])=>`${Number(v).toLocaleString()} ${esc(k)}`)
|
|
630
|
+
.concat(r.nfts?[`${r.nfts} NFT`]:[]).join(', ');
|
|
631
|
+
h+=`<table class="grid" style="margin-top:12px"><tbody>
|
|
632
|
+
<tr><td>when (UTC)</td><td>${dstamp(r.start)}</td></tr>
|
|
633
|
+
<tr><td>taken</td><td>${taken||'—'}</td></tr>
|
|
634
|
+
<tr><td>to</td><td class="mono"><a href="${safeUrl(r.to_explorer)}" target="_blank" rel="noopener">${esc(r.to)}</a>
|
|
635
|
+
${r.to_label?`<span class="pill t-${esc(r.to_type)}" style="margin-left:6px">${esc(r.to_label)}</span>`:''}</td></tr>
|
|
636
|
+
<tr><td>same hour</td><td>this address emptied ${r.other_wallets_same_hour??'?'} other wallet(s)</td></tr>
|
|
637
|
+
</tbody></table>`;
|
|
638
|
+
if((r.next||[]).length){
|
|
639
|
+
h+=`<h3 style="margin-top:16px">Where the money went next</h3>
|
|
640
|
+
<div class="muted">The memo names one account at the exchange — quote it with the transaction when you report.</div>
|
|
641
|
+
<table class="grid"><thead><tr><th>Exchange</th><th>Memo</th><th style="text-align:right">Amount</th></tr></thead><tbody>`+
|
|
642
|
+
r.next.map(d=>`<tr><td>${esc(d.exchange)}</td><td class="mono"><b>${esc(d.memo||'—')}</b></td>
|
|
643
|
+
<td class="num">${d.value!=null?Number(d.value).toLocaleString()+' '+esc(d.symbol):'—'}</td></tr>`).join('')+'</tbody></table>';
|
|
644
|
+
}
|
|
645
|
+
const left=(r.remaining_tokens||[]).map(x=>`${Number(x.amount).toLocaleString()} ${esc(x.symbol)}`)
|
|
646
|
+
.concat(r.remaining_nfts?[`${r.remaining_nfts} NFT`]:[]);
|
|
647
|
+
h+=`<h3 style="margin-top:16px">What to do now</h3><ol>
|
|
648
|
+
<li>This seed phrase is burned. Make a new wallet with a new seed phrase; never type it into a site, bot or "support" chat again.</li>
|
|
649
|
+
${left.length?`<li>Move what is still here to the new wallet right away: ${left.join(', ')}.</li>`:''}
|
|
650
|
+
<li>Send nothing to this wallet — ${r.resweeps?'the attacker took money that arrived after the drain, too.':'anything that arrives can be taken.'}</li>
|
|
651
|
+
<li>Other wallets made from the same seed phrase are exposed too: move them first.</li>
|
|
652
|
+
<li>${(r.next||[]).some(d=>d.memo)?'Report it to the police and to the exchange above, quoting the memo and the transaction.':'Report it to the police with this wallet, the receiving address and the time.'}</li></ol>`;
|
|
653
|
+
}
|
|
654
|
+
el.innerHTML=`<div style="max-width:860px">${h}</div>`;
|
|
655
|
+
}catch(e){ el.innerHTML=`<div class="err">${esc(e.message)}</div>`; }
|
|
656
|
+
}
|
|
657
|
+
|
|
613
658
|
async function loadVictims(a){
|
|
614
659
|
const el=$('#v-victims');
|
|
615
660
|
el.innerHTML='<div class="muted"><span class="spin"></span>Walking the money backwards…</div>';
|
|
@@ -185,6 +185,21 @@ def create_app() -> Flask:
|
|
|
185
185
|
v["symbol"] = asset["symbol"] if asset else chains.symbol(chain)
|
|
186
186
|
return jsonify(v)
|
|
187
187
|
|
|
188
|
+
@app.route("/api/check")
|
|
189
|
+
def api_check():
|
|
190
|
+
from crypttrace import drain
|
|
191
|
+
addr = request.args.get("address", "")
|
|
192
|
+
chain = request.args.get("chain", "eth")
|
|
193
|
+
bad = validate(addr, chain)
|
|
194
|
+
if bad:
|
|
195
|
+
return jsonify({"error": bad}), 400
|
|
196
|
+
try:
|
|
197
|
+
res = drain.check(addr, chain)
|
|
198
|
+
except _KNOWN_ERRORS as e:
|
|
199
|
+
return jsonify({"error": str(e)}), 400
|
|
200
|
+
res["to_explorer"] = chains.explorer_url(res["to"], chain) if res.get("to") else None
|
|
201
|
+
return jsonify(res)
|
|
202
|
+
|
|
188
203
|
@app.route("/api/victims")
|
|
189
204
|
def api_victims():
|
|
190
205
|
"""Addresses that fed this wallet — in a mass theft, the victim list."""
|
|
@@ -212,6 +227,7 @@ def create_app() -> Flask:
|
|
|
212
227
|
"address": r["address"], "hop": r.get("hop", 1),
|
|
213
228
|
"value": round(r["value"], 8), "txs": r["txs"],
|
|
214
229
|
"first_seen": r.get("first_ts"), "last_seen": r.get("last_ts"),
|
|
230
|
+
"nfts": r.get("nfts", 0),
|
|
215
231
|
"label": r.get("label", ""), "type": r.get("type", "unknown"),
|
|
216
232
|
"into": r.get("into", ""),
|
|
217
233
|
"explorer": chains.explorer_url(r["address"], chain),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: crypttrace
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.16.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
|
|
@@ -129,6 +129,30 @@ them (`UQ…`); jetton transfers are reported between the owners' wallets.
|
|
|
129
129
|
|
|
130
130
|
---
|
|
131
131
|
|
|
132
|
+
## Was my wallet drained?
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
crypttrace check YOUR_ADDRESS --chain ton
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
For someone who just found their wallet empty. `check` reads the wallet's
|
|
139
|
+
history and answers in plain language:
|
|
140
|
+
|
|
141
|
+
- **drained or not** — several signed transfers moving everything (tokens, NFTs,
|
|
142
|
+
then the coin) to one new address within minutes, leaving the wallet empty:
|
|
143
|
+
a program holding your key did that, not you;
|
|
144
|
+
- **a mass drain?** — whether the same address emptied other wallets in the same
|
|
145
|
+
hour (leaked keys from a shared service or tool, not a scam aimed at you);
|
|
146
|
+
- **is the key still in use?** — money that arrived after the drain and was
|
|
147
|
+
taken too;
|
|
148
|
+
- **where the money went next** — exchange deposits with the memo that names the
|
|
149
|
+
account (see *Exchange deposits with a memo*);
|
|
150
|
+
- **what to do now** — what is still on the wallet worth moving (on TON: real
|
|
151
|
+
jetton balances and NFTs from trusted collections, not airdropped spam), and
|
|
152
|
+
who to report to.
|
|
153
|
+
|
|
154
|
+
The web UI has the same check in its **Was I drained?** tab.
|
|
155
|
+
|
|
132
156
|
## Web UI
|
|
133
157
|
|
|
134
158
|
```bash
|
|
@@ -412,6 +436,13 @@ flip the timing verdict entirely. Each chain has a dust threshold; override with
|
|
|
412
436
|
paged rather than read one page deep, so an old sweep isn't hidden behind recent
|
|
413
437
|
spam.
|
|
414
438
|
|
|
439
|
+
**On TON, only payments count.** A drainer's collector also receives notes
|
|
440
|
+
people send the attacker, dust from look-alike addresses, and gas refunds and
|
|
441
|
+
notices from jetton wallets and NFT items. None of those senders are victims,
|
|
442
|
+
and `victims` leaves them out. It also counts the NFTs (Telegram usernames,
|
|
443
|
+
numbers, domains) each wallet lost — a victim who lost only NFTs is listed too,
|
|
444
|
+
with an `nfts` column in the CSV. Failed (aborted) NFT transfers are not counted.
|
|
445
|
+
|
|
415
446
|
### Bitcoin clustering (common-input-ownership)
|
|
416
447
|
|
|
417
448
|
Bitcoin's UTXO model enables the strongest clustering heuristic in blockchain
|
|
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
|