crypttrace 0.4.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.
Files changed (45) hide show
  1. {crypttrace-0.4.0/src/crypttrace.egg-info → crypttrace-0.5.0}/PKG-INFO +35 -3
  2. {crypttrace-0.4.0 → crypttrace-0.5.0}/README.md +34 -2
  3. {crypttrace-0.4.0 → crypttrace-0.5.0}/src/crypttrace/__init__.py +1 -1
  4. {crypttrace-0.4.0 → crypttrace-0.5.0}/src/crypttrace/assess.py +48 -1
  5. {crypttrace-0.4.0 → crypttrace-0.5.0}/src/crypttrace/chains.py +10 -2
  6. {crypttrace-0.4.0 → crypttrace-0.5.0}/src/crypttrace/cli.py +58 -0
  7. {crypttrace-0.4.0 → crypttrace-0.5.0}/src/crypttrace/fetchers/solana.py +2 -1
  8. {crypttrace-0.4.0 → crypttrace-0.5.0}/src/crypttrace/fetchers/tron.py +4 -0
  9. {crypttrace-0.4.0 → crypttrace-0.5.0}/src/crypttrace/investigate.py +22 -1
  10. crypttrace-0.5.0/src/crypttrace/poisoning.py +262 -0
  11. {crypttrace-0.4.0 → crypttrace-0.5.0}/src/crypttrace/store.py +14 -8
  12. {crypttrace-0.4.0 → crypttrace-0.5.0/src/crypttrace.egg-info}/PKG-INFO +35 -3
  13. {crypttrace-0.4.0 → crypttrace-0.5.0}/src/crypttrace.egg-info/SOURCES.txt +1 -0
  14. {crypttrace-0.4.0 → crypttrace-0.5.0}/LICENSE +0 -0
  15. {crypttrace-0.4.0 → crypttrace-0.5.0}/pyproject.toml +0 -0
  16. {crypttrace-0.4.0 → crypttrace-0.5.0}/setup.cfg +0 -0
  17. {crypttrace-0.4.0 → crypttrace-0.5.0}/src/crypttrace/addresses.py +0 -0
  18. {crypttrace-0.4.0 → crypttrace-0.5.0}/src/crypttrace/analysis.py +0 -0
  19. {crypttrace-0.4.0 → crypttrace-0.5.0}/src/crypttrace/assets.py +0 -0
  20. {crypttrace-0.4.0 → crypttrace-0.5.0}/src/crypttrace/bridges.py +0 -0
  21. {crypttrace-0.4.0 → crypttrace-0.5.0}/src/crypttrace/config.py +0 -0
  22. {crypttrace-0.4.0 → crypttrace-0.5.0}/src/crypttrace/fetchers/__init__.py +0 -0
  23. {crypttrace-0.4.0 → crypttrace-0.5.0}/src/crypttrace/fetchers/bitcoin.py +0 -0
  24. {crypttrace-0.4.0 → crypttrace-0.5.0}/src/crypttrace/fetchers/etherscan.py +0 -0
  25. {crypttrace-0.4.0 → crypttrace-0.5.0}/src/crypttrace/fetchers/http.py +0 -0
  26. {crypttrace-0.4.0 → crypttrace-0.5.0}/src/crypttrace/funder.py +0 -0
  27. {crypttrace-0.4.0 → crypttrace-0.5.0}/src/crypttrace/labels/__init__.py +0 -0
  28. {crypttrace-0.4.0 → crypttrace-0.5.0}/src/crypttrace/labels/audit.py +0 -0
  29. {crypttrace-0.4.0 → crypttrace-0.5.0}/src/crypttrace/labels/bulk.py +0 -0
  30. {crypttrace-0.4.0 → crypttrace-0.5.0}/src/crypttrace/labels/known.json +0 -0
  31. {crypttrace-0.4.0 → crypttrace-0.5.0}/src/crypttrace/labels/labels.py +0 -0
  32. {crypttrace-0.4.0 → crypttrace-0.5.0}/src/crypttrace/labels/partial.json +0 -0
  33. {crypttrace-0.4.0 → crypttrace-0.5.0}/src/crypttrace/offramp.py +0 -0
  34. {crypttrace-0.4.0 → crypttrace-0.5.0}/src/crypttrace/prices.py +0 -0
  35. {crypttrace-0.4.0 → crypttrace-0.5.0}/src/crypttrace/render.py +0 -0
  36. {crypttrace-0.4.0 → crypttrace-0.5.0}/src/crypttrace/report.py +0 -0
  37. {crypttrace-0.4.0 → crypttrace-0.5.0}/src/crypttrace/trace.py +0 -0
  38. {crypttrace-0.4.0 → crypttrace-0.5.0}/src/crypttrace/verify.py +0 -0
  39. {crypttrace-0.4.0 → crypttrace-0.5.0}/src/crypttrace/watch.py +0 -0
  40. {crypttrace-0.4.0 → crypttrace-0.5.0}/src/crypttrace/web/index.html +0 -0
  41. {crypttrace-0.4.0 → crypttrace-0.5.0}/src/crypttrace/webapp.py +0 -0
  42. {crypttrace-0.4.0 → crypttrace-0.5.0}/src/crypttrace.egg-info/dependency_links.txt +0 -0
  43. {crypttrace-0.4.0 → crypttrace-0.5.0}/src/crypttrace.egg-info/entry_points.txt +0 -0
  44. {crypttrace-0.4.0 → crypttrace-0.5.0}/src/crypttrace.egg-info/requires.txt +0 -0
  45. {crypttrace-0.4.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.4.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
 
@@ -268,6 +275,33 @@ crypttrace watch run --once # single check, for scheduled tasks
268
275
  Optional Telegram alerts: set `CRYPTTRACE_TG_TOKEN` and `CRYPTTRACE_TG_CHAT`,
269
276
  then pass `--telegram`.
270
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
+
271
305
  ### Off-ramp detection
272
306
 
273
307
  Laundered funds reaching an exchange land on a per-user *deposit address* —
@@ -496,8 +530,6 @@ cases/ # worked investigations with their data
496
530
 
497
531
  - More exchanges on Bitcoin, Tron and Solana (now: Binance, OKX, HTX, Bybit),
498
532
  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
533
  - Stablecoin freeze check: whether USDT/USDC at an address is already frozen
502
534
  by the issuer, and who to ask for a freeze
503
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
 
@@ -234,6 +241,33 @@ crypttrace watch run --once # single check, for scheduled tasks
234
241
  Optional Telegram alerts: set `CRYPTTRACE_TG_TOKEN` and `CRYPTTRACE_TG_CHAT`,
235
242
  then pass `--telegram`.
236
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
+
237
271
  ### Off-ramp detection
238
272
 
239
273
  Laundered funds reaching an exchange land on a per-user *deposit address* —
@@ -462,8 +496,6 @@ cases/ # worked investigations with their data
462
496
 
463
497
  - More exchanges on Bitcoin, Tron and Solana (now: Binance, OKX, HTX, Bybit),
464
498
  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
499
  - Stablecoin freeze check: whether USDT/USDC at an address is already frozen
468
500
  by the issuer, and who to ask for a freeze
469
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.4.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
- asset_key = (contract or "").lower()
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=contract or "")
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,64 @@ def crosschain(
262
262
  "not proof. Verify each candidate before relying on it.[/dim]")
263
263
 
264
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
+
265
323
  @app.command()
266
324
  def offramp(
267
325
  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:
@@ -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",
@@ -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
- q = ("SELECT from_addr,to_addr,value,symbol,contract,ts,tx_hash,fee_share"
120
- " FROM transfers WHERE chain=? AND (from_addr=? OR to_addr=?)"
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
- q = ("SELECT from_addr,to_addr,value,symbol,contract,ts,tx_hash,fee_share"
126
- " FROM transfers WHERE chain=? AND (from_addr=? OR to_addr=?)"
127
- " AND (contract IS NULL OR contract='') ORDER BY ts DESC")
128
- args = (chain, address, address)
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.4.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
 
@@ -268,6 +275,33 @@ crypttrace watch run --once # single check, for scheduled tasks
268
275
  Optional Telegram alerts: set `CRYPTTRACE_TG_TOKEN` and `CRYPTTRACE_TG_CHAT`,
269
276
  then pass `--telegram`.
270
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
+
271
305
  ### Off-ramp detection
272
306
 
273
307
  Laundered funds reaching an exchange land on a per-user *deposit address* —
@@ -496,8 +530,6 @@ cases/ # worked investigations with their data
496
530
 
497
531
  - More exchanges on Bitcoin, Tron and Solana (now: Binance, OKX, HTX, Bybit),
498
532
  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
533
  - Stablecoin freeze check: whether USDT/USDC at an address is already frozen
502
534
  by the issuer, and who to ask for a freeze
503
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
File without changes
File without changes
File without changes