crypttrace 0.17.0__tar.gz → 0.19.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.17.0/src/crypttrace.egg-info → crypttrace-0.19.0}/PKG-INFO +9 -2
- {crypttrace-0.17.0 → crypttrace-0.19.0}/README.md +8 -1
- {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/__init__.py +1 -1
- {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/chains.py +15 -4
- {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/config.py +16 -1
- {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/fetchers/etherscan.py +13 -0
- {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/labels/known.json +2 -2
- {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/labels/labels.py +7 -3
- crypttrace-0.19.0/src/crypttrace/labels/names.py +8 -0
- {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/labels/tonapi.py +2 -7
- crypttrace-0.19.0/src/crypttrace/labels/tronscan.py +95 -0
- {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/store.py +8 -0
- {crypttrace-0.17.0 → crypttrace-0.19.0/src/crypttrace.egg-info}/PKG-INFO +9 -2
- {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace.egg-info/SOURCES.txt +2 -0
- {crypttrace-0.17.0 → crypttrace-0.19.0}/LICENSE +0 -0
- {crypttrace-0.17.0 → crypttrace-0.19.0}/pyproject.toml +0 -0
- {crypttrace-0.17.0 → crypttrace-0.19.0}/setup.cfg +0 -0
- {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/addresses.py +0 -0
- {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/analysis.py +0 -0
- {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/assess.py +0 -0
- {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/assets.py +0 -0
- {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/bridges.py +0 -0
- {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/cli.py +0 -0
- {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/drain.py +0 -0
- {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/evidence.py +0 -0
- {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/fetchers/__init__.py +0 -0
- {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/fetchers/bitcoin.py +0 -0
- {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/fetchers/http.py +0 -0
- {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/fetchers/solana.py +0 -0
- {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/fetchers/ton.py +0 -0
- {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/fetchers/tron.py +0 -0
- {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/freeze.py +0 -0
- {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/funder.py +0 -0
- {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/investigate.py +0 -0
- {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/labels/__init__.py +0 -0
- {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/labels/audit.py +0 -0
- {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/labels/bulk.py +0 -0
- {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/labels/partial.json +0 -0
- {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/offramp.py +0 -0
- {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/poisoning.py +0 -0
- {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/prices.py +0 -0
- {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/render.py +0 -0
- {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/report.py +0 -0
- {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/spam.py +0 -0
- {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/trace.py +0 -0
- {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/verify.py +0 -0
- {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/watch.py +0 -0
- {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/web/index.html +0 -0
- {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/webapp.py +0 -0
- {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace.egg-info/dependency_links.txt +0 -0
- {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace.egg-info/entry_points.txt +0 -0
- {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace.egg-info/requires.txt +0 -0
- {crypttrace-0.17.0 → crypttrace-0.19.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.19.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
|
|
@@ -104,6 +104,7 @@ export TRONGRID_API_KEY=xxxx # optional: higher TronGrid quota
|
|
|
104
104
|
export CRYPTTRACE_SOLANA_RPC=https://… # optional: your own (faster) Solana RPC
|
|
105
105
|
export TONCENTER_API_KEY=xxxx # optional: toncenter allows ~1 request/s without it
|
|
106
106
|
export TONAPI_KEY=xxxx # optional: faster tonapi name lookups on TON
|
|
107
|
+
export TRONSCAN_API_KEY=xxxx # optional: Tron exchange and scam tags from Tronscan
|
|
107
108
|
```
|
|
108
109
|
|
|
109
110
|
(In PowerShell: `$env:TRONGRID_API_KEY = "xxxx"` and so on.)
|
|
@@ -537,6 +538,13 @@ accounts. These become labels of kind `explorer-tag`, cached for a week. A
|
|
|
537
538
|
"kucoin-1.ton" — is not taken as a label. A trace stops at a DEX: the funds
|
|
538
539
|
come back to the sender in another token.
|
|
539
540
|
|
|
541
|
+
**Tron: tags from Tronscan.** With a free Tronscan API key (`TRONSCAN_API_KEY`,
|
|
542
|
+
created in a Tronscan account), Tron addresses the curated set does not know
|
|
543
|
+
get Tronscan's tags: exchange wallets ("Binance-Cold 1", "Okex 1") become
|
|
544
|
+
exchange labels and a red tag (scam, phishing) makes the address a scam — both
|
|
545
|
+
as `explorer-tag` labels naming Tronscan as the source, cached for a week.
|
|
546
|
+
Without a key nothing is fetched.
|
|
547
|
+
|
|
540
548
|
**Exchange deposits with a memo.** A TON exchange takes all customers' deposits
|
|
541
549
|
into one hot wallet and tells them apart by the transfer comment, the *memo*.
|
|
542
550
|
The memo is the lead: it names one account at the exchange, which holds that
|
|
@@ -681,7 +689,6 @@ cases/ # worked investigations with their data
|
|
|
681
689
|
- More exchanges on Bitcoin, Tron, Solana (now: Binance, OKX, HTX, Bybit) and
|
|
682
690
|
TON (now: Binance),
|
|
683
691
|
and refreshing these lists as the exchanges republish them
|
|
684
|
-
- Internal transactions (completes `funder` and contract-mediated transfers)
|
|
685
692
|
- More label sources: Chainabuse, CryptoScamDB, exchange deposit-address sets
|
|
686
693
|
- Entity clustering on EVM via the gas-funding heuristic
|
|
687
694
|
|
|
@@ -70,6 +70,7 @@ export TRONGRID_API_KEY=xxxx # optional: higher TronGrid quota
|
|
|
70
70
|
export CRYPTTRACE_SOLANA_RPC=https://… # optional: your own (faster) Solana RPC
|
|
71
71
|
export TONCENTER_API_KEY=xxxx # optional: toncenter allows ~1 request/s without it
|
|
72
72
|
export TONAPI_KEY=xxxx # optional: faster tonapi name lookups on TON
|
|
73
|
+
export TRONSCAN_API_KEY=xxxx # optional: Tron exchange and scam tags from Tronscan
|
|
73
74
|
```
|
|
74
75
|
|
|
75
76
|
(In PowerShell: `$env:TRONGRID_API_KEY = "xxxx"` and so on.)
|
|
@@ -503,6 +504,13 @@ accounts. These become labels of kind `explorer-tag`, cached for a week. A
|
|
|
503
504
|
"kucoin-1.ton" — is not taken as a label. A trace stops at a DEX: the funds
|
|
504
505
|
come back to the sender in another token.
|
|
505
506
|
|
|
507
|
+
**Tron: tags from Tronscan.** With a free Tronscan API key (`TRONSCAN_API_KEY`,
|
|
508
|
+
created in a Tronscan account), Tron addresses the curated set does not know
|
|
509
|
+
get Tronscan's tags: exchange wallets ("Binance-Cold 1", "Okex 1") become
|
|
510
|
+
exchange labels and a red tag (scam, phishing) makes the address a scam — both
|
|
511
|
+
as `explorer-tag` labels naming Tronscan as the source, cached for a week.
|
|
512
|
+
Without a key nothing is fetched.
|
|
513
|
+
|
|
506
514
|
**Exchange deposits with a memo.** A TON exchange takes all customers' deposits
|
|
507
515
|
into one hot wallet and tells them apart by the transfer comment, the *memo*.
|
|
508
516
|
The memo is the lead: it names one account at the exchange, which holds that
|
|
@@ -647,7 +655,6 @@ cases/ # worked investigations with their data
|
|
|
647
655
|
- More exchanges on Bitcoin, Tron, Solana (now: Binance, OKX, HTX, Bybit) and
|
|
648
656
|
TON (now: Binance),
|
|
649
657
|
and refreshing these lists as the exchanges republish them
|
|
650
|
-
- Internal transactions (completes `funder` and contract-mediated transfers)
|
|
651
658
|
- More label sources: Chainabuse, CryptoScamDB, exchange deposit-address sets
|
|
652
659
|
- Entity clustering on EVM via the gas-funding heuristic
|
|
653
660
|
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""crypttrace — OSINT crypto investigation CLI."""
|
|
2
|
-
__version__ = "0.
|
|
2
|
+
__version__ = "0.19.0"
|
|
@@ -102,15 +102,26 @@ def balance(address: str, chain: str = "eth") -> float:
|
|
|
102
102
|
# ---------- normalized row builders ----------
|
|
103
103
|
|
|
104
104
|
def _evm_native(address: str, chain: str, limit: int) -> List[Dict]:
|
|
105
|
+
"""The native coin moved: normal transactions plus internal ones (sent by contracts —
|
|
106
|
+
withdrawals from mixers and exchanges, ETH back from swaps). Failed calls moved nothing."""
|
|
105
107
|
rows = []
|
|
106
|
-
for tx in etherscan.get_txs(address, chain, limit=limit)
|
|
108
|
+
calls = [(tx, False) for tx in etherscan.get_txs(address, chain, limit=limit)]
|
|
109
|
+
calls += [(tx, True) for tx in etherscan.get_internal_txs(address, chain, limit=limit)]
|
|
110
|
+
for tx, internal in calls:
|
|
111
|
+
if str(tx.get("isError", "0")) == "1":
|
|
112
|
+
continue
|
|
107
113
|
try:
|
|
108
114
|
val = int(tx.get("value", 0)) / config.WEI
|
|
109
115
|
except (TypeError, ValueError):
|
|
110
116
|
val = 0.0
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
117
|
+
if internal and val <= 0:
|
|
118
|
+
continue
|
|
119
|
+
row = {"from": tx.get("from", "").lower(), "to": (tx.get("to") or "").lower(),
|
|
120
|
+
"value": val, "timestamp": int(tx.get("timeStamp", "0") or 0),
|
|
121
|
+
"hash": tx.get("hash", ""), "symbol": symbol(chain)}
|
|
122
|
+
if internal:
|
|
123
|
+
row["internal"] = True
|
|
124
|
+
rows.append(row)
|
|
114
125
|
return rows
|
|
115
126
|
|
|
116
127
|
|
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
"""Configuration and shared constants."""
|
|
2
2
|
import os
|
|
3
|
+
import sys
|
|
3
4
|
from pathlib import Path
|
|
4
5
|
|
|
6
|
+
|
|
7
|
+
def env(name: str, default: str = "") -> str:
|
|
8
|
+
"""An environment variable — falling back, on Windows, to the user environment, so a key
|
|
9
|
+
saved with [Environment]::SetEnvironmentVariable(..., "User") works in an already-open shell."""
|
|
10
|
+
value = os.environ.get(name)
|
|
11
|
+
if not value and sys.platform == "win32":
|
|
12
|
+
try:
|
|
13
|
+
import winreg
|
|
14
|
+
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment") as reg:
|
|
15
|
+
value = winreg.QueryValueEx(reg, name)[0]
|
|
16
|
+
except OSError:
|
|
17
|
+
value = None
|
|
18
|
+
return (value or default).strip()
|
|
19
|
+
|
|
5
20
|
# Data dir for cache + labels (created on first run)
|
|
6
21
|
DATA_DIR = Path(os.environ.get("CRYPTTRACE_HOME", Path.home() / ".crypttrace"))
|
|
7
22
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
@@ -11,7 +26,7 @@ CACHE_DB = DATA_DIR / "cache.sqlite"
|
|
|
11
26
|
# Etherscan v2 API. One key works across all EVM chains via chainid param.
|
|
12
27
|
# Get a free key at https://etherscan.io/myapikey and export it:
|
|
13
28
|
# export ETHERSCAN_API_KEY=xxxx
|
|
14
|
-
ETHERSCAN_API_KEY =
|
|
29
|
+
ETHERSCAN_API_KEY = env("ETHERSCAN_API_KEY")
|
|
15
30
|
ETHERSCAN_BASE = "https://api.etherscan.io/v2/api"
|
|
16
31
|
|
|
17
32
|
# Supported EVM chains: name -> chainid
|
|
@@ -104,6 +104,19 @@ def get_txs(address: str, chain: str = "eth", limit: int = 1000,
|
|
|
104
104
|
return result if isinstance(result, list) else []
|
|
105
105
|
|
|
106
106
|
|
|
107
|
+
def get_internal_txs(address: str, chain: str = "eth", limit: int = 1000,
|
|
108
|
+
sort: str = "desc") -> List[dict]:
|
|
109
|
+
"""Internal transactions: the native coin a contract sent or received as part of a call
|
|
110
|
+
(a mixer or exchange withdrawal, ETH back from a DEX swap, a multisig payout)."""
|
|
111
|
+
data = _call(chain, {
|
|
112
|
+
"module": "account", "action": "txlistinternal", "address": address,
|
|
113
|
+
"startblock": "0", "endblock": "99999999",
|
|
114
|
+
"page": "1", "offset": str(limit), "sort": sort,
|
|
115
|
+
})
|
|
116
|
+
result = data.get("result")
|
|
117
|
+
return result if isinstance(result, list) else []
|
|
118
|
+
|
|
119
|
+
|
|
107
120
|
def get_token_txs(address: str, chain: str = "eth", limit: int = 1000) -> List[dict]:
|
|
108
121
|
"""ERC-20 token transfers, newest first."""
|
|
109
122
|
data = _call(chain, {
|
|
@@ -10,10 +10,10 @@
|
|
|
10
10
|
"0x1522900b6dafac587d499a862861c0869be6e428": {"name": "OKX", "type": "exchange"},
|
|
11
11
|
"0x2faf487a4414fe77e2327f0bf4ae2a264a776ad2": {"name": "FTX (defunct)", "type": "exchange"},
|
|
12
12
|
"0x722122df12d4e14e13ac3b6895a86e84145b6967": {"name": "Tornado Cash: Router", "type": "mixer"},
|
|
13
|
-
"0xd90e2f925da726b50c4ed8d0fb90ad053324f31b": {"name": "Tornado Cash: 0.1 ETH", "
|
|
13
|
+
"0xd90e2f925da726b50c4ed8d0fb90ad053324f31b": {"name": "Tornado Cash: router", "type": "mixer", "chain": "eth", "source": "own check of its internal transactions (2026-10-17): pays out withdrawals of the 0.1, 1 and 10 ETH pools, so a router, not the 0.1 ETH pool it was labelled as", "source_kind": "research", "added": "2026-10-17"},
|
|
14
14
|
"0x910cbd523d972eb0a6f4cae4618ad62622b39dbf": {"name": "Tornado Cash: 10 ETH", "type": "mixer"},
|
|
15
15
|
"0xa160cdab225685da1d56aa342ad8841c3b53f291": {"name": "Tornado Cash: 100 ETH", "type": "mixer"},
|
|
16
|
-
"0x12d66f87a04a9e220743712ce6d9bb1b5616b8fc": {"name": "Tornado Cash: 1 ETH", "type": "mixer"},
|
|
16
|
+
"0x12d66f87a04a9e220743712ce6d9bb1b5616b8fc": {"name": "Tornado Cash: 0.1 ETH", "type": "mixer", "chain": "eth", "source": "own check of its internal transactions (2026-10-17): every withdrawal pays out 0.1 ETH in total (recipient plus relayer fee); was labelled 1 ETH", "source_kind": "research", "added": "2026-10-17"},
|
|
17
17
|
"0x3ee18b2214aff97000d974cf647e7c347e8fa585": {"name": "Wormhole: Token Bridge", "type": "bridge"},
|
|
18
18
|
"0x99c9fc46f92e8a1c0dec1b1747d010903e884be1": {"name": "Optimism: Gateway (L1 Bridge)", "type": "bridge"},
|
|
19
19
|
"0x4dbd4fc535ac27206064b68ffcf827b0a60bab3f": {"name": "Arbitrum: Delayed Inbox", "type": "bridge"},
|
|
@@ -17,7 +17,7 @@ from typing import Optional, Dict, List
|
|
|
17
17
|
import requests
|
|
18
18
|
|
|
19
19
|
from crypttrace import addresses, config
|
|
20
|
-
from crypttrace.labels import bulk, tonapi
|
|
20
|
+
from crypttrace.labels import bulk, tonapi, tronscan
|
|
21
21
|
|
|
22
22
|
_HERE = Path(__file__).parent
|
|
23
23
|
_IMPORTED = config.DATA_DIR / "imported_labels.json"
|
|
@@ -189,15 +189,19 @@ def lookup(address: str) -> Optional[dict]:
|
|
|
189
189
|
found = _match_partial(address) or bulk.lookup(address)
|
|
190
190
|
if not found and (len(address) == 48 or ":" in address):
|
|
191
191
|
found = tonapi.lookup(address) # cached explorer tag, no network here
|
|
192
|
+
elif not found and len(address) == 34 and address.startswith("T"):
|
|
193
|
+
found = tronscan.lookup(address)
|
|
192
194
|
return found
|
|
193
195
|
|
|
194
196
|
|
|
195
197
|
def enrich(addrs, chain: str) -> None:
|
|
196
|
-
"""Fetch explorer tags the curated set lacks (TON: tonapi
|
|
198
|
+
"""Fetch explorer tags the curated set lacks (TON: tonapi, Tron: Tronscan). Network, cached."""
|
|
199
|
+
_load()
|
|
197
200
|
if chain == "ton":
|
|
198
|
-
_load()
|
|
199
201
|
tonapi.enrich(a for a in addrs
|
|
200
202
|
if a and not _KNOWN.get((addresses.ton_friendly(a) or a).lower()))
|
|
203
|
+
elif chain == "tron":
|
|
204
|
+
tronscan.enrich(a for a in addrs if a and not _KNOWN.get(a.lower()))
|
|
201
205
|
|
|
202
206
|
|
|
203
207
|
def is_deposit(address: str) -> bool:
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Exchange names as explorers spell them — shared by the explorer-tag sources."""
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
# custodial platforms that hold KYC and can freeze: a deposit there is a lead
|
|
5
|
+
EXCHANGES = re.compile(
|
|
6
|
+
r"\b(binance|bybit|okx|okex|kucoin|htx|huobi|gate(\.io)?|mexc|bitget|kraken|coinbase|"
|
|
7
|
+
r"bitfinex|bingx|bitmart|lbank|whitebit|exmo|coinex|poloniex|crypto\.com|bitpanda|"
|
|
8
|
+
r"bithumb|upbit|cryptobot|crypto bot|xrocket|wallet in telegram|telegram wallet)\b", re.I)
|
|
@@ -26,6 +26,7 @@ import requests
|
|
|
26
26
|
|
|
27
27
|
from crypttrace import addresses, config
|
|
28
28
|
from crypttrace.fetchers import http
|
|
29
|
+
from crypttrace.labels.names import EXCHANGES
|
|
29
30
|
|
|
30
31
|
API = "https://tonapi.io/v2/accounts/{}"
|
|
31
32
|
CACHE = config.DATA_DIR / "tonapi_names.json"
|
|
@@ -33,12 +34,6 @@ MAX_AGE = 7 * 24 * 3600
|
|
|
33
34
|
_KEY = os.environ.get("TONAPI_KEY")
|
|
34
35
|
http.MIN_INTERVAL["tonapi.io"] = 0.12 if _KEY else 1.1
|
|
35
36
|
|
|
36
|
-
# custodial platforms that hold KYC and can freeze: a deposit there is a lead
|
|
37
|
-
_EXCHANGES = re.compile(
|
|
38
|
-
r"\b(binance|bybit|okx|okex|kucoin|htx|huobi|gate(\.io)?|mexc|bitget|kraken|coinbase|"
|
|
39
|
-
r"bitfinex|bingx|bitmart|lbank|whitebit|exmo|coinex|poloniex|crypto\.com|bitpanda|"
|
|
40
|
-
r"bithumb|upbit|cryptobot|crypto bot|xrocket|wallet in telegram|telegram wallet)\b", re.I)
|
|
41
|
-
|
|
42
37
|
_cache: Optional[Dict[str, dict]] = None
|
|
43
38
|
|
|
44
39
|
|
|
@@ -73,7 +68,7 @@ def classify(account: dict) -> Optional[dict]:
|
|
|
73
68
|
return {"name": name or "DEX contract", "type": "dex"}
|
|
74
69
|
if not name or self_named:
|
|
75
70
|
return None
|
|
76
|
-
if
|
|
71
|
+
if EXCHANGES.search(name):
|
|
77
72
|
memo = " (memo required)" if account.get("memo_required") else ""
|
|
78
73
|
return {"name": f"{name}{memo}", "type": "exchange", "memo_required": bool(account.get("memo_required"))}
|
|
79
74
|
return {"name": name, "type": "service"}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Tron address tags from Tronscan — exchange wallets and risk warnings.
|
|
2
|
+
|
|
3
|
+
Tronscan tags exchange hot and cold wallets ("Binance-Cold 1", "OKX-Hot") and
|
|
4
|
+
puts red tags on addresses it flags as scams or phishing. Both become labels
|
|
5
|
+
of kind "explorer-tag", with Tronscan named as the source; a red tag makes the
|
|
6
|
+
address a scam. Grey tags and user feedback are not enough to call an address
|
|
7
|
+
anything.
|
|
8
|
+
|
|
9
|
+
Needs a free API key (TRONSCAN_API_KEY, created in a Tronscan account); without
|
|
10
|
+
one nothing is fetched and Tron labels come from the curated set alone.
|
|
11
|
+
Lookups are cached on disk for a week; only `enrich()` uses the network.
|
|
12
|
+
"""
|
|
13
|
+
import datetime as dt
|
|
14
|
+
import json
|
|
15
|
+
import time
|
|
16
|
+
from typing import Dict, Iterable, Optional
|
|
17
|
+
|
|
18
|
+
import requests
|
|
19
|
+
|
|
20
|
+
from crypttrace import config
|
|
21
|
+
from crypttrace.fetchers import http
|
|
22
|
+
from crypttrace.labels.names import EXCHANGES
|
|
23
|
+
|
|
24
|
+
API = "https://apilist.tronscanapi.com/api/accountv2"
|
|
25
|
+
CACHE = config.DATA_DIR / "tronscan_tags.json"
|
|
26
|
+
MAX_AGE = 7 * 24 * 3600
|
|
27
|
+
http.MIN_INTERVAL["apilist.tronscanapi.com"] = 0.25
|
|
28
|
+
_cache: Optional[Dict[str, dict]] = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _key() -> Optional[str]:
|
|
32
|
+
return config.env("TRONSCAN_API_KEY") or None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _load() -> Dict[str, dict]:
|
|
36
|
+
global _cache
|
|
37
|
+
if _cache is None:
|
|
38
|
+
try:
|
|
39
|
+
_cache = json.loads(CACHE.read_text(encoding="utf-8"))
|
|
40
|
+
except (OSError, ValueError):
|
|
41
|
+
_cache = {}
|
|
42
|
+
return _cache
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _save() -> None:
|
|
46
|
+
try:
|
|
47
|
+
CACHE.parent.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
tmp = CACHE.with_suffix(".tmp")
|
|
49
|
+
tmp.write_text(json.dumps(_load(), ensure_ascii=False), encoding="utf-8")
|
|
50
|
+
tmp.replace(CACHE)
|
|
51
|
+
except OSError:
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def classify(account: dict) -> Optional[dict]:
|
|
56
|
+
"""A label from one Tronscan account record, or None if it names nothing."""
|
|
57
|
+
red = (account.get("redTag") or "").strip()
|
|
58
|
+
tag = (account.get("publicTag") or account.get("addressTag") or "").strip()
|
|
59
|
+
if red:
|
|
60
|
+
return {"name": f"{tag or red} (Tronscan: {red})", "type": "scam"}
|
|
61
|
+
if not tag:
|
|
62
|
+
return None
|
|
63
|
+
if EXCHANGES.search(tag.replace("-", " ")):
|
|
64
|
+
return {"name": tag, "type": "exchange"}
|
|
65
|
+
return {"name": tag, "type": "service"}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def lookup(address: str) -> Optional[dict]:
|
|
69
|
+
"""Cached Tronscan label, without touching the network."""
|
|
70
|
+
entry = _load().get(address)
|
|
71
|
+
if not entry or not entry.get("label"):
|
|
72
|
+
return None
|
|
73
|
+
return {**entry["label"], "chain": "tron", "source_kind": "explorer-tag",
|
|
74
|
+
"source": f"Tronscan address tag, fetched {entry.get('day', '')}"}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def enrich(addrs: Iterable[str]) -> None:
|
|
78
|
+
"""Fetch Tronscan tags for addresses not in the cache (or stale). No key, no fetch."""
|
|
79
|
+
key = _key()
|
|
80
|
+
if not key:
|
|
81
|
+
return
|
|
82
|
+
cache, now, changed = _load(), time.time(), False
|
|
83
|
+
for a in dict.fromkeys(addrs):
|
|
84
|
+
if not a or not a.startswith("T") or len(a) != 34 or now - cache.get(a, {}).get("at", 0) < MAX_AGE:
|
|
85
|
+
continue
|
|
86
|
+
try:
|
|
87
|
+
rec = http.request_json(API, {"address": a}, timeout=20, headers={"TRON-PRO-API-KEY": key})
|
|
88
|
+
except (http.RateLimited, requests.RequestException, ValueError):
|
|
89
|
+
continue
|
|
90
|
+
if not isinstance(rec, dict) or "__status__" in rec:
|
|
91
|
+
continue
|
|
92
|
+
cache[a] = {"at": now, "day": dt.date.today().isoformat(), "label": classify(rec)}
|
|
93
|
+
changed = True
|
|
94
|
+
if changed:
|
|
95
|
+
_save()
|
|
@@ -86,6 +86,14 @@ def _conn() -> sqlite3.Connection:
|
|
|
86
86
|
c.execute("DELETE FROM fetched WHERE chain='ton'")
|
|
87
87
|
c.execute("PRAGMA user_version=3")
|
|
88
88
|
c.commit()
|
|
89
|
+
if c.execute("PRAGMA user_version").fetchone()[0] < 4:
|
|
90
|
+
# 0.19: EVM native history now includes internal transactions — read it again
|
|
91
|
+
evm = tuple(config.CHAINS)
|
|
92
|
+
marks = ",".join("?" * len(evm))
|
|
93
|
+
c.execute(f"DELETE FROM transfers WHERE chain IN ({marks}) AND (contract IS NULL OR contract='')", evm)
|
|
94
|
+
c.execute(f"DELETE FROM fetched WHERE chain IN ({marks}) AND asset=''", evm)
|
|
95
|
+
c.execute("PRAGMA user_version=4")
|
|
96
|
+
c.commit()
|
|
89
97
|
return c
|
|
90
98
|
|
|
91
99
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: crypttrace
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.19.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
|
|
@@ -104,6 +104,7 @@ export TRONGRID_API_KEY=xxxx # optional: higher TronGrid quota
|
|
|
104
104
|
export CRYPTTRACE_SOLANA_RPC=https://… # optional: your own (faster) Solana RPC
|
|
105
105
|
export TONCENTER_API_KEY=xxxx # optional: toncenter allows ~1 request/s without it
|
|
106
106
|
export TONAPI_KEY=xxxx # optional: faster tonapi name lookups on TON
|
|
107
|
+
export TRONSCAN_API_KEY=xxxx # optional: Tron exchange and scam tags from Tronscan
|
|
107
108
|
```
|
|
108
109
|
|
|
109
110
|
(In PowerShell: `$env:TRONGRID_API_KEY = "xxxx"` and so on.)
|
|
@@ -537,6 +538,13 @@ accounts. These become labels of kind `explorer-tag`, cached for a week. A
|
|
|
537
538
|
"kucoin-1.ton" — is not taken as a label. A trace stops at a DEX: the funds
|
|
538
539
|
come back to the sender in another token.
|
|
539
540
|
|
|
541
|
+
**Tron: tags from Tronscan.** With a free Tronscan API key (`TRONSCAN_API_KEY`,
|
|
542
|
+
created in a Tronscan account), Tron addresses the curated set does not know
|
|
543
|
+
get Tronscan's tags: exchange wallets ("Binance-Cold 1", "Okex 1") become
|
|
544
|
+
exchange labels and a red tag (scam, phishing) makes the address a scam — both
|
|
545
|
+
as `explorer-tag` labels naming Tronscan as the source, cached for a week.
|
|
546
|
+
Without a key nothing is fetched.
|
|
547
|
+
|
|
540
548
|
**Exchange deposits with a memo.** A TON exchange takes all customers' deposits
|
|
541
549
|
into one hot wallet and tells them apart by the transfer comment, the *memo*.
|
|
542
550
|
The memo is the lead: it names one account at the exchange, which holds that
|
|
@@ -681,7 +689,6 @@ cases/ # worked investigations with their data
|
|
|
681
689
|
- More exchanges on Bitcoin, Tron, Solana (now: Binance, OKX, HTX, Bybit) and
|
|
682
690
|
TON (now: Binance),
|
|
683
691
|
and refreshing these lists as the exchanges republish them
|
|
684
|
-
- Internal transactions (completes `funder` and contract-mediated transfers)
|
|
685
692
|
- More label sources: Chainabuse, CryptoScamDB, exchange deposit-address sets
|
|
686
693
|
- Entity clustering on EVM via the gas-funding heuristic
|
|
687
694
|
|
|
@@ -44,6 +44,8 @@ src/crypttrace/labels/audit.py
|
|
|
44
44
|
src/crypttrace/labels/bulk.py
|
|
45
45
|
src/crypttrace/labels/known.json
|
|
46
46
|
src/crypttrace/labels/labels.py
|
|
47
|
+
src/crypttrace/labels/names.py
|
|
47
48
|
src/crypttrace/labels/partial.json
|
|
48
49
|
src/crypttrace/labels/tonapi.py
|
|
50
|
+
src/crypttrace/labels/tronscan.py
|
|
49
51
|
src/crypttrace/web/index.html
|
|
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
|