crypttrace 0.13.0__tar.gz → 0.14.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 (49) hide show
  1. {crypttrace-0.13.0/src/crypttrace.egg-info → crypttrace-0.14.0}/PKG-INFO +25 -1
  2. {crypttrace-0.13.0 → crypttrace-0.14.0}/README.md +24 -0
  3. {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/__init__.py +1 -1
  4. {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/chains.py +30 -0
  5. {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/cli.py +4 -1
  6. {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/fetchers/ton.py +14 -3
  7. {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/labels/labels.py +17 -2
  8. crypttrace-0.14.0/src/crypttrace/labels/tonapi.py +109 -0
  9. {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/offramp.py +4 -0
  10. {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/render.py +14 -0
  11. {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/report.py +8 -1
  12. {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/store.py +17 -5
  13. {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/trace.py +43 -13
  14. {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/web/index.html +7 -1
  15. {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/webapp.py +1 -0
  16. {crypttrace-0.13.0 → crypttrace-0.14.0/src/crypttrace.egg-info}/PKG-INFO +25 -1
  17. {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace.egg-info/SOURCES.txt +1 -0
  18. {crypttrace-0.13.0 → crypttrace-0.14.0}/LICENSE +0 -0
  19. {crypttrace-0.13.0 → crypttrace-0.14.0}/pyproject.toml +0 -0
  20. {crypttrace-0.13.0 → crypttrace-0.14.0}/setup.cfg +0 -0
  21. {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/addresses.py +0 -0
  22. {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/analysis.py +0 -0
  23. {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/assess.py +0 -0
  24. {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/assets.py +0 -0
  25. {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/bridges.py +0 -0
  26. {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/config.py +0 -0
  27. {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/fetchers/__init__.py +0 -0
  28. {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/fetchers/bitcoin.py +0 -0
  29. {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/fetchers/etherscan.py +0 -0
  30. {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/fetchers/http.py +0 -0
  31. {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/fetchers/solana.py +0 -0
  32. {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/fetchers/tron.py +0 -0
  33. {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/freeze.py +0 -0
  34. {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/funder.py +0 -0
  35. {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/investigate.py +0 -0
  36. {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/labels/__init__.py +0 -0
  37. {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/labels/audit.py +0 -0
  38. {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/labels/bulk.py +0 -0
  39. {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/labels/known.json +0 -0
  40. {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/labels/partial.json +0 -0
  41. {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/poisoning.py +0 -0
  42. {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/prices.py +0 -0
  43. {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/spam.py +0 -0
  44. {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/verify.py +0 -0
  45. {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/watch.py +0 -0
  46. {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace.egg-info/dependency_links.txt +0 -0
  47. {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace.egg-info/entry_points.txt +0 -0
  48. {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace.egg-info/requires.txt +0 -0
  49. {crypttrace-0.13.0 → crypttrace-0.14.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.13.0
3
+ Version: 0.14.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
@@ -103,6 +103,7 @@ own credentials (all optional):
103
103
  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
+ export TONAPI_KEY=xxxx # optional: faster tonapi name lookups on TON
106
107
  ```
107
108
 
108
109
  (In PowerShell: `$env:TRONGRID_API_KEY = "xxxx"` and so on.)
@@ -481,6 +482,29 @@ wallets. That is what lets `offramp` flag deposit addresses on those chains. Fun
481
482
  list still show as `unknown`. Every label records where it came from — see
482
483
  `crypttrace labels audit`.
483
484
 
485
+ **TON: names from tonapi.** On TON, addresses the curated set does not know are
486
+ looked up on tonapi, which names exchange hot wallets ("Bybit 7", "Kucoin 1"),
487
+ DEX contracts (STON.fi, DeDust) and services (Fragment), and flags scam
488
+ accounts. These become labels of kind `explorer-tag`, cached for a week. A
489
+ `.ton` name an owner chose for himself — "crypto-exch-io.ton", or an impostor's
490
+ "kucoin-1.ton" — is not taken as a label. A trace stops at a DEX: the funds
491
+ come back to the sender in another token.
492
+
493
+ **Exchange deposits with a memo.** A TON exchange takes all customers' deposits
494
+ into one hot wallet and tells them apart by the transfer comment, the *memo*.
495
+ The memo is the lead: it names one account at the exchange, which holds that
496
+ customer's KYC. `trace`, `profile`, the web UI and the HTML report show every
497
+ deposit an address made to an exchange with its memo:
498
+
499
+ ```
500
+ ├── ──1280.0000 TON (1 tx)──▶ 🟢 UQCA1BI4QR…la6Yug [KuCoin hot wallet (memo required)]
501
+ │ └── ↳ deposit memo 2093842166 — names one account at KuCoin, which holds its KYC
502
+ ```
503
+
504
+ In the fragment-api case this found the attacker's KuCoin account directly, and
505
+ the account of the exchange service he cashed out through (its relay deposits
506
+ under one memo every time).
507
+
484
508
  **Deposit addresses, labelled outright.** OKX signs every address that holds
485
509
  customer funds — over 300,000, nearly all of them per-customer deposit addresses
486
510
  — and publishes the list with its proof of reserves. `crypttrace update-labels
@@ -69,6 +69,7 @@ own credentials (all optional):
69
69
  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
+ export TONAPI_KEY=xxxx # optional: faster tonapi name lookups on TON
72
73
  ```
73
74
 
74
75
  (In PowerShell: `$env:TRONGRID_API_KEY = "xxxx"` and so on.)
@@ -447,6 +448,29 @@ wallets. That is what lets `offramp` flag deposit addresses on those chains. Fun
447
448
  list still show as `unknown`. Every label records where it came from — see
448
449
  `crypttrace labels audit`.
449
450
 
451
+ **TON: names from tonapi.** On TON, addresses the curated set does not know are
452
+ looked up on tonapi, which names exchange hot wallets ("Bybit 7", "Kucoin 1"),
453
+ DEX contracts (STON.fi, DeDust) and services (Fragment), and flags scam
454
+ accounts. These become labels of kind `explorer-tag`, cached for a week. A
455
+ `.ton` name an owner chose for himself — "crypto-exch-io.ton", or an impostor's
456
+ "kucoin-1.ton" — is not taken as a label. A trace stops at a DEX: the funds
457
+ come back to the sender in another token.
458
+
459
+ **Exchange deposits with a memo.** A TON exchange takes all customers' deposits
460
+ into one hot wallet and tells them apart by the transfer comment, the *memo*.
461
+ The memo is the lead: it names one account at the exchange, which holds that
462
+ customer's KYC. `trace`, `profile`, the web UI and the HTML report show every
463
+ deposit an address made to an exchange with its memo:
464
+
465
+ ```
466
+ ├── ──1280.0000 TON (1 tx)──▶ 🟢 UQCA1BI4QR…la6Yug [KuCoin hot wallet (memo required)]
467
+ │ └── ↳ deposit memo 2093842166 — names one account at KuCoin, which holds its KYC
468
+ ```
469
+
470
+ In the fragment-api case this found the attacker's KuCoin account directly, and
471
+ the account of the exchange service he cashed out through (its relay deposits
472
+ under one memo every time).
473
+
450
474
  **Deposit addresses, labelled outright.** OKX signs every address that holds
451
475
  customer funds — over 300,000, nearly all of them per-customer deposit addresses
452
476
  — and publishes the list with its proof of reserves. `crypttrace update-labels
@@ -1,2 +1,2 @@
1
1
  """crypttrace — OSINT crypto investigation CLI."""
2
- __version__ = "0.13.0"
2
+ __version__ = "0.14.0"
@@ -15,6 +15,7 @@ from typing import List, Dict, Optional
15
15
  from crypttrace import config
16
16
  from crypttrace import addresses as _addresses
17
17
  from crypttrace import spam as _spam
18
+ from crypttrace.labels import labels as _labels
18
19
  from crypttrace.fetchers import etherscan, bitcoin, http as _http, tron, solana, ton
19
20
 
20
21
  EVM_CHAINS = set(config.CHAINS)
@@ -250,6 +251,35 @@ def flows(address: str, chain: str, top: int, direction: str = "out",
250
251
  return [(a, v, c) for a, (v, c) in ranked][:top]
251
252
 
252
253
 
254
+ def exchange_deposits(address: str, chain: str, limit: int = 1000,
255
+ asset: Optional[dict] = None, only_to: Optional[str] = None) -> List[Dict]:
256
+ """Deposits this address made to exchanges with a memo, per (exchange wallet, memo).
257
+
258
+ On TON an exchange's hot wallet takes every customer's deposits and tells
259
+ them apart by the memo (the transfer comment). The memo is therefore the
260
+ lead: it names one account at the exchange, which holds that customer's KYC.
261
+ Returns [{exchange_address, exchange, memo, value, txs, symbol, last_ts, hash}].
262
+ """
263
+ me = norm_addr(address, chain)
264
+ rows = [r for r in transfers(address, chain, limit, asset=asset)
265
+ if r.get("from") == me and (r.get("memo") or "").strip()
266
+ and (not only_to or r.get("to") == only_to)]
267
+ _labels.enrich({r.get("to") for r in rows}, chain)
268
+ agg: Dict[tuple, dict] = {}
269
+ for r in rows:
270
+ to, memo = r.get("to") or "", r["memo"].strip()
271
+ if _labels.type_of(to) != "exchange":
272
+ continue
273
+ rec = agg.setdefault((to, memo), {"exchange_address": to, "exchange": _labels.label_of(to),
274
+ "memo": memo, "value": 0.0, "txs": 0,
275
+ "symbol": r.get("symbol", ""), "last_ts": 0, "hash": ""})
276
+ rec["value"] += r.get("value", 0) or 0
277
+ rec["txs"] += 1
278
+ if (r.get("timestamp") or 0) >= rec["last_ts"]:
279
+ rec["last_ts"], rec["hash"] = r.get("timestamp") or 0, r.get("hash", "")
280
+ return sorted(agg.values(), key=lambda d: -d["value"])
281
+
282
+
253
283
  def outflows(address: str, chain: str, top: int, limit: int = 1000, asset=None):
254
284
  return flows(address, chain, top, "out", limit, asset)
255
285
 
@@ -159,6 +159,9 @@ def profile(
159
159
  chains_mod.symbol(chain)))
160
160
  if rows:
161
161
  console.print(render.counterparties_rows_table(address, chain, rows))
162
+ deposits = chains_mod.exchange_deposits(address, chain)
163
+ if deposits:
164
+ console.print(render.deposits_table(deposits))
162
165
  else:
163
166
  console.print("[dim]No transactions found for this address on this chain.[/dim]")
164
167
 
@@ -191,7 +194,7 @@ def trace(
191
194
  console.print(tree)
192
195
  console.print(
193
196
  "\n[dim]Legend: \U0001F7E2 exchange \U0001F7E3 mixer \U0001F534 sanctioned/scam"
194
- " \U0001F309 bridge ⚪ unknown[/dim]"
197
+ " \U0001F309 bridge \U0001F504 DEX \U0001F537 service ⚪ unknown[/dim]"
195
198
  )
196
199
 
197
200
 
@@ -70,6 +70,11 @@ def _addr(raw: Optional[str]) -> str:
70
70
  return addresses.ton_friendly(raw or "") or (raw or "")
71
71
 
72
72
 
73
+ def _memo(text) -> str:
74
+ """A transfer comment, cleaned — exchanges read deposit memos from it."""
75
+ return str(text or "").replace("\x00", "").strip()
76
+
77
+
73
78
  def _hex(tx_hash: str) -> str:
74
79
  """toncenter gives base64 hashes; explorers link by hex."""
75
80
  try:
@@ -100,8 +105,12 @@ def transfers(address: str, limit: int = 1000) -> List[Dict]:
100
105
  continue
101
106
  # the sender's and the receiver's transactions have different hashes;
102
107
  # the message hash is the same on both sides (the store keys on it)
103
- rows.append({"from": src, "to": dst, "value": value, "timestamp": ts,
104
- "hash": h, "symbol": "TON", "msg_hash": _hex(m.get("hash") or "")})
108
+ row = {"from": src, "to": dst, "value": value, "timestamp": ts,
109
+ "hash": h, "symbol": "TON", "msg_hash": _hex(m.get("hash") or "")}
110
+ memo = _memo(((m.get("message_content") or {}).get("decoded") or {}).get("comment"))
111
+ if memo:
112
+ row["memo"] = memo
113
+ rows.append(row)
105
114
  return rows
106
115
 
107
116
 
@@ -140,9 +149,11 @@ def jetton_transfers(address: str, limit: int = 1000) -> List[Dict]:
140
149
  value = int(t.get("amount") or 0) / (10 ** meta["decimals"])
141
150
  except (ValueError, TypeError):
142
151
  continue
152
+ payload = t.get("decoded_forward_payload") or {}
153
+ memo = _memo(payload.get("comment")) if isinstance(payload, dict) else ""
143
154
  rows.append({"from": _addr(t.get("source")), "to": _addr(t.get("destination")),
144
155
  "value": value, "timestamp": int(t.get("transaction_now") or 0),
145
156
  "hash": _hex(t.get("transaction_hash", "")),
146
157
  "symbol": meta["symbol"], "name": meta["name"], "contract": master,
147
- "flagged": meta["scam"]})
158
+ "flagged": meta["scam"], **({"memo": memo} if memo else {})})
148
159
  return rows
@@ -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
20
+ from crypttrace.labels import bulk, tonapi
21
21
 
22
22
  _HERE = Path(__file__).parent
23
23
  _IMPORTED = config.DATA_DIR / "imported_labels.json"
@@ -162,6 +162,8 @@ RISK = {
162
162
  "scam": 85,
163
163
  "bridge": 40,
164
164
  "exchange": 20,
165
+ "dex": 0,
166
+ "service": 0,
165
167
  "unknown": 0,
166
168
  }
167
169
 
@@ -171,6 +173,8 @@ TYPE_ICON = {
171
173
  "scam": "\U0001F534",
172
174
  "bridge": "\U0001F309", # bridge
173
175
  "exchange": "\U0001F7E2", # green
176
+ "dex": "\U0001F504", # swap arrows
177
+ "service": "\U0001F537", # blue diamond
174
178
  "unknown": "⚪", # white
175
179
  }
176
180
 
@@ -182,7 +186,18 @@ def lookup(address: str) -> Optional[dict]:
182
186
  hit = _KNOWN.get(address.lower())
183
187
  if hit:
184
188
  return hit
185
- return _match_partial(address) or bulk.lookup(address)
189
+ found = _match_partial(address) or bulk.lookup(address)
190
+ if not found and (len(address) == 48 or ":" in address):
191
+ found = tonapi.lookup(address) # cached explorer tag, no network here
192
+ return found
193
+
194
+
195
+ def enrich(addrs, chain: str) -> None:
196
+ """Fetch explorer tags the curated set lacks (TON: tonapi names). Network, cached."""
197
+ if chain == "ton":
198
+ _load()
199
+ tonapi.enrich(a for a in addrs
200
+ if a and not _KNOWN.get((addresses.ton_friendly(a) or a).lower()))
186
201
 
187
202
 
188
203
  def is_deposit(address: str) -> bool:
@@ -0,0 +1,109 @@
1
+ """TON account names from tonapi — exchanges, DEXes and known services.
2
+
3
+ tonapi names the hot wallets of exchanges ("Bybit 7", "Kucoin 1"), DEX routers
4
+ ("STON.fi DEX") and services ("Fragment"), and flags scam accounts. That is
5
+ the TON equivalent of an explorer's address tags, so it is used the same way:
6
+ a label with its source, of kind "explorer-tag".
7
+
8
+ Two things are deliberately *not* trusted:
9
+ - names ending in .ton / .t.me — those are DNS names the owner gave himself
10
+ ("crypto-exch-io.ton", or an impostor's "kucoin-1.ton");
11
+ - anything unknown to tonapi stays unknown — no guessing from interfaces alone,
12
+ except that STON.fi / DeDust contracts are DEXes whatever they are called.
13
+
14
+ Lookups are cached on disk for a week (positive and negative), so a trace pays
15
+ for each address once. Only `enrich()` touches the network; `lookup()` reads
16
+ the cache, which keeps label lookups cheap everywhere else.
17
+ """
18
+ import datetime as dt
19
+ import json
20
+ import os
21
+ import re
22
+ import time
23
+ from typing import Dict, Iterable, Optional
24
+
25
+ import requests
26
+
27
+ from crypttrace import addresses, config
28
+ from crypttrace.fetchers import http
29
+
30
+ API = "https://tonapi.io/v2/accounts/{}"
31
+ CACHE = config.DATA_DIR / "tonapi_names.json"
32
+ MAX_AGE = 7 * 24 * 3600
33
+ _KEY = os.environ.get("TONAPI_KEY")
34
+ http.MIN_INTERVAL["tonapi.io"] = 0.12 if _KEY else 1.1
35
+
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
+ _cache: Optional[Dict[str, dict]] = None
43
+
44
+
45
+ def _load() -> Dict[str, dict]:
46
+ global _cache
47
+ if _cache is None:
48
+ try:
49
+ _cache = json.loads(CACHE.read_text(encoding="utf-8"))
50
+ except (OSError, ValueError):
51
+ _cache = {}
52
+ return _cache
53
+
54
+
55
+ def _save() -> None:
56
+ try:
57
+ CACHE.parent.mkdir(parents=True, exist_ok=True)
58
+ tmp = CACHE.with_suffix(".tmp")
59
+ tmp.write_text(json.dumps(_load(), ensure_ascii=False), encoding="utf-8")
60
+ tmp.replace(CACHE)
61
+ except OSError:
62
+ pass
63
+
64
+
65
+ def classify(account: dict) -> Optional[dict]:
66
+ """A label from one tonapi account record, or None if it names nothing we trust."""
67
+ name = (account.get("name") or "").strip()
68
+ ifaces = " ".join(account.get("interfaces") or [])
69
+ self_named = bool(re.search(r"\.(ton|t\.me)$", name, re.I))
70
+ if account.get("is_scam"):
71
+ return {"name": f"{name or 'account'} (flagged as scam by tonapi)", "type": "scam"}
72
+ if "stonfi" in ifaces or "dedust" in ifaces:
73
+ return {"name": name or "DEX contract", "type": "dex"}
74
+ if not name or self_named:
75
+ return None
76
+ if _EXCHANGES.search(name):
77
+ memo = " (memo required)" if account.get("memo_required") else ""
78
+ return {"name": f"{name}{memo}", "type": "exchange", "memo_required": bool(account.get("memo_required"))}
79
+ return {"name": name, "type": "service"}
80
+
81
+
82
+ def lookup(address: str) -> Optional[dict]:
83
+ """Cached tonapi label for a TON address, without touching the network."""
84
+ key = addresses.ton_friendly(address) or address
85
+ entry = _load().get(key)
86
+ if not entry or not entry.get("label"):
87
+ return None
88
+ return {**entry["label"], "chain": "ton", "source_kind": "explorer-tag",
89
+ "source": f"tonapi account name, fetched {entry.get('day', '')}"}
90
+
91
+
92
+ def enrich(addrs: Iterable[str]) -> None:
93
+ """Fetch tonapi names for addresses not in the cache (or stale)."""
94
+ cache, now, changed = _load(), time.time(), False
95
+ for a in dict.fromkeys(addrs):
96
+ key = addresses.ton_friendly(a or "")
97
+ if not key or now - cache.get(key, {}).get("at", 0) < MAX_AGE:
98
+ continue
99
+ try:
100
+ rec = http.request_json(API.format(key), {}, timeout=20,
101
+ headers={"Authorization": f"Bearer {_KEY}"} if _KEY else None)
102
+ except (http.RateLimited, requests.RequestException, ValueError):
103
+ continue
104
+ if not isinstance(rec, dict) or "__status__" in rec:
105
+ continue
106
+ cache[key] = {"at": now, "day": dt.date.today().isoformat(), "label": classify(rec)}
107
+ changed = True
108
+ if changed:
109
+ _save()
@@ -30,6 +30,10 @@ def _one(address: str, chain: str, threshold: float, asset: Optional[dict]) -> O
30
30
  to = r.get("to") or ""
31
31
  if r.get("from") != me or not to:
32
32
  continue
33
+ # a deposit with a memo (TON) is a customer paying into their own exchange
34
+ # account — the memo is the lead (chains.exchange_deposits), not this address
35
+ if r.get("memo") and labels.type_of(to) == "exchange":
36
+ continue
33
37
  val = r.get("value", 0) or 0
34
38
  out_total += val
35
39
  # sending *into* another customer's deposit address is a cash-out by
@@ -224,6 +224,20 @@ def funding_tree(address: str, hops: list, symbol: str = "ETH") -> Tree:
224
224
  return root
225
225
 
226
226
 
227
+ def deposits_table(deposits: list) -> Table:
228
+ """Exchange deposits made with a memo — each memo names one account at the exchange."""
229
+ t = Table(title="Exchange deposits — the memo names one account at the exchange (KYC)",
230
+ header_style="bold")
231
+ t.add_column("Exchange wallet")
232
+ t.add_column("Memo", style="bold green")
233
+ t.add_column("Amount", justify="right")
234
+ t.add_column("Txs", justify="right")
235
+ for d in deposits:
236
+ t.add_row(escape(d["exchange"]), escape(d["memo"]), f"{d['value']:,.4f} {escape(d['symbol'])}",
237
+ str(d["txs"]))
238
+ return t
239
+
240
+
227
241
  def holdings_table(address: str, chain: str, holdings: list, show_all: bool = False) -> Table:
228
242
  """Token holdings with per-token and total USD value.
229
243
 
@@ -228,7 +228,8 @@ def _render_md(d: dict, json_name: str) -> str:
228
228
  if d["key_findings"]:
229
229
  L.append("| Entity | Type | Risk | Value reached | ≈ USD |\n|---|---|---|---|---|\n")
230
230
  for f in d["key_findings"]:
231
- L.append(f"| {f['label']} | {f['type']} | {f['risk']}/100 | {f['value_reached']} "
231
+ memo = f" (deposit memo {f['memo']})" if f.get("memo") else ""
232
+ L.append(f"| {f['label']}{memo} | {f['type']} | {f['risk']}/100 | {f['value_reached']} "
232
233
  f"{f.get('symbol', '')} | {prices.fmt_usd(f.get('usd_reached'))} |\n")
233
234
  else:
234
235
  L.append("_None within the traced depth._\n")
@@ -390,6 +391,12 @@ def render_html(d: dict, raw_json: str, digest: str, json_name: str) -> str:
390
391
  if sig["name"] in ("address poisoning", "paid a look-alike"):
391
392
  acts.append(f"<div class='box alert'><b>Address poisoning.</b> {_e(sig['observed'])} — "
392
393
  f"{_e(sig['implication'])}.</div>")
394
+ for f in d["key_findings"]:
395
+ if f.get("memo"):
396
+ acts.append(f"<div class='box alert'><b>Deposit to {_e(labels.company(f['label']))} with memo "
397
+ f"{_e(f['memo'])}.</b> The memo names one account at the exchange, which holds "
398
+ "its owner's KYC and can freeze it. Quote the memo and the transaction hashes "
399
+ "below in the report to the exchange and the police.</div>")
393
400
  exch = sorted({labels.company(f["label"]) for f in d["key_findings"]
394
401
  if f["type"] in ("exchange", "offramp")})
395
402
  if exch:
@@ -67,6 +67,16 @@ def _conn() -> sqlite3.Connection:
67
67
  c.execute("DELETE FROM fetched WHERE chain='ton'")
68
68
  c.execute("PRAGMA user_version=1")
69
69
  c.commit()
70
+ if c.execute("PRAGMA user_version").fetchone()[0] < 2:
71
+ # 0.14: transfer comments — on TON an exchange deposit's memo names the account
72
+ cols = [r[1] for r in c.execute("PRAGMA table_info(transfers)")]
73
+ if "memo" not in cols:
74
+ c.execute("ALTER TABLE transfers ADD COLUMN memo TEXT")
75
+ # TON rows stored before had their memos dropped: read them again
76
+ c.execute("DELETE FROM transfers WHERE chain='ton'")
77
+ c.execute("DELETE FROM fetched WHERE chain='ton'")
78
+ c.execute("PRAGMA user_version=2")
79
+ c.commit()
70
80
  return c
71
81
 
72
82
 
@@ -90,12 +100,12 @@ def save(chain: str, address: str, rows: List[dict], asset_key: str = "",
90
100
  try:
91
101
  c.executemany(
92
102
  "INSERT OR REPLACE INTO transfers"
93
- "(id,chain,tx_hash,from_addr,to_addr,value,symbol,contract,ts,fee_share)"
94
- " VALUES (?,?,?,?,?,?,?,?,?,?)",
103
+ "(id,chain,tx_hash,from_addr,to_addr,value,symbol,contract,ts,fee_share,memo)"
104
+ " VALUES (?,?,?,?,?,?,?,?,?,?,?)",
95
105
  [(_row_id(chain, r), chain, r.get("hash", ""), r.get("from", ""),
96
106
  r.get("to", ""), float(r.get("value", 0) or 0), r.get("symbol", ""),
97
107
  r.get("contract", ""), int(r.get("timestamp", 0) or 0),
98
- float(r.get("fee_share", 0) or 0)) for r in rows])
108
+ float(r.get("fee_share", 0) or 0), r.get("memo") or None) for r in rows])
99
109
  c.execute("INSERT OR REPLACE INTO fetched(chain,address,asset,fetched_at,rows,complete)"
100
110
  " VALUES (?,?,?,?,?,?)",
101
111
  (chain, address, asset_key, int(time.time()), len(rows), int(complete)))
@@ -125,7 +135,7 @@ def is_fresh(chain: str, address: str, asset_key: str = "",
125
135
  def load(chain: str, address: str, contract: str = "", native_symbol: str = "") -> List[dict]:
126
136
  """Every stored transfer touching this address, newest first."""
127
137
  c = _conn()
128
- cols = "SELECT from_addr,to_addr,value,symbol,contract,ts,tx_hash,fee_share FROM transfers"
138
+ cols = "SELECT from_addr,to_addr,value,symbol,contract,ts,tx_hash,fee_share,memo FROM transfers"
129
139
  try:
130
140
  if contract == "*":
131
141
  # every token, whatever its contract (address-poisoning checks need fakes too)
@@ -151,13 +161,15 @@ def load(chain: str, address: str, contract: str = "", native_symbol: str = "")
151
161
  finally:
152
162
  c.close()
153
163
  out = []
154
- for f, t, v, sym, con, ts, h, fee in rows:
164
+ for f, t, v, sym, con, ts, h, fee, memo in rows:
155
165
  r = {"from": f, "to": t, "value": v, "symbol": sym,
156
166
  "timestamp": ts, "hash": h}
157
167
  if con:
158
168
  r["contract"] = con
159
169
  if fee:
160
170
  r["fee_share"] = fee
171
+ if memo:
172
+ r["memo"] = memo
161
173
  out.append(r)
162
174
  return out
163
175
 
@@ -33,16 +33,27 @@ def _edge_text(ctx, to, val, cnt):
33
33
  return Text.assemble(Text(f"──{money} ({cnt} tx){tail}{arrow}"), render.addr_label(to))
34
34
 
35
35
 
36
- def _record_finding(findings, ctx, to, val, cnt):
36
+ def _record_finding(findings, ctx, to, val, cnt, memos=()):
37
37
  if findings is None:
38
38
  return
39
39
  hit = labels.lookup(to)
40
- if not hit:
40
+ if not hit or hit["type"] in ("dex", "service"):
41
41
  return
42
- findings.append({"address": to, "type": hit["type"], "label": hit["name"],
43
- "risk": labels.risk_score(to), "value_reached": round(val, 6),
44
- "symbol": ctx.symbol, "usd_reached": prices.usd(val, ctx.price),
45
- "tx_count": cnt})
42
+ f = {"address": to, "type": hit["type"], "label": hit["name"],
43
+ "risk": labels.risk_score(to), "value_reached": round(val, 6),
44
+ "symbol": ctx.symbol, "usd_reached": prices.usd(val, ctx.price),
45
+ "tx_count": cnt}
46
+ if memos:
47
+ f["memo"] = ", ".join(memos)
48
+ findings.append(f)
49
+
50
+
51
+ def _deposit_memos(sender, to, ctx):
52
+ """Memos of `sender`'s deposits into exchange wallet `to` (TON) — each names an account."""
53
+ if ctx.direction != "out" or labels.type_of(to) != "exchange":
54
+ return []
55
+ from crypttrace import chains
56
+ return [d["memo"] for d in chains.exchange_deposits(sender, ctx.chain, asset=ctx.asset, only_to=to)]
46
57
 
47
58
 
48
59
  def _expand(node, address, ctx, depth, seen, findings=None, is_root=False):
@@ -54,6 +65,10 @@ def _expand(node, address, ctx, depth, seen, findings=None, is_root=False):
54
65
  if not is_root and labels.type_of(address) in ("exchange", "mixer", "sanctioned"):
55
66
  node.add(Text("↳ trail ends here (identifiable entity — subpoena / off-chain)", style="dim"))
56
67
  return
68
+ if not is_root and labels.type_of(address) == "dex":
69
+ node.add(Text("↳ DEX swap — the funds come back to the sender in another token; "
70
+ "trace the sender with --asset to follow them", style="cyan"))
71
+ return
57
72
  if not is_root and labels.type_of(address) == "bridge":
58
73
  node.add(Text("↳ bridge — funds leave this chain; run `crypttrace crosschain` "
59
74
  "on the sender to find the destination chain", style="cyan"))
@@ -75,9 +90,15 @@ def _expand(node, address, ctx, depth, seen, findings=None, is_root=False):
75
90
  "usd_reached": prices.usd(off["forwarded"], ctx.price),
76
91
  "tx_count": 0})
77
92
  return
78
- for to, val, cnt in _outflows(address, ctx.chain, ctx.branching, ctx.asset, ctx.direction):
79
- _record_finding(findings, ctx, to, val, cnt)
93
+ outs = _outflows(address, ctx.chain, ctx.branching, ctx.asset, ctx.direction)
94
+ labels.enrich([to for to, _, _ in outs], ctx.chain)
95
+ for to, val, cnt in outs:
96
+ memos = _deposit_memos(address, to, ctx)
97
+ _record_finding(findings, ctx, to, val, cnt, memos)
80
98
  child = node.add(_edge_text(ctx, to, val, cnt))
99
+ for memo in memos:
100
+ child.add(Text(f"↳ deposit memo {memo} — names one account at "
101
+ f"{labels.company(labels.label_of(to))}, which holds its KYC", style="bold green"))
81
102
  _expand(child, to, ctx, depth - 1, seen, findings)
82
103
 
83
104
 
@@ -149,18 +170,27 @@ def build_graph(address, chain, depth, branching, asset=None, direction="out"):
149
170
  if d <= 0 or key in seen:
150
171
  return
151
172
  seen.add(key)
152
- if not is_root and labels.type_of(addr) in ("exchange", "mixer", "sanctioned", "bridge"):
173
+ if not is_root and labels.type_of(addr) in ("exchange", "mixer", "sanctioned", "bridge", "dex"):
153
174
  nodes[key]["terminal"] = True
154
175
  return
155
- for to, val, cnt in _outflows(addr, chain, branching, asset, direction):
176
+ outs = _outflows(addr, chain, branching, asset, direction)
177
+ labels.enrich([to for to, _, _ in outs], chain)
178
+ for to, val, cnt in outs:
156
179
  _node(to, hop + 1)
157
180
  # arrows always point the way the money actually travelled
158
181
  src, dst = (key, _norm(to)) if direction == "out" else (_norm(to), key)
159
- edges.append({"from": src, "to": dst,
160
- "value": round(val, 4), "tx": cnt,
161
- "usd": prices.usd(val, price)})
182
+ edge = {"from": src, "to": dst, "value": round(val, 4), "tx": cnt,
183
+ "usd": prices.usd(val, price)}
184
+ if direction == "out" and labels.type_of(to) == "exchange":
185
+ from crypttrace import chains as _ch
186
+ memos = [m["memo"] for m in _ch.exchange_deposits(addr, chain, asset=asset, only_to=to)]
187
+ if memos:
188
+ edge["memo"] = ", ".join(memos)
189
+ edges.append(edge)
162
190
  _walk(to, d - 1, seen, False, hop + 1)
163
191
 
164
192
  _walk(address, depth, set(), True, 0)
193
+ for n in nodes.values(): # labels found by enrichment after the node was made
194
+ n["type"], n["label"] = labels.type_of(n["id"]), labels.label_of(n["id"])
165
195
  return {"nodes": list(nodes.values()), "edges": edges,
166
196
  "symbol": symbol, "direction": direction}
@@ -368,7 +368,7 @@ function renderGraph(g){
368
368
  const edges=g.edges.map(e=>({
369
369
  from:e.from,to:e.to,arrows:{to:{enabled:true,scaleFactor:.7}},
370
370
  label:`${e.value} ${g.symbol}${e.usd!=null?' ≈'+fmtUsd(e.usd):''}`,
371
- title:`${e.value} ${g.symbol} in ${e.tx} tx — click to open on explorer`,
371
+ title:`${e.value} ${g.symbol} in ${e.tx} tx${e.memo?' · deposit memo '+e.memo:''} — click to open on explorer`,
372
372
  font:{color:'#93a1b5',size:10.5,strokeWidth:4,strokeColor:'rgba(8,11,18,.85)',align:'horizontal'},
373
373
  color:{color:'rgba(255,255,255,.18)',highlight:'#4d9dff',hover:'#4d9dff'},
374
374
  width:1.2, hoverWidth:2.4, selectionWidth:2.4,
@@ -444,6 +444,12 @@ async function inspect(addr){
444
444
  kv('transfers',p.txs)+
445
445
  kv('first seen',fmtTs(p.first_seen))+
446
446
  kv('last seen',fmtTs(p.last_seen)));
447
+ if(p.deposits && p.deposits.length){
448
+ h+=card('🏦 Exchange deposits with a memo',
449
+ `<div class="muted">The memo names one account at the exchange, which holds its KYC — quote it
450
+ with the transaction in a report or freeze request.</div>`+
451
+ p.deposits.map(d=>kv(esc(d.exchange),`memo <b class="mono">${esc(d.memo)}</b> · ${esc(d.value.toLocaleString())} ${esc(d.symbol)} · ${d.txs} tx`)).join(''),'alert');
452
+ }
447
453
  if(o.offramp){
448
454
  h+=card('⚠ Off-ramp detected',
449
455
  `<div class="muted">~${Math.round(o.offramp.fraction*100)}% of outgoing funds forwarded to
@@ -119,6 +119,7 @@ def create_app() -> Flask:
119
119
  "type": labels.type_of(addr),
120
120
  "risk": labels.risk_score(addr),
121
121
  "explorer": chains.explorer_url(addr, chain),
122
+ "deposits": chains.exchange_deposits(addr, chain)[:5],
122
123
  })
123
124
 
124
125
  @app.route("/api/trace")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crypttrace
3
- Version: 0.13.0
3
+ Version: 0.14.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
@@ -103,6 +103,7 @@ own credentials (all optional):
103
103
  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
+ export TONAPI_KEY=xxxx # optional: faster tonapi name lookups on TON
106
107
  ```
107
108
 
108
109
  (In PowerShell: `$env:TRONGRID_API_KEY = "xxxx"` and so on.)
@@ -481,6 +482,29 @@ wallets. That is what lets `offramp` flag deposit addresses on those chains. Fun
481
482
  list still show as `unknown`. Every label records where it came from — see
482
483
  `crypttrace labels audit`.
483
484
 
485
+ **TON: names from tonapi.** On TON, addresses the curated set does not know are
486
+ looked up on tonapi, which names exchange hot wallets ("Bybit 7", "Kucoin 1"),
487
+ DEX contracts (STON.fi, DeDust) and services (Fragment), and flags scam
488
+ accounts. These become labels of kind `explorer-tag`, cached for a week. A
489
+ `.ton` name an owner chose for himself — "crypto-exch-io.ton", or an impostor's
490
+ "kucoin-1.ton" — is not taken as a label. A trace stops at a DEX: the funds
491
+ come back to the sender in another token.
492
+
493
+ **Exchange deposits with a memo.** A TON exchange takes all customers' deposits
494
+ into one hot wallet and tells them apart by the transfer comment, the *memo*.
495
+ The memo is the lead: it names one account at the exchange, which holds that
496
+ customer's KYC. `trace`, `profile`, the web UI and the HTML report show every
497
+ deposit an address made to an exchange with its memo:
498
+
499
+ ```
500
+ ├── ──1280.0000 TON (1 tx)──▶ 🟢 UQCA1BI4QR…la6Yug [KuCoin hot wallet (memo required)]
501
+ │ └── ↳ deposit memo 2093842166 — names one account at KuCoin, which holds its KYC
502
+ ```
503
+
504
+ In the fragment-api case this found the attacker's KuCoin account directly, and
505
+ the account of the exchange service he cashed out through (its relay deposits
506
+ under one memo every time).
507
+
484
508
  **Deposit addresses, labelled outright.** OKX signs every address that holds
485
509
  customer funds — over 300,000, nearly all of them per-customer deposit addresses
486
510
  — and publishes the list with its proof of reserves. `crypttrace update-labels
@@ -43,4 +43,5 @@ src/crypttrace/labels/bulk.py
43
43
  src/crypttrace/labels/known.json
44
44
  src/crypttrace/labels/labels.py
45
45
  src/crypttrace/labels/partial.json
46
+ src/crypttrace/labels/tonapi.py
46
47
  src/crypttrace/web/index.html
File without changes
File without changes
File without changes