crypttrace 0.2.0__py3-none-any.whl
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/__init__.py +2 -0
- crypttrace/addresses.py +132 -0
- crypttrace/analysis.py +222 -0
- crypttrace/assess.py +273 -0
- crypttrace/assets.py +68 -0
- crypttrace/bridges.py +90 -0
- crypttrace/chains.py +254 -0
- crypttrace/cli.py +795 -0
- crypttrace/config.py +27 -0
- crypttrace/fetchers/__init__.py +0 -0
- crypttrace/fetchers/bitcoin.py +186 -0
- crypttrace/fetchers/etherscan.py +114 -0
- crypttrace/fetchers/http.py +139 -0
- crypttrace/fetchers/solana.py +97 -0
- crypttrace/fetchers/tron.py +128 -0
- crypttrace/funder.py +76 -0
- crypttrace/investigate.py +204 -0
- crypttrace/labels/__init__.py +0 -0
- crypttrace/labels/audit.py +102 -0
- crypttrace/labels/known.json +148 -0
- crypttrace/labels/labels.py +199 -0
- crypttrace/labels/partial.json +15 -0
- crypttrace/offramp.py +58 -0
- crypttrace/prices.py +90 -0
- crypttrace/render.py +241 -0
- crypttrace/report.py +175 -0
- crypttrace/store.py +215 -0
- crypttrace/trace.py +165 -0
- crypttrace/verify.py +153 -0
- crypttrace/watch.py +138 -0
- crypttrace/web/index.html +660 -0
- crypttrace/webapp.py +292 -0
- crypttrace-0.2.0.dist-info/METADATA +503 -0
- crypttrace-0.2.0.dist-info/RECORD +38 -0
- crypttrace-0.2.0.dist-info/WHEEL +5 -0
- crypttrace-0.2.0.dist-info/entry_points.txt +2 -0
- crypttrace-0.2.0.dist-info/licenses/LICENSE +21 -0
- crypttrace-0.2.0.dist-info/top_level.txt +1 -0
crypttrace/__init__.py
ADDED
crypttrace/addresses.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Address validation — checksums, not guesses.
|
|
2
|
+
|
|
3
|
+
Every address format in use carries a checksum precisely so that a mistyped or
|
|
4
|
+
corrupted address can be rejected rather than acted upon. A forensics tool has
|
|
5
|
+
no excuse for skipping that check: a single wrong character turns a claim about
|
|
6
|
+
one wallet into a claim about a different, usually non-existent one.
|
|
7
|
+
|
|
8
|
+
This validates without any network access or third-party library, so it works
|
|
9
|
+
on user input, on label files, and in tests.
|
|
10
|
+
"""
|
|
11
|
+
import hashlib
|
|
12
|
+
from typing import Optional, Tuple
|
|
13
|
+
|
|
14
|
+
BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
|
15
|
+
B58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------- bech32
|
|
19
|
+
|
|
20
|
+
def _bech32_polymod(values) -> int:
|
|
21
|
+
gen = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
|
|
22
|
+
chk = 1
|
|
23
|
+
for v in values:
|
|
24
|
+
top = chk >> 25
|
|
25
|
+
chk = (chk & 0x1ffffff) << 5 ^ v
|
|
26
|
+
for i in range(5):
|
|
27
|
+
chk ^= gen[i] if ((top >> i) & 1) else 0
|
|
28
|
+
return chk
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _hrp_expand(hrp: str):
|
|
32
|
+
return [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def check_bech32(addr: str, expected_hrp: str = "bc") -> Tuple[bool, str]:
|
|
36
|
+
"""Validate a bech32 / bech32m address (BIP-173 / BIP-350)."""
|
|
37
|
+
if addr.lower() != addr and addr.upper() != addr:
|
|
38
|
+
return False, "mixed case is not allowed in bech32"
|
|
39
|
+
a = addr.lower()
|
|
40
|
+
pos = a.rfind("1")
|
|
41
|
+
if pos < 1 or pos + 7 > len(a) or len(a) > 90:
|
|
42
|
+
return False, "malformed: bad separator position or length"
|
|
43
|
+
hrp, data = a[:pos], a[pos + 1:]
|
|
44
|
+
if expected_hrp and hrp != expected_hrp:
|
|
45
|
+
return False, f"wrong network prefix '{hrp}' (expected '{expected_hrp}')"
|
|
46
|
+
if any(c not in BECH32_CHARSET for c in data):
|
|
47
|
+
return False, "contains characters not in the bech32 alphabet"
|
|
48
|
+
const = _bech32_polymod(_hrp_expand(hrp) + [BECH32_CHARSET.find(c) for c in data])
|
|
49
|
+
if const == 1:
|
|
50
|
+
return True, "bech32 checksum valid"
|
|
51
|
+
if const == 0x2bc830a3:
|
|
52
|
+
return True, "bech32m checksum valid"
|
|
53
|
+
return False, "checksum does not match — the address is mistyped or invented"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------- base58check
|
|
57
|
+
|
|
58
|
+
def _b58_decode(s: str) -> Optional[bytes]:
|
|
59
|
+
n = 0
|
|
60
|
+
for ch in s:
|
|
61
|
+
idx = B58_ALPHABET.find(ch)
|
|
62
|
+
if idx < 0:
|
|
63
|
+
return None
|
|
64
|
+
n = n * 58 + idx
|
|
65
|
+
raw = n.to_bytes((n.bit_length() + 7) // 8, "big") if n else b""
|
|
66
|
+
pad = len(s) - len(s.lstrip("1"))
|
|
67
|
+
return b"\x00" * pad + raw
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def check_base58check(addr: str, expect_prefix: Optional[bytes] = None) -> Tuple[bool, str]:
|
|
71
|
+
"""Validate a base58check address (legacy Bitcoin, Tron)."""
|
|
72
|
+
raw = _b58_decode(addr)
|
|
73
|
+
if raw is None:
|
|
74
|
+
return False, "contains characters not in the base58 alphabet"
|
|
75
|
+
if len(raw) < 5:
|
|
76
|
+
return False, "too short to contain a checksum"
|
|
77
|
+
payload, checksum = raw[:-4], raw[-4:]
|
|
78
|
+
if hashlib.sha256(hashlib.sha256(payload).digest()).digest()[:4] != checksum:
|
|
79
|
+
return False, "checksum does not match — the address is mistyped or invented"
|
|
80
|
+
if expect_prefix and not payload.startswith(expect_prefix):
|
|
81
|
+
return False, f"unexpected version byte {payload[:1].hex()}"
|
|
82
|
+
return True, "base58check checksum valid"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ---------------------------------------------------------------- per chain
|
|
86
|
+
|
|
87
|
+
def validate(address: str, chain: str) -> Tuple[bool, str]:
|
|
88
|
+
"""Is this a well-formed address for this chain? Checks the checksum where one exists."""
|
|
89
|
+
a = (address or "").strip()
|
|
90
|
+
if not a:
|
|
91
|
+
return False, "empty address"
|
|
92
|
+
|
|
93
|
+
if chain in ("eth", "bsc", "polygon", "arbitrum", "optimism", "base"):
|
|
94
|
+
if not a.startswith("0x") or len(a) != 42:
|
|
95
|
+
return False, "EVM addresses are 0x followed by 40 hex characters"
|
|
96
|
+
if any(c not in "0123456789abcdefABCDEF" for c in a[2:]):
|
|
97
|
+
return False, "contains non-hexadecimal characters"
|
|
98
|
+
# EIP-55 mixed-case addresses carry a checksum; all-one-case ones don't
|
|
99
|
+
body = a[2:]
|
|
100
|
+
if body != body.lower() and body != body.upper():
|
|
101
|
+
return (True, "valid hex (EIP-55 capitalisation present but unverified)")
|
|
102
|
+
return True, "valid hex address (no checksum in this format)"
|
|
103
|
+
|
|
104
|
+
if chain == "btc":
|
|
105
|
+
if a.startswith(("bc1", "tb1")):
|
|
106
|
+
return check_bech32(a, "bc" if a.startswith("bc1") else "tb")
|
|
107
|
+
if a.startswith(("1", "3")):
|
|
108
|
+
return check_base58check(a)
|
|
109
|
+
return False, "not a recognised Bitcoin address format"
|
|
110
|
+
|
|
111
|
+
if chain == "tron":
|
|
112
|
+
if not a.startswith("T") or len(a) != 34:
|
|
113
|
+
return False, "Tron addresses start with T and are 34 characters"
|
|
114
|
+
return check_base58check(a, b"\x41")
|
|
115
|
+
|
|
116
|
+
if chain == "sol":
|
|
117
|
+
if not (32 <= len(a) <= 44):
|
|
118
|
+
return False, "Solana addresses are 32–44 base58 characters"
|
|
119
|
+
raw = _b58_decode(a)
|
|
120
|
+
if raw is None:
|
|
121
|
+
return False, "contains characters not in the base58 alphabet"
|
|
122
|
+
if len(raw) != 32:
|
|
123
|
+
return False, "does not decode to a 32-byte public key"
|
|
124
|
+
return True, "valid 32-byte key (Solana addresses carry no checksum)"
|
|
125
|
+
|
|
126
|
+
return False, f"unknown chain '{chain}'"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def looks_like_txid(value: str) -> bool:
|
|
130
|
+
"""64 hex characters — a transaction id, which people paste in by mistake."""
|
|
131
|
+
v = (value or "").strip()
|
|
132
|
+
return len(v) == 64 and all(c in "0123456789abcdefABCDEF" for c in v)
|
crypttrace/analysis.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Analytical views: *who* lost the money, and *when* it moved.
|
|
2
|
+
|
|
3
|
+
Two things a fund-flow graph shows but can't hand you as evidence:
|
|
4
|
+
|
|
5
|
+
* the list of addresses that fed a consolidation wallet — in a mass theft
|
|
6
|
+
that list is the set of victims, and it belongs in a spreadsheet attached
|
|
7
|
+
to a police report, not only in a picture;
|
|
8
|
+
* the timing of the movement. A theft executed by an automated tool looks
|
|
9
|
+
completely different from an owner spending their own coins: hundreds of
|
|
10
|
+
transfers inside minutes, rather than spread over months.
|
|
11
|
+
"""
|
|
12
|
+
import csv
|
|
13
|
+
from bisect import bisect_left, bisect_right
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Dict, List, Optional, Tuple
|
|
17
|
+
|
|
18
|
+
from crypttrace import chains
|
|
19
|
+
from crypttrace.labels import labels
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Amounts below these are treated as dust: spam sent to well-known addresses to
|
|
23
|
+
# pollute their history (and sometimes to deanonymise the owner). Left in, dust
|
|
24
|
+
# drowns out the transfers an investigation is actually about.
|
|
25
|
+
DUST = {"btc": 0.0005, "eth": 0.002, "bsc": 0.005, "polygon": 5.0,
|
|
26
|
+
"arbitrum": 0.002, "optimism": 0.002, "base": 0.002,
|
|
27
|
+
"tron": 5.0, "sol": 0.01}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def dust_threshold(chain: str, asset: Optional[dict] = None) -> float:
|
|
31
|
+
if asset: # stablecoins: anything under a dollar is noise
|
|
32
|
+
return 1.0 if asset.get("stable") else 0.0
|
|
33
|
+
return DUST.get(chain, 0.0)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _ts(unix) -> str:
|
|
37
|
+
try:
|
|
38
|
+
return datetime.fromtimestamp(int(unix), tz=timezone.utc).strftime("%Y-%m-%d %H:%M")
|
|
39
|
+
except (ValueError, TypeError, OSError):
|
|
40
|
+
return "?"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _direct(address: str, chain: str, asset: Optional[dict], limit: int,
|
|
44
|
+
direction: str = "in", min_value: float = 0.0) -> Dict[str, dict]:
|
|
45
|
+
"""Aggregate counterparties one hop away, keeping amounts and timestamps."""
|
|
46
|
+
me = chains.norm_addr(address, chain)
|
|
47
|
+
near, far = ("to", "from") if direction == "in" else ("from", "to")
|
|
48
|
+
agg: Dict[str, dict] = {}
|
|
49
|
+
for r in chains.transfers(address, chain, limit, asset=asset):
|
|
50
|
+
if r.get(near) != me:
|
|
51
|
+
continue
|
|
52
|
+
other = r.get(far)
|
|
53
|
+
val = r.get("value", 0) or 0
|
|
54
|
+
if not other or val <= 0 or val < min_value:
|
|
55
|
+
continue
|
|
56
|
+
rec = agg.setdefault(other, {"address": other, "value": 0.0, "txs": 0,
|
|
57
|
+
"first_ts": None, "last_ts": None})
|
|
58
|
+
rec["value"] += val
|
|
59
|
+
rec["txs"] += 1
|
|
60
|
+
ts = int(r.get("timestamp") or 0)
|
|
61
|
+
rec["first_ts"] = ts if rec["first_ts"] is None else min(rec["first_ts"], ts)
|
|
62
|
+
rec["last_ts"] = ts if rec["last_ts"] is None else max(rec["last_ts"], ts)
|
|
63
|
+
return agg
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def collect_sources(address: str, chain: str = "btc", depth: int = 1,
|
|
67
|
+
asset: Optional[dict] = None, limit: int = 1000,
|
|
68
|
+
max_addresses: int = 400,
|
|
69
|
+
min_value: Optional[float] = None) -> List[dict]:
|
|
70
|
+
"""Every address that fed `address`, walking back `depth` hops.
|
|
71
|
+
|
|
72
|
+
In a mass-drain incident this is the victim list. Results carry the hop
|
|
73
|
+
distance so direct senders can be told apart from earlier sources. Dust is
|
|
74
|
+
excluded by default — well-known addresses get spammed, and those senders
|
|
75
|
+
are not victims.
|
|
76
|
+
"""
|
|
77
|
+
if min_value is None:
|
|
78
|
+
min_value = dust_threshold(chain, asset)
|
|
79
|
+
|
|
80
|
+
found: Dict[str, dict] = {}
|
|
81
|
+
frontier = [chains.norm_addr(address, chain)]
|
|
82
|
+
seen = {chains.norm_addr(address, chain)}
|
|
83
|
+
|
|
84
|
+
for hop in range(1, depth + 1):
|
|
85
|
+
next_frontier = []
|
|
86
|
+
for node in frontier:
|
|
87
|
+
if len(found) >= max_addresses:
|
|
88
|
+
break
|
|
89
|
+
try:
|
|
90
|
+
sources = _direct(node, chain, asset, limit, "in", min_value)
|
|
91
|
+
except chains.ChainError:
|
|
92
|
+
continue
|
|
93
|
+
for addr, rec in sources.items():
|
|
94
|
+
key = chains.norm_addr(addr, chain)
|
|
95
|
+
if key in found:
|
|
96
|
+
found[key]["value"] += rec["value"]
|
|
97
|
+
found[key]["txs"] += rec["txs"]
|
|
98
|
+
continue
|
|
99
|
+
rec = dict(rec)
|
|
100
|
+
rec["hop"] = hop
|
|
101
|
+
rec["into"] = node
|
|
102
|
+
rec["label"] = labels.label_of(addr)
|
|
103
|
+
rec["type"] = labels.type_of(addr)
|
|
104
|
+
found[key] = rec
|
|
105
|
+
if key not in seen:
|
|
106
|
+
seen.add(key)
|
|
107
|
+
next_frontier.append(addr)
|
|
108
|
+
frontier = next_frontier
|
|
109
|
+
if not frontier or len(found) >= max_addresses:
|
|
110
|
+
break
|
|
111
|
+
|
|
112
|
+
return sorted(found.values(), key=lambda r: r["value"], reverse=True)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def export_csv(rows: List[dict], path: Path, chain: str, symbol: str) -> Path:
|
|
116
|
+
"""Write the source list to CSV — the form an exchange or police force wants."""
|
|
117
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
cols = ["address", "hop", f"amount_sent_{symbol}", "transactions",
|
|
119
|
+
"first_seen_utc", "last_seen_utc", "sent_into", "label", "explorer"]
|
|
120
|
+
with open(path, "w", newline="", encoding="utf-8") as fh:
|
|
121
|
+
w = csv.writer(fh)
|
|
122
|
+
w.writerow(cols)
|
|
123
|
+
for r in rows:
|
|
124
|
+
w.writerow([r["address"], r.get("hop", 1), round(r["value"], 8), r["txs"],
|
|
125
|
+
_ts(r.get("first_ts")), _ts(r.get("last_ts")),
|
|
126
|
+
r.get("into", ""), r.get("label", ""),
|
|
127
|
+
chains.explorer_url(r["address"], chain)])
|
|
128
|
+
return path
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ---------- timing ----------
|
|
132
|
+
|
|
133
|
+
def tightest_window(timestamps: List[int], fraction: float = 0.8) -> Optional[Tuple[int, int, int, int]]:
|
|
134
|
+
"""Shortest time span containing `fraction` of the events.
|
|
135
|
+
|
|
136
|
+
Returns (span_seconds, count, start_ts, end_ts). This is what separates an
|
|
137
|
+
automated sweep from normal wallet use: hundreds of transfers inside minutes.
|
|
138
|
+
`count` is every event inside the window, not just the `fraction` used to
|
|
139
|
+
find it: when many transfers share a timestamp, the window holds more.
|
|
140
|
+
"""
|
|
141
|
+
ts = sorted(t for t in timestamps if t)
|
|
142
|
+
n = len(ts)
|
|
143
|
+
if n < 2:
|
|
144
|
+
return None
|
|
145
|
+
need = max(2, int(n * fraction))
|
|
146
|
+
best = None
|
|
147
|
+
for i in range(0, n - need + 1):
|
|
148
|
+
j = i + need - 1
|
|
149
|
+
span = ts[j] - ts[i]
|
|
150
|
+
if best is None or span < best[0]:
|
|
151
|
+
best = (span, ts[i], ts[j])
|
|
152
|
+
span, start, end = best
|
|
153
|
+
return span, bisect_right(ts, end) - bisect_left(ts, start), start, end
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def timeline(address: str, chain: str = "eth", asset: Optional[dict] = None,
|
|
157
|
+
limit: int = 1000, buckets: int = 24,
|
|
158
|
+
min_value: Optional[float] = None) -> dict:
|
|
159
|
+
"""Bucketed in/out activity plus burst detection (dust excluded by default)."""
|
|
160
|
+
if min_value is None:
|
|
161
|
+
min_value = dust_threshold(chain, asset)
|
|
162
|
+
me = chains.norm_addr(address, chain)
|
|
163
|
+
rows = chains.transfers(address, chain, limit, asset=asset)
|
|
164
|
+
total_rows = len(rows)
|
|
165
|
+
events = [{"ts": int(r.get("timestamp") or 0),
|
|
166
|
+
"value": r.get("value", 0) or 0,
|
|
167
|
+
"dir": "out" if r.get("from") == me else "in"} for r in rows]
|
|
168
|
+
events = [e for e in events if e["ts"] > 0 and e["value"] >= min_value]
|
|
169
|
+
dust_skipped = total_rows - len(events)
|
|
170
|
+
if not events:
|
|
171
|
+
return {"events": 0, "buckets": [], "burst": None, "dust_skipped": dust_skipped,
|
|
172
|
+
"first_ts": None, "last_ts": None, "in_total": 0.0, "out_total": 0.0}
|
|
173
|
+
|
|
174
|
+
events.sort(key=lambda e: e["ts"])
|
|
175
|
+
first, last = events[0]["ts"], events[-1]["ts"]
|
|
176
|
+
span = max(1, last - first)
|
|
177
|
+
width = max(1, span // buckets)
|
|
178
|
+
|
|
179
|
+
# assign each event to exactly one bucket (clamping the final edge inwards),
|
|
180
|
+
# otherwise the last event lands in two buckets and gets counted twice
|
|
181
|
+
grid = [{"start": first + i * width, "end": first + (i + 1) * width,
|
|
182
|
+
"count": 0, "in": 0.0, "out": 0.0} for i in range(buckets)]
|
|
183
|
+
for e in events:
|
|
184
|
+
idx = min(buckets - 1, (e["ts"] - first) // width)
|
|
185
|
+
b = grid[idx]
|
|
186
|
+
b["count"] += 1
|
|
187
|
+
b["in" if e["dir"] == "in" else "out"] += e["value"]
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
"events": len(events),
|
|
191
|
+
"buckets": grid,
|
|
192
|
+
"burst": tightest_window([e["ts"] for e in events]),
|
|
193
|
+
"dust_skipped": dust_skipped,
|
|
194
|
+
"first_ts": first, "last_ts": last,
|
|
195
|
+
"in_total": sum(e["value"] for e in events if e["dir"] == "in"),
|
|
196
|
+
"out_total": sum(e["value"] for e in events if e["dir"] == "out"),
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def describe_burst(burst, total_events: int) -> Optional[str]:
|
|
201
|
+
"""Plain-language read on the timing, if it looks automated."""
|
|
202
|
+
if not burst:
|
|
203
|
+
return None
|
|
204
|
+
span, count, start, end = burst
|
|
205
|
+
if span <= 0:
|
|
206
|
+
if count >= total_events:
|
|
207
|
+
return f"All {count} transfers share one timestamp — a single batched operation."
|
|
208
|
+
return (f"{count} of {total_events} transfers share one timestamp "
|
|
209
|
+
f"({_ts(start)} UTC) — a single batched operation.")
|
|
210
|
+
minutes = span / 60
|
|
211
|
+
rate = count / max(minutes, 0.01)
|
|
212
|
+
when = f"{_ts(start)} → {_ts(end)} UTC"
|
|
213
|
+
if minutes <= 90 and count >= 20:
|
|
214
|
+
return (f"{count} of {total_events} transfers happened inside "
|
|
215
|
+
f"{minutes:.0f} minutes ({when}), about {rate:.0f} per minute. "
|
|
216
|
+
"That rate is characteristic of an automated tool spending keys it "
|
|
217
|
+
"already holds, not of an owner moving their own funds.")
|
|
218
|
+
if minutes <= 60 * 24:
|
|
219
|
+
return (f"{count} of {total_events} transfers fall inside {minutes/60:.1f} hours ({when})."
|
|
220
|
+
" Activity is concentrated rather than spread out.")
|
|
221
|
+
return (f"Activity is spread over {minutes/60/24:.0f} days — no burst pattern, "
|
|
222
|
+
"which is what ordinary wallet use looks like.")
|
crypttrace/assess.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Assessment — turning observations into a stated conclusion.
|
|
2
|
+
|
|
3
|
+
Collecting public data and drawing a graph is collection. Intelligence is the
|
|
4
|
+
step after: saying what the data supports, how strongly, and on what evidence.
|
|
5
|
+
|
|
6
|
+
So every signal here carries four things — what was *observed*, what it
|
|
7
|
+
*implies*, how *confident* we are, and the numbers a reader can check. The
|
|
8
|
+
overall confidence is deliberately reduced when the underlying figures could
|
|
9
|
+
not be reconciled with the chain, or when the labels involved carry no source.
|
|
10
|
+
An assessment that cannot be checked should not sound certain.
|
|
11
|
+
"""
|
|
12
|
+
import math
|
|
13
|
+
from collections import Counter
|
|
14
|
+
from dataclasses import dataclass, field, asdict
|
|
15
|
+
from typing import Dict, List, Optional
|
|
16
|
+
|
|
17
|
+
from crypttrace import analysis, chains
|
|
18
|
+
from crypttrace.labels import labels
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Signal:
|
|
23
|
+
name: str
|
|
24
|
+
observed: str
|
|
25
|
+
implication: str
|
|
26
|
+
confidence: str # high / medium / low
|
|
27
|
+
weight: int # contribution to the risk figure
|
|
28
|
+
evidence: dict = field(default_factory=dict)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
CONF_ORDER = {"high": 3, "medium": 2, "low": 1}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------- detectors
|
|
35
|
+
|
|
36
|
+
def constant_fee_signature(values: List[float], min_hits: int = 5) -> Optional[dict]:
|
|
37
|
+
"""Do the amounts look like round sums minus one fixed fee?
|
|
38
|
+
|
|
39
|
+
A person sends round-ish numbers. An automated sweeper takes whatever is in
|
|
40
|
+
a wallet and subtracts a hardcoded fee, so the *gap to the next round
|
|
41
|
+
number* repeats exactly across unrelated victims. That repetition is the
|
|
42
|
+
fingerprint — it found the 3,300-sat fee in the Coldcard case by hand.
|
|
43
|
+
"""
|
|
44
|
+
deltas = Counter()
|
|
45
|
+
for v in values:
|
|
46
|
+
if v <= 0:
|
|
47
|
+
continue
|
|
48
|
+
for step in (1.0, 0.5, 0.1, 0.05, 0.01):
|
|
49
|
+
up = math.ceil(round(v / step, 9)) * step
|
|
50
|
+
d = round(up - v, 8)
|
|
51
|
+
if 0 < d < step * 0.5:
|
|
52
|
+
deltas[d] += 1
|
|
53
|
+
break
|
|
54
|
+
if not deltas:
|
|
55
|
+
return None
|
|
56
|
+
delta, hits = deltas.most_common(1)[0]
|
|
57
|
+
total = len([v for v in values if v > 0])
|
|
58
|
+
if hits < min_hits or total == 0 or hits / total < 0.10:
|
|
59
|
+
return None
|
|
60
|
+
return {"fee": delta, "hits": hits, "of": total, "share": hits / total}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def repeated_amounts(values: List[float], min_repeat: int = 3) -> List[tuple]:
|
|
64
|
+
"""Identical amounts arriving from unrelated addresses — batch automation."""
|
|
65
|
+
c = Counter(round(v, 8) for v in values if v > 0)
|
|
66
|
+
return [(amt, n) for amt, n in c.most_common(5) if n >= min_repeat]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def burst(timestamps: List[int], fraction: float = 0.8) -> Optional[tuple]:
|
|
70
|
+
"""(span_seconds, count, start_ts, end_ts) of the tightest burst; None under 5 events."""
|
|
71
|
+
if sum(1 for t in timestamps if t) < 5:
|
|
72
|
+
return None
|
|
73
|
+
return analysis.tightest_window(timestamps, fraction)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------- assessment
|
|
77
|
+
|
|
78
|
+
def assess(address: str, chain: str = "eth", asset: Optional[dict] = None,
|
|
79
|
+
depth: int = 2, branching: int = 5) -> Dict:
|
|
80
|
+
"""Produce a reasoned assessment of an address, with evidence."""
|
|
81
|
+
from crypttrace import verify as verify_mod
|
|
82
|
+
|
|
83
|
+
me = chains.norm_addr(address, chain)
|
|
84
|
+
signals: List[Signal] = []
|
|
85
|
+
caveats: List[str] = []
|
|
86
|
+
|
|
87
|
+
# --- what the address itself is -----------------------------------
|
|
88
|
+
hit = labels.lookup(address)
|
|
89
|
+
sourced = bool(hit and hit.get("source"))
|
|
90
|
+
if hit:
|
|
91
|
+
kind = hit.get("type", "unknown")
|
|
92
|
+
conf = "high" if sourced else "medium"
|
|
93
|
+
weight = {"sanctioned": 60, "mixer": 45, "scam": 55,
|
|
94
|
+
"bridge": 10, "exchange": 5}.get(kind, 0)
|
|
95
|
+
signals.append(Signal(
|
|
96
|
+
name="known entity",
|
|
97
|
+
observed=f"address is labelled '{hit['name']}'",
|
|
98
|
+
implication={"sanctioned": "on an international sanctions list",
|
|
99
|
+
"mixer": "a privacy service that breaks the on-chain trail",
|
|
100
|
+
"scam": "tied to a recorded theft or fraud",
|
|
101
|
+
"exchange": "a custodial service that holds customer identity",
|
|
102
|
+
"bridge": "a cross-chain bridge"}.get(kind, "a known service"),
|
|
103
|
+
confidence=conf, weight=weight,
|
|
104
|
+
evidence={"label": hit["name"], "source": hit.get("source", ""),
|
|
105
|
+
"source_kind": hit.get("source_kind", "")}))
|
|
106
|
+
if not sourced:
|
|
107
|
+
caveats.append("the label on this address carries no recorded source, "
|
|
108
|
+
"so it is inherited rather than evidenced")
|
|
109
|
+
|
|
110
|
+
# --- flows --------------------------------------------------------
|
|
111
|
+
try:
|
|
112
|
+
rows = chains.transfers(address, chain, 1000, asset=asset)
|
|
113
|
+
except chains.ChainError as e:
|
|
114
|
+
return {"address": address, "chain": chain, "error": str(e),
|
|
115
|
+
"signals": [], "risk": 0, "confidence": "none"}
|
|
116
|
+
|
|
117
|
+
inbound = [r for r in rows if r.get("to") == me]
|
|
118
|
+
outbound = [r for r in rows if r.get("from") == me]
|
|
119
|
+
received = sum(r.get("value", 0) for r in inbound)
|
|
120
|
+
sent = sum(r.get("value", 0) for r in outbound)
|
|
121
|
+
|
|
122
|
+
# --- automation ----------------------------------------------------
|
|
123
|
+
fee_sig = constant_fee_signature([r.get("value", 0) for r in inbound])
|
|
124
|
+
if fee_sig:
|
|
125
|
+
signals.append(Signal(
|
|
126
|
+
name="automated collection",
|
|
127
|
+
observed=(f"{fee_sig['hits']} of {fee_sig['of']} incoming amounts are a round "
|
|
128
|
+
f"figure minus exactly {fee_sig['fee']:.8f}"),
|
|
129
|
+
implication="funds were swept by a tool with a hardcoded fee, not moved by owners",
|
|
130
|
+
confidence="high" if fee_sig["share"] > 0.3 else "medium",
|
|
131
|
+
weight=35, evidence=fee_sig))
|
|
132
|
+
|
|
133
|
+
b = burst([int(r.get("timestamp") or 0) for r in inbound])
|
|
134
|
+
if b and b[0] > 0:
|
|
135
|
+
span_min = b[0] / 60
|
|
136
|
+
rate = b[1] / max(span_min, 0.01)
|
|
137
|
+
if span_min <= 90 and b[1] >= 20:
|
|
138
|
+
signals.append(Signal(
|
|
139
|
+
name="burst of activity",
|
|
140
|
+
observed=f"{b[1]} transfers arrived within {span_min:.0f} minutes (~{rate:.0f}/min)",
|
|
141
|
+
implication="a rate consistent with automation rather than human activity",
|
|
142
|
+
confidence="high", weight=25,
|
|
143
|
+
evidence={"count": b[1], "span_minutes": round(span_min, 1),
|
|
144
|
+
"from_ts": b[2], "to_ts": b[3]}))
|
|
145
|
+
|
|
146
|
+
rep = repeated_amounts([r.get("value", 0) for r in inbound])
|
|
147
|
+
if rep:
|
|
148
|
+
top = rep[0]
|
|
149
|
+
signals.append(Signal(
|
|
150
|
+
name="repeated amounts",
|
|
151
|
+
observed=f"the exact amount {top[0]} arrived {top[1]} times from different addresses",
|
|
152
|
+
implication="batch processing rather than independent human transfers",
|
|
153
|
+
confidence="medium", weight=10,
|
|
154
|
+
evidence={"repeats": rep[:3]}))
|
|
155
|
+
|
|
156
|
+
# --- where the money goes -----------------------------------------
|
|
157
|
+
try:
|
|
158
|
+
onward = chains.flows(address, chain, branching, "out", asset=asset)
|
|
159
|
+
except chains.ChainError:
|
|
160
|
+
onward = []
|
|
161
|
+
|
|
162
|
+
exposure = {"mixer": 0.0, "sanctioned": 0.0, "exchange": 0.0, "bridge": 0.0}
|
|
163
|
+
named: Dict[str, str] = {}
|
|
164
|
+
for other, val, _cnt in onward:
|
|
165
|
+
t = labels.type_of(other)
|
|
166
|
+
if t in exposure:
|
|
167
|
+
exposure[t] += val
|
|
168
|
+
named.setdefault(t, labels.label_of(other) or other)
|
|
169
|
+
|
|
170
|
+
if exposure["sanctioned"] > 0:
|
|
171
|
+
signals.append(Signal(
|
|
172
|
+
name="sanctions exposure",
|
|
173
|
+
observed=f"{exposure['sanctioned']:.8f} sent to sanctioned wallets ({named['sanctioned']})",
|
|
174
|
+
implication="funds moved to entities on an official sanctions list",
|
|
175
|
+
confidence="high", weight=50,
|
|
176
|
+
evidence={"amount": exposure["sanctioned"], "entity": named["sanctioned"]}))
|
|
177
|
+
if exposure["mixer"] > 0:
|
|
178
|
+
signals.append(Signal(
|
|
179
|
+
name="mixer exposure",
|
|
180
|
+
observed=f"{exposure['mixer']:.8f} sent into {named['mixer']}",
|
|
181
|
+
implication="the on-chain trail is deliberately broken from that point",
|
|
182
|
+
confidence="high", weight=40,
|
|
183
|
+
evidence={"amount": exposure["mixer"], "service": named["mixer"]}))
|
|
184
|
+
if exposure["exchange"] > 0:
|
|
185
|
+
signals.append(Signal(
|
|
186
|
+
name="exchange contact",
|
|
187
|
+
observed=f"{exposure['exchange']:.8f} sent to {named['exchange']}",
|
|
188
|
+
implication="reaches a custodial service that holds the recipient's identity — "
|
|
189
|
+
"the point where a legal request can work",
|
|
190
|
+
confidence="high", weight=5,
|
|
191
|
+
evidence={"amount": exposure["exchange"], "exchange": named["exchange"]}))
|
|
192
|
+
if exposure["bridge"] > 0:
|
|
193
|
+
signals.append(Signal(
|
|
194
|
+
name="cross-chain movement",
|
|
195
|
+
observed=f"{exposure['bridge']:.8f} sent into {named['bridge']}",
|
|
196
|
+
implication="funds left this chain; the trail continues elsewhere",
|
|
197
|
+
confidence="medium", weight=15,
|
|
198
|
+
evidence={"amount": exposure["bridge"], "bridge": named["bridge"]}))
|
|
199
|
+
|
|
200
|
+
# --- holding behaviour ---------------------------------------------
|
|
201
|
+
if received > 0 and sent == 0 and len(inbound) >= 3:
|
|
202
|
+
signals.append(Signal(
|
|
203
|
+
name="funds held",
|
|
204
|
+
observed=f"received {received:.8f} across {len(inbound)} transfers, spent nothing",
|
|
205
|
+
implication="proceeds are being held rather than cashed out — "
|
|
206
|
+
"the window to act is still open",
|
|
207
|
+
confidence="high", weight=0,
|
|
208
|
+
evidence={"received": received, "transfers": len(inbound)}))
|
|
209
|
+
|
|
210
|
+
# --- can we trust our own numbers? ---------------------------------
|
|
211
|
+
v = verify_mod.reconcile(address, chain, asset)
|
|
212
|
+
if v["status"] == "mismatch":
|
|
213
|
+
caveats.append("the tool's own totals do not reconcile with the chain — "
|
|
214
|
+
"treat every figure here as unreliable until explained")
|
|
215
|
+
elif v["status"] == "partial":
|
|
216
|
+
caveats.append("only part of this address's history was read, so amounts are "
|
|
217
|
+
"a lower bound and absent signals may simply be unseen")
|
|
218
|
+
elif v["status"] == "consistent":
|
|
219
|
+
caveats.append("this chain publishes no independent gross totals, so attribution "
|
|
220
|
+
"could only be partly cross-checked")
|
|
221
|
+
|
|
222
|
+
# --- overall --------------------------------------------------------
|
|
223
|
+
risk = min(100, sum(s.weight for s in signals))
|
|
224
|
+
if v["status"] == "mismatch":
|
|
225
|
+
confidence = "low"
|
|
226
|
+
elif not signals:
|
|
227
|
+
confidence = "low"
|
|
228
|
+
else:
|
|
229
|
+
best = max(CONF_ORDER[s.confidence] for s in signals)
|
|
230
|
+
confidence = {3: "high", 2: "medium", 1: "low"}[best]
|
|
231
|
+
if v["status"] in ("partial", "consistent") and confidence == "high":
|
|
232
|
+
confidence = "medium"
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
"address": address, "chain": chain,
|
|
236
|
+
"risk": risk, "confidence": confidence,
|
|
237
|
+
"assessment": _statement(signals, risk, confidence, hit),
|
|
238
|
+
"signals": [asdict(s) for s in signals],
|
|
239
|
+
"caveats": caveats,
|
|
240
|
+
"verification": {"status": v["status"], "headline": verify_mod.headline(v)},
|
|
241
|
+
"totals": {"received": received, "sent": sent,
|
|
242
|
+
"inbound": len(inbound), "outbound": len(outbound)},
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _statement(signals: List[Signal], risk: int, confidence: str, hit) -> str:
|
|
247
|
+
"""One paragraph a person can read, act on, and argue with."""
|
|
248
|
+
if not signals:
|
|
249
|
+
return ("Nothing in the data collected distinguishes this address. That is not a "
|
|
250
|
+
"clearance — an unlabelled address with unremarkable flows is simply "
|
|
251
|
+
"unknown.")
|
|
252
|
+
names = {s.name for s in signals}
|
|
253
|
+
parts = []
|
|
254
|
+
if hit:
|
|
255
|
+
parts.append(f"This address is a known entity: {hit['name']}.")
|
|
256
|
+
if "automated collection" in names or "burst of activity" in names:
|
|
257
|
+
parts.append("The pattern of incoming funds indicates an automated sweep rather "
|
|
258
|
+
"than owners moving their own money.")
|
|
259
|
+
if "sanctions exposure" in names:
|
|
260
|
+
parts.append("Funds moved on to sanctioned wallets, which puts this in "
|
|
261
|
+
"law-enforcement territory.")
|
|
262
|
+
if "mixer exposure" in names:
|
|
263
|
+
parts.append("Part of the value entered a mixer, where on-chain tracing ends.")
|
|
264
|
+
if "exchange contact" in names:
|
|
265
|
+
parts.append("Some value reached a custodial exchange — the one point in this "
|
|
266
|
+
"chain where identity can realistically be obtained.")
|
|
267
|
+
if "cross-chain movement" in names:
|
|
268
|
+
parts.append("Some value left this chain through a bridge and would need to be "
|
|
269
|
+
"picked up on the destination network.")
|
|
270
|
+
if "funds held" in names:
|
|
271
|
+
parts.append("The proceeds have not been spent, so intervention is still possible.")
|
|
272
|
+
parts.append(f"Overall risk {risk}/100, confidence {confidence}.")
|
|
273
|
+
return " ".join(parts)
|