crypttrace 0.5.0__tar.gz → 0.6.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 (46) hide show
  1. {crypttrace-0.5.0/src/crypttrace.egg-info → crypttrace-0.6.0}/PKG-INFO +32 -3
  2. {crypttrace-0.5.0 → crypttrace-0.6.0}/README.md +31 -2
  3. {crypttrace-0.5.0 → crypttrace-0.6.0}/src/crypttrace/__init__.py +1 -1
  4. {crypttrace-0.5.0 → crypttrace-0.6.0}/src/crypttrace/assess.py +15 -1
  5. {crypttrace-0.5.0 → crypttrace-0.6.0}/src/crypttrace/cli.py +32 -0
  6. crypttrace-0.6.0/src/crypttrace/freeze.py +150 -0
  7. {crypttrace-0.5.0 → crypttrace-0.6.0}/src/crypttrace/investigate.py +21 -1
  8. {crypttrace-0.5.0 → crypttrace-0.6.0}/src/crypttrace/web/index.html +16 -2
  9. {crypttrace-0.5.0 → crypttrace-0.6.0}/src/crypttrace/webapp.py +11 -0
  10. {crypttrace-0.5.0 → crypttrace-0.6.0/src/crypttrace.egg-info}/PKG-INFO +32 -3
  11. {crypttrace-0.5.0 → crypttrace-0.6.0}/src/crypttrace.egg-info/SOURCES.txt +1 -0
  12. {crypttrace-0.5.0 → crypttrace-0.6.0}/LICENSE +0 -0
  13. {crypttrace-0.5.0 → crypttrace-0.6.0}/pyproject.toml +0 -0
  14. {crypttrace-0.5.0 → crypttrace-0.6.0}/setup.cfg +0 -0
  15. {crypttrace-0.5.0 → crypttrace-0.6.0}/src/crypttrace/addresses.py +0 -0
  16. {crypttrace-0.5.0 → crypttrace-0.6.0}/src/crypttrace/analysis.py +0 -0
  17. {crypttrace-0.5.0 → crypttrace-0.6.0}/src/crypttrace/assets.py +0 -0
  18. {crypttrace-0.5.0 → crypttrace-0.6.0}/src/crypttrace/bridges.py +0 -0
  19. {crypttrace-0.5.0 → crypttrace-0.6.0}/src/crypttrace/chains.py +0 -0
  20. {crypttrace-0.5.0 → crypttrace-0.6.0}/src/crypttrace/config.py +0 -0
  21. {crypttrace-0.5.0 → crypttrace-0.6.0}/src/crypttrace/fetchers/__init__.py +0 -0
  22. {crypttrace-0.5.0 → crypttrace-0.6.0}/src/crypttrace/fetchers/bitcoin.py +0 -0
  23. {crypttrace-0.5.0 → crypttrace-0.6.0}/src/crypttrace/fetchers/etherscan.py +0 -0
  24. {crypttrace-0.5.0 → crypttrace-0.6.0}/src/crypttrace/fetchers/http.py +0 -0
  25. {crypttrace-0.5.0 → crypttrace-0.6.0}/src/crypttrace/fetchers/solana.py +0 -0
  26. {crypttrace-0.5.0 → crypttrace-0.6.0}/src/crypttrace/fetchers/tron.py +0 -0
  27. {crypttrace-0.5.0 → crypttrace-0.6.0}/src/crypttrace/funder.py +0 -0
  28. {crypttrace-0.5.0 → crypttrace-0.6.0}/src/crypttrace/labels/__init__.py +0 -0
  29. {crypttrace-0.5.0 → crypttrace-0.6.0}/src/crypttrace/labels/audit.py +0 -0
  30. {crypttrace-0.5.0 → crypttrace-0.6.0}/src/crypttrace/labels/bulk.py +0 -0
  31. {crypttrace-0.5.0 → crypttrace-0.6.0}/src/crypttrace/labels/known.json +0 -0
  32. {crypttrace-0.5.0 → crypttrace-0.6.0}/src/crypttrace/labels/labels.py +0 -0
  33. {crypttrace-0.5.0 → crypttrace-0.6.0}/src/crypttrace/labels/partial.json +0 -0
  34. {crypttrace-0.5.0 → crypttrace-0.6.0}/src/crypttrace/offramp.py +0 -0
  35. {crypttrace-0.5.0 → crypttrace-0.6.0}/src/crypttrace/poisoning.py +0 -0
  36. {crypttrace-0.5.0 → crypttrace-0.6.0}/src/crypttrace/prices.py +0 -0
  37. {crypttrace-0.5.0 → crypttrace-0.6.0}/src/crypttrace/render.py +0 -0
  38. {crypttrace-0.5.0 → crypttrace-0.6.0}/src/crypttrace/report.py +0 -0
  39. {crypttrace-0.5.0 → crypttrace-0.6.0}/src/crypttrace/store.py +0 -0
  40. {crypttrace-0.5.0 → crypttrace-0.6.0}/src/crypttrace/trace.py +0 -0
  41. {crypttrace-0.5.0 → crypttrace-0.6.0}/src/crypttrace/verify.py +0 -0
  42. {crypttrace-0.5.0 → crypttrace-0.6.0}/src/crypttrace/watch.py +0 -0
  43. {crypttrace-0.5.0 → crypttrace-0.6.0}/src/crypttrace.egg-info/dependency_links.txt +0 -0
  44. {crypttrace-0.5.0 → crypttrace-0.6.0}/src/crypttrace.egg-info/entry_points.txt +0 -0
  45. {crypttrace-0.5.0 → crypttrace-0.6.0}/src/crypttrace.egg-info/requires.txt +0 -0
  46. {crypttrace-0.5.0 → crypttrace-0.6.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.5.0
3
+ Version: 0.6.0
4
4
  Summary: OSINT toolkit for crypto investigations: trace stolen funds across Ethereum, Bitcoin, Tron and Solana
5
5
  Author: bobslayerX
6
6
  License-Expression: MIT
@@ -163,6 +163,11 @@ It is also honest with you: most stolen crypto is not recovered, and what
163
163
  matters is speed and whether the funds touch a regulated exchange. The tool
164
164
  gives you evidence and timing — it cannot move funds or name a person by itself.
165
165
 
166
+ **Stolen USDT or USDC?** Check at once whether it is still sitting at the
167
+ thief's address — Tether and Circle can freeze it there, and only there:
168
+ `crypttrace freeze THEIR_ADDRESS --chain tron`. `investigate` does this too
169
+ and puts it first.
170
+
166
171
  **Sent money to an address that looked right?** That is usually address
167
172
  poisoning — see below. Check your own wallet with
168
173
  `crypttrace poisoning YOUR_ADDRESS`.
@@ -195,6 +200,9 @@ crypttrace tokens 0xADDRESS
195
200
  # Who bootstrapped this wallet's first gas? Follow it toward a KYC point
196
201
  crypttrace funder 0xADDRESS --hops 6
197
202
 
203
+ # Is the USDT/USDC at this address frozen by Tether/Circle — and who can freeze it?
204
+ crypttrace freeze TADDRESS --chain tron
205
+
198
206
  # Address poisoning: look-alike addresses planted in a wallet's history
199
207
  crypttrace poisoning TADDRESS --chain tron
200
208
 
@@ -275,6 +283,29 @@ crypttrace watch run --once # single check, for scheduled tasks
275
283
  Optional Telegram alerts: set `CRYPTTRACE_TG_TOKEN` and `CRYPTTRACE_TG_CHAT`,
276
284
  then pass `--telegram`.
277
285
 
286
+ ### Stablecoin freezes
287
+
288
+ Tether and Circle can freeze USDT and USDC at any address. For a victim of a
289
+ stablecoin theft that is the one lever that actually stops the money — while it
290
+ is still there. `crypttrace freeze` reads the issuers' own contracts (USDT's
291
+ `isBlackListed`, USDC's `isBlacklisted`, balances; token-account state on
292
+ Solana) on Ethereum, Tron and Solana, and answers three things: how much is
293
+ there, whether it is already frozen, and if not, who can freeze it:
294
+
295
+ - **Tether** freezes USDT at the request of law enforcement, often before a
296
+ court case — so the route is the police, quickly
297
+ ([policy](https://tether.to/en/legal/?tab=law-enforcement-requests)).
298
+ - **Circle** freezes USDC only on a legal order such as a court order
299
+ ([terms](https://www.circle.com/legal/usdc-terms)), which takes longer.
300
+
301
+ No API key is needed: Ethereum is read through a public node (set
302
+ `CRYPTTRACE_ETH_RPC` to use your own). `investigate` runs the check on the
303
+ address the money went to and makes it the first step; the web UI shows it in
304
+ the side panel; a freeze also counts in the assessment. A check that fails is
305
+ reported as unknown, never as "not frozen". Other chains carry bridged or
306
+ third-party versions of these tokens that the issuers cannot freeze in the same
307
+ way, so they are not checked.
308
+
278
309
  ### Address poisoning
279
310
 
280
311
  Wallets show addresses shortened — `TDDD34…rCr9Ps`. A poisoner generates an
@@ -530,8 +561,6 @@ cases/ # worked investigations with their data
530
561
 
531
562
  - More exchanges on Bitcoin, Tron and Solana (now: Binance, OKX, HTX, Bybit),
532
563
  and refreshing these lists as the exchanges republish them
533
- - Stablecoin freeze check: whether USDT/USDC at an address is already frozen
534
- by the issuer, and who to ask for a freeze
535
564
  - `report --html`: one self-contained file with the interactive graph, to
536
565
  send to an exchange or attach to a police report
537
566
  - `report --pdf` for exchange and law-enforcement filings
@@ -129,6 +129,11 @@ It is also honest with you: most stolen crypto is not recovered, and what
129
129
  matters is speed and whether the funds touch a regulated exchange. The tool
130
130
  gives you evidence and timing — it cannot move funds or name a person by itself.
131
131
 
132
+ **Stolen USDT or USDC?** Check at once whether it is still sitting at the
133
+ thief's address — Tether and Circle can freeze it there, and only there:
134
+ `crypttrace freeze THEIR_ADDRESS --chain tron`. `investigate` does this too
135
+ and puts it first.
136
+
132
137
  **Sent money to an address that looked right?** That is usually address
133
138
  poisoning — see below. Check your own wallet with
134
139
  `crypttrace poisoning YOUR_ADDRESS`.
@@ -161,6 +166,9 @@ crypttrace tokens 0xADDRESS
161
166
  # Who bootstrapped this wallet's first gas? Follow it toward a KYC point
162
167
  crypttrace funder 0xADDRESS --hops 6
163
168
 
169
+ # Is the USDT/USDC at this address frozen by Tether/Circle — and who can freeze it?
170
+ crypttrace freeze TADDRESS --chain tron
171
+
164
172
  # Address poisoning: look-alike addresses planted in a wallet's history
165
173
  crypttrace poisoning TADDRESS --chain tron
166
174
 
@@ -241,6 +249,29 @@ crypttrace watch run --once # single check, for scheduled tasks
241
249
  Optional Telegram alerts: set `CRYPTTRACE_TG_TOKEN` and `CRYPTTRACE_TG_CHAT`,
242
250
  then pass `--telegram`.
243
251
 
252
+ ### Stablecoin freezes
253
+
254
+ Tether and Circle can freeze USDT and USDC at any address. For a victim of a
255
+ stablecoin theft that is the one lever that actually stops the money — while it
256
+ is still there. `crypttrace freeze` reads the issuers' own contracts (USDT's
257
+ `isBlackListed`, USDC's `isBlacklisted`, balances; token-account state on
258
+ Solana) on Ethereum, Tron and Solana, and answers three things: how much is
259
+ there, whether it is already frozen, and if not, who can freeze it:
260
+
261
+ - **Tether** freezes USDT at the request of law enforcement, often before a
262
+ court case — so the route is the police, quickly
263
+ ([policy](https://tether.to/en/legal/?tab=law-enforcement-requests)).
264
+ - **Circle** freezes USDC only on a legal order such as a court order
265
+ ([terms](https://www.circle.com/legal/usdc-terms)), which takes longer.
266
+
267
+ No API key is needed: Ethereum is read through a public node (set
268
+ `CRYPTTRACE_ETH_RPC` to use your own). `investigate` runs the check on the
269
+ address the money went to and makes it the first step; the web UI shows it in
270
+ the side panel; a freeze also counts in the assessment. A check that fails is
271
+ reported as unknown, never as "not frozen". Other chains carry bridged or
272
+ third-party versions of these tokens that the issuers cannot freeze in the same
273
+ way, so they are not checked.
274
+
244
275
  ### Address poisoning
245
276
 
246
277
  Wallets show addresses shortened — `TDDD34…rCr9Ps`. A poisoner generates an
@@ -496,8 +527,6 @@ cases/ # worked investigations with their data
496
527
 
497
528
  - More exchanges on Bitcoin, Tron and Solana (now: Binance, OKX, HTX, Bybit),
498
529
  and refreshing these lists as the exchanges republish them
499
- - Stablecoin freeze check: whether USDT/USDC at an address is already frozen
500
- by the issuer, and who to ask for a freeze
501
530
  - `report --html`: one self-contained file with the interactive graph, to
502
531
  send to an exchange or attach to a police report
503
532
  - `report --pdf` for exchange and law-enforcement filings
@@ -1,2 +1,2 @@
1
1
  """crypttrace — OSINT crypto investigation CLI."""
2
- __version__ = "0.5.0"
2
+ __version__ = "0.6.0"
@@ -14,7 +14,7 @@ from collections import Counter
14
14
  from dataclasses import dataclass, field, asdict
15
15
  from typing import Dict, List, Optional
16
16
 
17
- from crypttrace import analysis, chains, poisoning
17
+ from crypttrace import analysis, chains, freeze, poisoning
18
18
  from crypttrace.labels import labels
19
19
 
20
20
 
@@ -234,6 +234,17 @@ def assess(address: str, chain: str = "eth", asset: Optional[dict] = None,
234
234
  implication="this wallet was likely the victim of address poisoning",
235
235
  confidence=p["confidence"], weight=0, evidence={"pairs": sent_to_fake[:5]}))
236
236
 
237
+ # --- stablecoin issuer freeze ---------------------------------------
238
+ frozen = [e for e in freeze.check(address, chain) if e.get("frozen")]
239
+ if frozen:
240
+ e = frozen[0]
241
+ signals.append(Signal(
242
+ name="frozen by issuer",
243
+ observed=f"{e['frozen_amount']:,.2f} {e['token']} at this address is frozen by {e['issuer']}",
244
+ implication="the issuer blocked it — usually at the request of law enforcement "
245
+ "or under sanctions",
246
+ confidence="high", weight=40, evidence={"freezes": frozen}))
247
+
237
248
  # --- holding behaviour ---------------------------------------------
238
249
  if received > 0 and sent == 0 and len(inbound) >= 3:
239
250
  signals.append(Signal(
@@ -314,6 +325,9 @@ def _statement(signals: List[Signal], risk: int, confidence: str, hit) -> str:
314
325
  if "paid a look-alike" in names:
315
326
  parts.append("This wallet sent money to a look-alike of an address it had used "
316
327
  "before — the signature of an address-poisoning theft.")
328
+ if "frozen by issuer" in names:
329
+ parts.append("Stablecoins here are frozen by their issuer, which usually follows a "
330
+ "law-enforcement request or a sanctions listing.")
317
331
  if "funds held" in names:
318
332
  parts.append("The proceeds have not been spent, so intervention is still possible.")
319
333
  parts.append(f"Overall risk {risk}/100, confidence {confidence}.")
@@ -262,6 +262,38 @@ def crosschain(
262
262
  "not proof. Verify each candidate before relying on it.[/dim]")
263
263
 
264
264
 
265
+ @app.command()
266
+ def freeze(
267
+ address: str = typer.Argument(..., help="Address holding the USDT/USDC"),
268
+ chain: str = CHAIN_OPT,
269
+ ):
270
+ """Is USDT/USDC at this address frozen by its issuer — and who can freeze it?"""
271
+ from crypttrace import freeze as freeze_mod
272
+ entries = freeze_mod.check(address, chain)
273
+ if not entries:
274
+ console.print("[dim]USDT/USDC freezes are checked on Ethereum, Tron and Solana, where "
275
+ "Tether and Circle issue them directly and can freeze them.[/dim]")
276
+ return
277
+ for e in entries:
278
+ head = f"[bold]{e['token']}[/bold] ({e['issuer']})"
279
+ if e.get("error"):
280
+ console.print(f"{head}: [yellow]could not be read[/yellow] — {e['error']}")
281
+ elif e["frozen"]:
282
+ console.print(f"{head}: [bold green]FROZEN[/bold green] — {e['frozen_amount']:,.2f} "
283
+ f"{e['token']} cannot move" + (f"; {e['movable']:,.2f} still can"
284
+ if e["movable"] else ""))
285
+ elif e["balance"]:
286
+ console.print(f"{head}: [bold red]{e['balance']:,.2f} {e['token']} NOT frozen[/bold red]"
287
+ " — it can still be moved")
288
+ console.print(f" {e['how']}")
289
+ console.print(f" [dim]{e['url']}[/dim]")
290
+ else:
291
+ console.print(f"{head}: none at this address")
292
+ if labels.type_of(address) == "exchange":
293
+ console.print("\n[dim]This is an exchange's own wallet: ask the exchange, "
294
+ "not the issuer.[/dim]")
295
+
296
+
265
297
  @app.command()
266
298
  def poisoning(
267
299
  address: str = typer.Argument(..., help="Your wallet, or the address the money went to"),
@@ -0,0 +1,150 @@
1
+ """Stablecoin freezes — has the issuer already frozen the money, and who can?
2
+
3
+ Tether (USDT) and Circle (USDC) can freeze their tokens at any address. For a
4
+ victim that is the one mechanism that actually stops stolen stablecoins, and it
5
+ works only while the money is still sitting there. So the useful answers are:
6
+ how much USDT/USDC is at this address, is it already frozen, and if not, who
7
+ can freeze it and what they need.
8
+
9
+ Everything is read from the issuers' own contracts, not from third-party lists:
10
+
11
+ * Ethereum — USDT's isBlackListed / USDC's isBlacklisted and balanceOf, via
12
+ a public JSON-RPC node (no API key; set CRYPTTRACE_ETH_RPC to use your own);
13
+ * Tron — the same calls through TronGrid;
14
+ * Solana — the state of the owner's token accounts, which the issuer freezes
15
+ one account at a time ("frozen").
16
+
17
+ Other chains carry bridged or third-party versions of these tokens that the
18
+ issuers cannot freeze in the same way, so they are not checked.
19
+ """
20
+ import os
21
+ from typing import Dict, List
22
+
23
+ import requests
24
+
25
+ from crypttrace import addresses, assets
26
+ from crypttrace.fetchers import solana
27
+
28
+ ETH_RPC = os.environ.get("CRYPTTRACE_ETH_RPC", "https://ethereum-rpc.publicnode.com")
29
+ TRON_API = "https://api.trongrid.io"
30
+
31
+ # selector of the issuer's own "is this address frozen?" function
32
+ _EVM_FROZEN = {"USDT": "0xe47d6060", # isBlackListed(address)
33
+ "USDC": "0xfe575a87"} # isBlacklisted(address)
34
+ _BALANCE_OF = "0x70a08231" # balanceOf(address)
35
+ _TRON_FROZEN = {"USDT": "isBlackListed(address)", "USDC": "isBlacklisted(address)"}
36
+
37
+ ISSUERS = {
38
+ "USDT": {"issuer": "Tether",
39
+ "how": ("Tether freezes USDT at the request of law enforcement, often before "
40
+ "a court case. Give the police this address and ask them to request a "
41
+ "freeze from Tether."),
42
+ "url": "https://tether.to/en/legal/?tab=law-enforcement-requests"},
43
+ "USDC": {"issuer": "Circle",
44
+ "how": ("Circle freezes USDC only on a legal order — a court order, a sanctions "
45
+ "designation or a request from authorities with jurisdiction over it. "
46
+ "Expect to need a court order; ask the police or a lawyer."),
47
+ "url": "https://www.circle.com/legal/usdc-terms"},
48
+ }
49
+
50
+ SUPPORTED = ("eth", "tron", "sol")
51
+
52
+
53
+ class FreezeError(RuntimeError):
54
+ pass
55
+
56
+
57
+ def _eth_call(contract: str, selector: str, address: str) -> int:
58
+ data = selector + address.lower().replace("0x", "").rjust(64, "0")
59
+ try:
60
+ r = requests.post(ETH_RPC, timeout=30, json={
61
+ "jsonrpc": "2.0", "id": 1, "method": "eth_call",
62
+ "params": [{"to": contract, "data": data}, "latest"]}).json()
63
+ except (requests.RequestException, ValueError) as e:
64
+ raise FreezeError(f"Ethereum node unreachable: {e}")
65
+ if "result" not in r:
66
+ raise FreezeError(f"Ethereum node refused the call: {r.get('error')}")
67
+ return int(r["result"] or "0x0", 16)
68
+
69
+
70
+ def _tron_call(contract: str, signature: str, address: str) -> int:
71
+ raw = addresses._b58_decode(address) # 0x41 + 20 bytes + checksum
72
+ if not raw or len(raw) != 25:
73
+ raise FreezeError("not a Tron address")
74
+ headers = {"TRON-PRO-API-KEY": os.environ["TRONGRID_API_KEY"]} \
75
+ if os.environ.get("TRONGRID_API_KEY") else {}
76
+ try:
77
+ r = requests.post(f"{TRON_API}/wallet/triggerconstantcontract", timeout=30,
78
+ headers=headers, json={
79
+ # a read-only call: the caller does not matter, and
80
+ # an unactivated address can be refused as the caller
81
+ "owner_address": contract, "contract_address": contract,
82
+ "function_selector": signature,
83
+ "parameter": raw[1:21].hex().rjust(64, "0"), "visible": True}).json()
84
+ except (requests.RequestException, ValueError) as e:
85
+ raise FreezeError(f"TronGrid unreachable: {e}")
86
+ out = (r.get("constant_result") or [None])[0]
87
+ if out is None:
88
+ raise FreezeError(f"TronGrid refused the call: {r.get('result') or r}")
89
+ return int(out or "0", 16)
90
+
91
+
92
+ def _sol_accounts(owner: str, mint: str) -> List[dict]:
93
+ try:
94
+ r = solana._rpc("getTokenAccountsByOwner",
95
+ [owner, {"mint": mint}, {"encoding": "jsonParsed"}])
96
+ except solana.SolanaError as e:
97
+ raise FreezeError(str(e))
98
+ out = []
99
+ for v in (r.get("value") if isinstance(r, dict) else r) or []:
100
+ info = v["account"]["data"]["parsed"]["info"]
101
+ out.append({"state": info.get("state"),
102
+ "amount": float(info["tokenAmount"].get("uiAmountString") or 0)})
103
+ return out
104
+
105
+
106
+ def _one(address: str, chain: str, symbol: str, contract: str) -> Dict:
107
+ if chain == "eth":
108
+ frozen = _eth_call(contract, _EVM_FROZEN[symbol], address) == 1
109
+ balance = _eth_call(contract, _BALANCE_OF, address) / 1e6
110
+ frozen_amount = balance if frozen else 0.0
111
+ elif chain == "tron":
112
+ frozen = _tron_call(contract, _TRON_FROZEN[symbol], address) == 1
113
+ balance = _tron_call(contract, "balanceOf(address)", address) / 1e6
114
+ frozen_amount = balance if frozen else 0.0
115
+ else: # sol: frozen per token account
116
+ accts = _sol_accounts(address, contract)
117
+ balance = sum(a["amount"] for a in accts)
118
+ frozen_amount = sum(a["amount"] for a in accts if a["state"] == "frozen")
119
+ frozen = any(a["state"] == "frozen" for a in accts)
120
+ return {"token": symbol, "balance": balance, "frozen": frozen,
121
+ "frozen_amount": frozen_amount, "movable": balance - frozen_amount}
122
+
123
+
124
+ def check(address: str, chain: str) -> List[Dict]:
125
+ """One entry per issuer stablecoin on this chain; empty where none applies.
126
+
127
+ Each entry: token, issuer, balance, frozen, frozen_amount, movable (what can
128
+ still leave), how (what the issuer needs), url, and error if it could not be
129
+ read. A failed read is reported, never guessed.
130
+ """
131
+ if chain not in SUPPORTED:
132
+ return []
133
+ from crypttrace import chains
134
+ out = []
135
+ for sym in ("usdt", "usdc"):
136
+ tok = assets.tokens_for(chain).get(sym)
137
+ if not tok:
138
+ continue
139
+ symbol = tok["symbol"]
140
+ entry = {"token": symbol, **ISSUERS[symbol]}
141
+ try:
142
+ if chains.OFFLINE:
143
+ raise FreezeError("not checked: working offline")
144
+ entry.update(_one(address, chain, symbol, tok["contract"]))
145
+ except (FreezeError, KeyError, TypeError, ValueError) as e:
146
+ entry.update({"balance": None, "frozen": None, "frozen_amount": None,
147
+ "movable": None, "error": str(e)})
148
+ out.append(entry)
149
+ return out
150
+
@@ -14,7 +14,7 @@ from pathlib import Path
14
14
  from typing import Dict, List, Optional
15
15
 
16
16
  from crypttrace import chains, prices, report as report_mod, trace as trace_mod
17
- from crypttrace import funder as funder_mod, offramp as offramp_mod, poisoning
17
+ from crypttrace import freeze as freeze_mod, funder as funder_mod, offramp as offramp_mod, poisoning
18
18
  from crypttrace.labels import labels
19
19
 
20
20
 
@@ -83,6 +83,9 @@ def analyse(address: str, chain: str = "eth", asset: Optional[dict] = None,
83
83
  except chains.ChainError:
84
84
  result["poisoning"] = []
85
85
 
86
+ # an exchange's own wallet is the exchange's to freeze, not the issuer's
87
+ result["freeze"] = [] if result["type"] == "exchange" else freeze_mod.check(address, chain)
88
+
86
89
  result["guidance"] = build_guidance(result)
87
90
  return result
88
91
 
@@ -148,6 +151,23 @@ def build_guidance(r: dict) -> dict:
148
151
  "urgent": False,
149
152
  })
150
153
 
154
+ for e in r.get("freeze") or []:
155
+ if e.get("frozen"):
156
+ steps.insert(0, {
157
+ "title": f"{e['frozen_amount']:,.2f} {e['token']} here is already frozen by {e['issuer']}",
158
+ "body": ("It cannot be moved. Frozen funds can be returned to victims, but only "
159
+ f"through a legal process: tell the police and {e['issuer']} that you "
160
+ "are a victim of this address, and include this report."),
161
+ "urgent": True,
162
+ })
163
+ elif e.get("movable"):
164
+ steps.insert(0, {
165
+ "title": f"Ask for a freeze: {e['movable']:,.2f} {e['token']} is still here",
166
+ "body": (f"{e['how']} It can be moved at any moment, so this is the most "
167
+ f"time-critical step. {e['issuer']}'s policy: {e['url']}"),
168
+ "urgent": True,
169
+ })
170
+
151
171
  # always-applicable steps
152
172
  steps.append({
153
173
  "title": "Report it to the police",
@@ -412,10 +412,11 @@ async function inspect(addr){
412
412
  const c=chain();
413
413
  $('#side').innerHTML=`<div class="card"><div class="muted"><span class="spin"></span>Loading ${esc(short(addr))}…</div></div>`;
414
414
  try{
415
- const [p,f,o]=await Promise.all([
415
+ const [p,f,o,fz]=await Promise.all([
416
416
  api(`/api/profile?address=${q(addr)}&chain=${c}`),
417
417
  api(`/api/funder?address=${q(addr)}&chain=${c}`).catch(()=>({hops:[]})),
418
- api(`/api/offramp?address=${q(addr)}&chain=${c}`).catch(()=>({offramp:null}))
418
+ api(`/api/offramp?address=${q(addr)}&chain=${c}`).catch(()=>({offramp:null})),
419
+ api(`/api/freeze?address=${q(addr)}&chain=${c}`).catch(()=>({freeze:[]}))
419
420
  ]);
420
421
  // A label is a claim about who controls an address — show what backs it.
421
422
  let ev={known:false};
@@ -445,6 +446,19 @@ async function inspect(addr){
445
446
  <b style="color:var(--green)">${esc(o.offramp.exchange)}</b> — likely a deposit address, i.e. a
446
447
  <b>KYC identification point</b>.</div>`,'alert');
447
448
  }
449
+ // Tether/Circle can freeze USDT/USDC in place — the one thing that stops them
450
+ const fzs=(fz.freeze||[]).filter(e=>e.error||e.frozen||e.balance>0);
451
+ if(fzs.length&&!fz.exchange){
452
+ const amt=v=>Number(v).toLocaleString(undefined,{maximumFractionDigits:2});
453
+ const open=fzs.filter(e=>!e.frozen&&e.balance>0);
454
+ h+=card('Stablecoin freeze',
455
+ fzs.map(e=>kv(esc(e.token), e.error ? '<span class="muted">could not be read</span>'
456
+ : e.frozen ? `<b style="color:var(--green)">${amt(e.frozen_amount)} frozen by ${esc(e.issuer)}</b>`
457
+ : `<b style="color:#ff6b6b">${amt(e.balance)} not frozen</b>`)).join('')+
458
+ (open.length?`<div class="vfy-note">${esc(open[0].how)}
459
+ <a href="${safeUrl(open[0].url)}" target="_blank" rel="noopener">${esc(open[0].issuer)} policy ↗</a></div>`:''),
460
+ open.length?'alert':'');
461
+ }
448
462
  if(f.hops&&f.hops.length){
449
463
  h+=card('First-funder chain',
450
464
  f.hops.map(x=>kv('◀ funded by',
@@ -255,6 +255,17 @@ def create_app() -> Flask:
255
255
  return jsonify({"error": str(e)}), 400
256
256
  return jsonify({"hops": hops})
257
257
 
258
+ @app.route("/api/freeze")
259
+ def api_freeze():
260
+ addr = request.args.get("address", "")
261
+ chain = request.args.get("chain", "eth")
262
+ bad = validate(addr, chain)
263
+ if bad:
264
+ return jsonify({"error": bad}), 400
265
+ from crypttrace import freeze as freeze_mod
266
+ return jsonify({"freeze": freeze_mod.check(addr, chain),
267
+ "exchange": labels.type_of(addr) == "exchange"})
268
+
258
269
  @app.route("/api/offramp")
259
270
  def api_offramp():
260
271
  addr = request.args.get("address", "")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crypttrace
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: OSINT toolkit for crypto investigations: trace stolen funds across Ethereum, Bitcoin, Tron and Solana
5
5
  Author: bobslayerX
6
6
  License-Expression: MIT
@@ -163,6 +163,11 @@ It is also honest with you: most stolen crypto is not recovered, and what
163
163
  matters is speed and whether the funds touch a regulated exchange. The tool
164
164
  gives you evidence and timing — it cannot move funds or name a person by itself.
165
165
 
166
+ **Stolen USDT or USDC?** Check at once whether it is still sitting at the
167
+ thief's address — Tether and Circle can freeze it there, and only there:
168
+ `crypttrace freeze THEIR_ADDRESS --chain tron`. `investigate` does this too
169
+ and puts it first.
170
+
166
171
  **Sent money to an address that looked right?** That is usually address
167
172
  poisoning — see below. Check your own wallet with
168
173
  `crypttrace poisoning YOUR_ADDRESS`.
@@ -195,6 +200,9 @@ crypttrace tokens 0xADDRESS
195
200
  # Who bootstrapped this wallet's first gas? Follow it toward a KYC point
196
201
  crypttrace funder 0xADDRESS --hops 6
197
202
 
203
+ # Is the USDT/USDC at this address frozen by Tether/Circle — and who can freeze it?
204
+ crypttrace freeze TADDRESS --chain tron
205
+
198
206
  # Address poisoning: look-alike addresses planted in a wallet's history
199
207
  crypttrace poisoning TADDRESS --chain tron
200
208
 
@@ -275,6 +283,29 @@ crypttrace watch run --once # single check, for scheduled tasks
275
283
  Optional Telegram alerts: set `CRYPTTRACE_TG_TOKEN` and `CRYPTTRACE_TG_CHAT`,
276
284
  then pass `--telegram`.
277
285
 
286
+ ### Stablecoin freezes
287
+
288
+ Tether and Circle can freeze USDT and USDC at any address. For a victim of a
289
+ stablecoin theft that is the one lever that actually stops the money — while it
290
+ is still there. `crypttrace freeze` reads the issuers' own contracts (USDT's
291
+ `isBlackListed`, USDC's `isBlacklisted`, balances; token-account state on
292
+ Solana) on Ethereum, Tron and Solana, and answers three things: how much is
293
+ there, whether it is already frozen, and if not, who can freeze it:
294
+
295
+ - **Tether** freezes USDT at the request of law enforcement, often before a
296
+ court case — so the route is the police, quickly
297
+ ([policy](https://tether.to/en/legal/?tab=law-enforcement-requests)).
298
+ - **Circle** freezes USDC only on a legal order such as a court order
299
+ ([terms](https://www.circle.com/legal/usdc-terms)), which takes longer.
300
+
301
+ No API key is needed: Ethereum is read through a public node (set
302
+ `CRYPTTRACE_ETH_RPC` to use your own). `investigate` runs the check on the
303
+ address the money went to and makes it the first step; the web UI shows it in
304
+ the side panel; a freeze also counts in the assessment. A check that fails is
305
+ reported as unknown, never as "not frozen". Other chains carry bridged or
306
+ third-party versions of these tokens that the issuers cannot freeze in the same
307
+ way, so they are not checked.
308
+
278
309
  ### Address poisoning
279
310
 
280
311
  Wallets show addresses shortened — `TDDD34…rCr9Ps`. A poisoner generates an
@@ -530,8 +561,6 @@ cases/ # worked investigations with their data
530
561
 
531
562
  - More exchanges on Bitcoin, Tron and Solana (now: Binance, OKX, HTX, Bybit),
532
563
  and refreshing these lists as the exchanges republish them
533
- - Stablecoin freeze check: whether USDT/USDC at an address is already frozen
534
- by the issuer, and who to ask for a freeze
535
564
  - `report --html`: one self-contained file with the interactive graph, to
536
565
  send to an exchange or attach to a police report
537
566
  - `report --pdf` for exchange and law-enforcement filings
@@ -10,6 +10,7 @@ src/crypttrace/bridges.py
10
10
  src/crypttrace/chains.py
11
11
  src/crypttrace/cli.py
12
12
  src/crypttrace/config.py
13
+ src/crypttrace/freeze.py
13
14
  src/crypttrace/funder.py
14
15
  src/crypttrace/investigate.py
15
16
  src/crypttrace/offramp.py
File without changes
File without changes
File without changes