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.
Files changed (53) hide show
  1. {crypttrace-0.17.0/src/crypttrace.egg-info → crypttrace-0.19.0}/PKG-INFO +9 -2
  2. {crypttrace-0.17.0 → crypttrace-0.19.0}/README.md +8 -1
  3. {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/__init__.py +1 -1
  4. {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/chains.py +15 -4
  5. {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/config.py +16 -1
  6. {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/fetchers/etherscan.py +13 -0
  7. {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/labels/known.json +2 -2
  8. {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/labels/labels.py +7 -3
  9. crypttrace-0.19.0/src/crypttrace/labels/names.py +8 -0
  10. {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/labels/tonapi.py +2 -7
  11. crypttrace-0.19.0/src/crypttrace/labels/tronscan.py +95 -0
  12. {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/store.py +8 -0
  13. {crypttrace-0.17.0 → crypttrace-0.19.0/src/crypttrace.egg-info}/PKG-INFO +9 -2
  14. {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace.egg-info/SOURCES.txt +2 -0
  15. {crypttrace-0.17.0 → crypttrace-0.19.0}/LICENSE +0 -0
  16. {crypttrace-0.17.0 → crypttrace-0.19.0}/pyproject.toml +0 -0
  17. {crypttrace-0.17.0 → crypttrace-0.19.0}/setup.cfg +0 -0
  18. {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/addresses.py +0 -0
  19. {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/analysis.py +0 -0
  20. {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/assess.py +0 -0
  21. {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/assets.py +0 -0
  22. {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/bridges.py +0 -0
  23. {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/cli.py +0 -0
  24. {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/drain.py +0 -0
  25. {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/evidence.py +0 -0
  26. {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/fetchers/__init__.py +0 -0
  27. {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/fetchers/bitcoin.py +0 -0
  28. {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/fetchers/http.py +0 -0
  29. {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/fetchers/solana.py +0 -0
  30. {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/fetchers/ton.py +0 -0
  31. {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/fetchers/tron.py +0 -0
  32. {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/freeze.py +0 -0
  33. {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/funder.py +0 -0
  34. {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/investigate.py +0 -0
  35. {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/labels/__init__.py +0 -0
  36. {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/labels/audit.py +0 -0
  37. {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/labels/bulk.py +0 -0
  38. {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/labels/partial.json +0 -0
  39. {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/offramp.py +0 -0
  40. {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/poisoning.py +0 -0
  41. {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/prices.py +0 -0
  42. {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/render.py +0 -0
  43. {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/report.py +0 -0
  44. {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/spam.py +0 -0
  45. {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/trace.py +0 -0
  46. {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/verify.py +0 -0
  47. {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/watch.py +0 -0
  48. {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/web/index.html +0 -0
  49. {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace/webapp.py +0 -0
  50. {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace.egg-info/dependency_links.txt +0 -0
  51. {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace.egg-info/entry_points.txt +0 -0
  52. {crypttrace-0.17.0 → crypttrace-0.19.0}/src/crypttrace.egg-info/requires.txt +0 -0
  53. {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.17.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.17.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
- rows.append({"from": tx.get("from", "").lower(), "to": tx.get("to", "").lower(),
112
- "value": val, "timestamp": int(tx.get("timeStamp", "0") or 0),
113
- "hash": tx.get("hash", ""), "symbol": symbol(chain)})
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 = os.environ.get("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", "type": "mixer"},
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 names). Network, cached."""
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 _EXCHANGES.search(name):
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.17.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