crypttrace 0.5.0__tar.gz → 0.7.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.5.0/src/crypttrace.egg-info → crypttrace-0.7.0}/PKG-INFO +51 -11
- {crypttrace-0.5.0 → crypttrace-0.7.0}/README.md +50 -10
- {crypttrace-0.5.0 → crypttrace-0.7.0}/src/crypttrace/__init__.py +1 -1
- {crypttrace-0.5.0 → crypttrace-0.7.0}/src/crypttrace/assess.py +15 -1
- {crypttrace-0.5.0 → crypttrace-0.7.0}/src/crypttrace/chains.py +12 -0
- {crypttrace-0.5.0 → crypttrace-0.7.0}/src/crypttrace/cli.py +40 -5
- crypttrace-0.7.0/src/crypttrace/freeze.py +161 -0
- {crypttrace-0.5.0 → crypttrace-0.7.0}/src/crypttrace/investigate.py +28 -6
- crypttrace-0.7.0/src/crypttrace/report.py +470 -0
- {crypttrace-0.5.0 → crypttrace-0.7.0}/src/crypttrace/store.py +3 -1
- {crypttrace-0.5.0 → crypttrace-0.7.0}/src/crypttrace/web/index.html +16 -2
- {crypttrace-0.5.0 → crypttrace-0.7.0}/src/crypttrace/webapp.py +11 -0
- {crypttrace-0.5.0 → crypttrace-0.7.0/src/crypttrace.egg-info}/PKG-INFO +51 -11
- {crypttrace-0.5.0 → crypttrace-0.7.0}/src/crypttrace.egg-info/SOURCES.txt +1 -0
- crypttrace-0.5.0/src/crypttrace/report.py +0 -175
- {crypttrace-0.5.0 → crypttrace-0.7.0}/LICENSE +0 -0
- {crypttrace-0.5.0 → crypttrace-0.7.0}/pyproject.toml +0 -0
- {crypttrace-0.5.0 → crypttrace-0.7.0}/setup.cfg +0 -0
- {crypttrace-0.5.0 → crypttrace-0.7.0}/src/crypttrace/addresses.py +0 -0
- {crypttrace-0.5.0 → crypttrace-0.7.0}/src/crypttrace/analysis.py +0 -0
- {crypttrace-0.5.0 → crypttrace-0.7.0}/src/crypttrace/assets.py +0 -0
- {crypttrace-0.5.0 → crypttrace-0.7.0}/src/crypttrace/bridges.py +0 -0
- {crypttrace-0.5.0 → crypttrace-0.7.0}/src/crypttrace/config.py +0 -0
- {crypttrace-0.5.0 → crypttrace-0.7.0}/src/crypttrace/fetchers/__init__.py +0 -0
- {crypttrace-0.5.0 → crypttrace-0.7.0}/src/crypttrace/fetchers/bitcoin.py +0 -0
- {crypttrace-0.5.0 → crypttrace-0.7.0}/src/crypttrace/fetchers/etherscan.py +0 -0
- {crypttrace-0.5.0 → crypttrace-0.7.0}/src/crypttrace/fetchers/http.py +0 -0
- {crypttrace-0.5.0 → crypttrace-0.7.0}/src/crypttrace/fetchers/solana.py +0 -0
- {crypttrace-0.5.0 → crypttrace-0.7.0}/src/crypttrace/fetchers/tron.py +0 -0
- {crypttrace-0.5.0 → crypttrace-0.7.0}/src/crypttrace/funder.py +0 -0
- {crypttrace-0.5.0 → crypttrace-0.7.0}/src/crypttrace/labels/__init__.py +0 -0
- {crypttrace-0.5.0 → crypttrace-0.7.0}/src/crypttrace/labels/audit.py +0 -0
- {crypttrace-0.5.0 → crypttrace-0.7.0}/src/crypttrace/labels/bulk.py +0 -0
- {crypttrace-0.5.0 → crypttrace-0.7.0}/src/crypttrace/labels/known.json +0 -0
- {crypttrace-0.5.0 → crypttrace-0.7.0}/src/crypttrace/labels/labels.py +0 -0
- {crypttrace-0.5.0 → crypttrace-0.7.0}/src/crypttrace/labels/partial.json +0 -0
- {crypttrace-0.5.0 → crypttrace-0.7.0}/src/crypttrace/offramp.py +0 -0
- {crypttrace-0.5.0 → crypttrace-0.7.0}/src/crypttrace/poisoning.py +0 -0
- {crypttrace-0.5.0 → crypttrace-0.7.0}/src/crypttrace/prices.py +0 -0
- {crypttrace-0.5.0 → crypttrace-0.7.0}/src/crypttrace/render.py +0 -0
- {crypttrace-0.5.0 → crypttrace-0.7.0}/src/crypttrace/trace.py +0 -0
- {crypttrace-0.5.0 → crypttrace-0.7.0}/src/crypttrace/verify.py +0 -0
- {crypttrace-0.5.0 → crypttrace-0.7.0}/src/crypttrace/watch.py +0 -0
- {crypttrace-0.5.0 → crypttrace-0.7.0}/src/crypttrace.egg-info/dependency_links.txt +0 -0
- {crypttrace-0.5.0 → crypttrace-0.7.0}/src/crypttrace.egg-info/entry_points.txt +0 -0
- {crypttrace-0.5.0 → crypttrace-0.7.0}/src/crypttrace.egg-info/requires.txt +0 -0
- {crypttrace-0.5.0 → crypttrace-0.7.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.7.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
|
|
|
@@ -217,7 +225,7 @@ crypttrace crosschain 0xADDRESS --window 48
|
|
|
217
225
|
crypttrace watch add 0xADDRESS --note "my stolen ETH"
|
|
218
226
|
crypttrace watch run --interval 300 # or --once for cron / Task Scheduler
|
|
219
227
|
|
|
220
|
-
# Full investigation
|
|
228
|
+
# Full investigation as a case file to send: one HTML page (plus Markdown + JSON)
|
|
221
229
|
crypttrace report 0xADDRESS --depth 3
|
|
222
230
|
|
|
223
231
|
# Refresh label lists (OFAC sanctions, …)
|
|
@@ -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
|
|
@@ -429,10 +460,24 @@ are ignored, and every address is checked before it is stored.
|
|
|
429
460
|
|
|
430
461
|
### Reports
|
|
431
462
|
|
|
432
|
-
`report` runs the full analysis and writes
|
|
433
|
-
with `--out`)
|
|
434
|
-
|
|
435
|
-
the
|
|
463
|
+
`report` runs the full analysis and writes a **case file** to
|
|
464
|
+
`~/crypttrace-reports/` (override with `--out`); `investigate` saves the same
|
|
465
|
+
file with the victim's next steps in it. The case file is one HTML page, made
|
|
466
|
+
for whoever receives it — an exchange's compliance team, the police, a lawyer:
|
|
467
|
+
|
|
468
|
+
- the conclusion in plain words, and what to do now: stablecoins still at the
|
|
469
|
+
address and who can freeze them, the exchanges the money reached, address
|
|
470
|
+
poisoning;
|
|
471
|
+
- the fund-flow graph, each address linking to a block explorer;
|
|
472
|
+
- every transfer along the trace with its transaction hashes — the first thing
|
|
473
|
+
an exchange asks for;
|
|
474
|
+
- where each label comes from, how the tool checked its own arithmetic, and
|
|
475
|
+
what the method cannot tell you.
|
|
476
|
+
|
|
477
|
+
It has no scripts and loads nothing from the internet, so it opens in any
|
|
478
|
+
browser or mail client, prints (or saves as PDF) cleanly, and opening it tells
|
|
479
|
+
no one. The raw data is embedded for analysts and written next to it as JSON,
|
|
480
|
+
with its SHA-256 printed on the page; a Markdown version is written too.
|
|
436
481
|
|
|
437
482
|
### Self-verification
|
|
438
483
|
|
|
@@ -530,11 +575,6 @@ cases/ # worked investigations with their data
|
|
|
530
575
|
|
|
531
576
|
- More exchanges on Bitcoin, Tron and Solana (now: Binance, OKX, HTX, Bybit),
|
|
532
577
|
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
|
-
- `report --html`: one self-contained file with the interactive graph, to
|
|
536
|
-
send to an exchange or attach to a police report
|
|
537
|
-
- `report --pdf` for exchange and law-enforcement filings
|
|
538
578
|
- Internal transactions (completes `funder` and contract-mediated transfers)
|
|
539
579
|
- Per-mint filtering for Solana SPL tokens
|
|
540
580
|
- More label sources: Chainabuse, CryptoScamDB, exchange deposit-address sets
|
|
@@ -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
|
|
|
@@ -183,7 +191,7 @@ crypttrace crosschain 0xADDRESS --window 48
|
|
|
183
191
|
crypttrace watch add 0xADDRESS --note "my stolen ETH"
|
|
184
192
|
crypttrace watch run --interval 300 # or --once for cron / Task Scheduler
|
|
185
193
|
|
|
186
|
-
# Full investigation
|
|
194
|
+
# Full investigation as a case file to send: one HTML page (plus Markdown + JSON)
|
|
187
195
|
crypttrace report 0xADDRESS --depth 3
|
|
188
196
|
|
|
189
197
|
# Refresh label lists (OFAC sanctions, …)
|
|
@@ -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
|
|
@@ -395,10 +426,24 @@ are ignored, and every address is checked before it is stored.
|
|
|
395
426
|
|
|
396
427
|
### Reports
|
|
397
428
|
|
|
398
|
-
`report` runs the full analysis and writes
|
|
399
|
-
with `--out`)
|
|
400
|
-
|
|
401
|
-
the
|
|
429
|
+
`report` runs the full analysis and writes a **case file** to
|
|
430
|
+
`~/crypttrace-reports/` (override with `--out`); `investigate` saves the same
|
|
431
|
+
file with the victim's next steps in it. The case file is one HTML page, made
|
|
432
|
+
for whoever receives it — an exchange's compliance team, the police, a lawyer:
|
|
433
|
+
|
|
434
|
+
- the conclusion in plain words, and what to do now: stablecoins still at the
|
|
435
|
+
address and who can freeze them, the exchanges the money reached, address
|
|
436
|
+
poisoning;
|
|
437
|
+
- the fund-flow graph, each address linking to a block explorer;
|
|
438
|
+
- every transfer along the trace with its transaction hashes — the first thing
|
|
439
|
+
an exchange asks for;
|
|
440
|
+
- where each label comes from, how the tool checked its own arithmetic, and
|
|
441
|
+
what the method cannot tell you.
|
|
442
|
+
|
|
443
|
+
It has no scripts and loads nothing from the internet, so it opens in any
|
|
444
|
+
browser or mail client, prints (or saves as PDF) cleanly, and opening it tells
|
|
445
|
+
no one. The raw data is embedded for analysts and written next to it as JSON,
|
|
446
|
+
with its SHA-256 printed on the page; a Markdown version is written too.
|
|
402
447
|
|
|
403
448
|
### Self-verification
|
|
404
449
|
|
|
@@ -496,11 +541,6 @@ cases/ # worked investigations with their data
|
|
|
496
541
|
|
|
497
542
|
- More exchanges on Bitcoin, Tron and Solana (now: Binance, OKX, HTX, Bybit),
|
|
498
543
|
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
|
-
- `report --html`: one self-contained file with the interactive graph, to
|
|
502
|
-
send to an exchange or attach to a police report
|
|
503
|
-
- `report --pdf` for exchange and law-enforcement filings
|
|
504
544
|
- Internal transactions (completes `funder` and contract-mediated transfers)
|
|
505
545
|
- Per-mint filtering for Solana SPL tokens
|
|
506
546
|
- More label sources: Chainabuse, CryptoScamDB, exchange deposit-address sets
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""crypttrace — OSINT crypto investigation CLI."""
|
|
2
|
-
__version__ = "0.
|
|
2
|
+
__version__ = "0.7.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=freeze.describe_frozen(e),
|
|
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}.")
|
|
@@ -30,6 +30,14 @@ EXPLORER = {
|
|
|
30
30
|
"sol": "https://solscan.io/account/{}",
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
TX_EXPLORER = {
|
|
34
|
+
"eth": "https://etherscan.io/tx/{}", "bsc": "https://bscscan.com/tx/{}",
|
|
35
|
+
"polygon": "https://polygonscan.com/tx/{}", "arbitrum": "https://arbiscan.io/tx/{}",
|
|
36
|
+
"optimism": "https://optimistic.etherscan.io/tx/{}", "base": "https://basescan.org/tx/{}",
|
|
37
|
+
"btc": "https://mempool.space/tx/{}", "tron": "https://tronscan.org/#/transaction/{}",
|
|
38
|
+
"sol": "https://solscan.io/tx/{}",
|
|
39
|
+
}
|
|
40
|
+
|
|
33
41
|
_UPSTREAM_ERRORS = (etherscan.EtherscanError, bitcoin.BitcoinError,
|
|
34
42
|
tron.TronError, solana.SolanaError)
|
|
35
43
|
|
|
@@ -59,6 +67,10 @@ def explorer_url(address: str, chain: str) -> str:
|
|
|
59
67
|
return EXPLORER.get(chain, EXPLORER["eth"]).format(address)
|
|
60
68
|
|
|
61
69
|
|
|
70
|
+
def tx_url(tx_hash: str, chain: str) -> str:
|
|
71
|
+
return TX_EXPLORER.get(chain, TX_EXPLORER["eth"]).format(tx_hash)
|
|
72
|
+
|
|
73
|
+
|
|
62
74
|
def check(chain: str) -> None:
|
|
63
75
|
if chain not in ALL_CHAINS:
|
|
64
76
|
raise ChainError(f"unsupported chain '{chain}'. Options: {ALL_CHAINS}")
|
|
@@ -224,7 +224,7 @@ def report(
|
|
|
224
224
|
help="Folder to save the report in",
|
|
225
225
|
),
|
|
226
226
|
):
|
|
227
|
-
"""Run a full investigation and save
|
|
227
|
+
"""Run a full investigation and save it as an HTML case file (plus Markdown and JSON)."""
|
|
228
228
|
try:
|
|
229
229
|
asset_desc = assets.resolve_asset(asset, chain)
|
|
230
230
|
except ValueError as e:
|
|
@@ -232,12 +232,14 @@ def report(
|
|
|
232
232
|
raise typer.Exit(1)
|
|
233
233
|
try:
|
|
234
234
|
with console.status("Gathering on-chain data and tracing funds…"):
|
|
235
|
-
|
|
236
|
-
except etherscan.EtherscanError as e:
|
|
235
|
+
paths = report_mod.generate(address, chain, depth, branching, out, asset_desc)
|
|
236
|
+
except (chains_mod.ChainError, etherscan.EtherscanError) as e:
|
|
237
237
|
console.print(f"[red]Error:[/red] {e}")
|
|
238
238
|
raise typer.Exit(1)
|
|
239
|
-
console.print(f"[green]✓
|
|
240
|
-
console.print(
|
|
239
|
+
console.print(f"[green]✓ Case file saved:[/green] {paths['html']}")
|
|
240
|
+
console.print("[dim] One self-contained page: open it in a browser, print it to PDF, or "
|
|
241
|
+
"send it as it is.[/dim]")
|
|
242
|
+
console.print("[dim] Markdown and raw JSON are saved next to it.[/dim]")
|
|
241
243
|
|
|
242
244
|
|
|
243
245
|
@app.command()
|
|
@@ -262,6 +264,39 @@ def crosschain(
|
|
|
262
264
|
"not proof. Verify each candidate before relying on it.[/dim]")
|
|
263
265
|
|
|
264
266
|
|
|
267
|
+
@app.command()
|
|
268
|
+
def freeze(
|
|
269
|
+
address: str = typer.Argument(..., help="Address holding the USDT/USDC"),
|
|
270
|
+
chain: str = CHAIN_OPT,
|
|
271
|
+
):
|
|
272
|
+
"""Is USDT/USDC at this address frozen by its issuer — and who can freeze it?"""
|
|
273
|
+
from crypttrace import freeze as freeze_mod
|
|
274
|
+
entries = freeze_mod.check(address, chain)
|
|
275
|
+
if not entries:
|
|
276
|
+
console.print("[dim]USDT/USDC freezes are checked on Ethereum, Tron and Solana, where "
|
|
277
|
+
"Tether and Circle issue them directly and can freeze them.[/dim]")
|
|
278
|
+
return
|
|
279
|
+
for e in entries:
|
|
280
|
+
head = f"[bold]{e['token']}[/bold] ({e['issuer']})"
|
|
281
|
+
if e.get("error"):
|
|
282
|
+
console.print(f"{head}: [yellow]could not be read[/yellow] — {e['error']}")
|
|
283
|
+
elif e["frozen"]:
|
|
284
|
+
console.print(f"{head}: [bold green]FROZEN[/bold green] — {freeze_mod.describe_frozen(e)}"
|
|
285
|
+
+ (f"; {e['movable']:,.2f} still can move" if e["actionable"] else ""))
|
|
286
|
+
elif e["actionable"]:
|
|
287
|
+
console.print(f"{head}: [bold red]{e['balance']:,.2f} {e['token']} NOT frozen[/bold red]"
|
|
288
|
+
" — it can still be moved")
|
|
289
|
+
console.print(f" {e['how']}")
|
|
290
|
+
console.print(f" [dim]{e['url']}[/dim]")
|
|
291
|
+
elif e["balance"]:
|
|
292
|
+
console.print(f"{head}: only dust ({e['balance']:.6f}) — nothing to freeze")
|
|
293
|
+
else:
|
|
294
|
+
console.print(f"{head}: none at this address")
|
|
295
|
+
if labels.type_of(address) == "exchange":
|
|
296
|
+
console.print("\n[dim]This is an exchange's own wallet: ask the exchange, "
|
|
297
|
+
"not the issuer.[/dim]")
|
|
298
|
+
|
|
299
|
+
|
|
265
300
|
@app.command()
|
|
266
301
|
def poisoning(
|
|
267
302
|
address: str = typer.Argument(..., help="Your wallet, or the address the money went to"),
|
|
@@ -0,0 +1,161 @@
|
|
|
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
|
+
MIN_ACTIONABLE = 1.0 # USDT/USDC below this is dust
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class FreezeError(RuntimeError):
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _eth_call(contract: str, selector: str, address: str) -> int:
|
|
59
|
+
data = selector + address.lower().replace("0x", "").rjust(64, "0")
|
|
60
|
+
try:
|
|
61
|
+
r = requests.post(ETH_RPC, timeout=30, json={
|
|
62
|
+
"jsonrpc": "2.0", "id": 1, "method": "eth_call",
|
|
63
|
+
"params": [{"to": contract, "data": data}, "latest"]}).json()
|
|
64
|
+
except (requests.RequestException, ValueError) as e:
|
|
65
|
+
raise FreezeError(f"Ethereum node unreachable: {e}")
|
|
66
|
+
if "result" not in r:
|
|
67
|
+
raise FreezeError(f"Ethereum node refused the call: {r.get('error')}")
|
|
68
|
+
return int(r["result"] or "0x0", 16)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _tron_call(contract: str, signature: str, address: str) -> int:
|
|
72
|
+
raw = addresses._b58_decode(address) # 0x41 + 20 bytes + checksum
|
|
73
|
+
if not raw or len(raw) != 25:
|
|
74
|
+
raise FreezeError("not a Tron address")
|
|
75
|
+
headers = {"TRON-PRO-API-KEY": os.environ["TRONGRID_API_KEY"]} \
|
|
76
|
+
if os.environ.get("TRONGRID_API_KEY") else {}
|
|
77
|
+
try:
|
|
78
|
+
r = requests.post(f"{TRON_API}/wallet/triggerconstantcontract", timeout=30,
|
|
79
|
+
headers=headers, json={
|
|
80
|
+
# a read-only call: the caller does not matter, and
|
|
81
|
+
# an unactivated address can be refused as the caller
|
|
82
|
+
"owner_address": contract, "contract_address": contract,
|
|
83
|
+
"function_selector": signature,
|
|
84
|
+
"parameter": raw[1:21].hex().rjust(64, "0"), "visible": True}).json()
|
|
85
|
+
except (requests.RequestException, ValueError) as e:
|
|
86
|
+
raise FreezeError(f"TronGrid unreachable: {e}")
|
|
87
|
+
out = (r.get("constant_result") or [None])[0]
|
|
88
|
+
if out is None:
|
|
89
|
+
raise FreezeError(f"TronGrid refused the call: {r.get('result') or r}")
|
|
90
|
+
return int(out or "0", 16)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _sol_accounts(owner: str, mint: str) -> List[dict]:
|
|
94
|
+
try:
|
|
95
|
+
r = solana._rpc("getTokenAccountsByOwner",
|
|
96
|
+
[owner, {"mint": mint}, {"encoding": "jsonParsed"}])
|
|
97
|
+
except solana.SolanaError as e:
|
|
98
|
+
raise FreezeError(str(e))
|
|
99
|
+
out = []
|
|
100
|
+
for v in (r.get("value") if isinstance(r, dict) else r) or []:
|
|
101
|
+
info = v["account"]["data"]["parsed"]["info"]
|
|
102
|
+
out.append({"state": info.get("state"),
|
|
103
|
+
"amount": float(info["tokenAmount"].get("uiAmountString") or 0)})
|
|
104
|
+
return out
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _one(address: str, chain: str, symbol: str, contract: str) -> Dict:
|
|
108
|
+
if chain == "eth":
|
|
109
|
+
frozen = _eth_call(contract, _EVM_FROZEN[symbol], address) == 1
|
|
110
|
+
balance = _eth_call(contract, _BALANCE_OF, address) / 1e6
|
|
111
|
+
frozen_amount = balance if frozen else 0.0
|
|
112
|
+
elif chain == "tron":
|
|
113
|
+
frozen = _tron_call(contract, _TRON_FROZEN[symbol], address) == 1
|
|
114
|
+
balance = _tron_call(contract, "balanceOf(address)", address) / 1e6
|
|
115
|
+
frozen_amount = balance if frozen else 0.0
|
|
116
|
+
else: # sol: frozen per token account
|
|
117
|
+
accts = _sol_accounts(address, contract)
|
|
118
|
+
balance = sum(a["amount"] for a in accts)
|
|
119
|
+
frozen_amount = sum(a["amount"] for a in accts if a["state"] == "frozen")
|
|
120
|
+
frozen = any(a["state"] == "frozen" for a in accts)
|
|
121
|
+
movable = balance - frozen_amount
|
|
122
|
+
return {"token": symbol, "balance": balance, "frozen": frozen,
|
|
123
|
+
"frozen_amount": frozen_amount, "movable": movable,
|
|
124
|
+
# leftover dust is not worth a freeze request, or a victim's panic
|
|
125
|
+
"actionable": movable >= MIN_ACTIONABLE}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def describe_frozen(e: Dict) -> str:
|
|
129
|
+
"""'4,021.97 USDT here is frozen by Tether', or the blacklisting alone when empty."""
|
|
130
|
+
if (e.get("frozen_amount") or 0) >= MIN_ACTIONABLE:
|
|
131
|
+
return f"{e['frozen_amount']:,.2f} {e['token']} here is frozen by {e['issuer']}"
|
|
132
|
+
return f"This address is blacklisted by {e['issuer']} (no {e['token']} left on it)"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def check(address: str, chain: str) -> List[Dict]:
|
|
136
|
+
"""One entry per issuer stablecoin on this chain; empty where none applies.
|
|
137
|
+
|
|
138
|
+
Each entry: token, issuer, balance, frozen, frozen_amount, movable (what can
|
|
139
|
+
still leave), how (what the issuer needs), url, and error if it could not be
|
|
140
|
+
read. A failed read is reported, never guessed.
|
|
141
|
+
"""
|
|
142
|
+
if chain not in SUPPORTED:
|
|
143
|
+
return []
|
|
144
|
+
from crypttrace import chains
|
|
145
|
+
out = []
|
|
146
|
+
for sym in ("usdt", "usdc"):
|
|
147
|
+
tok = assets.tokens_for(chain).get(sym)
|
|
148
|
+
if not tok:
|
|
149
|
+
continue
|
|
150
|
+
symbol = tok["symbol"]
|
|
151
|
+
entry = {"token": symbol, **ISSUERS[symbol]}
|
|
152
|
+
try:
|
|
153
|
+
if chains.OFFLINE:
|
|
154
|
+
raise FreezeError("not checked: working offline")
|
|
155
|
+
entry.update(_one(address, chain, symbol, tok["contract"]))
|
|
156
|
+
except (FreezeError, KeyError, TypeError, ValueError) as e:
|
|
157
|
+
entry.update({"balance": None, "frozen": None, "frozen_amount": None,
|
|
158
|
+
"movable": None, "actionable": None, "error": str(e)})
|
|
159
|
+
out.append(entry)
|
|
160
|
+
return out
|
|
161
|
+
|
|
@@ -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
|
|
|
@@ -32,7 +32,7 @@ def analyse(address: str, chain: str = "eth", asset: Optional[dict] = None,
|
|
|
32
32
|
"""Run every check and return a structured result (no printing)."""
|
|
33
33
|
result = {
|
|
34
34
|
"address": address, "chain": chain,
|
|
35
|
-
"asset": asset["symbol"] if asset else chains.symbol(chain),
|
|
35
|
+
"asset": asset["symbol"] if asset else chains.symbol(chain), "depth": depth,
|
|
36
36
|
"generated": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
|
|
37
37
|
"errors": [],
|
|
38
38
|
}
|
|
@@ -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": freeze_mod.describe_frozen(e),
|
|
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("actionable"):
|
|
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",
|
|
@@ -206,11 +226,13 @@ def guidance_markdown(r: dict) -> str:
|
|
|
206
226
|
|
|
207
227
|
|
|
208
228
|
def save_case(r: dict, out_dir: Path, asset: Optional[dict] = None) -> Path:
|
|
209
|
-
"""Write the
|
|
210
|
-
|
|
229
|
+
"""Write the case file (HTML, plus Markdown and JSON) with the victim's next steps;
|
|
230
|
+
returns the HTML one — the file to send."""
|
|
231
|
+
paths = report_mod.generate(r["address"], r["chain"], r.get("depth", 3), 3, out_dir, asset,
|
|
232
|
+
guidance=r.get("guidance"))
|
|
211
233
|
try:
|
|
212
|
-
with open(
|
|
234
|
+
with open(paths["md"], "a", encoding="utf-8") as fh:
|
|
213
235
|
fh.write(guidance_markdown(r))
|
|
214
236
|
except OSError:
|
|
215
237
|
pass
|
|
216
|
-
return
|
|
238
|
+
return paths["html"]
|