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.
- {crypttrace-0.9.0/src/crypttrace.egg-info → crypttrace-0.11.0}/PKG-INFO +10 -6
- {crypttrace-0.9.0 → crypttrace-0.11.0}/README.md +9 -5
- {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/__init__.py +1 -1
- {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/chains.py +3 -6
- {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/fetchers/solana.py +40 -7
- {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/fetchers/tron.py +24 -4
- {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/poisoning.py +31 -6
- {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/store.py +3 -1
- {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/watch.py +0 -3
- {crypttrace-0.9.0 → crypttrace-0.11.0/src/crypttrace.egg-info}/PKG-INFO +10 -6
- {crypttrace-0.9.0 → crypttrace-0.11.0}/LICENSE +0 -0
- {crypttrace-0.9.0 → crypttrace-0.11.0}/pyproject.toml +0 -0
- {crypttrace-0.9.0 → crypttrace-0.11.0}/setup.cfg +0 -0
- {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/addresses.py +0 -0
- {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/analysis.py +0 -0
- {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/assess.py +0 -0
- {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/assets.py +0 -0
- {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/bridges.py +0 -0
- {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/cli.py +0 -0
- {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/config.py +0 -0
- {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/fetchers/__init__.py +0 -0
- {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/fetchers/bitcoin.py +0 -0
- {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/fetchers/etherscan.py +0 -0
- {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/fetchers/http.py +0 -0
- {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/freeze.py +0 -0
- {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/funder.py +0 -0
- {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/investigate.py +0 -0
- {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/labels/__init__.py +0 -0
- {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/labels/audit.py +0 -0
- {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/labels/bulk.py +0 -0
- {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/labels/known.json +0 -0
- {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/labels/labels.py +0 -0
- {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/labels/partial.json +0 -0
- {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/offramp.py +0 -0
- {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/prices.py +0 -0
- {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/render.py +0 -0
- {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/report.py +0 -0
- {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/trace.py +0 -0
- {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/verify.py +0 -0
- {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/web/index.html +0 -0
- {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace/webapp.py +0 -0
- {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace.egg-info/SOURCES.txt +0 -0
- {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace.egg-info/dependency_links.txt +0 -0
- {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace.egg-info/entry_points.txt +0 -0
- {crypttrace-0.9.0 → crypttrace-0.11.0}/src/crypttrace.egg-info/requires.txt +0 -0
- {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.
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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.
|
|
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("
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
rows.append({
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
107
|
+
# the endpoint lists every transaction type; TRX transfers are a subset of it
|
|
87
108
|
rows = []
|
|
88
|
-
for tx in
|
|
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
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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!=''
|
|
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.
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|