crypttrace 0.4.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.
- {crypttrace-0.4.0/src/crypttrace.egg-info → crypttrace-0.6.0}/PKG-INFO +66 -5
- {crypttrace-0.4.0 → crypttrace-0.6.0}/README.md +65 -4
- {crypttrace-0.4.0 → crypttrace-0.6.0}/src/crypttrace/__init__.py +1 -1
- {crypttrace-0.4.0 → crypttrace-0.6.0}/src/crypttrace/assess.py +62 -1
- {crypttrace-0.4.0 → crypttrace-0.6.0}/src/crypttrace/chains.py +10 -2
- {crypttrace-0.4.0 → crypttrace-0.6.0}/src/crypttrace/cli.py +90 -0
- {crypttrace-0.4.0 → crypttrace-0.6.0}/src/crypttrace/fetchers/solana.py +2 -1
- {crypttrace-0.4.0 → crypttrace-0.6.0}/src/crypttrace/fetchers/tron.py +4 -0
- crypttrace-0.6.0/src/crypttrace/freeze.py +150 -0
- {crypttrace-0.4.0 → crypttrace-0.6.0}/src/crypttrace/investigate.py +42 -1
- crypttrace-0.6.0/src/crypttrace/poisoning.py +262 -0
- {crypttrace-0.4.0 → crypttrace-0.6.0}/src/crypttrace/store.py +14 -8
- {crypttrace-0.4.0 → crypttrace-0.6.0}/src/crypttrace/web/index.html +16 -2
- {crypttrace-0.4.0 → crypttrace-0.6.0}/src/crypttrace/webapp.py +11 -0
- {crypttrace-0.4.0 → crypttrace-0.6.0/src/crypttrace.egg-info}/PKG-INFO +66 -5
- {crypttrace-0.4.0 → crypttrace-0.6.0}/src/crypttrace.egg-info/SOURCES.txt +2 -0
- {crypttrace-0.4.0 → crypttrace-0.6.0}/LICENSE +0 -0
- {crypttrace-0.4.0 → crypttrace-0.6.0}/pyproject.toml +0 -0
- {crypttrace-0.4.0 → crypttrace-0.6.0}/setup.cfg +0 -0
- {crypttrace-0.4.0 → crypttrace-0.6.0}/src/crypttrace/addresses.py +0 -0
- {crypttrace-0.4.0 → crypttrace-0.6.0}/src/crypttrace/analysis.py +0 -0
- {crypttrace-0.4.0 → crypttrace-0.6.0}/src/crypttrace/assets.py +0 -0
- {crypttrace-0.4.0 → crypttrace-0.6.0}/src/crypttrace/bridges.py +0 -0
- {crypttrace-0.4.0 → crypttrace-0.6.0}/src/crypttrace/config.py +0 -0
- {crypttrace-0.4.0 → crypttrace-0.6.0}/src/crypttrace/fetchers/__init__.py +0 -0
- {crypttrace-0.4.0 → crypttrace-0.6.0}/src/crypttrace/fetchers/bitcoin.py +0 -0
- {crypttrace-0.4.0 → crypttrace-0.6.0}/src/crypttrace/fetchers/etherscan.py +0 -0
- {crypttrace-0.4.0 → crypttrace-0.6.0}/src/crypttrace/fetchers/http.py +0 -0
- {crypttrace-0.4.0 → crypttrace-0.6.0}/src/crypttrace/funder.py +0 -0
- {crypttrace-0.4.0 → crypttrace-0.6.0}/src/crypttrace/labels/__init__.py +0 -0
- {crypttrace-0.4.0 → crypttrace-0.6.0}/src/crypttrace/labels/audit.py +0 -0
- {crypttrace-0.4.0 → crypttrace-0.6.0}/src/crypttrace/labels/bulk.py +0 -0
- {crypttrace-0.4.0 → crypttrace-0.6.0}/src/crypttrace/labels/known.json +0 -0
- {crypttrace-0.4.0 → crypttrace-0.6.0}/src/crypttrace/labels/labels.py +0 -0
- {crypttrace-0.4.0 → crypttrace-0.6.0}/src/crypttrace/labels/partial.json +0 -0
- {crypttrace-0.4.0 → crypttrace-0.6.0}/src/crypttrace/offramp.py +0 -0
- {crypttrace-0.4.0 → crypttrace-0.6.0}/src/crypttrace/prices.py +0 -0
- {crypttrace-0.4.0 → crypttrace-0.6.0}/src/crypttrace/render.py +0 -0
- {crypttrace-0.4.0 → crypttrace-0.6.0}/src/crypttrace/report.py +0 -0
- {crypttrace-0.4.0 → crypttrace-0.6.0}/src/crypttrace/trace.py +0 -0
- {crypttrace-0.4.0 → crypttrace-0.6.0}/src/crypttrace/verify.py +0 -0
- {crypttrace-0.4.0 → crypttrace-0.6.0}/src/crypttrace/watch.py +0 -0
- {crypttrace-0.4.0 → crypttrace-0.6.0}/src/crypttrace.egg-info/dependency_links.txt +0 -0
- {crypttrace-0.4.0 → crypttrace-0.6.0}/src/crypttrace.egg-info/entry_points.txt +0 -0
- {crypttrace-0.4.0 → crypttrace-0.6.0}/src/crypttrace.egg-info/requires.txt +0 -0
- {crypttrace-0.4.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.
|
|
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,15 @@ 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
|
+
|
|
171
|
+
**Sent money to an address that looked right?** That is usually address
|
|
172
|
+
poisoning — see below. Check your own wallet with
|
|
173
|
+
`crypttrace poisoning YOUR_ADDRESS`.
|
|
174
|
+
|
|
166
175
|
## Commands
|
|
167
176
|
|
|
168
177
|
```bash
|
|
@@ -191,6 +200,12 @@ crypttrace tokens 0xADDRESS
|
|
|
191
200
|
# Who bootstrapped this wallet's first gas? Follow it toward a KYC point
|
|
192
201
|
crypttrace funder 0xADDRESS --hops 6
|
|
193
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
|
+
|
|
206
|
+
# Address poisoning: look-alike addresses planted in a wallet's history
|
|
207
|
+
crypttrace poisoning TADDRESS --chain tron
|
|
208
|
+
|
|
194
209
|
# Is this an exchange deposit address (i.e. the cash-out point)?
|
|
195
210
|
crypttrace offramp 0xADDRESS
|
|
196
211
|
|
|
@@ -268,6 +283,56 @@ crypttrace watch run --once # single check, for scheduled tasks
|
|
|
268
283
|
Optional Telegram alerts: set `CRYPTTRACE_TG_TOKEN` and `CRYPTTRACE_TG_CHAT`,
|
|
269
284
|
then pass `--telegram`.
|
|
270
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
|
+
|
|
309
|
+
### Address poisoning
|
|
310
|
+
|
|
311
|
+
Wallets show addresses shortened — `TDDD34…rCr9Ps`. A poisoner generates an
|
|
312
|
+
address with the same first and last characters and plants it in the victim's
|
|
313
|
+
history with a zero-value transfer, a speck of dust or a counterfeit token. The
|
|
314
|
+
next time the victim copies "the address I paid last time", they copy the
|
|
315
|
+
attacker's. It is common on Tron and Ethereum, and it needs no hacking at all.
|
|
316
|
+
|
|
317
|
+
`crypttrace poisoning` looks at it from both ends:
|
|
318
|
+
|
|
319
|
+
- **Your wallet:** pairs of addresses in your history that share their visible
|
|
320
|
+
characters, which one arrived later with bait, and — the part that matters —
|
|
321
|
+
whether you then sent real money to it.
|
|
322
|
+
- **The address your money went to:** who paid it real money right after it
|
|
323
|
+
lured them, and which address it was imitating, read from the payer's own
|
|
324
|
+
history. `investigate` and the assessment run this automatically.
|
|
325
|
+
|
|
326
|
+
Tested on a documented Tron case from August 2026: from the victim's wallet it
|
|
327
|
+
finds the 2,527,862 USDT sent to `TDDDHi…rCr9Ps`, a look-alike of
|
|
328
|
+
`TDDD34…rCr9Ps`, an address the wallet had paid 8.4M USDT in all. From the
|
|
329
|
+
attacker's side it finds the same payment and two more victims of $2.4M and
|
|
330
|
+
$2.0M, each lured by a look-alike created three to twelve minutes earlier.
|
|
331
|
+
|
|
332
|
+
Cheap look-alikes match only one or two characters at each end, which happens
|
|
333
|
+
by chance; those are reported only when they arrived as bait after the real
|
|
334
|
+
address was in use.
|
|
335
|
+
|
|
271
336
|
### Off-ramp detection
|
|
272
337
|
|
|
273
338
|
Laundered funds reaching an exchange land on a per-user *deposit address* —
|
|
@@ -496,10 +561,6 @@ cases/ # worked investigations with their data
|
|
|
496
561
|
|
|
497
562
|
- More exchanges on Bitcoin, Tron and Solana (now: Binance, OKX, HTX, Bybit),
|
|
498
563
|
and refreshing these lists as the exchanges republish them
|
|
499
|
-
- Address-poisoning detection: flag look-alike addresses (same first and last
|
|
500
|
-
characters) in a victim's history before they copy the wrong one
|
|
501
|
-
- Stablecoin freeze check: whether USDT/USDC at an address is already frozen
|
|
502
|
-
by the issuer, and who to ask for a freeze
|
|
503
564
|
- `report --html`: one self-contained file with the interactive graph, to
|
|
504
565
|
send to an exchange or attach to a police report
|
|
505
566
|
- `report --pdf` for exchange and law-enforcement filings
|
|
@@ -129,6 +129,15 @@ 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
|
+
|
|
137
|
+
**Sent money to an address that looked right?** That is usually address
|
|
138
|
+
poisoning — see below. Check your own wallet with
|
|
139
|
+
`crypttrace poisoning YOUR_ADDRESS`.
|
|
140
|
+
|
|
132
141
|
## Commands
|
|
133
142
|
|
|
134
143
|
```bash
|
|
@@ -157,6 +166,12 @@ crypttrace tokens 0xADDRESS
|
|
|
157
166
|
# Who bootstrapped this wallet's first gas? Follow it toward a KYC point
|
|
158
167
|
crypttrace funder 0xADDRESS --hops 6
|
|
159
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
|
+
|
|
172
|
+
# Address poisoning: look-alike addresses planted in a wallet's history
|
|
173
|
+
crypttrace poisoning TADDRESS --chain tron
|
|
174
|
+
|
|
160
175
|
# Is this an exchange deposit address (i.e. the cash-out point)?
|
|
161
176
|
crypttrace offramp 0xADDRESS
|
|
162
177
|
|
|
@@ -234,6 +249,56 @@ crypttrace watch run --once # single check, for scheduled tasks
|
|
|
234
249
|
Optional Telegram alerts: set `CRYPTTRACE_TG_TOKEN` and `CRYPTTRACE_TG_CHAT`,
|
|
235
250
|
then pass `--telegram`.
|
|
236
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
|
+
|
|
275
|
+
### Address poisoning
|
|
276
|
+
|
|
277
|
+
Wallets show addresses shortened — `TDDD34…rCr9Ps`. A poisoner generates an
|
|
278
|
+
address with the same first and last characters and plants it in the victim's
|
|
279
|
+
history with a zero-value transfer, a speck of dust or a counterfeit token. The
|
|
280
|
+
next time the victim copies "the address I paid last time", they copy the
|
|
281
|
+
attacker's. It is common on Tron and Ethereum, and it needs no hacking at all.
|
|
282
|
+
|
|
283
|
+
`crypttrace poisoning` looks at it from both ends:
|
|
284
|
+
|
|
285
|
+
- **Your wallet:** pairs of addresses in your history that share their visible
|
|
286
|
+
characters, which one arrived later with bait, and — the part that matters —
|
|
287
|
+
whether you then sent real money to it.
|
|
288
|
+
- **The address your money went to:** who paid it real money right after it
|
|
289
|
+
lured them, and which address it was imitating, read from the payer's own
|
|
290
|
+
history. `investigate` and the assessment run this automatically.
|
|
291
|
+
|
|
292
|
+
Tested on a documented Tron case from August 2026: from the victim's wallet it
|
|
293
|
+
finds the 2,527,862 USDT sent to `TDDDHi…rCr9Ps`, a look-alike of
|
|
294
|
+
`TDDD34…rCr9Ps`, an address the wallet had paid 8.4M USDT in all. From the
|
|
295
|
+
attacker's side it finds the same payment and two more victims of $2.4M and
|
|
296
|
+
$2.0M, each lured by a look-alike created three to twelve minutes earlier.
|
|
297
|
+
|
|
298
|
+
Cheap look-alikes match only one or two characters at each end, which happens
|
|
299
|
+
by chance; those are reported only when they arrived as bait after the real
|
|
300
|
+
address was in use.
|
|
301
|
+
|
|
237
302
|
### Off-ramp detection
|
|
238
303
|
|
|
239
304
|
Laundered funds reaching an exchange land on a per-user *deposit address* —
|
|
@@ -462,10 +527,6 @@ cases/ # worked investigations with their data
|
|
|
462
527
|
|
|
463
528
|
- More exchanges on Bitcoin, Tron and Solana (now: Binance, OKX, HTX, Bybit),
|
|
464
529
|
and refreshing these lists as the exchanges republish them
|
|
465
|
-
- Address-poisoning detection: flag look-alike addresses (same first and last
|
|
466
|
-
characters) in a victim's history before they copy the wrong one
|
|
467
|
-
- Stablecoin freeze check: whether USDT/USDC at an address is already frozen
|
|
468
|
-
by the issuer, and who to ask for a freeze
|
|
469
530
|
- `report --html`: one self-contained file with the interactive graph, to
|
|
470
531
|
send to an exchange or attach to a police report
|
|
471
532
|
- `report --pdf` for exchange and law-enforcement filings
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""crypttrace — OSINT crypto investigation CLI."""
|
|
2
|
-
__version__ = "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
|
|
17
|
+
from crypttrace import analysis, chains, freeze, poisoning
|
|
18
18
|
from crypttrace.labels import labels
|
|
19
19
|
|
|
20
20
|
|
|
@@ -197,6 +197,54 @@ def assess(address: str, chain: str = "eth", asset: Optional[dict] = None,
|
|
|
197
197
|
confidence="medium", weight=15,
|
|
198
198
|
evidence={"amount": exposure["bridge"], "bridge": named["bridge"]}))
|
|
199
199
|
|
|
200
|
+
# --- address poisoning ----------------------------------------------
|
|
201
|
+
try:
|
|
202
|
+
lured = poisoning.baited_payments(address, chain)
|
|
203
|
+
pairs = poisoning.lookalikes(address, chain)
|
|
204
|
+
spam = poisoning.campaign(address, chain)
|
|
205
|
+
except chains.ChainError:
|
|
206
|
+
lured, pairs, spam = [], [], None
|
|
207
|
+
if lured:
|
|
208
|
+
top = lured[0]
|
|
209
|
+
confirmed = [x for x in lured if x["imitates"]]
|
|
210
|
+
signals.append(Signal(
|
|
211
|
+
name="address poisoning",
|
|
212
|
+
observed=(f"{len(lured)} payer(s) sent real money right after this address lured "
|
|
213
|
+
f"them ({top['lure']}); largest {top['paid']:.2f} {top['symbol']}"
|
|
214
|
+
+ (f", imitating {confirmed[0]['imitates']}" if confirmed else "")),
|
|
215
|
+
implication="payments made to a look-alike of an address the payer really meant — "
|
|
216
|
+
"the victim copied this address from their own history",
|
|
217
|
+
confidence="high" if confirmed else "medium", weight=55,
|
|
218
|
+
evidence={"payments": lured[:5]}))
|
|
219
|
+
if spam:
|
|
220
|
+
signals.append(Signal(
|
|
221
|
+
name="poisoning campaign",
|
|
222
|
+
observed=(f"sent {spam['bait_transfers']} zero, dust or counterfeit transfers to "
|
|
223
|
+
f"{spam['targets']} different wallets"),
|
|
224
|
+
implication="plants itself in strangers' histories — the address-poisoning pattern",
|
|
225
|
+
confidence="high", weight=40, evidence=spam))
|
|
226
|
+
sent_to_fake = [p for p in pairs if p["sent_to_lookalike"] > 0]
|
|
227
|
+
if sent_to_fake:
|
|
228
|
+
p = sent_to_fake[0]
|
|
229
|
+
signals.append(Signal(
|
|
230
|
+
name="paid a look-alike",
|
|
231
|
+
observed=(f"sent {p['sent_to_lookalike']:.2f} to {p['lookalike']}, which imitates "
|
|
232
|
+
f"{p['genuine']} (same first {p['matching']['prefix']} and last "
|
|
233
|
+
f"{p['matching']['suffix']} characters)"),
|
|
234
|
+
implication="this wallet was likely the victim of address poisoning",
|
|
235
|
+
confidence=p["confidence"], weight=0, evidence={"pairs": sent_to_fake[:5]}))
|
|
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
|
+
|
|
200
248
|
# --- holding behaviour ---------------------------------------------
|
|
201
249
|
if received > 0 and sent == 0 and len(inbound) >= 3:
|
|
202
250
|
signals.append(Signal(
|
|
@@ -267,6 +315,19 @@ def _statement(signals: List[Signal], risk: int, confidence: str, hit) -> str:
|
|
|
267
315
|
if "cross-chain movement" in names:
|
|
268
316
|
parts.append("Some value left this chain through a bridge and would need to be "
|
|
269
317
|
"picked up on the destination network.")
|
|
318
|
+
if "address poisoning" in names:
|
|
319
|
+
parts.append("Money reached this address through address poisoning: it was made to "
|
|
320
|
+
"look like an address the payer really meant, and planted in their "
|
|
321
|
+
"history first.")
|
|
322
|
+
if "poisoning campaign" in names:
|
|
323
|
+
parts.append("It sends worthless transfers to many strangers, the way poisoners "
|
|
324
|
+
"plant look-alike addresses.")
|
|
325
|
+
if "paid a look-alike" in names:
|
|
326
|
+
parts.append("This wallet sent money to a look-alike of an address it had used "
|
|
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.")
|
|
270
331
|
if "funds held" in names:
|
|
271
332
|
parts.append("The proceeds have not been spent, so intervention is still possible.")
|
|
272
333
|
parts.append(f"Overall risk {risk}/100, confidence {confidence}.")
|
|
@@ -151,11 +151,19 @@ def transfers(address: str, chain: str = "eth", limit: int = 1000,
|
|
|
151
151
|
contract = asset.get("contract") if asset else None
|
|
152
152
|
|
|
153
153
|
from crypttrace import store
|
|
154
|
-
|
|
154
|
+
# What the stored rows are filed under: "" native, a contract, "*" every
|
|
155
|
+
# token, or "spl" on Solana, whose token rows carry no mint to tell apart.
|
|
156
|
+
if not asset:
|
|
157
|
+
asset_key = ""
|
|
158
|
+
elif chain == "sol":
|
|
159
|
+
asset_key = "spl"
|
|
160
|
+
else:
|
|
161
|
+
asset_key = (contract or "*").lower()
|
|
155
162
|
skip_read = fresh or FORCE_FRESH
|
|
156
163
|
if USE_STORE and not skip_read:
|
|
157
164
|
if OFFLINE or store.is_fresh(chain, address, asset_key):
|
|
158
|
-
rows = store.load(address=address, chain=chain, contract=
|
|
165
|
+
rows = store.load(address=address, chain=chain, contract=asset_key,
|
|
166
|
+
native_symbol=symbol(chain))
|
|
159
167
|
if rows or OFFLINE:
|
|
160
168
|
rows.sort(key=lambda r: r.get("timestamp", 0), reverse=not oldest_first)
|
|
161
169
|
return rows
|
|
@@ -262,6 +262,96 @@ 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
|
+
|
|
297
|
+
@app.command()
|
|
298
|
+
def poisoning(
|
|
299
|
+
address: str = typer.Argument(..., help="Your wallet, or the address the money went to"),
|
|
300
|
+
chain: str = CHAIN_OPT,
|
|
301
|
+
limit: int = typer.Option(1000, "--limit", help="How much history to read"),
|
|
302
|
+
):
|
|
303
|
+
"""Address poisoning: look-alike addresses planted in a wallet's history."""
|
|
304
|
+
from crypttrace import poisoning as poison_mod
|
|
305
|
+
from datetime import timezone
|
|
306
|
+
when = lambda ts: datetime.fromtimestamp(ts, timezone.utc).strftime("%Y-%m-%d %H:%M UTC") \
|
|
307
|
+
if ts else "?"
|
|
308
|
+
try:
|
|
309
|
+
pairs = poison_mod.lookalikes(address, chain, limit)
|
|
310
|
+
lured = poison_mod.baited_payments(address, chain, limit)
|
|
311
|
+
spam = poison_mod.campaign(address, chain, limit)
|
|
312
|
+
except (chains_mod.ChainError, etherscan.EtherscanError) as e:
|
|
313
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
314
|
+
raise typer.Exit(1)
|
|
315
|
+
|
|
316
|
+
paid = [p for p in pairs if p["sent_to_lookalike"] > 0]
|
|
317
|
+
for p in paid:
|
|
318
|
+
console.print(f"[bold red]✗ Money went to a look-alike:[/bold red] "
|
|
319
|
+
f"{p['sent_to_lookalike']:.4f} {'/'.join(p['symbols'])} sent to it")
|
|
320
|
+
console.print(f" real address {p['genuine']}")
|
|
321
|
+
console.print(f" look-alike {p['lookalike']}")
|
|
322
|
+
console.print(f" same first {p['matching']['prefix']} and last "
|
|
323
|
+
f"{p['matching']['suffix']} characters; first seen {when(p['lookalike_first_seen'])}")
|
|
324
|
+
if paid:
|
|
325
|
+
console.print(" This is address poisoning. Run [bold]crypttrace investigate "
|
|
326
|
+
f"{paid[0]['lookalike']} --chain {chain}[/bold] to follow the money.\n")
|
|
327
|
+
|
|
328
|
+
planted = [p for p in pairs if p["sent_to_lookalike"] <= 0]
|
|
329
|
+
if planted:
|
|
330
|
+
console.print(f"[yellow]![/yellow] {len(planted)} look-alike address(es) planted in this "
|
|
331
|
+
"history — never copy an address from here:")
|
|
332
|
+
for p in planted[:10]:
|
|
333
|
+
console.print(f" {poison_mod.short(p['lookalike'], chain)} imitates "
|
|
334
|
+
f"{poison_mod.short(p['genuine'], chain)} [dim]({p['resemblance']} "
|
|
335
|
+
f"match, {p['bait_transfers']} bait transfer(s), "
|
|
336
|
+
f"{when(p['lookalike_first_seen'])})[/dim]")
|
|
337
|
+
console.print()
|
|
338
|
+
|
|
339
|
+
for x in lured:
|
|
340
|
+
tag = f"imitating [bold]{x['imitates']}[/bold]" if x["imitates"] \
|
|
341
|
+
else "[dim](the imitated address was not found in the payer's recent history)[/dim]"
|
|
342
|
+
console.print(f"[bold red]✗ Lured payment:[/bold red] {x['payer']} paid "
|
|
343
|
+
f"{x['paid']:.2f} {x['symbol']} on {when(x['paid_ts'])}, after this "
|
|
344
|
+
f"address lured it ({x['lure']}) — {tag}")
|
|
345
|
+
if spam:
|
|
346
|
+
console.print(f"[bold red]✗ Poisoning campaign:[/bold red] this address sent "
|
|
347
|
+
f"{spam['bait_transfers']} zero/dust/counterfeit transfers to "
|
|
348
|
+
f"{spam['targets']} different wallets.")
|
|
349
|
+
|
|
350
|
+
if not (pairs or lured or spam):
|
|
351
|
+
console.print("[green]No look-alike addresses or poisoning pattern found[/green] "
|
|
352
|
+
f"[dim]in the last {limit} transfers.[/dim]")
|
|
353
|
+
|
|
354
|
+
|
|
265
355
|
@app.command()
|
|
266
356
|
def offramp(
|
|
267
357
|
address: str = typer.Argument(..., help="Address to check"),
|
|
@@ -82,7 +82,8 @@ def _parse_tx(sig: str) -> List[Dict]:
|
|
|
82
82
|
val = 0.0
|
|
83
83
|
rows.append({"from": info.get("source", "") or info.get("authority", ""),
|
|
84
84
|
"to": info.get("destination", ""), "value": val,
|
|
85
|
-
"timestamp": ts, "hash": sig, "symbol": "SPL"
|
|
85
|
+
"timestamp": ts, "hash": sig, "symbol": "SPL",
|
|
86
|
+
"contract": "spl"}) # keeps token rows apart from SOL in the store
|
|
86
87
|
return rows
|
|
87
88
|
|
|
88
89
|
|
|
@@ -109,6 +109,10 @@ def token_transfers(address: str, limit: int = 200) -> List[Dict]:
|
|
|
109
109
|
d = _get(f"/v1/accounts/{address}/transactions/trc20", {"limit": min(limit, 200)})
|
|
110
110
|
rows = []
|
|
111
111
|
for t in d.get("data", []) or []:
|
|
112
|
+
# the endpoint also lists Approval events, whose "value" is an allowance
|
|
113
|
+
# (often 2**256-1), not money that moved
|
|
114
|
+
if t.get("type", "Transfer") != "Transfer":
|
|
115
|
+
continue
|
|
112
116
|
info = t.get("token_info") or {}
|
|
113
117
|
dec = int(info.get("decimals") or 6)
|
|
114
118
|
try:
|
|
@@ -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
|
|
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
|
|
|
@@ -78,6 +78,14 @@ def analyse(address: str, chain: str = "eth", asset: Optional[dict] = None,
|
|
|
78
78
|
except Exception:
|
|
79
79
|
result["offramp"] = None
|
|
80
80
|
|
|
81
|
+
try:
|
|
82
|
+
result["poisoning"] = poisoning.baited_payments(address, chain)
|
|
83
|
+
except chains.ChainError:
|
|
84
|
+
result["poisoning"] = []
|
|
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
|
+
|
|
81
89
|
result["guidance"] = build_guidance(result)
|
|
82
90
|
return result
|
|
83
91
|
|
|
@@ -127,6 +135,39 @@ def build_guidance(r: dict) -> dict:
|
|
|
127
135
|
"urgent": False,
|
|
128
136
|
})
|
|
129
137
|
|
|
138
|
+
lured = r.get("poisoning") or []
|
|
139
|
+
if lured:
|
|
140
|
+
x = lured[0]
|
|
141
|
+
like = (f", made to look like {x['imitates']}, an address the sender had really "
|
|
142
|
+
f"used before" if x["imitates"] else "")
|
|
143
|
+
steps.insert(0, {
|
|
144
|
+
"title": "How it happened: address poisoning",
|
|
145
|
+
"body": (f"{x['payer']} sent {x['paid']:.2f} {x['symbol']} to this address{like}. "
|
|
146
|
+
"Before that, this address had planted itself in that wallet's "
|
|
147
|
+
"history, so it was copied from there by mistake. Tell the exchange "
|
|
148
|
+
"and the police it is an address-poisoning case. Never copy an "
|
|
149
|
+
"address from your transaction history; check your own wallet for "
|
|
150
|
+
"other look-alikes with: crypttrace poisoning YOUR_ADDRESS"),
|
|
151
|
+
"urgent": False,
|
|
152
|
+
})
|
|
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
|
+
|
|
130
171
|
# always-applicable steps
|
|
131
172
|
steps.append({
|
|
132
173
|
"title": "Report it to the police",
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Address poisoning — look-alike addresses planted in a wallet's history.
|
|
2
|
+
|
|
3
|
+
Wallets show addresses shortened, "0x1234…abcd". An attacker who sees a victim
|
|
4
|
+
pay 0x1234…abcd generates an address with the same first and last characters,
|
|
5
|
+
then sends the victim a zero-value transfer (or a fake token) from it, so the
|
|
6
|
+
look-alike sits at the top of the victim's history. The next time the victim
|
|
7
|
+
copies "the address I paid last time", they copy the attacker's.
|
|
8
|
+
|
|
9
|
+
Three views of the same attack:
|
|
10
|
+
|
|
11
|
+
* lookalikes(victim) — pairs of counterparties in one wallet's history that
|
|
12
|
+
share their visible characters, which one arrived later with a zero or dust
|
|
13
|
+
transfer, and whether real money was then sent to it;
|
|
14
|
+
* baited_payments(address) — the poisoner's side, which is usually the
|
|
15
|
+
address a victim brings to an investigation: someone paid it real money
|
|
16
|
+
after exchanging a zero or dust transfer with it. On EVM chains the bait is
|
|
17
|
+
often transferFrom(victim, look-alike, 0), which records a zero transfer
|
|
18
|
+
*from the victim*, so the look-alike itself never has to send anything;
|
|
19
|
+
* campaign(address) — an address sending zero/dust transfers to many
|
|
20
|
+
unrelated wallets (the dust-sending style, common on Tron).
|
|
21
|
+
"""
|
|
22
|
+
from collections import defaultdict
|
|
23
|
+
from typing import Dict, List, Optional, Tuple
|
|
24
|
+
|
|
25
|
+
from crypttrace import analysis, assets, chains
|
|
26
|
+
|
|
27
|
+
# How closely two addresses must match at the ends (after "0x", "T", "bc1q"…).
|
|
28
|
+
# Strong: rare by chance, flagged on resemblance alone. Weak: the cheap
|
|
29
|
+
# look-alikes seen on Tron (first and last two characters), flagged only when
|
|
30
|
+
# the look-alike arrived with bait after the genuine address was in use.
|
|
31
|
+
STRONG = (3, 3, 7) # min prefix, min suffix, min total
|
|
32
|
+
WEAK = (1, 2, 3)
|
|
33
|
+
# A campaign: this many distinct recipients of zero/dust transfers.
|
|
34
|
+
CAMPAIGN_MIN_TARGETS = 10
|
|
35
|
+
|
|
36
|
+
_ALL_TOKENS = {"contract": None, "symbol": "*"}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _body(address: str, chain: str) -> str:
|
|
40
|
+
"""The part of an address that varies — what a shortened display shows."""
|
|
41
|
+
a = address if chains.case_sensitive(chain) else address.lower()
|
|
42
|
+
for p in ("0x", "bc1q", "bc1p", "bc1", "tb1q", "T"):
|
|
43
|
+
if a.startswith(p) and (p != "T" or chain == "tron"):
|
|
44
|
+
return a[len(p):]
|
|
45
|
+
return a
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def match_lengths(a: str, b: str, chain: str) -> Tuple[int, int]:
|
|
49
|
+
"""How many leading and trailing characters two addresses share."""
|
|
50
|
+
x, y = _body(a, chain), _body(b, chain)
|
|
51
|
+
pre = 0
|
|
52
|
+
while pre < min(len(x), len(y)) and x[pre] == y[pre]:
|
|
53
|
+
pre += 1
|
|
54
|
+
suf = 0
|
|
55
|
+
while suf < min(len(x), len(y)) - pre and x[-1 - suf] == y[-1 - suf]:
|
|
56
|
+
suf += 1
|
|
57
|
+
return pre, suf
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def resemblance(a: str, b: str, chain: str) -> Optional[str]:
|
|
61
|
+
"""'strong', 'weak' or None."""
|
|
62
|
+
if chains.norm_addr(a, chain) == chains.norm_addr(b, chain):
|
|
63
|
+
return None
|
|
64
|
+
pre, suf = match_lengths(a, b, chain)
|
|
65
|
+
for name, (p, s, t) in (("strong", STRONG), ("weak", WEAK)):
|
|
66
|
+
if pre >= p and suf >= s and pre + suf >= t:
|
|
67
|
+
return name
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _history(address: str, chain: str, limit: int) -> List[dict]:
|
|
72
|
+
"""Native and every-token transfers, fakes included — they are the bait."""
|
|
73
|
+
rows = list(chains.transfers(address, chain, limit))
|
|
74
|
+
if chain != "btc":
|
|
75
|
+
try:
|
|
76
|
+
rows += chains.transfers(address, chain, limit, asset=_ALL_TOKENS)
|
|
77
|
+
except chains.ChainError:
|
|
78
|
+
pass
|
|
79
|
+
return rows
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _is_bait(row: dict, chain: str) -> bool:
|
|
83
|
+
"""A transfer whose only purpose is to appear in a history: zero value, dust,
|
|
84
|
+
or a counterfeit token wearing a real token's symbol."""
|
|
85
|
+
v = row.get("value", 0) or 0
|
|
86
|
+
if v <= 0:
|
|
87
|
+
return True
|
|
88
|
+
contract = (row.get("contract") or "").lower()
|
|
89
|
+
if not contract:
|
|
90
|
+
return v < analysis.dust_threshold(chain)
|
|
91
|
+
real = assets.tokens_for(chain).get((row.get("symbol") or "").lower())
|
|
92
|
+
if real:
|
|
93
|
+
if contract != real["contract"].lower():
|
|
94
|
+
return True # 'USDT' from the wrong contract
|
|
95
|
+
return real.get("stable", False) and v < 1.0
|
|
96
|
+
return False # unknown token, real amount: not bait
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def lookalikes(address: str, chain: str, limit: int = 1000) -> List[dict]:
|
|
100
|
+
"""Look-alike counterparty pairs in `address`'s history, newest risk first.
|
|
101
|
+
|
|
102
|
+
For each pair the earlier-used address is taken as the genuine one; the
|
|
103
|
+
later one is the suspected look-alike. `sent_to_lookalike` > 0 means real
|
|
104
|
+
value went to it after it appeared — the usual way this theft completes.
|
|
105
|
+
"""
|
|
106
|
+
me = chains.norm_addr(address, chain)
|
|
107
|
+
seen: Dict[str, dict] = {}
|
|
108
|
+
for r in _history(address, chain, limit):
|
|
109
|
+
frm, to = r.get("from") or "", r.get("to") or ""
|
|
110
|
+
if frm == me and to and to != me:
|
|
111
|
+
other, outgoing = to, True
|
|
112
|
+
elif to == me and frm and frm != me:
|
|
113
|
+
other, outgoing = frm, False
|
|
114
|
+
else:
|
|
115
|
+
continue
|
|
116
|
+
ts = int(r.get("timestamp") or 0)
|
|
117
|
+
c = seen.setdefault(other, {"address": other, "first_ts": ts, "rows": 0, "bait": 0,
|
|
118
|
+
"bait_first_ts": None, "sent_value": 0.0,
|
|
119
|
+
"sent_first_ts": None, "symbols": set()})
|
|
120
|
+
c["first_ts"] = min(c["first_ts"], ts) if c["first_ts"] else ts
|
|
121
|
+
c["rows"] += 1
|
|
122
|
+
c["symbols"].add(r.get("symbol") or "?")
|
|
123
|
+
if _is_bait(r, chain):
|
|
124
|
+
c["bait"] += 1
|
|
125
|
+
c["bait_first_ts"] = ts if c["bait_first_ts"] is None else min(c["bait_first_ts"], ts)
|
|
126
|
+
elif outgoing:
|
|
127
|
+
c["sent_value"] += r.get("value", 0) or 0
|
|
128
|
+
c["sent_first_ts"] = ts if c["sent_first_ts"] is None else min(c["sent_first_ts"], ts)
|
|
129
|
+
|
|
130
|
+
# only compare addresses that could match: bucket by visible ends
|
|
131
|
+
buckets = defaultdict(list)
|
|
132
|
+
for a in seen:
|
|
133
|
+
b = _body(a, chain)
|
|
134
|
+
if len(b) >= WEAK[0] + WEAK[1]:
|
|
135
|
+
buckets[(b[:WEAK[0]], b[-WEAK[1]:])].append(a)
|
|
136
|
+
|
|
137
|
+
findings = []
|
|
138
|
+
for group in buckets.values():
|
|
139
|
+
if len(group) < 2:
|
|
140
|
+
continue
|
|
141
|
+
# genuine = the one this wallet paid real value to first (else the earliest seen)
|
|
142
|
+
def rank(a):
|
|
143
|
+
c = seen[a]
|
|
144
|
+
return (c["sent_first_ts"] is None, c["sent_first_ts"] or c["first_ts"], c["first_ts"])
|
|
145
|
+
genuine = min(group, key=rank)
|
|
146
|
+
for fake in group:
|
|
147
|
+
level = resemblance(genuine, fake, chain) if fake != genuine else None
|
|
148
|
+
if not level:
|
|
149
|
+
continue
|
|
150
|
+
g, f = seen[genuine], seen[fake]
|
|
151
|
+
baited = f["bait"] > 0 and (f["bait_first_ts"] or 0) >= (g["first_ts"] or 0)
|
|
152
|
+
if level == "weak" and not baited:
|
|
153
|
+
continue # a two-character coincidence is not evidence by itself
|
|
154
|
+
pre, suf = match_lengths(genuine, fake, chain)
|
|
155
|
+
findings.append({
|
|
156
|
+
"genuine": genuine, "lookalike": fake, "resemblance": level,
|
|
157
|
+
"confidence": "high" if level == "strong" or f["sent_value"] > 0 else "medium",
|
|
158
|
+
"matching": {"prefix": pre, "suffix": suf},
|
|
159
|
+
"lookalike_first_seen": f["first_ts"],
|
|
160
|
+
"lookalike_after_genuine_s": (f["first_ts"] - g["first_ts"])
|
|
161
|
+
if f["first_ts"] and g["first_ts"] else None,
|
|
162
|
+
"bait_transfers": f["bait"],
|
|
163
|
+
"sent_to_lookalike": f["sent_value"],
|
|
164
|
+
"sent_to_genuine": g["sent_value"],
|
|
165
|
+
"symbols": sorted(f["symbols"]),
|
|
166
|
+
})
|
|
167
|
+
findings.sort(key=lambda x: (x["sent_to_lookalike"] <= 0, -x["bait_transfers"]))
|
|
168
|
+
return findings
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _substantial(row: dict, chain: str) -> bool:
|
|
172
|
+
"""Money a victim would actually lose: native well above dust, or a real
|
|
173
|
+
stablecoin of at least $10. Excludes the few-dollar top-ups an operator
|
|
174
|
+
uses to activate a fresh look-alike."""
|
|
175
|
+
v = row.get("value", 0) or 0
|
|
176
|
+
contract = (row.get("contract") or "").lower()
|
|
177
|
+
if not contract:
|
|
178
|
+
return v >= 100 * analysis.dust_threshold(chain)
|
|
179
|
+
real = assets.tokens_for(chain).get((row.get("symbol") or "").lower())
|
|
180
|
+
return bool(real and real.get("stable") and contract == real["contract"].lower() and v >= 10)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def baited_payments(address: str, chain: str, limit: int = 1000,
|
|
184
|
+
confirm: int = 3) -> List[dict]:
|
|
185
|
+
"""Payers who sent `address` real money right after it lured them.
|
|
186
|
+
|
|
187
|
+
The lure is either bait (a zero, dust or counterfeit transfer between the
|
|
188
|
+
two) or this address first sending the payer a small real amount — what
|
|
189
|
+
makes it appear in the payer's history. For up to `confirm` payers their
|
|
190
|
+
own history is read for the address this one imitates, which turns a
|
|
191
|
+
pattern into a specific claim.
|
|
192
|
+
"""
|
|
193
|
+
me = chains.norm_addr(address, chain)
|
|
194
|
+
rows = _history(address, chain, limit)
|
|
195
|
+
first_active = min((int(r.get("timestamp") or 0) for r in rows if r.get("timestamp")), default=0)
|
|
196
|
+
lure: Dict[str, dict] = {} # counterparty -> earliest lure
|
|
197
|
+
paid: Dict[Tuple[str, str], dict] = {}
|
|
198
|
+
for r in rows:
|
|
199
|
+
frm, to = r.get("from") or "", r.get("to") or ""
|
|
200
|
+
other = to if frm == me else frm if to == me else ""
|
|
201
|
+
if not other or other == me:
|
|
202
|
+
continue
|
|
203
|
+
ts, v = int(r.get("timestamp") or 0), r.get("value", 0) or 0
|
|
204
|
+
if _is_bait(r, chain):
|
|
205
|
+
kind = "bait"
|
|
206
|
+
elif frm == me:
|
|
207
|
+
kind = "small transfer from this address"
|
|
208
|
+
elif _substantial(r, chain):
|
|
209
|
+
p = paid.setdefault((other, r.get("symbol") or "?"), {"value": 0.0, "first_ts": ts})
|
|
210
|
+
p["value"] += v
|
|
211
|
+
p["first_ts"] = min(p["first_ts"], ts)
|
|
212
|
+
continue
|
|
213
|
+
else:
|
|
214
|
+
continue
|
|
215
|
+
l = lure.get(other)
|
|
216
|
+
if l is None or ts < l["ts"]:
|
|
217
|
+
lure[other] = {"ts": ts, "kind": kind, "value": v, "symbol": r.get("symbol")}
|
|
218
|
+
out = []
|
|
219
|
+
for (payer, symbol), p in paid.items():
|
|
220
|
+
l = lure.get(payer)
|
|
221
|
+
if not l or l["ts"] > p["first_ts"]:
|
|
222
|
+
continue
|
|
223
|
+
if l["kind"] != "bait" and l["symbol"] == symbol and l["value"] > 0.01 * p["value"]:
|
|
224
|
+
continue # a real exchange of similar size, not a lure
|
|
225
|
+
out.append({"payer": payer, "paid": p["value"], "symbol": symbol,
|
|
226
|
+
"lure": l["kind"], "lure_ts": l["ts"], "paid_ts": p["first_ts"],
|
|
227
|
+
"address_age_s": p["first_ts"] - first_active if first_active else None,
|
|
228
|
+
"imitates": None})
|
|
229
|
+
out.sort(key=lambda x: -x["paid"])
|
|
230
|
+
for x in out[:confirm]:
|
|
231
|
+
try:
|
|
232
|
+
for pair in lookalikes(x["payer"], chain, limit):
|
|
233
|
+
if chains.norm_addr(pair["lookalike"], chain) == me:
|
|
234
|
+
x["imitates"] = pair["genuine"]
|
|
235
|
+
break
|
|
236
|
+
except chains.ChainError:
|
|
237
|
+
pass
|
|
238
|
+
return out
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def campaign(address: str, chain: str, limit: int = 1000) -> Optional[dict]:
|
|
242
|
+
"""Does `address` send zero/dust transfers to many unrelated wallets?"""
|
|
243
|
+
me = chains.norm_addr(address, chain)
|
|
244
|
+
targets, bait, real_out = set(), 0, 0
|
|
245
|
+
for r in _history(address, chain, limit):
|
|
246
|
+
if r.get("from") != me or not r.get("to") or r.get("to") == me:
|
|
247
|
+
continue
|
|
248
|
+
if _is_bait(r, chain):
|
|
249
|
+
bait += 1
|
|
250
|
+
targets.add(r["to"])
|
|
251
|
+
else:
|
|
252
|
+
real_out += 1
|
|
253
|
+
if len(targets) < CAMPAIGN_MIN_TARGETS or bait < 2 * real_out:
|
|
254
|
+
return None
|
|
255
|
+
return {"targets": len(targets), "bait_transfers": bait, "real_transfers_out": real_out,
|
|
256
|
+
"sample": sorted(targets)[:5]}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def short(address: str, chain: str, pre: int = 6, suf: int = 6) -> str:
|
|
260
|
+
"""How a wallet would show it — the part a poisoner imitates."""
|
|
261
|
+
head = len(address) - len(_body(address, chain))
|
|
262
|
+
return address[:head + pre] + "…" + address[-suf:]
|
|
@@ -114,18 +114,24 @@ def is_fresh(chain: str, address: str, asset_key: str = "",
|
|
|
114
114
|
def load(chain: str, address: str, contract: str = "", native_symbol: str = "") -> List[dict]:
|
|
115
115
|
"""Every stored transfer touching this address, newest first."""
|
|
116
116
|
c = _conn()
|
|
117
|
+
cols = "SELECT from_addr,to_addr,value,symbol,contract,ts,tx_hash,fee_share FROM transfers"
|
|
117
118
|
try:
|
|
118
|
-
if contract:
|
|
119
|
-
|
|
120
|
-
|
|
119
|
+
if contract == "*":
|
|
120
|
+
# every token, whatever its contract (address-poisoning checks need fakes too)
|
|
121
|
+
q = (f"{cols} WHERE chain=? AND (from_addr=? OR to_addr=?)"
|
|
122
|
+
" AND contract IS NOT NULL AND contract!='' ORDER BY ts DESC")
|
|
123
|
+
args = (chain, address, address)
|
|
124
|
+
elif contract:
|
|
125
|
+
q = (f"{cols} WHERE chain=? AND (from_addr=? OR to_addr=?)"
|
|
121
126
|
" AND lower(contract)=lower(?) ORDER BY ts DESC")
|
|
122
127
|
args = (chain, address, address, contract)
|
|
123
128
|
else:
|
|
124
|
-
# native asset: rows carry no contract
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
" AND (contract IS NULL OR contract='')
|
|
128
|
-
|
|
129
|
+
# native asset: rows carry no contract. Older stores filed Solana
|
|
130
|
+
# SPL rows without one too, so the symbol is checked as well.
|
|
131
|
+
q = (f"{cols} WHERE chain=? AND (from_addr=? OR to_addr=?)"
|
|
132
|
+
" AND (contract IS NULL OR contract='')"
|
|
133
|
+
+ (" AND symbol=?" if native_symbol else "") + " ORDER BY ts DESC")
|
|
134
|
+
args = (chain, address, address) + ((native_symbol,) if native_symbol else ())
|
|
129
135
|
rows = c.execute(q, args).fetchall()
|
|
130
136
|
finally:
|
|
131
137
|
c.close()
|
|
@@ -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.
|
|
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,15 @@ 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
|
+
|
|
171
|
+
**Sent money to an address that looked right?** That is usually address
|
|
172
|
+
poisoning — see below. Check your own wallet with
|
|
173
|
+
`crypttrace poisoning YOUR_ADDRESS`.
|
|
174
|
+
|
|
166
175
|
## Commands
|
|
167
176
|
|
|
168
177
|
```bash
|
|
@@ -191,6 +200,12 @@ crypttrace tokens 0xADDRESS
|
|
|
191
200
|
# Who bootstrapped this wallet's first gas? Follow it toward a KYC point
|
|
192
201
|
crypttrace funder 0xADDRESS --hops 6
|
|
193
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
|
+
|
|
206
|
+
# Address poisoning: look-alike addresses planted in a wallet's history
|
|
207
|
+
crypttrace poisoning TADDRESS --chain tron
|
|
208
|
+
|
|
194
209
|
# Is this an exchange deposit address (i.e. the cash-out point)?
|
|
195
210
|
crypttrace offramp 0xADDRESS
|
|
196
211
|
|
|
@@ -268,6 +283,56 @@ crypttrace watch run --once # single check, for scheduled tasks
|
|
|
268
283
|
Optional Telegram alerts: set `CRYPTTRACE_TG_TOKEN` and `CRYPTTRACE_TG_CHAT`,
|
|
269
284
|
then pass `--telegram`.
|
|
270
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
|
+
|
|
309
|
+
### Address poisoning
|
|
310
|
+
|
|
311
|
+
Wallets show addresses shortened — `TDDD34…rCr9Ps`. A poisoner generates an
|
|
312
|
+
address with the same first and last characters and plants it in the victim's
|
|
313
|
+
history with a zero-value transfer, a speck of dust or a counterfeit token. The
|
|
314
|
+
next time the victim copies "the address I paid last time", they copy the
|
|
315
|
+
attacker's. It is common on Tron and Ethereum, and it needs no hacking at all.
|
|
316
|
+
|
|
317
|
+
`crypttrace poisoning` looks at it from both ends:
|
|
318
|
+
|
|
319
|
+
- **Your wallet:** pairs of addresses in your history that share their visible
|
|
320
|
+
characters, which one arrived later with bait, and — the part that matters —
|
|
321
|
+
whether you then sent real money to it.
|
|
322
|
+
- **The address your money went to:** who paid it real money right after it
|
|
323
|
+
lured them, and which address it was imitating, read from the payer's own
|
|
324
|
+
history. `investigate` and the assessment run this automatically.
|
|
325
|
+
|
|
326
|
+
Tested on a documented Tron case from August 2026: from the victim's wallet it
|
|
327
|
+
finds the 2,527,862 USDT sent to `TDDDHi…rCr9Ps`, a look-alike of
|
|
328
|
+
`TDDD34…rCr9Ps`, an address the wallet had paid 8.4M USDT in all. From the
|
|
329
|
+
attacker's side it finds the same payment and two more victims of $2.4M and
|
|
330
|
+
$2.0M, each lured by a look-alike created three to twelve minutes earlier.
|
|
331
|
+
|
|
332
|
+
Cheap look-alikes match only one or two characters at each end, which happens
|
|
333
|
+
by chance; those are reported only when they arrived as bait after the real
|
|
334
|
+
address was in use.
|
|
335
|
+
|
|
271
336
|
### Off-ramp detection
|
|
272
337
|
|
|
273
338
|
Laundered funds reaching an exchange land on a per-user *deposit address* —
|
|
@@ -496,10 +561,6 @@ cases/ # worked investigations with their data
|
|
|
496
561
|
|
|
497
562
|
- More exchanges on Bitcoin, Tron and Solana (now: Binance, OKX, HTX, Bybit),
|
|
498
563
|
and refreshing these lists as the exchanges republish them
|
|
499
|
-
- Address-poisoning detection: flag look-alike addresses (same first and last
|
|
500
|
-
characters) in a victim's history before they copy the wrong one
|
|
501
|
-
- Stablecoin freeze check: whether USDT/USDC at an address is already frozen
|
|
502
|
-
by the issuer, and who to ask for a freeze
|
|
503
564
|
- `report --html`: one self-contained file with the interactive graph, to
|
|
504
565
|
send to an exchange or attach to a police report
|
|
505
566
|
- `report --pdf` for exchange and law-enforcement filings
|
|
@@ -10,9 +10,11 @@ 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
|
|
17
|
+
src/crypttrace/poisoning.py
|
|
16
18
|
src/crypttrace/prices.py
|
|
17
19
|
src/crypttrace/render.py
|
|
18
20
|
src/crypttrace/report.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|