crypttrace 0.9.0__tar.gz → 0.11.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. {crypttrace-0.9.0/src/crypttrace.egg-info → crypttrace-0.11.0}/PKG-INFO +10 -6
  2. {crypttrace-0.9.0 → crypttrace-0.11.0}/README.md +9 -5
  3. {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/__init__.py +1 -1
  4. {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/chains.py +3 -6
  5. {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/fetchers/solana.py +40 -7
  6. {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/fetchers/tron.py +24 -4
  7. {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/poisoning.py +31 -6
  8. {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/store.py +3 -1
  9. {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/watch.py +0 -3
  10. {crypttrace-0.9.0 → crypttrace-0.11.0/src/crypttrace.egg-info}/PKG-INFO +10 -6
  11. {crypttrace-0.9.0 → crypttrace-0.11.0}/LICENSE +0 -0
  12. {crypttrace-0.9.0 → crypttrace-0.11.0}/pyproject.toml +0 -0
  13. {crypttrace-0.9.0 → crypttrace-0.11.0}/setup.cfg +0 -0
  14. {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/addresses.py +0 -0
  15. {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/analysis.py +0 -0
  16. {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/assess.py +0 -0
  17. {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/assets.py +0 -0
  18. {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/bridges.py +0 -0
  19. {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/cli.py +0 -0
  20. {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/config.py +0 -0
  21. {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/fetchers/__init__.py +0 -0
  22. {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/fetchers/bitcoin.py +0 -0
  23. {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/fetchers/etherscan.py +0 -0
  24. {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/fetchers/http.py +0 -0
  25. {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/freeze.py +0 -0
  26. {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/funder.py +0 -0
  27. {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/investigate.py +0 -0
  28. {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/labels/__init__.py +0 -0
  29. {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/labels/audit.py +0 -0
  30. {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/labels/bulk.py +0 -0
  31. {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/labels/known.json +0 -0
  32. {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/labels/labels.py +0 -0
  33. {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/labels/partial.json +0 -0
  34. {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/offramp.py +0 -0
  35. {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/prices.py +0 -0
  36. {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/render.py +0 -0
  37. {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/report.py +0 -0
  38. {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/trace.py +0 -0
  39. {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/verify.py +0 -0
  40. {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/web/index.html +0 -0
  41. {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/webapp.py +0 -0
  42. {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace.egg-info/SOURCES.txt +0 -0
  43. {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace.egg-info/dependency_links.txt +0 -0
  44. {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace.egg-info/entry_points.txt +0 -0
  45. {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace.egg-info/requires.txt +0 -0
  46. {crypttrace-0.9.0 → crypttrace-0.11.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.9.0
3
+ Version: 0.11.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
@@ -338,11 +338,16 @@ Tested on a documented Tron case from August 2026: from the victim's wallet it
338
338
  finds the 2,527,862 USDT sent to `TDDDHi…rCr9Ps`, a look-alike of
339
339
  `TDDD34…rCr9Ps`, an address the wallet had paid 8.4M USDT in all. From the
340
340
  attacker's side it finds the same payment and two more victims of $2.4M and
341
- $2.0M, each lured by a look-alike created three to twelve minutes earlier.
341
+ $2.0M, each lured by a look-alike created three to twelve minutes earlier, and
342
+ for all three it names the address that was imitated.
342
343
 
343
- Cheap look-alikes match only one or two characters at each end, which happens
344
- by chance; those are reported only when they arrived as bait after the real
345
- address was in use.
344
+ Look-alikes come in three strengths. Matching several characters at both ends is
345
+ reported on sight. So is one that copies only the ending, as long as the shared
346
+ ending is long enough to be no accident (6 characters on Tron and Solana, 7 on
347
+ bech32 Bitcoin, 8 on hex) — one of the three victims above was caught that way,
348
+ by `…E1doqb`. Cheap look-alikes that match one or two characters at each end
349
+ happen by chance, so they are reported only when they arrived as bait after the
350
+ real address was in use.
346
351
 
347
352
  ### Off-ramp detection
348
353
 
@@ -587,7 +592,6 @@ cases/ # worked investigations with their data
587
592
  - More exchanges on Bitcoin, Tron and Solana (now: Binance, OKX, HTX, Bybit),
588
593
  and refreshing these lists as the exchanges republish them
589
594
  - Internal transactions (completes `funder` and contract-mediated transfers)
590
- - Per-mint filtering for Solana SPL tokens
591
595
  - More label sources: Chainabuse, CryptoScamDB, exchange deposit-address sets
592
596
  - Spam/dust token filtering in `tokens`
593
597
  - Entity clustering on EVM via the gas-funding heuristic
@@ -304,11 +304,16 @@ Tested on a documented Tron case from August 2026: from the victim's wallet it
304
304
  finds the 2,527,862 USDT sent to `TDDDHi…rCr9Ps`, a look-alike of
305
305
  `TDDD34…rCr9Ps`, an address the wallet had paid 8.4M USDT in all. From the
306
306
  attacker's side it finds the same payment and two more victims of $2.4M and
307
- $2.0M, each lured by a look-alike created three to twelve minutes earlier.
307
+ $2.0M, each lured by a look-alike created three to twelve minutes earlier, and
308
+ for all three it names the address that was imitated.
308
309
 
309
- Cheap look-alikes match only one or two characters at each end, which happens
310
- by chance; those are reported only when they arrived as bait after the real
311
- address was in use.
310
+ Look-alikes come in three strengths. Matching several characters at both ends is
311
+ reported on sight. So is one that copies only the ending, as long as the shared
312
+ ending is long enough to be no accident (6 characters on Tron and Solana, 7 on
313
+ bech32 Bitcoin, 8 on hex) — one of the three victims above was caught that way,
314
+ by `…E1doqb`. Cheap look-alikes that match one or two characters at each end
315
+ happen by chance, so they are reported only when they arrived as bait after the
316
+ real address was in use.
312
317
 
313
318
  ### Off-ramp detection
314
319
 
@@ -553,7 +558,6 @@ cases/ # worked investigations with their data
553
558
  - More exchanges on Bitcoin, Tron and Solana (now: Binance, OKX, HTX, Bybit),
554
559
  and refreshing these lists as the exchanges republish them
555
560
  - Internal transactions (completes `funder` and contract-mediated transfers)
556
- - Per-mint filtering for Solana SPL tokens
557
561
  - More label sources: Chainabuse, CryptoScamDB, exchange deposit-address sets
558
562
  - Spam/dust token filtering in `tokens`
559
563
  - Entity clustering on EVM via the gas-funding heuristic
@@ -1,2 +1,2 @@
1
1
  """crypttrace — OSINT crypto investigation CLI."""
2
- __version__ = "0.9.0"
2
+ __version__ = "0.11.0"
@@ -138,9 +138,8 @@ def _sol_rows(address: str, limit: int, token: bool, contract: Optional[str]) ->
138
138
  rows = solana.transfers(address, limit)
139
139
  if token:
140
140
  rows = [r for r in rows if r.get("symbol") != "SOL"]
141
- if contract:
142
- rows = [r for r in rows if r.get("mint") in (None, contract)
143
- or (r.get("contract") or "") == contract]
141
+ if contract: # SPL rows carry their mint as the contract
142
+ rows = [r for r in rows if (r.get("contract") or "") == contract]
144
143
  else:
145
144
  rows = [r for r in rows if r.get("symbol") == "SOL"]
146
145
  return rows
@@ -164,11 +163,9 @@ def transfers(address: str, chain: str = "eth", limit: int = 1000,
164
163
 
165
164
  from crypttrace import store
166
165
  # What the stored rows are filed under: "" native, a contract, "*" every
167
- # token, or "spl" on Solana, whose token rows carry no mint to tell apart.
166
+ # token (Solana token rows carry their mint as the contract).
168
167
  if not asset:
169
168
  asset_key = ""
170
- elif chain == "sol":
171
- asset_key = "spl"
172
169
  else:
173
170
  asset_key = (contract or "*").lower()
174
171
  skip_read = fresh or FORCE_FRESH
@@ -10,10 +10,15 @@ from typing import List, Dict
10
10
 
11
11
  import requests
12
12
 
13
+ from crypttrace import assets
14
+
13
15
  RPC = os.environ.get("CRYPTTRACE_SOLANA_RPC", "https://api.mainnet-beta.solana.com")
14
16
  LAMPORTS = 1_000_000_000
15
17
  MAX_TXS = 25 # keep the number of RPC round-trips sane
16
18
 
19
+ # mint -> symbol for the tokens crypttrace knows; others show as "SPL"
20
+ _KNOWN_MINTS = {t["contract"]: t["symbol"] for t in assets.tokens_for("sol").values()}
21
+
17
22
 
18
23
  class SolanaError(RuntimeError):
19
24
  pass
@@ -59,10 +64,26 @@ def _parse_tx(sig: str) -> List[Dict]:
59
64
  return []
60
65
  ts = int(tx.get("blockTime") or 0)
61
66
  rows = []
67
+ meta = tx.get("meta") or {}
62
68
  msg = (tx.get("transaction") or {}).get("message") or {}
63
69
  instrs = list(msg.get("instructions") or [])
64
- for inner in (tx.get("meta") or {}).get("innerInstructions") or []:
70
+ for inner in meta.get("innerInstructions") or []:
65
71
  instrs.extend(inner.get("instructions") or [])
72
+
73
+ # An SPL transfer names token *accounts*, not wallets. The token balances in
74
+ # the transaction's metadata say which wallet owns each account, which mint
75
+ # it holds and how many decimals that mint has.
76
+ keys = [k.get("pubkey") if isinstance(k, dict) else k for k in msg.get("accountKeys") or []]
77
+ loaded = meta.get("loadedAddresses") or {}
78
+ if not any(isinstance(k, dict) and k.get("source") == "lookupTable"
79
+ for k in msg.get("accountKeys") or []):
80
+ keys += list(loaded.get("writable") or []) + list(loaded.get("readonly") or [])
81
+ accounts = {}
82
+ for b in (meta.get("preTokenBalances") or []) + (meta.get("postTokenBalances") or []):
83
+ i = b.get("accountIndex")
84
+ if isinstance(i, int) and i < len(keys):
85
+ accounts[keys[i]] = {"owner": b.get("owner") or "", "mint": b.get("mint") or "",
86
+ "decimals": (b.get("uiTokenAmount") or {}).get("decimals")}
66
87
  for ins in instrs:
67
88
  parsed = ins.get("parsed")
68
89
  if not isinstance(parsed, dict):
@@ -75,15 +96,27 @@ def _parse_tx(sig: str) -> List[Dict]:
75
96
  "value": (info.get("lamports") or 0) / LAMPORTS,
76
97
  "timestamp": ts, "hash": sig, "symbol": "SOL"})
77
98
  elif prog == "spl-token" and ptype in ("transfer", "transferChecked"):
99
+ src = accounts.get(info.get("source", ""), {})
100
+ dst = accounts.get(info.get("destination", ""), {})
101
+ mint = info.get("mint") or src.get("mint") or dst.get("mint") or ""
102
+ decimals = src.get("decimals") if src.get("decimals") is not None else dst.get("decimals")
78
103
  amt = info.get("tokenAmount") or {}
79
104
  try:
80
- val = float(amt.get("uiAmountString") or amt.get("uiAmount") or info.get("amount") or 0)
105
+ if amt.get("uiAmountString") or amt.get("uiAmount") is not None:
106
+ val = float(amt.get("uiAmountString") or amt.get("uiAmount") or 0)
107
+ elif decimals is not None: # plain "transfer" gives raw base units
108
+ val = int(info.get("amount") or 0) / (10 ** int(decimals))
109
+ else:
110
+ continue # no way to know the unit: don't guess
81
111
  except (TypeError, ValueError):
82
- val = 0.0
83
- rows.append({"from": info.get("source", "") or info.get("authority", ""),
84
- "to": info.get("destination", ""), "value": val,
85
- "timestamp": ts, "hash": sig, "symbol": "SPL",
86
- "contract": "spl"}) # keeps token rows apart from SOL in the store
112
+ continue
113
+ rows.append({
114
+ # the wallets behind the token accounts; the account itself if unknown
115
+ "from": src.get("owner") or info.get("authority") or info.get("source", ""),
116
+ "to": dst.get("owner") or info.get("destination", ""),
117
+ "value": val, "timestamp": ts, "hash": sig,
118
+ "symbol": _KNOWN_MINTS.get(mint, "SPL"),
119
+ "contract": mint or "spl"}) # the mint; keeps token rows apart from SOL
87
120
  return rows
88
121
 
89
122
 
@@ -82,10 +82,31 @@ def balance(address: str) -> float:
82
82
  return (data[0].get("balance") or 0) / SUN
83
83
 
84
84
 
85
+ PAGE = 200 # TronGrid's page size
86
+
87
+
88
+ def _pages(path: str, limit: int) -> List[Dict]:
89
+ """Up to `limit` items, following TronGrid's fingerprint cursor page by page.
90
+
91
+ One page (200 items) used to be all we read, so a busy wallet's history
92
+ ended a few days back and older transfers were simply invisible."""
93
+ items: List[Dict] = []
94
+ params = {"limit": min(limit, PAGE)}
95
+ while len(items) < limit:
96
+ d = _get(path, dict(params))
97
+ page = d.get("data", []) or []
98
+ items += page
99
+ cursor = (d.get("meta") or {}).get("fingerprint")
100
+ if len(page) < params["limit"] or not cursor:
101
+ break
102
+ params["fingerprint"] = cursor
103
+ return items[:limit]
104
+
105
+
85
106
  def _native(address: str, limit: int) -> List[Dict]:
86
- d = _get(f"/v1/accounts/{address}/transactions", {"limit": min(limit, 200)})
107
+ # the endpoint lists every transaction type; TRX transfers are a subset of it
87
108
  rows = []
88
- for tx in d.get("data", []) or []:
109
+ for tx in _pages(f"/v1/accounts/{address}/transactions", limit):
89
110
  try:
90
111
  c = (tx.get("raw_data", {}).get("contract") or [])[0]
91
112
  if c.get("type") != "TransferContract":
@@ -106,9 +127,8 @@ def _native(address: str, limit: int) -> List[Dict]:
106
127
 
107
128
  def token_transfers(address: str, limit: int = 200) -> List[Dict]:
108
129
  """TRC20 transfers (USDT and friends) — already base58 in the API."""
109
- d = _get(f"/v1/accounts/{address}/transactions/trc20", {"limit": min(limit, 200)})
110
130
  rows = []
111
- for t in d.get("data", []) or []:
131
+ for t in _pages(f"/v1/accounts/{address}/transactions/trc20", limit):
112
132
  # the endpoint also lists Approval events, whose "value" is an allowance
113
133
  # (often 2**256-1), not money that moved
114
134
  if t.get("type", "Transfer") != "Transfer":
@@ -19,6 +19,7 @@ Three views of the same attack:
19
19
  * campaign(address) — an address sending zero/dust transfers to many
20
20
  unrelated wallets (the dust-sending style, common on Tron).
21
21
  """
22
+ import math
22
23
  from collections import defaultdict
23
24
  from typing import Dict, List, Optional, Tuple
24
25
 
@@ -30,6 +31,20 @@ from crypttrace import analysis, assets, chains
30
31
  # the look-alike arrived with bait after the genuine address was in use.
31
32
  STRONG = (3, 3, 7) # min prefix, min suffix, min total
32
33
  WEAK = (1, 2, 3)
34
+ # Many people check only the ending, and poisoners know it: a look-alike can match
35
+ # the last characters alone. That is strong evidence once the shared ending carries
36
+ # 32 bits — 6 base58 characters, 7 bech32, 8 hex — about 1 in 4 billion by chance.
37
+ SUFFIX_BITS = 32
38
+
39
+
40
+ def _suffix_chars(address: str, chain: str) -> int:
41
+ if chains.is_evm(chain):
42
+ bits = 4.0 # hex
43
+ elif address.lower().startswith(("bc1", "tb1")):
44
+ bits = 5.0 # bech32
45
+ else:
46
+ bits = 5.86 # base58: Tron, Solana, legacy Bitcoin
47
+ return math.ceil(SUFFIX_BITS / bits)
33
48
  # A campaign: this many distinct recipients of zero/dust transfers.
34
49
  CAMPAIGN_MIN_TARGETS = 10
35
50
 
@@ -62,9 +77,12 @@ def resemblance(a: str, b: str, chain: str) -> Optional[str]:
62
77
  if chains.norm_addr(a, chain) == chains.norm_addr(b, chain):
63
78
  return None
64
79
  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
80
+ p, s, t = STRONG
81
+ if (pre >= p and suf >= s and pre + suf >= t) or suf >= _suffix_chars(a, chain):
82
+ return "strong"
83
+ p, s, t = WEAK
84
+ if pre >= p and suf >= s and pre + suf >= t:
85
+ return "weak"
68
86
  return None
69
87
 
70
88
 
@@ -128,13 +146,17 @@ def lookalikes(address: str, chain: str, limit: int = 1000) -> List[dict]:
128
146
  c["sent_first_ts"] = ts if c["sent_first_ts"] is None else min(c["sent_first_ts"], ts)
129
147
 
130
148
  # only compare addresses that could match: bucket by visible ends
149
+ # (and by the ending alone, for look-alikes that only imitate the last characters)
131
150
  buckets = defaultdict(list)
132
151
  for a in seen:
133
152
  b = _body(a, chain)
134
153
  if len(b) >= WEAK[0] + WEAK[1]:
135
- buckets[(b[:WEAK[0]], b[-WEAK[1]:])].append(a)
154
+ buckets[("ends", b[:WEAK[0]], b[-WEAK[1]:])].append(a)
155
+ n = _suffix_chars(a, chain)
156
+ if len(b) > n:
157
+ buckets[("tail", b[-n:])].append(a)
136
158
 
137
- findings = []
159
+ findings, compared = [], set()
138
160
  for group in buckets.values():
139
161
  if len(group) < 2:
140
162
  continue
@@ -144,7 +166,10 @@ def lookalikes(address: str, chain: str, limit: int = 1000) -> List[dict]:
144
166
  return (c["sent_first_ts"] is None, c["sent_first_ts"] or c["first_ts"], c["first_ts"])
145
167
  genuine = min(group, key=rank)
146
168
  for fake in group:
147
- level = resemblance(genuine, fake, chain) if fake != genuine else None
169
+ if fake == genuine or (genuine, fake) in compared:
170
+ continue # a pair can sit in both kinds of bucket
171
+ compared.add((genuine, fake))
172
+ level = resemblance(genuine, fake, chain)
148
173
  if not level:
149
174
  continue
150
175
  g, f = seen[genuine], seen[fake]
@@ -118,8 +118,10 @@ def load(chain: str, address: str, contract: str = "", native_symbol: str = "")
118
118
  try:
119
119
  if contract == "*":
120
120
  # every token, whatever its contract (address-poisoning checks need fakes too)
121
+ # (Solana rows filed as "spl" by 0.5-0.10 name token accounts, not wallets)
121
122
  q = (f"{cols} WHERE chain=? AND (from_addr=? OR to_addr=?)"
122
- " AND contract IS NOT NULL AND contract!='' ORDER BY ts DESC")
123
+ " AND contract IS NOT NULL AND contract!='' AND contract!='spl'"
124
+ " ORDER BY ts DESC")
123
125
  args = (chain, address, address)
124
126
  elif contract:
125
127
  q = (f"{cols} WHERE chain=? AND (from_addr=? OR to_addr=?)"
@@ -43,7 +43,6 @@ def _save(d: Dict[str, dict]) -> None:
43
43
 
44
44
 
45
45
  _ALL_TOKENS = {"contract": None, "symbol": "*"}
46
- _SPL = {"contract": "spl", "symbol": "SPL"}
47
46
 
48
47
 
49
48
  def _key(row: dict) -> str:
@@ -62,8 +61,6 @@ def _rows(address: str, chain: str, limit: int) -> List[dict]:
62
61
  tokens = chains.transfers(address, chain, limit, asset=_ALL_TOKENS, fresh=True)
63
62
  except chains.ChainError:
64
63
  return rows # the native history alone still gets watched
65
- if chain == "sol": # Solana token rows carry no mint to tell tokens apart
66
- return rows + [{**r, "_asset": _SPL} for r in tokens]
67
64
  stables = {t["contract"].lower(): dict(t) for t in assets.tokens_for(chain).values()
68
65
  if t.get("stable")}
69
66
  return rows + [{**r, "_asset": stables[(r.get("contract") or "").lower()]}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crypttrace
3
- Version: 0.9.0
3
+ Version: 0.11.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
@@ -338,11 +338,16 @@ Tested on a documented Tron case from August 2026: from the victim's wallet it
338
338
  finds the 2,527,862 USDT sent to `TDDDHi…rCr9Ps`, a look-alike of
339
339
  `TDDD34…rCr9Ps`, an address the wallet had paid 8.4M USDT in all. From the
340
340
  attacker's side it finds the same payment and two more victims of $2.4M and
341
- $2.0M, each lured by a look-alike created three to twelve minutes earlier.
341
+ $2.0M, each lured by a look-alike created three to twelve minutes earlier, and
342
+ for all three it names the address that was imitated.
342
343
 
343
- Cheap look-alikes match only one or two characters at each end, which happens
344
- by chance; those are reported only when they arrived as bait after the real
345
- address was in use.
344
+ Look-alikes come in three strengths. Matching several characters at both ends is
345
+ reported on sight. So is one that copies only the ending, as long as the shared
346
+ ending is long enough to be no accident (6 characters on Tron and Solana, 7 on
347
+ bech32 Bitcoin, 8 on hex) — one of the three victims above was caught that way,
348
+ by `…E1doqb`. Cheap look-alikes that match one or two characters at each end
349
+ happen by chance, so they are reported only when they arrived as bait after the
350
+ real address was in use.
346
351
 
347
352
  ### Off-ramp detection
348
353
 
@@ -587,7 +592,6 @@ cases/ # worked investigations with their data
587
592
  - More exchanges on Bitcoin, Tron and Solana (now: Binance, OKX, HTX, Bybit),
588
593
  and refreshing these lists as the exchanges republish them
589
594
  - Internal transactions (completes `funder` and contract-mediated transfers)
590
- - Per-mint filtering for Solana SPL tokens
591
595
  - More label sources: Chainabuse, CryptoScamDB, exchange deposit-address sets
592
596
  - Spam/dust token filtering in `tokens`
593
597
  - Entity clustering on EVM via the gas-funding heuristic
File without changes
File without changes
File without changes