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/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