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/verify.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Self-verification: check the tool's own arithmetic against the chain.
|
|
2
|
+
|
|
3
|
+
A forensics tool that quietly miscounts is worse than no tool — its output
|
|
4
|
+
looks authoritative and gets pasted into reports. This module re-derives the
|
|
5
|
+
totals from what we parsed and compares them with the figures the chain index
|
|
6
|
+
reports independently, then says plainly whether the numbers agree.
|
|
7
|
+
|
|
8
|
+
It catches the two failure modes that matter:
|
|
9
|
+
|
|
10
|
+
* **wrong maths** — e.g. crediting one wallet with funds a hundred others
|
|
11
|
+
supplied, which is a real bug this tool once had;
|
|
12
|
+
* **incomplete history** — when an address has more transactions than were
|
|
13
|
+
fetched, so a conclusion is drawn from a slice rather than the whole
|
|
14
|
+
record. That one is dangerous precisely because nothing looks broken.
|
|
15
|
+
"""
|
|
16
|
+
from typing import Dict, List, Optional
|
|
17
|
+
|
|
18
|
+
from crypttrace import chains
|
|
19
|
+
from crypttrace.fetchers import bitcoin
|
|
20
|
+
|
|
21
|
+
# how far computed and reported totals may drift before we complain
|
|
22
|
+
REL_TOLERANCE = 0.005 # 0.5%
|
|
23
|
+
ABS_TOLERANCE = 1e-6
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _within(a: float, b: float) -> bool:
|
|
27
|
+
if a is None or b is None:
|
|
28
|
+
return True
|
|
29
|
+
diff = abs(a - b)
|
|
30
|
+
return diff <= ABS_TOLERANCE or diff <= max(abs(a), abs(b)) * REL_TOLERANCE
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def reconcile(address: str, chain: str = "btc", asset: Optional[dict] = None,
|
|
34
|
+
limit: int = 1000) -> Dict:
|
|
35
|
+
"""Compare our parsed totals with the chain's own aggregates."""
|
|
36
|
+
me = chains.norm_addr(address, chain)
|
|
37
|
+
notes: List[str] = []
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
rows = chains.transfers(address, chain, limit, asset=asset)
|
|
41
|
+
except chains.ChainError as e:
|
|
42
|
+
return {"status": "unavailable", "notes": [str(e)], "address": address,
|
|
43
|
+
"chain": chain, "analysed": 0}
|
|
44
|
+
|
|
45
|
+
got_in = sum(r["value"] for r in rows if r.get("to") == me)
|
|
46
|
+
got_out = sum(r["value"] for r in rows if r.get("from") == me)
|
|
47
|
+
|
|
48
|
+
# Miner fees are spent from the address but never arrive anywhere, so the
|
|
49
|
+
# chain's "sent" figure exceeds what recipients received by exactly the fees.
|
|
50
|
+
# Count each transaction's fee once, no matter how many outputs it had.
|
|
51
|
+
fees_by_tx = {}
|
|
52
|
+
for r in rows:
|
|
53
|
+
if r.get("from") == me and "fee_share" in r:
|
|
54
|
+
fees_by_tx[r.get("hash", "")] = r["fee_share"]
|
|
55
|
+
fees = sum(fees_by_tx.values())
|
|
56
|
+
|
|
57
|
+
ref_in = ref_out = ref_balance = None
|
|
58
|
+
ref_tx = None
|
|
59
|
+
|
|
60
|
+
if chain == "btc" and asset is None:
|
|
61
|
+
try:
|
|
62
|
+
s = bitcoin.stats(address)
|
|
63
|
+
ref_in, ref_out = s["received"], s["sent"]
|
|
64
|
+
ref_balance, ref_tx = s["balance"], s["tx_count"]
|
|
65
|
+
except bitcoin.BitcoinError as e:
|
|
66
|
+
notes.append(f"chain totals unavailable: {e}")
|
|
67
|
+
else:
|
|
68
|
+
try:
|
|
69
|
+
ref_balance = chains.balance(address, chain)
|
|
70
|
+
except chains.ChainError as e:
|
|
71
|
+
notes.append(f"balance unavailable: {e}")
|
|
72
|
+
|
|
73
|
+
# --- completeness -------------------------------------------------
|
|
74
|
+
complete = True
|
|
75
|
+
if ref_tx is not None:
|
|
76
|
+
# rows are per-transfer, a tx can produce several — compare loosely
|
|
77
|
+
if ref_tx > limit:
|
|
78
|
+
complete = False
|
|
79
|
+
notes.append(f"address has {ref_tx} transactions on chain but only "
|
|
80
|
+
f"{limit} were fetched — this is a partial view")
|
|
81
|
+
elif len(rows) >= limit:
|
|
82
|
+
complete = False
|
|
83
|
+
notes.append(f"hit the {limit}-transfer fetch limit — history may be truncated")
|
|
84
|
+
|
|
85
|
+
# --- arithmetic ---------------------------------------------------
|
|
86
|
+
checks = []
|
|
87
|
+
if ref_in is not None:
|
|
88
|
+
ok = _within(got_in, ref_in) or not complete
|
|
89
|
+
checks.append(("received", got_in, ref_in, ok))
|
|
90
|
+
if ref_out is not None:
|
|
91
|
+
# compare like with like: what recipients got, plus the fees we paid
|
|
92
|
+
sent_incl_fees = got_out + fees
|
|
93
|
+
ok = _within(sent_incl_fees, ref_out) or not complete
|
|
94
|
+
checks.append(("sent (incl. fees)", sent_incl_fees, ref_out, ok))
|
|
95
|
+
if fees > 0:
|
|
96
|
+
notes.append(f"of which {fees:.8f} went to miner fees across "
|
|
97
|
+
f"{len(fees_by_tx)} transaction(s) — recipients received "
|
|
98
|
+
f"{got_out:.8f}")
|
|
99
|
+
if ref_in is None and ref_balance is not None and asset is None and complete:
|
|
100
|
+
# no aggregate feed: fall back to net vs balance (fees make it inexact)
|
|
101
|
+
net = got_in - got_out
|
|
102
|
+
ok = abs(net - ref_balance) <= max(0.01, abs(ref_balance) * 0.02)
|
|
103
|
+
checks.append(("net vs balance", net, ref_balance, ok))
|
|
104
|
+
if not ok:
|
|
105
|
+
notes.append("net flow differs from the current balance; on account-based "
|
|
106
|
+
"chains gas costs explain small gaps, large ones do not")
|
|
107
|
+
|
|
108
|
+
failed = [c for c in checks if not c[3]]
|
|
109
|
+
strong = ref_in is not None or ref_out is not None # gross totals from the chain
|
|
110
|
+
if failed:
|
|
111
|
+
status = "mismatch"
|
|
112
|
+
for name, got, ref, _ in failed:
|
|
113
|
+
notes.append(f"{name}: computed {got:.8f}, chain reports {ref:.8f} "
|
|
114
|
+
f"(off by {abs(got-ref):.8f})")
|
|
115
|
+
elif not complete:
|
|
116
|
+
status = "partial"
|
|
117
|
+
elif checks and strong:
|
|
118
|
+
status = "verified"
|
|
119
|
+
elif checks:
|
|
120
|
+
# only the balance reconciled: weaker evidence, so don't claim more
|
|
121
|
+
status = "consistent"
|
|
122
|
+
notes.append("this chain publishes no independent gross totals, so only the "
|
|
123
|
+
"net balance could be checked — per-counterparty attribution "
|
|
124
|
+
"is not cross-verified")
|
|
125
|
+
else:
|
|
126
|
+
status = "unchecked"
|
|
127
|
+
notes.append("no independent totals available for this chain — "
|
|
128
|
+
"figures could not be cross-checked")
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
"address": address, "chain": chain, "status": status,
|
|
132
|
+
"analysed": len(rows), "chain_tx_count": ref_tx, "complete": complete,
|
|
133
|
+
"computed_received": got_in, "chain_received": ref_in,
|
|
134
|
+
"computed_sent": got_out, "chain_sent": ref_out, "fees": fees,
|
|
135
|
+
"chain_balance": ref_balance,
|
|
136
|
+
"checks": [{"name": n, "computed": g, "chain": r, "ok": o} for n, g, r, o in checks],
|
|
137
|
+
"notes": notes,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def headline(v: Dict) -> str:
|
|
142
|
+
"""One line a human can act on."""
|
|
143
|
+
return {
|
|
144
|
+
"verified": "Verified — gross totals match the chain independently.",
|
|
145
|
+
"consistent": "Consistent — the balance reconciles, but this chain offers no "
|
|
146
|
+
"independent gross totals, so attribution is only partly checked.",
|
|
147
|
+
"partial": "Partial view — only some of this address's history was read, "
|
|
148
|
+
"so totals are a floor, not the whole picture.",
|
|
149
|
+
"mismatch": "MISMATCH — the computed totals disagree with the chain. "
|
|
150
|
+
"Do not rely on this output until it is explained.",
|
|
151
|
+
"unchecked": "Not cross-checked — no independent totals for this chain.",
|
|
152
|
+
"unavailable": "Could not reach the chain to verify.",
|
|
153
|
+
}.get(v.get("status"), "Unknown verification state.")
|
crypttrace/watch.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Address monitoring — the feature that actually helps recover funds.
|
|
2
|
+
|
|
3
|
+
The one window to freeze stolen crypto is the moment it reaches an exchange
|
|
4
|
+
deposit address. Victims can't watch a chain 24/7. `watch` keeps a list of
|
|
5
|
+
addresses, detects new activity, and raises a loud HIGH alert the instant funds
|
|
6
|
+
move toward an exchange (directly, or to a detected deposit address). Everything
|
|
7
|
+
else is a quieter movement notice.
|
|
8
|
+
|
|
9
|
+
Honest limit: the tool tells you *when* to act; freezing funds still depends on
|
|
10
|
+
the exchange and law enforcement responding quickly.
|
|
11
|
+
"""
|
|
12
|
+
import json
|
|
13
|
+
import time
|
|
14
|
+
from typing import List, Dict, Optional
|
|
15
|
+
|
|
16
|
+
import requests
|
|
17
|
+
|
|
18
|
+
from crypttrace.fetchers import etherscan
|
|
19
|
+
from crypttrace.labels import labels
|
|
20
|
+
from crypttrace import config, offramp
|
|
21
|
+
|
|
22
|
+
WATCHLIST = config.DATA_DIR / "watchlist.json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _load() -> Dict[str, dict]:
|
|
26
|
+
if WATCHLIST.exists():
|
|
27
|
+
try:
|
|
28
|
+
return json.loads(WATCHLIST.read_text())
|
|
29
|
+
except (ValueError, OSError):
|
|
30
|
+
return {}
|
|
31
|
+
return {}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _save(d: Dict[str, dict]) -> None:
|
|
35
|
+
WATCHLIST.parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
WATCHLIST.write_text(json.dumps(d, indent=2))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _latest_ts(address: str, chain: str) -> int:
|
|
40
|
+
txs = etherscan.get_txs(address, chain, limit=1)
|
|
41
|
+
return int(txs[0]["timeStamp"]) if txs else 0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def add(address: str, chain: str = "eth", note: str = "") -> int:
|
|
45
|
+
d = _load()
|
|
46
|
+
baseline = _latest_ts(address, chain) # only alert on activity *after* now
|
|
47
|
+
d[address.lower()] = {"chain": chain, "note": note, "last_ts": baseline}
|
|
48
|
+
_save(d)
|
|
49
|
+
return baseline
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def remove(address: str) -> bool:
|
|
53
|
+
d = _load()
|
|
54
|
+
if address.lower() in d:
|
|
55
|
+
del d[address.lower()]
|
|
56
|
+
_save(d)
|
|
57
|
+
return True
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def all_watched() -> Dict[str, dict]:
|
|
62
|
+
return _load()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _classify(me: str, tx: dict, chain: str) -> dict:
|
|
66
|
+
frm = tx.get("from", "").lower()
|
|
67
|
+
to = tx.get("to", "").lower()
|
|
68
|
+
val = int(tx.get("value", 0)) / config.WEI
|
|
69
|
+
direction = "out" if frm == me else "in"
|
|
70
|
+
other = to if direction == "out" else frm
|
|
71
|
+
otype = labels.type_of(other)
|
|
72
|
+
|
|
73
|
+
sev, reason = "info", "incoming funds" if direction == "in" else "funds moved out"
|
|
74
|
+
if direction == "out" and otype == "exchange":
|
|
75
|
+
sev, reason = "high", f"→ EXCHANGE {labels.label_of(other)} — possible cash-out (freeze window)"
|
|
76
|
+
elif direction == "out" and otype in ("mixer", "sanctioned"):
|
|
77
|
+
sev, reason = "high", f"→ {otype} {labels.label_of(other)}"
|
|
78
|
+
elif direction == "out" and otype == "bridge":
|
|
79
|
+
sev, reason = "move", f"→ bridge {labels.label_of(other)} (funds may leave this chain)"
|
|
80
|
+
elif direction == "out" and val > 0:
|
|
81
|
+
off = offramp.detect(other, chain)
|
|
82
|
+
if off:
|
|
83
|
+
sev, reason = "high", f"→ likely {off['exchange']} DEPOSIT address — possible cash-out"
|
|
84
|
+
else:
|
|
85
|
+
sev, reason = "move", "funds moved out"
|
|
86
|
+
|
|
87
|
+
return {"direction": direction, "other": other, "otype": otype, "value": val,
|
|
88
|
+
"sev": sev, "reason": reason, "timestamp": int(tx.get("timeStamp", "0")),
|
|
89
|
+
"hash": tx.get("hash", "")}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def check(address: str, chain: str, since_ts: int, limit: int = 100) -> List[dict]:
|
|
93
|
+
"""New events (newer than since_ts), newest first, each classified."""
|
|
94
|
+
me = address.lower()
|
|
95
|
+
events = []
|
|
96
|
+
for tx in etherscan.get_txs(address, chain, limit=limit): # desc (newest first)
|
|
97
|
+
ts = int(tx.get("timeStamp", "0"))
|
|
98
|
+
if ts <= since_ts:
|
|
99
|
+
break
|
|
100
|
+
events.append(_classify(me, tx, chain))
|
|
101
|
+
return events
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def poll_once() -> List[dict]:
|
|
105
|
+
"""Check every watched address, update baselines, return all new alerts."""
|
|
106
|
+
d = _load()
|
|
107
|
+
alerts = []
|
|
108
|
+
for addr, meta in d.items():
|
|
109
|
+
try:
|
|
110
|
+
events = check(addr, meta.get("chain", "eth"), meta.get("last_ts", 0))
|
|
111
|
+
except etherscan.EtherscanError:
|
|
112
|
+
continue
|
|
113
|
+
if events:
|
|
114
|
+
meta["last_ts"] = max(e["timestamp"] for e in events)
|
|
115
|
+
for e in events:
|
|
116
|
+
e["address"] = addr
|
|
117
|
+
e["note"] = meta.get("note", "")
|
|
118
|
+
alerts.append(e)
|
|
119
|
+
if alerts:
|
|
120
|
+
_save(d)
|
|
121
|
+
# highest severity first
|
|
122
|
+
order = {"high": 0, "move": 1, "info": 2}
|
|
123
|
+
return sorted(alerts, key=lambda e: order.get(e["sev"], 3))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def telegram_notify(text: str) -> Optional[bool]:
|
|
127
|
+
"""Send an alert to Telegram if CRYPTTRACE_TG_TOKEN + CRYPTTRACE_TG_CHAT are set."""
|
|
128
|
+
import os
|
|
129
|
+
token = os.environ.get("CRYPTTRACE_TG_TOKEN")
|
|
130
|
+
chat = os.environ.get("CRYPTTRACE_TG_CHAT")
|
|
131
|
+
if not token or not chat:
|
|
132
|
+
return None
|
|
133
|
+
try:
|
|
134
|
+
r = requests.get(f"https://api.telegram.org/bot{token}/sendMessage",
|
|
135
|
+
params={"chat_id": chat, "text": text}, timeout=15)
|
|
136
|
+
return r.ok
|
|
137
|
+
except requests.RequestException:
|
|
138
|
+
return False
|