crypttrace 0.3.0__tar.gz → 0.5.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.3.0/src/crypttrace.egg-info → crypttrace-0.5.0}/PKG-INFO +48 -5
- {crypttrace-0.3.0 → crypttrace-0.5.0}/README.md +47 -4
- {crypttrace-0.3.0 → crypttrace-0.5.0}/src/crypttrace/__init__.py +1 -1
- {crypttrace-0.3.0 → crypttrace-0.5.0}/src/crypttrace/assess.py +48 -1
- {crypttrace-0.3.0 → crypttrace-0.5.0}/src/crypttrace/chains.py +10 -2
- {crypttrace-0.3.0 → crypttrace-0.5.0}/src/crypttrace/cli.py +91 -2
- {crypttrace-0.3.0 → crypttrace-0.5.0}/src/crypttrace/fetchers/solana.py +2 -1
- {crypttrace-0.3.0 → crypttrace-0.5.0}/src/crypttrace/fetchers/tron.py +4 -0
- {crypttrace-0.3.0 → crypttrace-0.5.0}/src/crypttrace/investigate.py +22 -1
- {crypttrace-0.3.0 → crypttrace-0.5.0}/src/crypttrace/labels/audit.py +3 -0
- crypttrace-0.5.0/src/crypttrace/labels/bulk.py +187 -0
- {crypttrace-0.3.0 → crypttrace-0.5.0}/src/crypttrace/labels/labels.py +8 -1
- {crypttrace-0.3.0 → crypttrace-0.5.0}/src/crypttrace/offramp.py +3 -1
- crypttrace-0.5.0/src/crypttrace/poisoning.py +262 -0
- {crypttrace-0.3.0 → crypttrace-0.5.0}/src/crypttrace/store.py +14 -8
- {crypttrace-0.3.0 → crypttrace-0.5.0/src/crypttrace.egg-info}/PKG-INFO +48 -5
- {crypttrace-0.3.0 → crypttrace-0.5.0}/src/crypttrace.egg-info/SOURCES.txt +2 -0
- {crypttrace-0.3.0 → crypttrace-0.5.0}/LICENSE +0 -0
- {crypttrace-0.3.0 → crypttrace-0.5.0}/pyproject.toml +0 -0
- {crypttrace-0.3.0 → crypttrace-0.5.0}/setup.cfg +0 -0
- {crypttrace-0.3.0 → crypttrace-0.5.0}/src/crypttrace/addresses.py +0 -0
- {crypttrace-0.3.0 → crypttrace-0.5.0}/src/crypttrace/analysis.py +0 -0
- {crypttrace-0.3.0 → crypttrace-0.5.0}/src/crypttrace/assets.py +0 -0
- {crypttrace-0.3.0 → crypttrace-0.5.0}/src/crypttrace/bridges.py +0 -0
- {crypttrace-0.3.0 → crypttrace-0.5.0}/src/crypttrace/config.py +0 -0
- {crypttrace-0.3.0 → crypttrace-0.5.0}/src/crypttrace/fetchers/__init__.py +0 -0
- {crypttrace-0.3.0 → crypttrace-0.5.0}/src/crypttrace/fetchers/bitcoin.py +0 -0
- {crypttrace-0.3.0 → crypttrace-0.5.0}/src/crypttrace/fetchers/etherscan.py +0 -0
- {crypttrace-0.3.0 → crypttrace-0.5.0}/src/crypttrace/fetchers/http.py +0 -0
- {crypttrace-0.3.0 → crypttrace-0.5.0}/src/crypttrace/funder.py +0 -0
- {crypttrace-0.3.0 → crypttrace-0.5.0}/src/crypttrace/labels/__init__.py +0 -0
- {crypttrace-0.3.0 → crypttrace-0.5.0}/src/crypttrace/labels/known.json +0 -0
- {crypttrace-0.3.0 → crypttrace-0.5.0}/src/crypttrace/labels/partial.json +0 -0
- {crypttrace-0.3.0 → crypttrace-0.5.0}/src/crypttrace/prices.py +0 -0
- {crypttrace-0.3.0 → crypttrace-0.5.0}/src/crypttrace/render.py +0 -0
- {crypttrace-0.3.0 → crypttrace-0.5.0}/src/crypttrace/report.py +0 -0
- {crypttrace-0.3.0 → crypttrace-0.5.0}/src/crypttrace/trace.py +0 -0
- {crypttrace-0.3.0 → crypttrace-0.5.0}/src/crypttrace/verify.py +0 -0
- {crypttrace-0.3.0 → crypttrace-0.5.0}/src/crypttrace/watch.py +0 -0
- {crypttrace-0.3.0 → crypttrace-0.5.0}/src/crypttrace/web/index.html +0 -0
- {crypttrace-0.3.0 → crypttrace-0.5.0}/src/crypttrace/webapp.py +0 -0
- {crypttrace-0.3.0 → crypttrace-0.5.0}/src/crypttrace.egg-info/dependency_links.txt +0 -0
- {crypttrace-0.3.0 → crypttrace-0.5.0}/src/crypttrace.egg-info/entry_points.txt +0 -0
- {crypttrace-0.3.0 → crypttrace-0.5.0}/src/crypttrace.egg-info/requires.txt +0 -0
- {crypttrace-0.3.0 → crypttrace-0.5.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.5.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,10 @@ 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
|
+
**Sent money to an address that looked right?** That is usually address
|
|
167
|
+
poisoning — see below. Check your own wallet with
|
|
168
|
+
`crypttrace poisoning YOUR_ADDRESS`.
|
|
169
|
+
|
|
166
170
|
## Commands
|
|
167
171
|
|
|
168
172
|
```bash
|
|
@@ -191,6 +195,9 @@ crypttrace tokens 0xADDRESS
|
|
|
191
195
|
# Who bootstrapped this wallet's first gas? Follow it toward a KYC point
|
|
192
196
|
crypttrace funder 0xADDRESS --hops 6
|
|
193
197
|
|
|
198
|
+
# Address poisoning: look-alike addresses planted in a wallet's history
|
|
199
|
+
crypttrace poisoning TADDRESS --chain tron
|
|
200
|
+
|
|
194
201
|
# Is this an exchange deposit address (i.e. the cash-out point)?
|
|
195
202
|
crypttrace offramp 0xADDRESS
|
|
196
203
|
|
|
@@ -216,6 +223,9 @@ crypttrace report 0xADDRESS --depth 3
|
|
|
216
223
|
# Refresh label lists (OFAC sanctions, …)
|
|
217
224
|
crypttrace update-labels
|
|
218
225
|
|
|
226
|
+
# …and add OKX's ~300k signed deposit addresses (downloads ~80 MB once)
|
|
227
|
+
crypttrace update-labels --okx
|
|
228
|
+
|
|
219
229
|
# Launch the web UI / list chains
|
|
220
230
|
crypttrace serve
|
|
221
231
|
crypttrace chains
|
|
@@ -265,6 +275,33 @@ crypttrace watch run --once # single check, for scheduled tasks
|
|
|
265
275
|
Optional Telegram alerts: set `CRYPTTRACE_TG_TOKEN` and `CRYPTTRACE_TG_CHAT`,
|
|
266
276
|
then pass `--telegram`.
|
|
267
277
|
|
|
278
|
+
### Address poisoning
|
|
279
|
+
|
|
280
|
+
Wallets show addresses shortened — `TDDD34…rCr9Ps`. A poisoner generates an
|
|
281
|
+
address with the same first and last characters and plants it in the victim's
|
|
282
|
+
history with a zero-value transfer, a speck of dust or a counterfeit token. The
|
|
283
|
+
next time the victim copies "the address I paid last time", they copy the
|
|
284
|
+
attacker's. It is common on Tron and Ethereum, and it needs no hacking at all.
|
|
285
|
+
|
|
286
|
+
`crypttrace poisoning` looks at it from both ends:
|
|
287
|
+
|
|
288
|
+
- **Your wallet:** pairs of addresses in your history that share their visible
|
|
289
|
+
characters, which one arrived later with bait, and — the part that matters —
|
|
290
|
+
whether you then sent real money to it.
|
|
291
|
+
- **The address your money went to:** who paid it real money right after it
|
|
292
|
+
lured them, and which address it was imitating, read from the payer's own
|
|
293
|
+
history. `investigate` and the assessment run this automatically.
|
|
294
|
+
|
|
295
|
+
Tested on a documented Tron case from August 2026: from the victim's wallet it
|
|
296
|
+
finds the 2,527,862 USDT sent to `TDDDHi…rCr9Ps`, a look-alike of
|
|
297
|
+
`TDDD34…rCr9Ps`, an address the wallet had paid 8.4M USDT in all. From the
|
|
298
|
+
attacker's side it finds the same payment and two more victims of $2.4M and
|
|
299
|
+
$2.0M, each lured by a look-alike created three to twelve minutes earlier.
|
|
300
|
+
|
|
301
|
+
Cheap look-alikes match only one or two characters at each end, which happens
|
|
302
|
+
by chance; those are reported only when they arrived as bait after the real
|
|
303
|
+
address was in use.
|
|
304
|
+
|
|
268
305
|
### Off-ramp detection
|
|
269
306
|
|
|
270
307
|
Laundered funds reaching an exchange land on a per-user *deposit address* —
|
|
@@ -380,6 +417,16 @@ flag deposit addresses on those chains. Funds sent to an exchange not on that
|
|
|
380
417
|
list still show as `unknown`. Every label records where it came from — see
|
|
381
418
|
`crypttrace labels audit`.
|
|
382
419
|
|
|
420
|
+
**Deposit addresses, labelled outright.** OKX signs every address that holds
|
|
421
|
+
customer funds — over 300,000, nearly all of them per-customer deposit addresses
|
|
422
|
+
— and publishes the list with its proof of reserves. `crypttrace update-labels
|
|
423
|
+
--okx` downloads the latest one into `~/.crypttrace/` (about 18 MB once
|
|
424
|
+
imported). With it, funds landing on an OKX deposit address are identified the
|
|
425
|
+
moment they arrive, before OKX sweeps them onward — which is when a freeze
|
|
426
|
+
request still has something to freeze. Without it, `offramp` only recognises a
|
|
427
|
+
deposit address after it has forwarded the money. Rows without OKX's signature
|
|
428
|
+
are ignored, and every address is checked before it is stored.
|
|
429
|
+
|
|
383
430
|
### Reports
|
|
384
431
|
|
|
385
432
|
`report` runs the full analysis and writes to `~/crypttrace-reports/` (override
|
|
@@ -483,10 +530,6 @@ cases/ # worked investigations with their data
|
|
|
483
530
|
|
|
484
531
|
- More exchanges on Bitcoin, Tron and Solana (now: Binance, OKX, HTX, Bybit),
|
|
485
532
|
and refreshing these lists as the exchanges republish them
|
|
486
|
-
- OKX's full signed address set (~310k, mostly deposit addresses) as an
|
|
487
|
-
optional `update-labels` download, so deposits are labelled outright
|
|
488
|
-
- Address-poisoning detection: flag look-alike addresses (same first and last
|
|
489
|
-
characters) in a victim's history before they copy the wrong one
|
|
490
533
|
- Stablecoin freeze check: whether USDT/USDC at an address is already frozen
|
|
491
534
|
by the issuer, and who to ask for a freeze
|
|
492
535
|
- `report --html`: one self-contained file with the interactive graph, to
|
|
@@ -129,6 +129,10 @@ 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
|
+
**Sent money to an address that looked right?** That is usually address
|
|
133
|
+
poisoning — see below. Check your own wallet with
|
|
134
|
+
`crypttrace poisoning YOUR_ADDRESS`.
|
|
135
|
+
|
|
132
136
|
## Commands
|
|
133
137
|
|
|
134
138
|
```bash
|
|
@@ -157,6 +161,9 @@ crypttrace tokens 0xADDRESS
|
|
|
157
161
|
# Who bootstrapped this wallet's first gas? Follow it toward a KYC point
|
|
158
162
|
crypttrace funder 0xADDRESS --hops 6
|
|
159
163
|
|
|
164
|
+
# Address poisoning: look-alike addresses planted in a wallet's history
|
|
165
|
+
crypttrace poisoning TADDRESS --chain tron
|
|
166
|
+
|
|
160
167
|
# Is this an exchange deposit address (i.e. the cash-out point)?
|
|
161
168
|
crypttrace offramp 0xADDRESS
|
|
162
169
|
|
|
@@ -182,6 +189,9 @@ crypttrace report 0xADDRESS --depth 3
|
|
|
182
189
|
# Refresh label lists (OFAC sanctions, …)
|
|
183
190
|
crypttrace update-labels
|
|
184
191
|
|
|
192
|
+
# …and add OKX's ~300k signed deposit addresses (downloads ~80 MB once)
|
|
193
|
+
crypttrace update-labels --okx
|
|
194
|
+
|
|
185
195
|
# Launch the web UI / list chains
|
|
186
196
|
crypttrace serve
|
|
187
197
|
crypttrace chains
|
|
@@ -231,6 +241,33 @@ crypttrace watch run --once # single check, for scheduled tasks
|
|
|
231
241
|
Optional Telegram alerts: set `CRYPTTRACE_TG_TOKEN` and `CRYPTTRACE_TG_CHAT`,
|
|
232
242
|
then pass `--telegram`.
|
|
233
243
|
|
|
244
|
+
### Address poisoning
|
|
245
|
+
|
|
246
|
+
Wallets show addresses shortened — `TDDD34…rCr9Ps`. A poisoner generates an
|
|
247
|
+
address with the same first and last characters and plants it in the victim's
|
|
248
|
+
history with a zero-value transfer, a speck of dust or a counterfeit token. The
|
|
249
|
+
next time the victim copies "the address I paid last time", they copy the
|
|
250
|
+
attacker's. It is common on Tron and Ethereum, and it needs no hacking at all.
|
|
251
|
+
|
|
252
|
+
`crypttrace poisoning` looks at it from both ends:
|
|
253
|
+
|
|
254
|
+
- **Your wallet:** pairs of addresses in your history that share their visible
|
|
255
|
+
characters, which one arrived later with bait, and — the part that matters —
|
|
256
|
+
whether you then sent real money to it.
|
|
257
|
+
- **The address your money went to:** who paid it real money right after it
|
|
258
|
+
lured them, and which address it was imitating, read from the payer's own
|
|
259
|
+
history. `investigate` and the assessment run this automatically.
|
|
260
|
+
|
|
261
|
+
Tested on a documented Tron case from August 2026: from the victim's wallet it
|
|
262
|
+
finds the 2,527,862 USDT sent to `TDDDHi…rCr9Ps`, a look-alike of
|
|
263
|
+
`TDDD34…rCr9Ps`, an address the wallet had paid 8.4M USDT in all. From the
|
|
264
|
+
attacker's side it finds the same payment and two more victims of $2.4M and
|
|
265
|
+
$2.0M, each lured by a look-alike created three to twelve minutes earlier.
|
|
266
|
+
|
|
267
|
+
Cheap look-alikes match only one or two characters at each end, which happens
|
|
268
|
+
by chance; those are reported only when they arrived as bait after the real
|
|
269
|
+
address was in use.
|
|
270
|
+
|
|
234
271
|
### Off-ramp detection
|
|
235
272
|
|
|
236
273
|
Laundered funds reaching an exchange land on a per-user *deposit address* —
|
|
@@ -346,6 +383,16 @@ flag deposit addresses on those chains. Funds sent to an exchange not on that
|
|
|
346
383
|
list still show as `unknown`. Every label records where it came from — see
|
|
347
384
|
`crypttrace labels audit`.
|
|
348
385
|
|
|
386
|
+
**Deposit addresses, labelled outright.** OKX signs every address that holds
|
|
387
|
+
customer funds — over 300,000, nearly all of them per-customer deposit addresses
|
|
388
|
+
— and publishes the list with its proof of reserves. `crypttrace update-labels
|
|
389
|
+
--okx` downloads the latest one into `~/.crypttrace/` (about 18 MB once
|
|
390
|
+
imported). With it, funds landing on an OKX deposit address are identified the
|
|
391
|
+
moment they arrive, before OKX sweeps them onward — which is when a freeze
|
|
392
|
+
request still has something to freeze. Without it, `offramp` only recognises a
|
|
393
|
+
deposit address after it has forwarded the money. Rows without OKX's signature
|
|
394
|
+
are ignored, and every address is checked before it is stored.
|
|
395
|
+
|
|
349
396
|
### Reports
|
|
350
397
|
|
|
351
398
|
`report` runs the full analysis and writes to `~/crypttrace-reports/` (override
|
|
@@ -449,10 +496,6 @@ cases/ # worked investigations with their data
|
|
|
449
496
|
|
|
450
497
|
- More exchanges on Bitcoin, Tron and Solana (now: Binance, OKX, HTX, Bybit),
|
|
451
498
|
and refreshing these lists as the exchanges republish them
|
|
452
|
-
- OKX's full signed address set (~310k, mostly deposit addresses) as an
|
|
453
|
-
optional `update-labels` download, so deposits are labelled outright
|
|
454
|
-
- Address-poisoning detection: flag look-alike addresses (same first and last
|
|
455
|
-
characters) in a victim's history before they copy the wrong one
|
|
456
499
|
- Stablecoin freeze check: whether USDT/USDC at an address is already frozen
|
|
457
500
|
by the issuer, and who to ask for a freeze
|
|
458
501
|
- `report --html`: one self-contained file with the interactive graph, to
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""crypttrace — OSINT crypto investigation CLI."""
|
|
2
|
-
__version__ = "0.
|
|
2
|
+
__version__ = "0.5.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, poisoning
|
|
18
18
|
from crypttrace.labels import labels
|
|
19
19
|
|
|
20
20
|
|
|
@@ -197,6 +197,43 @@ 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
|
+
|
|
200
237
|
# --- holding behaviour ---------------------------------------------
|
|
201
238
|
if received > 0 and sent == 0 and len(inbound) >= 3:
|
|
202
239
|
signals.append(Signal(
|
|
@@ -267,6 +304,16 @@ def _statement(signals: List[Signal], risk: int, confidence: str, hit) -> str:
|
|
|
267
304
|
if "cross-chain movement" in names:
|
|
268
305
|
parts.append("Some value left this chain through a bridge and would need to be "
|
|
269
306
|
"picked up on the destination network.")
|
|
307
|
+
if "address poisoning" in names:
|
|
308
|
+
parts.append("Money reached this address through address poisoning: it was made to "
|
|
309
|
+
"look like an address the payer really meant, and planted in their "
|
|
310
|
+
"history first.")
|
|
311
|
+
if "poisoning campaign" in names:
|
|
312
|
+
parts.append("It sends worthless transfers to many strangers, the way poisoners "
|
|
313
|
+
"plant look-alike addresses.")
|
|
314
|
+
if "paid a look-alike" in names:
|
|
315
|
+
parts.append("This wallet sent money to a look-alike of an address it had used "
|
|
316
|
+
"before — the signature of an address-poisoning theft.")
|
|
270
317
|
if "funds held" in names:
|
|
271
318
|
parts.append("The proceeds have not been spent, so intervention is still possible.")
|
|
272
319
|
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
|
|
@@ -5,10 +5,12 @@ known entities (exchanges, mixers, sanctioned wallets), and traces where the
|
|
|
5
5
|
funds went.
|
|
6
6
|
"""
|
|
7
7
|
import sys
|
|
8
|
+
import zipfile
|
|
8
9
|
from datetime import datetime
|
|
9
10
|
from pathlib import Path
|
|
10
11
|
from typing import Optional
|
|
11
12
|
|
|
13
|
+
import requests
|
|
12
14
|
import typer
|
|
13
15
|
from rich.console import Console
|
|
14
16
|
|
|
@@ -260,6 +262,64 @@ def crosschain(
|
|
|
260
262
|
"not proof. Verify each candidate before relying on it.[/dim]")
|
|
261
263
|
|
|
262
264
|
|
|
265
|
+
@app.command()
|
|
266
|
+
def poisoning(
|
|
267
|
+
address: str = typer.Argument(..., help="Your wallet, or the address the money went to"),
|
|
268
|
+
chain: str = CHAIN_OPT,
|
|
269
|
+
limit: int = typer.Option(1000, "--limit", help="How much history to read"),
|
|
270
|
+
):
|
|
271
|
+
"""Address poisoning: look-alike addresses planted in a wallet's history."""
|
|
272
|
+
from crypttrace import poisoning as poison_mod
|
|
273
|
+
from datetime import timezone
|
|
274
|
+
when = lambda ts: datetime.fromtimestamp(ts, timezone.utc).strftime("%Y-%m-%d %H:%M UTC") \
|
|
275
|
+
if ts else "?"
|
|
276
|
+
try:
|
|
277
|
+
pairs = poison_mod.lookalikes(address, chain, limit)
|
|
278
|
+
lured = poison_mod.baited_payments(address, chain, limit)
|
|
279
|
+
spam = poison_mod.campaign(address, chain, limit)
|
|
280
|
+
except (chains_mod.ChainError, etherscan.EtherscanError) as e:
|
|
281
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
282
|
+
raise typer.Exit(1)
|
|
283
|
+
|
|
284
|
+
paid = [p for p in pairs if p["sent_to_lookalike"] > 0]
|
|
285
|
+
for p in paid:
|
|
286
|
+
console.print(f"[bold red]✗ Money went to a look-alike:[/bold red] "
|
|
287
|
+
f"{p['sent_to_lookalike']:.4f} {'/'.join(p['symbols'])} sent to it")
|
|
288
|
+
console.print(f" real address {p['genuine']}")
|
|
289
|
+
console.print(f" look-alike {p['lookalike']}")
|
|
290
|
+
console.print(f" same first {p['matching']['prefix']} and last "
|
|
291
|
+
f"{p['matching']['suffix']} characters; first seen {when(p['lookalike_first_seen'])}")
|
|
292
|
+
if paid:
|
|
293
|
+
console.print(" This is address poisoning. Run [bold]crypttrace investigate "
|
|
294
|
+
f"{paid[0]['lookalike']} --chain {chain}[/bold] to follow the money.\n")
|
|
295
|
+
|
|
296
|
+
planted = [p for p in pairs if p["sent_to_lookalike"] <= 0]
|
|
297
|
+
if planted:
|
|
298
|
+
console.print(f"[yellow]![/yellow] {len(planted)} look-alike address(es) planted in this "
|
|
299
|
+
"history — never copy an address from here:")
|
|
300
|
+
for p in planted[:10]:
|
|
301
|
+
console.print(f" {poison_mod.short(p['lookalike'], chain)} imitates "
|
|
302
|
+
f"{poison_mod.short(p['genuine'], chain)} [dim]({p['resemblance']} "
|
|
303
|
+
f"match, {p['bait_transfers']} bait transfer(s), "
|
|
304
|
+
f"{when(p['lookalike_first_seen'])})[/dim]")
|
|
305
|
+
console.print()
|
|
306
|
+
|
|
307
|
+
for x in lured:
|
|
308
|
+
tag = f"imitating [bold]{x['imitates']}[/bold]" if x["imitates"] \
|
|
309
|
+
else "[dim](the imitated address was not found in the payer's recent history)[/dim]"
|
|
310
|
+
console.print(f"[bold red]✗ Lured payment:[/bold red] {x['payer']} paid "
|
|
311
|
+
f"{x['paid']:.2f} {x['symbol']} on {when(x['paid_ts'])}, after this "
|
|
312
|
+
f"address lured it ({x['lure']}) — {tag}")
|
|
313
|
+
if spam:
|
|
314
|
+
console.print(f"[bold red]✗ Poisoning campaign:[/bold red] this address sent "
|
|
315
|
+
f"{spam['bait_transfers']} zero/dust/counterfeit transfers to "
|
|
316
|
+
f"{spam['targets']} different wallets.")
|
|
317
|
+
|
|
318
|
+
if not (pairs or lured or spam):
|
|
319
|
+
console.print("[green]No look-alike addresses or poisoning pattern found[/green] "
|
|
320
|
+
f"[dim]in the last {limit} transfers.[/dim]")
|
|
321
|
+
|
|
322
|
+
|
|
263
323
|
@app.command()
|
|
264
324
|
def offramp(
|
|
265
325
|
address: str = typer.Argument(..., help="Address to check"),
|
|
@@ -352,6 +412,17 @@ def labels_audit():
|
|
|
352
412
|
for u in a["unsourced"][:8]:
|
|
353
413
|
console.print(f" [dim]{u['name']} ({u['type']})[/dim]")
|
|
354
414
|
|
|
415
|
+
from crypttrace.labels import bulk
|
|
416
|
+
downloaded = bulk.sets()
|
|
417
|
+
if downloaded:
|
|
418
|
+
console.print("\n [bold]downloaded exchange address sets[/bold] (self-published, "
|
|
419
|
+
"checked on import)")
|
|
420
|
+
for s in downloaded.values():
|
|
421
|
+
console.print(f" {s['name']:<34} {s['count']:>8} snapshot {s['snapshot']}")
|
|
422
|
+
else:
|
|
423
|
+
console.print("\n [dim]No exchange address sets downloaded. "
|
|
424
|
+
"`crypttrace update-labels --okx` adds OKX's ~300k deposit addresses.[/dim]")
|
|
425
|
+
|
|
355
426
|
|
|
356
427
|
@labels_app.command("why")
|
|
357
428
|
def labels_why(
|
|
@@ -393,15 +464,33 @@ def labels_check(
|
|
|
393
464
|
|
|
394
465
|
|
|
395
466
|
@app.command(name="update-labels")
|
|
396
|
-
def update_labels(
|
|
467
|
+
def update_labels(
|
|
468
|
+
okx: bool = typer.Option(False, "--okx",
|
|
469
|
+
help="Also download OKX's signed address list (~80 MB): "
|
|
470
|
+
"labels its ~300k deposit addresses directly"),
|
|
471
|
+
okx_file: Optional[Path] = typer.Option(None, "--okx-file",
|
|
472
|
+
help="Import an OKX proof-of-reserves zip you "
|
|
473
|
+
"already downloaded, instead of fetching it"),
|
|
474
|
+
):
|
|
397
475
|
"""Download the latest label lists (OFAC sanctions, etc.) into the local DB."""
|
|
476
|
+
from crypttrace.labels import bulk
|
|
398
477
|
console.print("Updating label database…")
|
|
399
478
|
for name, cnt, err in labels.update():
|
|
400
479
|
if err:
|
|
401
480
|
console.print(f" [red]✗[/red] {name}: {err}")
|
|
402
481
|
else:
|
|
403
482
|
console.print(f" [green]✓[/green] {name}: [bold]{cnt}[/bold] addresses")
|
|
404
|
-
|
|
483
|
+
if okx or okx_file:
|
|
484
|
+
try:
|
|
485
|
+
n, snap = bulk.import_okx(str(okx_file) if okx_file else None,
|
|
486
|
+
progress=lambda m: console.print(f" [dim]OKX: {m}[/dim]"))
|
|
487
|
+
console.print(f" [green]✓[/green] OKX signed addresses (snapshot {snap}): "
|
|
488
|
+
f"[bold]{n}[/bold] addresses")
|
|
489
|
+
except (requests.RequestException, ValueError, OSError, zipfile.BadZipFile) as e:
|
|
490
|
+
console.print(f" [red]✗[/red] OKX signed addresses: {e}")
|
|
491
|
+
console.print(f"[green]Done.[/green] {labels.count()} curated and imported labels loaded"
|
|
492
|
+
+ (f", plus {sum(s['count'] for s in bulk.sets().values())} exchange addresses"
|
|
493
|
+
if bulk.sets() else "") + ".")
|
|
405
494
|
console.print(f"[dim]Cache: {config.DATA_DIR / 'imported_labels.json'}[/dim]")
|
|
406
495
|
|
|
407
496
|
|
|
@@ -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:
|
|
@@ -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 funder as funder_mod, offramp as offramp_mod, poisoning
|
|
18
18
|
from crypttrace.labels import labels
|
|
19
19
|
|
|
20
20
|
|
|
@@ -78,6 +78,11 @@ 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
|
+
|
|
81
86
|
result["guidance"] = build_guidance(result)
|
|
82
87
|
return result
|
|
83
88
|
|
|
@@ -127,6 +132,22 @@ def build_guidance(r: dict) -> dict:
|
|
|
127
132
|
"urgent": False,
|
|
128
133
|
})
|
|
129
134
|
|
|
135
|
+
lured = r.get("poisoning") or []
|
|
136
|
+
if lured:
|
|
137
|
+
x = lured[0]
|
|
138
|
+
like = (f", made to look like {x['imitates']}, an address the sender had really "
|
|
139
|
+
f"used before" if x["imitates"] else "")
|
|
140
|
+
steps.insert(0, {
|
|
141
|
+
"title": "How it happened: address poisoning",
|
|
142
|
+
"body": (f"{x['payer']} sent {x['paid']:.2f} {x['symbol']} to this address{like}. "
|
|
143
|
+
"Before that, this address had planted itself in that wallet's "
|
|
144
|
+
"history, so it was copied from there by mistake. Tell the exchange "
|
|
145
|
+
"and the police it is an address-poisoning case. Never copy an "
|
|
146
|
+
"address from your transaction history; check your own wallet for "
|
|
147
|
+
"other look-alikes with: crypttrace poisoning YOUR_ADDRESS"),
|
|
148
|
+
"urgent": False,
|
|
149
|
+
})
|
|
150
|
+
|
|
130
151
|
# always-applicable steps
|
|
131
152
|
steps.append({
|
|
132
153
|
"title": "Report it to the police",
|
|
@@ -90,6 +90,9 @@ def evidence(address: str) -> Dict:
|
|
|
90
90
|
# Tron/Solana keys keep their case (it carries the checksum), but callers
|
|
91
91
|
# often pass addresses already lower-cased by the label layer.
|
|
92
92
|
meta = raw.get(address) or {k.lower(): v for k, v in raw.items()}.get(address.lower())
|
|
93
|
+
if not meta:
|
|
94
|
+
from crypttrace.labels import bulk # large downloaded sets (OKX, …)
|
|
95
|
+
meta = bulk.lookup(address)
|
|
93
96
|
if not meta:
|
|
94
97
|
return {"address": address, "known": False}
|
|
95
98
|
return {
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Large address sets published by exchanges themselves, kept in SQLite.
|
|
2
|
+
|
|
3
|
+
OKX signs every address that holds customer funds for its proof of reserves —
|
|
4
|
+
over 300,000 of them, most of them per-customer *deposit* addresses. A deposit
|
|
5
|
+
address is the cash-out point: funds that land there are with an exchange that
|
|
6
|
+
knows the depositor. Labelling them outright catches a deposit the moment funds
|
|
7
|
+
arrive, before the exchange sweeps them onward — the window in which a freeze
|
|
8
|
+
request can still work. The behavioural off-ramp heuristic only sees the sweep.
|
|
9
|
+
|
|
10
|
+
That is far too many to ship in known.json or to load on every run, so the set
|
|
11
|
+
is downloaded on request (`crypttrace update-labels --okx`) into a SQLite file
|
|
12
|
+
and looked up one address at a time. Curated labels in known.json always win.
|
|
13
|
+
"""
|
|
14
|
+
import csv
|
|
15
|
+
import io
|
|
16
|
+
import re
|
|
17
|
+
import sqlite3
|
|
18
|
+
import time
|
|
19
|
+
import zipfile
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Callable, Dict, Optional, Tuple
|
|
22
|
+
|
|
23
|
+
import requests
|
|
24
|
+
|
|
25
|
+
from crypttrace import addresses, config
|
|
26
|
+
|
|
27
|
+
DB_PATH: Path = config.DATA_DIR / "bulk_labels.sqlite"
|
|
28
|
+
|
|
29
|
+
OKX_PAGE = "https://www.okx.com/proof-of-reserves/download"
|
|
30
|
+
_OKX_FILE = re.compile(r"https://static\.okx\.com/cdn/okx/por/chain/por_csv_(\d{10})_V(\d+)\.zip")
|
|
31
|
+
|
|
32
|
+
# OKX network name -> crypttrace chain (only the chains crypttrace can trace)
|
|
33
|
+
OKX_NETWORKS = {"BTC": "btc", "TRON": "tron", "SOL": "sol", "ETH": "eth", "BSC": "bsc",
|
|
34
|
+
"POLYGON": "polygon", "ARBITRUM": "arbitrum", "OPTIMISM": "optimism",
|
|
35
|
+
"BASE": "base"}
|
|
36
|
+
|
|
37
|
+
_conn: Optional[sqlite3.Connection] = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _db() -> Optional[sqlite3.Connection]:
|
|
41
|
+
"""Open the store lazily; None when no set has been downloaded."""
|
|
42
|
+
global _conn
|
|
43
|
+
if _conn is None:
|
|
44
|
+
if not DB_PATH.exists():
|
|
45
|
+
return None
|
|
46
|
+
_conn = sqlite3.connect(DB_PATH, check_same_thread=False)
|
|
47
|
+
return _conn
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _create(c: sqlite3.Connection) -> None:
|
|
51
|
+
c.execute("CREATE TABLE IF NOT EXISTS labels (key TEXT PRIMARY KEY, chain TEXT, "
|
|
52
|
+
"set_name TEXT) WITHOUT ROWID")
|
|
53
|
+
c.execute("CREATE TABLE IF NOT EXISTS sets (set_name TEXT PRIMARY KEY, name TEXT, "
|
|
54
|
+
"type TEXT, role TEXT, source TEXT, source_kind TEXT, snapshot TEXT, "
|
|
55
|
+
"url TEXT, count INTEGER, imported_at INTEGER)")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def lookup(address: str) -> Optional[dict]:
|
|
59
|
+
c = _db()
|
|
60
|
+
if c is None:
|
|
61
|
+
return None
|
|
62
|
+
row = c.execute("SELECT s.name, s.type, s.role, s.source, s.source_kind, s.snapshot, l.chain "
|
|
63
|
+
"FROM labels l JOIN sets s USING (set_name) WHERE l.key = ?",
|
|
64
|
+
(address.strip().lower(),)).fetchone()
|
|
65
|
+
if not row:
|
|
66
|
+
return None
|
|
67
|
+
name, typ, role, source, kind, snapshot, chain = row
|
|
68
|
+
return {"name": name, "type": typ, "role": role, "source": source,
|
|
69
|
+
"source_kind": kind, "added": snapshot, "chain": chain}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def sets() -> Dict[str, dict]:
|
|
73
|
+
"""What has been downloaded, for `labels audit`."""
|
|
74
|
+
c = _db()
|
|
75
|
+
if c is None:
|
|
76
|
+
return {}
|
|
77
|
+
cols = ["set_name", "name", "snapshot", "url", "count", "imported_at"]
|
|
78
|
+
return {r[0]: dict(zip(cols, r)) for r in
|
|
79
|
+
c.execute(f"SELECT {', '.join(cols)} FROM sets").fetchall()}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def latest_okx_url(timeout: int = 30) -> str:
|
|
83
|
+
"""The newest address file linked from OKX's proof-of-reserves download page."""
|
|
84
|
+
html = requests.get(OKX_PAGE, timeout=timeout, headers={"User-Agent": "Mozilla/5.0"}).text
|
|
85
|
+
found = {(m.group(1), int(m.group(2))): m.group(0) for m in _OKX_FILE.finditer(html)}
|
|
86
|
+
if not found:
|
|
87
|
+
raise ValueError("no address file found on OKX's proof-of-reserves page")
|
|
88
|
+
return found[max(found)]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _okx_csv_name(zip_path: Path) -> str:
|
|
92
|
+
with zipfile.ZipFile(zip_path) as z:
|
|
93
|
+
return next(n for n in z.namelist() if n.lower().endswith(".csv"))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _okx_rows(zip_path: Path):
|
|
97
|
+
"""Yield (network, address) from OKX's file: a totals table, a blank line,
|
|
98
|
+
then one row per address with its network and ownership signature."""
|
|
99
|
+
with zipfile.ZipFile(zip_path) as z:
|
|
100
|
+
with z.open(_okx_csv_name(zip_path)) as raw:
|
|
101
|
+
f = io.TextIOWrapper(raw, encoding="utf-8-sig", newline="")
|
|
102
|
+
for line in f:
|
|
103
|
+
if line.lower().startswith("coin,type,network"):
|
|
104
|
+
header = next(csv.reader([line]))
|
|
105
|
+
break
|
|
106
|
+
else:
|
|
107
|
+
raise ValueError("unexpected OKX file layout: no address table")
|
|
108
|
+
idx = {h.strip().lower(): i for i, h in enumerate(header)}
|
|
109
|
+
n_i, a_i, s_i = idx["network"], idx["address"], idx["signature1"]
|
|
110
|
+
for row in csv.reader(f):
|
|
111
|
+
if len(row) > max(n_i, a_i, s_i) and row[s_i].strip(): # signed only
|
|
112
|
+
yield row[n_i].strip().upper(), row[a_i].strip()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def import_okx(source: Optional[str] = None,
|
|
116
|
+
progress: Optional[Callable[[str], None]] = None) -> Tuple[int, str]:
|
|
117
|
+
"""Download (or read a local copy of) OKX's signed address file and store it.
|
|
118
|
+
|
|
119
|
+
Returns (addresses stored, snapshot). The previous OKX set is replaced in
|
|
120
|
+
one transaction, so a failed import leaves the old one in place.
|
|
121
|
+
"""
|
|
122
|
+
say = progress or (lambda m: None)
|
|
123
|
+
url = source or latest_okx_url()
|
|
124
|
+
config.DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
|
|
126
|
+
if Path(url).exists():
|
|
127
|
+
zip_path, downloaded = Path(url), False
|
|
128
|
+
# the source text can end up in a report; a local path would leak the user's name
|
|
129
|
+
url = f"local copy {zip_path.name}"
|
|
130
|
+
else:
|
|
131
|
+
zip_path, downloaded = config.DATA_DIR / "okx_por.zip.part", True
|
|
132
|
+
say(f"downloading {url}")
|
|
133
|
+
with requests.get(url, stream=True, timeout=60,
|
|
134
|
+
headers={"User-Agent": "Mozilla/5.0"}) as r:
|
|
135
|
+
r.raise_for_status()
|
|
136
|
+
with open(zip_path, "wb") as fh:
|
|
137
|
+
for chunk in r.iter_content(1 << 20):
|
|
138
|
+
fh.write(chunk)
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
# the snapshot time is in the file name: okx_por_2026090800_V1.csv
|
|
142
|
+
stamp = re.search(r"(\d{10})", _okx_csv_name(zip_path) + " " + url)
|
|
143
|
+
snapshot = (f"{stamp[1][:4]}-{stamp[1][4:6]}-{stamp[1][6:8]}" if stamp
|
|
144
|
+
else "unknown date")
|
|
145
|
+
say("reading addresses")
|
|
146
|
+
rows, validators, bad = {}, 0, 0
|
|
147
|
+
for network, addr in _okx_rows(zip_path):
|
|
148
|
+
chain = OKX_NETWORKS.get(network)
|
|
149
|
+
if not chain:
|
|
150
|
+
continue
|
|
151
|
+
key = addr.lower()
|
|
152
|
+
if key in rows:
|
|
153
|
+
continue
|
|
154
|
+
if chain == "eth" and len(addr) == 98:
|
|
155
|
+
validators += 1 # a staking validator's public key, not an address
|
|
156
|
+
continue
|
|
157
|
+
if not addresses.validate(addr, chain)[0]:
|
|
158
|
+
bad += 1
|
|
159
|
+
continue
|
|
160
|
+
rows[key] = (key, chain, "okx")
|
|
161
|
+
|
|
162
|
+
c = sqlite3.connect(DB_PATH)
|
|
163
|
+
try:
|
|
164
|
+
_create(c)
|
|
165
|
+
with c:
|
|
166
|
+
c.execute("DELETE FROM labels WHERE set_name = 'okx'")
|
|
167
|
+
c.executemany("INSERT OR REPLACE INTO labels VALUES (?, ?, ?)", rows.values())
|
|
168
|
+
c.execute("INSERT OR REPLACE INTO sets VALUES (?,?,?,?,?,?,?,?,?,?)", (
|
|
169
|
+
"okx", "OKX deposit address", "exchange", "deposit",
|
|
170
|
+
f"one of the ~{len(rows) // 1000}k addresses OKX signed ('I am an OKX "
|
|
171
|
+
f"address') in its proof-of-reserves snapshot {snapshot} ({url}); most are "
|
|
172
|
+
"customer deposit addresses. Signature not verified by crypttrace.",
|
|
173
|
+
"self-published", snapshot, url, len(rows), int(time.time())))
|
|
174
|
+
finally:
|
|
175
|
+
c.close()
|
|
176
|
+
finally:
|
|
177
|
+
if downloaded:
|
|
178
|
+
zip_path.unlink(missing_ok=True)
|
|
179
|
+
|
|
180
|
+
global _conn
|
|
181
|
+
if _conn is not None:
|
|
182
|
+
_conn.close()
|
|
183
|
+
_conn = None
|
|
184
|
+
say(f"stored {len(rows)} addresses"
|
|
185
|
+
+ (f", skipped {validators} ETH staking validator keys" if validators else "")
|
|
186
|
+
+ (f", skipped {bad} malformed" if bad else ""))
|
|
187
|
+
return len(rows), snapshot
|
|
@@ -17,6 +17,7 @@ from typing import Optional, Dict, List
|
|
|
17
17
|
import requests
|
|
18
18
|
|
|
19
19
|
from crypttrace import addresses, config
|
|
20
|
+
from crypttrace.labels import bulk
|
|
20
21
|
|
|
21
22
|
_HERE = Path(__file__).parent
|
|
22
23
|
_IMPORTED = config.DATA_DIR / "imported_labels.json"
|
|
@@ -179,7 +180,13 @@ def lookup(address: str) -> Optional[dict]:
|
|
|
179
180
|
hit = _KNOWN.get(address.lower())
|
|
180
181
|
if hit:
|
|
181
182
|
return hit
|
|
182
|
-
return _match_partial(address)
|
|
183
|
+
return _match_partial(address) or bulk.lookup(address)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def is_deposit(address: str) -> bool:
|
|
187
|
+
"""A per-customer deposit address rather than one of the service's own wallets."""
|
|
188
|
+
hit = lookup(address)
|
|
189
|
+
return bool(hit and hit.get("role") == "deposit")
|
|
183
190
|
|
|
184
191
|
|
|
185
192
|
def company(label: str) -> str:
|
|
@@ -32,7 +32,9 @@ def _one(address: str, chain: str, threshold: float, asset: Optional[dict]) -> O
|
|
|
32
32
|
continue
|
|
33
33
|
val = r.get("value", 0) or 0
|
|
34
34
|
out_total += val
|
|
35
|
-
|
|
35
|
+
# sending *into* another customer's deposit address is a cash-out by
|
|
36
|
+
# this wallet, not evidence that this wallet is itself a deposit address
|
|
37
|
+
if labels.type_of(to) == "exchange" and not labels.is_deposit(to):
|
|
36
38
|
to_exchange[to] = to_exchange.get(to, 0.0) + val
|
|
37
39
|
|
|
38
40
|
if out_total <= 0 or not to_exchange:
|
|
@@ -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()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: crypttrace
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.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,10 @@ 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
|
+
**Sent money to an address that looked right?** That is usually address
|
|
167
|
+
poisoning — see below. Check your own wallet with
|
|
168
|
+
`crypttrace poisoning YOUR_ADDRESS`.
|
|
169
|
+
|
|
166
170
|
## Commands
|
|
167
171
|
|
|
168
172
|
```bash
|
|
@@ -191,6 +195,9 @@ crypttrace tokens 0xADDRESS
|
|
|
191
195
|
# Who bootstrapped this wallet's first gas? Follow it toward a KYC point
|
|
192
196
|
crypttrace funder 0xADDRESS --hops 6
|
|
193
197
|
|
|
198
|
+
# Address poisoning: look-alike addresses planted in a wallet's history
|
|
199
|
+
crypttrace poisoning TADDRESS --chain tron
|
|
200
|
+
|
|
194
201
|
# Is this an exchange deposit address (i.e. the cash-out point)?
|
|
195
202
|
crypttrace offramp 0xADDRESS
|
|
196
203
|
|
|
@@ -216,6 +223,9 @@ crypttrace report 0xADDRESS --depth 3
|
|
|
216
223
|
# Refresh label lists (OFAC sanctions, …)
|
|
217
224
|
crypttrace update-labels
|
|
218
225
|
|
|
226
|
+
# …and add OKX's ~300k signed deposit addresses (downloads ~80 MB once)
|
|
227
|
+
crypttrace update-labels --okx
|
|
228
|
+
|
|
219
229
|
# Launch the web UI / list chains
|
|
220
230
|
crypttrace serve
|
|
221
231
|
crypttrace chains
|
|
@@ -265,6 +275,33 @@ crypttrace watch run --once # single check, for scheduled tasks
|
|
|
265
275
|
Optional Telegram alerts: set `CRYPTTRACE_TG_TOKEN` and `CRYPTTRACE_TG_CHAT`,
|
|
266
276
|
then pass `--telegram`.
|
|
267
277
|
|
|
278
|
+
### Address poisoning
|
|
279
|
+
|
|
280
|
+
Wallets show addresses shortened — `TDDD34…rCr9Ps`. A poisoner generates an
|
|
281
|
+
address with the same first and last characters and plants it in the victim's
|
|
282
|
+
history with a zero-value transfer, a speck of dust or a counterfeit token. The
|
|
283
|
+
next time the victim copies "the address I paid last time", they copy the
|
|
284
|
+
attacker's. It is common on Tron and Ethereum, and it needs no hacking at all.
|
|
285
|
+
|
|
286
|
+
`crypttrace poisoning` looks at it from both ends:
|
|
287
|
+
|
|
288
|
+
- **Your wallet:** pairs of addresses in your history that share their visible
|
|
289
|
+
characters, which one arrived later with bait, and — the part that matters —
|
|
290
|
+
whether you then sent real money to it.
|
|
291
|
+
- **The address your money went to:** who paid it real money right after it
|
|
292
|
+
lured them, and which address it was imitating, read from the payer's own
|
|
293
|
+
history. `investigate` and the assessment run this automatically.
|
|
294
|
+
|
|
295
|
+
Tested on a documented Tron case from August 2026: from the victim's wallet it
|
|
296
|
+
finds the 2,527,862 USDT sent to `TDDDHi…rCr9Ps`, a look-alike of
|
|
297
|
+
`TDDD34…rCr9Ps`, an address the wallet had paid 8.4M USDT in all. From the
|
|
298
|
+
attacker's side it finds the same payment and two more victims of $2.4M and
|
|
299
|
+
$2.0M, each lured by a look-alike created three to twelve minutes earlier.
|
|
300
|
+
|
|
301
|
+
Cheap look-alikes match only one or two characters at each end, which happens
|
|
302
|
+
by chance; those are reported only when they arrived as bait after the real
|
|
303
|
+
address was in use.
|
|
304
|
+
|
|
268
305
|
### Off-ramp detection
|
|
269
306
|
|
|
270
307
|
Laundered funds reaching an exchange land on a per-user *deposit address* —
|
|
@@ -380,6 +417,16 @@ flag deposit addresses on those chains. Funds sent to an exchange not on that
|
|
|
380
417
|
list still show as `unknown`. Every label records where it came from — see
|
|
381
418
|
`crypttrace labels audit`.
|
|
382
419
|
|
|
420
|
+
**Deposit addresses, labelled outright.** OKX signs every address that holds
|
|
421
|
+
customer funds — over 300,000, nearly all of them per-customer deposit addresses
|
|
422
|
+
— and publishes the list with its proof of reserves. `crypttrace update-labels
|
|
423
|
+
--okx` downloads the latest one into `~/.crypttrace/` (about 18 MB once
|
|
424
|
+
imported). With it, funds landing on an OKX deposit address are identified the
|
|
425
|
+
moment they arrive, before OKX sweeps them onward — which is when a freeze
|
|
426
|
+
request still has something to freeze. Without it, `offramp` only recognises a
|
|
427
|
+
deposit address after it has forwarded the money. Rows without OKX's signature
|
|
428
|
+
are ignored, and every address is checked before it is stored.
|
|
429
|
+
|
|
383
430
|
### Reports
|
|
384
431
|
|
|
385
432
|
`report` runs the full analysis and writes to `~/crypttrace-reports/` (override
|
|
@@ -483,10 +530,6 @@ cases/ # worked investigations with their data
|
|
|
483
530
|
|
|
484
531
|
- More exchanges on Bitcoin, Tron and Solana (now: Binance, OKX, HTX, Bybit),
|
|
485
532
|
and refreshing these lists as the exchanges republish them
|
|
486
|
-
- OKX's full signed address set (~310k, mostly deposit addresses) as an
|
|
487
|
-
optional `update-labels` download, so deposits are labelled outright
|
|
488
|
-
- Address-poisoning detection: flag look-alike addresses (same first and last
|
|
489
|
-
characters) in a victim's history before they copy the wrong one
|
|
490
533
|
- Stablecoin freeze check: whether USDT/USDC at an address is already frozen
|
|
491
534
|
by the issuer, and who to ask for a freeze
|
|
492
535
|
- `report --html`: one self-contained file with the interactive graph, to
|
|
@@ -13,6 +13,7 @@ src/crypttrace/config.py
|
|
|
13
13
|
src/crypttrace/funder.py
|
|
14
14
|
src/crypttrace/investigate.py
|
|
15
15
|
src/crypttrace/offramp.py
|
|
16
|
+
src/crypttrace/poisoning.py
|
|
16
17
|
src/crypttrace/prices.py
|
|
17
18
|
src/crypttrace/render.py
|
|
18
19
|
src/crypttrace/report.py
|
|
@@ -35,6 +36,7 @@ src/crypttrace/fetchers/solana.py
|
|
|
35
36
|
src/crypttrace/fetchers/tron.py
|
|
36
37
|
src/crypttrace/labels/__init__.py
|
|
37
38
|
src/crypttrace/labels/audit.py
|
|
39
|
+
src/crypttrace/labels/bulk.py
|
|
38
40
|
src/crypttrace/labels/known.json
|
|
39
41
|
src/crypttrace/labels/labels.py
|
|
40
42
|
src/crypttrace/labels/partial.json
|
|
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
|