crypttrace 0.12.0__tar.gz → 0.13.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.12.0/src/crypttrace.egg-info → crypttrace-0.13.0}/PKG-INFO +12 -3
- {crypttrace-0.12.0 → crypttrace-0.13.0}/README.md +11 -2
- {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/__init__.py +1 -1
- {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/chains.py +7 -3
- {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/cli.py +6 -2
- {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/fetchers/ton.py +28 -16
- {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/fetchers/tron.py +1 -1
- {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/labels/known.json +4 -0
- {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/prices.py +12 -2
- {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/render.py +25 -8
- crypttrace-0.13.0/src/crypttrace/spam.py +72 -0
- {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/store.py +11 -0
- {crypttrace-0.12.0 → crypttrace-0.13.0/src/crypttrace.egg-info}/PKG-INFO +12 -3
- {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace.egg-info/SOURCES.txt +1 -0
- {crypttrace-0.12.0 → crypttrace-0.13.0}/LICENSE +0 -0
- {crypttrace-0.12.0 → crypttrace-0.13.0}/pyproject.toml +0 -0
- {crypttrace-0.12.0 → crypttrace-0.13.0}/setup.cfg +0 -0
- {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/addresses.py +0 -0
- {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/analysis.py +0 -0
- {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/assess.py +0 -0
- {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/assets.py +0 -0
- {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/bridges.py +0 -0
- {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/config.py +0 -0
- {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/fetchers/__init__.py +0 -0
- {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/fetchers/bitcoin.py +0 -0
- {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/fetchers/etherscan.py +0 -0
- {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/fetchers/http.py +0 -0
- {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/fetchers/solana.py +0 -0
- {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/freeze.py +0 -0
- {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/funder.py +0 -0
- {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/investigate.py +0 -0
- {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/labels/__init__.py +0 -0
- {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/labels/audit.py +0 -0
- {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/labels/bulk.py +0 -0
- {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/labels/labels.py +0 -0
- {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/labels/partial.json +0 -0
- {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/offramp.py +0 -0
- {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/poisoning.py +0 -0
- {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/report.py +0 -0
- {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/trace.py +0 -0
- {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/verify.py +0 -0
- {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/watch.py +0 -0
- {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/web/index.html +0 -0
- {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace/webapp.py +0 -0
- {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace.egg-info/dependency_links.txt +0 -0
- {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace.egg-info/entry_points.txt +0 -0
- {crypttrace-0.12.0 → crypttrace-0.13.0}/src/crypttrace.egg-info/requires.txt +0 -0
- {crypttrace-0.12.0 → crypttrace-0.13.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.13.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
|
|
@@ -400,7 +400,9 @@ crypttrace timeline bc1qADDRESS --chain btc --buckets 24
|
|
|
400
400
|

|
|
401
401
|
|
|
402
402
|
A worked example — 1,169 swept addresses, 1,082.58 BTC, with the commands to
|
|
403
|
-
reproduce it — is in [`cases/coldcard-2026`](cases/coldcard-2026).
|
|
403
|
+
reproduce it — is in [`cases/coldcard-2026`](cases/coldcard-2026). A TON one — 200
|
|
404
|
+
wallets emptied after a seed leak, followed to a KuCoin deposit memo — is in
|
|
405
|
+
[`cases/fragment-api-ton-2026`](cases/fragment-api-ton-2026).
|
|
404
406
|
|
|
405
407
|
**Dust is filtered by default.** Addresses that become publicly known get spammed
|
|
406
408
|
with tiny transfers, which otherwise bury the transactions that matter and can
|
|
@@ -455,6 +457,14 @@ with USDT would be meaningless.
|
|
|
455
457
|
|
|
456
458
|
Amounts carry approximate USD values (stablecoins pinned to $1, others priced
|
|
457
459
|
via CoinGecko). If pricing is unavailable, USD shows as `—` rather than a guess.
|
|
460
|
+
Only the real stablecoin contract gets the $1 — a token anyone minted and called
|
|
461
|
+
"USDT" is worth nothing.
|
|
462
|
+
|
|
463
|
+
`tokens` hides spam and counterfeit tokens and leaves them out of the total: a
|
|
464
|
+
"USDT" or "TON" that is not the real contract, a name that is a phishing link
|
|
465
|
+
("GRAM AT GRAMEVENT.ORG", "USDT - usdtunloсk.соm" with Cyrillic letters in the
|
|
466
|
+
domain), and on TON anything the indexer flags as a scam. `--all` lists them
|
|
467
|
+
with the reason.
|
|
458
468
|
|
|
459
469
|
### Labels
|
|
460
470
|
|
|
@@ -602,7 +612,6 @@ cases/ # worked investigations with their data
|
|
|
602
612
|
and refreshing these lists as the exchanges republish them
|
|
603
613
|
- Internal transactions (completes `funder` and contract-mediated transfers)
|
|
604
614
|
- More label sources: Chainabuse, CryptoScamDB, exchange deposit-address sets
|
|
605
|
-
- Spam/dust token filtering in `tokens`
|
|
606
615
|
- Entity clustering on EVM via the gas-funding heuristic
|
|
607
616
|
|
|
608
617
|
## Contributing
|
|
@@ -366,7 +366,9 @@ crypttrace timeline bc1qADDRESS --chain btc --buckets 24
|
|
|
366
366
|

|
|
367
367
|
|
|
368
368
|
A worked example — 1,169 swept addresses, 1,082.58 BTC, with the commands to
|
|
369
|
-
reproduce it — is in [`cases/coldcard-2026`](cases/coldcard-2026).
|
|
369
|
+
reproduce it — is in [`cases/coldcard-2026`](cases/coldcard-2026). A TON one — 200
|
|
370
|
+
wallets emptied after a seed leak, followed to a KuCoin deposit memo — is in
|
|
371
|
+
[`cases/fragment-api-ton-2026`](cases/fragment-api-ton-2026).
|
|
370
372
|
|
|
371
373
|
**Dust is filtered by default.** Addresses that become publicly known get spammed
|
|
372
374
|
with tiny transfers, which otherwise bury the transactions that matter and can
|
|
@@ -421,6 +423,14 @@ with USDT would be meaningless.
|
|
|
421
423
|
|
|
422
424
|
Amounts carry approximate USD values (stablecoins pinned to $1, others priced
|
|
423
425
|
via CoinGecko). If pricing is unavailable, USD shows as `—` rather than a guess.
|
|
426
|
+
Only the real stablecoin contract gets the $1 — a token anyone minted and called
|
|
427
|
+
"USDT" is worth nothing.
|
|
428
|
+
|
|
429
|
+
`tokens` hides spam and counterfeit tokens and leaves them out of the total: a
|
|
430
|
+
"USDT" or "TON" that is not the real contract, a name that is a phishing link
|
|
431
|
+
("GRAM AT GRAMEVENT.ORG", "USDT - usdtunloсk.соm" with Cyrillic letters in the
|
|
432
|
+
domain), and on TON anything the indexer flags as a scam. `--all` lists them
|
|
433
|
+
with the reason.
|
|
424
434
|
|
|
425
435
|
### Labels
|
|
426
436
|
|
|
@@ -568,7 +578,6 @@ cases/ # worked investigations with their data
|
|
|
568
578
|
and refreshing these lists as the exchanges republish them
|
|
569
579
|
- Internal transactions (completes `funder` and contract-mediated transfers)
|
|
570
580
|
- More label sources: Chainabuse, CryptoScamDB, exchange deposit-address sets
|
|
571
|
-
- Spam/dust token filtering in `tokens`
|
|
572
581
|
- Entity clustering on EVM via the gas-funding heuristic
|
|
573
582
|
|
|
574
583
|
## Contributing
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""crypttrace — OSINT crypto investigation CLI."""
|
|
2
|
-
__version__ = "0.
|
|
2
|
+
__version__ = "0.13.0"
|
|
@@ -14,6 +14,7 @@ from typing import List, Dict, Optional
|
|
|
14
14
|
|
|
15
15
|
from crypttrace import config
|
|
16
16
|
from crypttrace import addresses as _addresses
|
|
17
|
+
from crypttrace import spam as _spam
|
|
17
18
|
from crypttrace.fetchers import etherscan, bitcoin, http as _http, tron, solana, ton
|
|
18
19
|
|
|
19
20
|
EVM_CHAINS = set(config.CHAINS)
|
|
@@ -127,7 +128,7 @@ def _evm_token(address: str, chain: str, contract: Optional[str], limit: int) ->
|
|
|
127
128
|
rows.append({"from": t.get("from", "").lower(), "to": t.get("to", "").lower(),
|
|
128
129
|
"value": val, "timestamp": int(t.get("timeStamp", "0") or 0),
|
|
129
130
|
"hash": t.get("hash", ""), "symbol": t.get("tokenSymbol", "?"),
|
|
130
|
-
"contract": c})
|
|
131
|
+
"name": t.get("tokenName", ""), "contract": c})
|
|
131
132
|
return rows
|
|
132
133
|
|
|
133
134
|
|
|
@@ -278,8 +279,11 @@ def token_holdings(address: str, chain: str = "eth", limit: int = 1000) -> List[
|
|
|
278
279
|
agg: Dict[str, dict] = {}
|
|
279
280
|
for r in rows:
|
|
280
281
|
key = r.get("contract") or r.get("symbol", "?")
|
|
281
|
-
rec = agg.setdefault(key, {"symbol": r.get("symbol", "?"), "
|
|
282
|
-
"net": 0.0, "txs": 0
|
|
282
|
+
rec = agg.setdefault(key, {"symbol": r.get("symbol", "?"), "name": r.get("name", ""),
|
|
283
|
+
"contract": key, "net": 0.0, "txs": 0,
|
|
284
|
+
"spam": _spam.verdict(chain, r.get("contract") or "",
|
|
285
|
+
r.get("symbol", ""), r.get("name", ""),
|
|
286
|
+
bool(r.get("flagged")))})
|
|
283
287
|
if r.get("to") == me:
|
|
284
288
|
rec["net"] += r.get("value", 0.0)
|
|
285
289
|
if r.get("from") == me:
|
|
@@ -199,8 +199,12 @@ def trace(
|
|
|
199
199
|
def tokens(
|
|
200
200
|
address: str = typer.Argument(..., help="Address to inspect (0x…)"),
|
|
201
201
|
chain: str = CHAIN_OPT,
|
|
202
|
+
show_all: bool = typer.Option(False, "--all", help="Also list spam and counterfeit tokens"),
|
|
202
203
|
):
|
|
203
|
-
"""Show an address's token holdings (approx from transfer history) with USD.
|
|
204
|
+
"""Show an address's token holdings (approx from transfer history) with USD.
|
|
205
|
+
|
|
206
|
+
Spam and counterfeit tokens — a fake "USDT", a name that is a phishing link —
|
|
207
|
+
are hidden and never priced; --all lists them with the reason."""
|
|
204
208
|
try:
|
|
205
209
|
holdings = chains_mod.token_holdings(address, chain)
|
|
206
210
|
except (chains_mod.ChainError, etherscan.EtherscanError) as e:
|
|
@@ -209,7 +213,7 @@ def tokens(
|
|
|
209
213
|
if not holdings:
|
|
210
214
|
console.print("[dim]No token transfers found for this address on this chain.[/dim]")
|
|
211
215
|
return
|
|
212
|
-
console.print(render.holdings_table(address, chain, holdings))
|
|
216
|
+
console.print(render.holdings_table(address, chain, holdings, show_all))
|
|
213
217
|
|
|
214
218
|
|
|
215
219
|
@app.command()
|
|
@@ -28,10 +28,10 @@ TX_PAGE, JETTON_PAGE = 500, 1000
|
|
|
28
28
|
_KEY = os.environ.get("TONCENTER_API_KEY")
|
|
29
29
|
http.MIN_INTERVAL["toncenter.com"] = 0.12 if _KEY else 1.1
|
|
30
30
|
|
|
31
|
-
# jetton master (raw, lower-case) ->
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
# jetton master (raw, lower-case) -> what the token calls itself, starting with the ones we know
|
|
32
|
+
_META: Dict[str, dict] = {t["contract"]: {"decimals": t.get("decimals", 9), "symbol": t["symbol"],
|
|
33
|
+
"name": "", "scam": False}
|
|
34
|
+
for t in assets.tokens_for("ton").values()}
|
|
35
35
|
|
|
36
36
|
|
|
37
37
|
class TonError(RuntimeError):
|
|
@@ -98,21 +98,32 @@ def transfers(address: str, limit: int = 1000) -> List[Dict]:
|
|
|
98
98
|
src, dst = _addr(src), _addr(dst)
|
|
99
99
|
if me not in (src, dst):
|
|
100
100
|
continue
|
|
101
|
+
# the sender's and the receiver's transactions have different hashes;
|
|
102
|
+
# the message hash is the same on both sides (the store keys on it)
|
|
101
103
|
rows.append({"from": src, "to": dst, "value": value, "timestamp": ts,
|
|
102
|
-
"hash": h, "symbol": "TON"})
|
|
104
|
+
"hash": h, "symbol": "TON", "msg_hash": _hex(m.get("hash") or "")})
|
|
103
105
|
return rows
|
|
104
106
|
|
|
105
107
|
|
|
106
|
-
def
|
|
107
|
-
|
|
108
|
+
def _meta(master: str) -> Optional[dict]:
|
|
109
|
+
"""Decimals, symbol, name and the indexer's scam flag for a jetton master."""
|
|
110
|
+
if master not in _META:
|
|
108
111
|
try:
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
112
|
+
d = _get("/jetton/masters", {"address": master, "limit": 1})
|
|
113
|
+
content = ((d.get("jetton_masters") or [{}])[0].get("jetton_content")) or {}
|
|
114
|
+
# off-chain metadata (a URI) is resolved by the indexer into "metadata"
|
|
115
|
+
info = next((i for m in (d.get("metadata") or {}).values()
|
|
116
|
+
for i in (m.get("token_info") or []) if i.get("type") == "jetton_masters"), {})
|
|
117
|
+
_META[master] = {
|
|
118
|
+
"decimals": int(content.get("decimals") or (info.get("extra") or {}).get("decimals")
|
|
119
|
+
or 9), # TEP-64 default is 9
|
|
120
|
+
"symbol": info.get("symbol") or content.get("symbol") or "JETTON",
|
|
121
|
+
"name": info.get("name") or content.get("name") or "",
|
|
122
|
+
"scam": bool(info.get("is_scam")),
|
|
123
|
+
}
|
|
113
124
|
except (TonError, ValueError, TypeError):
|
|
114
125
|
return None
|
|
115
|
-
return
|
|
126
|
+
return _META[master]
|
|
116
127
|
|
|
117
128
|
|
|
118
129
|
def jetton_transfers(address: str, limit: int = 1000) -> List[Dict]:
|
|
@@ -122,15 +133,16 @@ def jetton_transfers(address: str, limit: int = 1000) -> List[Dict]:
|
|
|
122
133
|
if t.get("aborted"):
|
|
123
134
|
continue
|
|
124
135
|
master = (t.get("jetton_master") or "").lower()
|
|
125
|
-
|
|
126
|
-
if
|
|
136
|
+
meta = _meta(master)
|
|
137
|
+
if meta is None:
|
|
127
138
|
continue # unknown unit: don't guess
|
|
128
139
|
try:
|
|
129
|
-
value = int(t.get("amount") or 0) / (10 **
|
|
140
|
+
value = int(t.get("amount") or 0) / (10 ** meta["decimals"])
|
|
130
141
|
except (ValueError, TypeError):
|
|
131
142
|
continue
|
|
132
143
|
rows.append({"from": _addr(t.get("source")), "to": _addr(t.get("destination")),
|
|
133
144
|
"value": value, "timestamp": int(t.get("transaction_now") or 0),
|
|
134
145
|
"hash": _hex(t.get("transaction_hash", "")),
|
|
135
|
-
"symbol":
|
|
146
|
+
"symbol": meta["symbol"], "name": meta["name"], "contract": master,
|
|
147
|
+
"flagged": meta["scam"]})
|
|
136
148
|
return rows
|
|
@@ -142,7 +142,7 @@ def token_transfers(address: str, limit: int = 200) -> List[Dict]:
|
|
|
142
142
|
rows.append({"from": t.get("from", ""), "to": t.get("to", ""), "value": val,
|
|
143
143
|
"timestamp": int((t.get("block_timestamp") or 0) / 1000),
|
|
144
144
|
"hash": t.get("transaction_id", ""),
|
|
145
|
-
"symbol": info.get("symbol", "TRC20"),
|
|
145
|
+
"symbol": info.get("symbol", "TRC20"), "name": info.get("name", ""),
|
|
146
146
|
"contract": (info.get("address") or "").lower()})
|
|
147
147
|
return rows
|
|
148
148
|
|
|
@@ -35,6 +35,10 @@
|
|
|
35
35
|
"bc1qdaarag7729c2n4l2wnyt3hkhfpcs66n98z7uuh": {"name": "Coldcard Exploiter (Jul 2026) - collector C1", "type": "scam", "chain": "btc", "source": "own tracing: 100 inbound sweeps totalling 88.850 BTC at 01:10 UTC, forwarded to the 89 BTC pile", "source_kind": "research", "added": "2026-08-09"},
|
|
36
36
|
"bc1qh0l7q0mca3ln7wsl9luwns0jc9jhgrtft025l4": {"name": "Coldcard Exploiter (Jul 2026) - collector C2", "type": "scam", "chain": "btc", "source": "own tracing: 78 inbound sweeps totalling 0.769 BTC at 01:10 UTC; totals reconcile with chain", "source_kind": "research", "added": "2026-08-09"},
|
|
37
37
|
|
|
38
|
+
"UQDiqkA78AG6tqpWPP3WeQs4EvvOIXZyXAg4JZwjNq9Tqwnm": {"name": "fragment-api seed leak drainer (Sep 2026) - collector", "type": "scam", "chain": "ton", "source": "own tracing (cases/fragment-api-ton-2026): swept 200 wallets from 2026-09-29 18:49 UTC, 10,080 TON + 29,494 USDT", "source_kind": "research", "added": "2026-09-30"},
|
|
39
|
+
"UQCuquK3ZOsWuGCh21eFC_SrmB6pezbtKP_Mm2kJDactC5Wb": {"name": "fragment-api seed leak drainer (Sep 2026) - second wallet", "type": "scam", "chain": "ton", "source": "own tracing (cases/fragment-api-ton-2026): received the collector's remaining TON, jettons and 15 stolen NFTs on 2026-09-30 16:34 UTC", "source_kind": "research", "added": "2026-09-30"},
|
|
40
|
+
"UQCA1BI4QRZ8qYmskSRDzJmkucGodYRTZCf_b9hckjla6Yug": {"name": "KuCoin hot wallet (memo required)", "type": "exchange", "chain": "ton", "source": "tonapi account name 'Kucoin 1', memo_required, fetched 2026-09-30", "source_kind": "explorer-tag", "added": "2026-09-30"},
|
|
41
|
+
|
|
38
42
|
"1DEuKerATHWRfyB14s6QCt4LPkfCTx4zaW": {"name": "Binance reserve wallet", "type": "exchange", "chain": "btc", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-09-23; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-09-23"},
|
|
39
43
|
"1PJiGp2yDLvUgqeBsuZVCBADArNsk6XEiw": {"name": "Binance reserve wallet", "type": "exchange", "chain": "btc", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-09-23; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-09-23"},
|
|
40
44
|
"1PPWMe42cywnM61nVatkmiggwdRQYLiCyS": {"name": "Binance reserve wallet", "type": "exchange", "chain": "btc", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-09-23; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-09-23"},
|
|
@@ -8,6 +8,8 @@ from typing import Optional, Dict
|
|
|
8
8
|
|
|
9
9
|
import requests
|
|
10
10
|
|
|
11
|
+
from crypttrace import assets
|
|
12
|
+
|
|
11
13
|
# symbols treated as ~$1
|
|
12
14
|
_STABLE = {"usdt", "usdc", "dai", "busd", "tusd", "usdp", "gusd", "frax", "lusd"}
|
|
13
15
|
|
|
@@ -54,9 +56,17 @@ def native_price(chain: str = "eth") -> Optional[float]:
|
|
|
54
56
|
|
|
55
57
|
|
|
56
58
|
def token_price(contract: str, chain: str = "eth", symbol: str = "") -> Optional[float]:
|
|
57
|
-
"""USD price of
|
|
59
|
+
"""USD price of a token by contract. Stablecoins short-circuit to $1 — but only
|
|
60
|
+
the real contract where the registry knows it: a "USDT" anyone can mint is not
|
|
61
|
+
worth a dollar."""
|
|
58
62
|
if symbol.lower() in _STABLE:
|
|
59
|
-
|
|
63
|
+
real = {t["symbol"].lower(): t["contract"].lower()
|
|
64
|
+
for t in assets.tokens_for(chain).values() if t.get("stable")}
|
|
65
|
+
if not assets.tokens_for(chain):
|
|
66
|
+
return 1.0 # no registry for this chain: trust the symbol
|
|
67
|
+
if (contract or "").lower() == real.get(symbol.lower()):
|
|
68
|
+
return 1.0
|
|
69
|
+
return None
|
|
60
70
|
key = f"{chain}:{contract.lower()}"
|
|
61
71
|
if key in _cache:
|
|
62
72
|
return _cache[key]
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Terminal rendering helpers (rich)."""
|
|
2
2
|
from datetime import datetime, timezone
|
|
3
|
+
from rich.markup import escape
|
|
3
4
|
from rich.table import Table
|
|
4
5
|
from rich.tree import Tree
|
|
5
6
|
from rich.text import Text
|
|
@@ -223,20 +224,36 @@ def funding_tree(address: str, hops: list, symbol: str = "ETH") -> Tree:
|
|
|
223
224
|
return root
|
|
224
225
|
|
|
225
226
|
|
|
226
|
-
def holdings_table(address: str, chain: str, holdings: list) -> Table:
|
|
227
|
-
"""Token holdings with per-token and total USD value.
|
|
228
|
-
|
|
227
|
+
def holdings_table(address: str, chain: str, holdings: list, show_all: bool = False) -> Table:
|
|
228
|
+
"""Token holdings with per-token and total USD value.
|
|
229
|
+
|
|
230
|
+
Spam and counterfeit tokens (see spam.py) are left out unless `show_all`;
|
|
231
|
+
they never count towards the total either way.
|
|
232
|
+
"""
|
|
233
|
+
hidden = [h for h in holdings if h.get("spam")]
|
|
234
|
+
shown = holdings if show_all else [h for h in holdings if not h.get("spam")]
|
|
235
|
+
t = Table(title=f"Token holdings — {address} ({chain})", header_style="bold",
|
|
236
|
+
caption=(f"{len(hidden)} spam or counterfeit token(s) hidden — --all shows them"
|
|
237
|
+
if hidden and not show_all else None))
|
|
229
238
|
t.add_column("Token")
|
|
230
239
|
t.add_column("Amount", justify="right")
|
|
231
240
|
t.add_column("USD", justify="right")
|
|
232
241
|
t.add_column("Txs", justify="right")
|
|
242
|
+
if show_all:
|
|
243
|
+
t.add_column("Note")
|
|
233
244
|
total = 0.0
|
|
234
|
-
for h in
|
|
235
|
-
|
|
236
|
-
|
|
245
|
+
for h in shown:
|
|
246
|
+
if h.get("spam"):
|
|
247
|
+
usd = None
|
|
248
|
+
else:
|
|
249
|
+
usd = prices.usd(h["net"], prices.token_price(h["contract"], chain, h["symbol"]))
|
|
237
250
|
if usd is not None:
|
|
238
251
|
total += usd
|
|
239
|
-
|
|
252
|
+
cells = [escape(h["symbol"]), f"{h['net']:.4f}", prices.fmt_usd(usd), str(h["txs"])]
|
|
253
|
+
if show_all:
|
|
254
|
+
cells.append(f"[red]{escape(h['spam'])}[/red]" if h.get("spam") else "")
|
|
255
|
+
t.add_row(*cells)
|
|
240
256
|
t.add_section()
|
|
241
|
-
t.add_row("[bold]Total[/bold]", "", f"[bold]{prices.fmt_usd(total)}[/bold]", ""
|
|
257
|
+
t.add_row("[bold]Total[/bold]", "", f"[bold]{prices.fmt_usd(total)}[/bold]", "",
|
|
258
|
+
*([""] if show_all else []))
|
|
242
259
|
return t
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Spam and counterfeit tokens — what `tokens` hides by default.
|
|
2
|
+
|
|
3
|
+
Anyone can mint a token and send it to any address, so a wallet's token list
|
|
4
|
+
fills up with two kinds of junk:
|
|
5
|
+
|
|
6
|
+
- counterfeits: a token called "USDT" or "TON" that is not the real contract,
|
|
7
|
+
sent so the balance looks bigger or to bait a victim into a fake swap;
|
|
8
|
+
- link bait: the name *is* the advert — "GRAM AT GRAMEVENT.ORG",
|
|
9
|
+
"USDT - usdtunlock.com" — pointing at a phishing site.
|
|
10
|
+
|
|
11
|
+
Neither is worth money, and pricing them as the real thing would put a fake
|
|
12
|
+
million dollars into a case file. A token is judged by its contract against the
|
|
13
|
+
registry in assets.py, and by its name after undoing look-alike letters
|
|
14
|
+
(Cyrillic "с" for Latin "c" is how "usdtunloсk.соm" dodges a naive filter).
|
|
15
|
+
"""
|
|
16
|
+
import re
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
from crypttrace import assets
|
|
20
|
+
|
|
21
|
+
# Cyrillic and Greek letters that look like Latin ones, and ₮ for T
|
|
22
|
+
_LOOKALIKE = str.maketrans({
|
|
23
|
+
"а": "a", "в": "b", "е": "e", "к": "k", "м": "m", "н": "h", "о": "o", "р": "p",
|
|
24
|
+
"с": "c", "т": "t", "у": "y", "х": "x", "і": "i", "ј": "j", "ѕ": "s",
|
|
25
|
+
"А": "A", "В": "B", "Е": "E", "К": "K", "М": "M", "Н": "H", "О": "O", "Р": "P",
|
|
26
|
+
"С": "C", "Т": "T", "У": "Y", "Х": "X", "І": "I", "Ј": "J", "Ѕ": "S",
|
|
27
|
+
"ο": "o", "Ο": "O", "α": "a", "Α": "A", "ε": "e", "Ε": "E", "ν": "v", "Τ": "T",
|
|
28
|
+
"₮": "T",
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
_URL = re.compile(
|
|
32
|
+
r"(https?://|www\.|t\.me/|\b[a-z0-9-]{2,}\.(com|org|net|io|xyz|click|app|site|top|info|"
|
|
33
|
+
r"cc|me|gg|pro|live|vip|fun|online|link|claim|finance|exchange|network|tech|store|"
|
|
34
|
+
r"shop|club|world|space|website|biz|us|co)\b)", re.I)
|
|
35
|
+
_HANDLE = re.compile(r"(^|\s)@[a-z0-9_]{4,}", re.I)
|
|
36
|
+
_BAIT = re.compile(r"\b(claim|unlock|airdrop|reward|voucher|visit|bonus|giveaway)\b", re.I)
|
|
37
|
+
|
|
38
|
+
# a token named after the chain's own coin is never that coin
|
|
39
|
+
_NATIVE = {"eth": "ETH", "tron": "TRX", "ton": "TON"}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def plain(text: str) -> str:
|
|
43
|
+
"""Text with look-alike letters replaced by the Latin ones they imitate."""
|
|
44
|
+
return (text or "").translate(_LOOKALIKE)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _ticker(symbol: str) -> str:
|
|
48
|
+
return re.sub(r"[^A-Z0-9]", "", plain(symbol).upper())
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def verdict(chain: str, contract: str, symbol: str, name: str = "",
|
|
52
|
+
flagged: bool = False) -> Optional[str]:
|
|
53
|
+
"""Why this token is junk, or None if nothing marks it as such."""
|
|
54
|
+
contract = (contract or "").lower()
|
|
55
|
+
known = {t["contract"].lower(): t for t in assets.tokens_for(chain).values()}
|
|
56
|
+
if contract and contract in known:
|
|
57
|
+
return None
|
|
58
|
+
if flagged:
|
|
59
|
+
return "flagged as scam by the indexer"
|
|
60
|
+
text = plain(f"{symbol} {name}")
|
|
61
|
+
link = _URL.search(text) or _HANDLE.search(text)
|
|
62
|
+
if link:
|
|
63
|
+
return f"link in its name ({link.group(0).strip()})"
|
|
64
|
+
if _BAIT.search(text):
|
|
65
|
+
return f"bait word in its name ({_BAIT.search(text).group(0)})"
|
|
66
|
+
tick = _ticker(symbol)
|
|
67
|
+
for t in known.values():
|
|
68
|
+
if tick and tick == _ticker(t["symbol"]):
|
|
69
|
+
return f"counterfeit {t['symbol']} (not the real contract)"
|
|
70
|
+
if tick and tick == _NATIVE.get(chain):
|
|
71
|
+
return f"counterfeit {_NATIVE[chain]} (the coin itself is not a token)"
|
|
72
|
+
return None
|
|
@@ -60,11 +60,22 @@ def _conn() -> sqlite3.Connection:
|
|
|
60
60
|
PRIMARY KEY (chain, address, asset)
|
|
61
61
|
);
|
|
62
62
|
""")
|
|
63
|
+
if c.execute("PRAGMA user_version").fetchone()[0] < 1:
|
|
64
|
+
# 0.12.0 stored a TON transfer once per side (two transaction hashes),
|
|
65
|
+
# so a history fetched from both ends counted it twice. Refetch TON.
|
|
66
|
+
c.execute("DELETE FROM transfers WHERE chain='ton'")
|
|
67
|
+
c.execute("DELETE FROM fetched WHERE chain='ton'")
|
|
68
|
+
c.execute("PRAGMA user_version=1")
|
|
69
|
+
c.commit()
|
|
63
70
|
return c
|
|
64
71
|
|
|
65
72
|
|
|
66
73
|
def _row_id(chain: str, r: dict) -> str:
|
|
67
74
|
"""Content hash — the same transfer seen from either side stores once."""
|
|
75
|
+
if r.get("msg_hash"):
|
|
76
|
+
# TON: sender and receiver each have their own transaction for one transfer
|
|
77
|
+
key = "|".join([chain, "msg", str(r["msg_hash"]), str(r.get("symbol", ""))])
|
|
78
|
+
return hashlib.sha1(key.encode()).hexdigest()
|
|
68
79
|
key = "|".join([
|
|
69
80
|
chain, str(r.get("hash", "")), str(r.get("from", "")), str(r.get("to", "")),
|
|
70
81
|
str(r.get("symbol", "")), f"{float(r.get('value', 0)):.12f}",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: crypttrace
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.13.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
|
|
@@ -400,7 +400,9 @@ crypttrace timeline bc1qADDRESS --chain btc --buckets 24
|
|
|
400
400
|

|
|
401
401
|
|
|
402
402
|
A worked example — 1,169 swept addresses, 1,082.58 BTC, with the commands to
|
|
403
|
-
reproduce it — is in [`cases/coldcard-2026`](cases/coldcard-2026).
|
|
403
|
+
reproduce it — is in [`cases/coldcard-2026`](cases/coldcard-2026). A TON one — 200
|
|
404
|
+
wallets emptied after a seed leak, followed to a KuCoin deposit memo — is in
|
|
405
|
+
[`cases/fragment-api-ton-2026`](cases/fragment-api-ton-2026).
|
|
404
406
|
|
|
405
407
|
**Dust is filtered by default.** Addresses that become publicly known get spammed
|
|
406
408
|
with tiny transfers, which otherwise bury the transactions that matter and can
|
|
@@ -455,6 +457,14 @@ with USDT would be meaningless.
|
|
|
455
457
|
|
|
456
458
|
Amounts carry approximate USD values (stablecoins pinned to $1, others priced
|
|
457
459
|
via CoinGecko). If pricing is unavailable, USD shows as `—` rather than a guess.
|
|
460
|
+
Only the real stablecoin contract gets the $1 — a token anyone minted and called
|
|
461
|
+
"USDT" is worth nothing.
|
|
462
|
+
|
|
463
|
+
`tokens` hides spam and counterfeit tokens and leaves them out of the total: a
|
|
464
|
+
"USDT" or "TON" that is not the real contract, a name that is a phishing link
|
|
465
|
+
("GRAM AT GRAMEVENT.ORG", "USDT - usdtunloсk.соm" with Cyrillic letters in the
|
|
466
|
+
domain), and on TON anything the indexer flags as a scam. `--all` lists them
|
|
467
|
+
with the reason.
|
|
458
468
|
|
|
459
469
|
### Labels
|
|
460
470
|
|
|
@@ -602,7 +612,6 @@ cases/ # worked investigations with their data
|
|
|
602
612
|
and refreshing these lists as the exchanges republish them
|
|
603
613
|
- Internal transactions (completes `funder` and contract-mediated transfers)
|
|
604
614
|
- More label sources: Chainabuse, CryptoScamDB, exchange deposit-address sets
|
|
605
|
-
- Spam/dust token filtering in `tokens`
|
|
606
615
|
- Entity clustering on EVM via the gas-funding heuristic
|
|
607
616
|
|
|
608
617
|
## Contributing
|
|
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
|