crypttrace 0.8.0__tar.gz → 0.10.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.8.0/src/crypttrace.egg-info → crypttrace-0.10.0}/PKG-INFO +21 -9
- {crypttrace-0.8.0 → crypttrace-0.10.0}/README.md +20 -8
- {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/__init__.py +1 -1
- {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/cli.py +22 -7
- {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/fetchers/tron.py +24 -4
- {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/poisoning.py +31 -6
- crypttrace-0.10.0/src/crypttrace/watch.py +194 -0
- {crypttrace-0.8.0 → crypttrace-0.10.0/src/crypttrace.egg-info}/PKG-INFO +21 -9
- crypttrace-0.8.0/src/crypttrace/watch.py +0 -138
- {crypttrace-0.8.0 → crypttrace-0.10.0}/LICENSE +0 -0
- {crypttrace-0.8.0 → crypttrace-0.10.0}/pyproject.toml +0 -0
- {crypttrace-0.8.0 → crypttrace-0.10.0}/setup.cfg +0 -0
- {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/addresses.py +0 -0
- {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/analysis.py +0 -0
- {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/assess.py +0 -0
- {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/assets.py +0 -0
- {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/bridges.py +0 -0
- {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/chains.py +0 -0
- {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/config.py +0 -0
- {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/fetchers/__init__.py +0 -0
- {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/fetchers/bitcoin.py +0 -0
- {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/fetchers/etherscan.py +0 -0
- {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/fetchers/http.py +0 -0
- {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/fetchers/solana.py +0 -0
- {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/freeze.py +0 -0
- {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/funder.py +0 -0
- {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/investigate.py +0 -0
- {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/labels/__init__.py +0 -0
- {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/labels/audit.py +0 -0
- {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/labels/bulk.py +0 -0
- {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/labels/known.json +0 -0
- {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/labels/labels.py +0 -0
- {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/labels/partial.json +0 -0
- {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/offramp.py +0 -0
- {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/prices.py +0 -0
- {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/render.py +0 -0
- {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/report.py +0 -0
- {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/store.py +0 -0
- {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/trace.py +0 -0
- {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/verify.py +0 -0
- {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/web/index.html +0 -0
- {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace/webapp.py +0 -0
- {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace.egg-info/SOURCES.txt +0 -0
- {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace.egg-info/dependency_links.txt +0 -0
- {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace.egg-info/entry_points.txt +0 -0
- {crypttrace-0.8.0 → crypttrace-0.10.0}/src/crypttrace.egg-info/requires.txt +0 -0
- {crypttrace-0.8.0 → crypttrace-0.10.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.10.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
|
|
@@ -226,7 +226,7 @@ crypttrace timeline bc1qADDRESS --chain btc
|
|
|
226
226
|
crypttrace crosschain 0xADDRESS --window 48
|
|
227
227
|
|
|
228
228
|
# Watch addresses; alert loudly the moment funds head for an exchange
|
|
229
|
-
crypttrace watch add
|
|
229
|
+
crypttrace watch add TADDRESS --chain tron --note "my stolen USDT"
|
|
230
230
|
crypttrace watch run --interval 300 # or --once for cron / Task Scheduler
|
|
231
231
|
|
|
232
232
|
# Full investigation as a case file to send: one HTML page (plus Markdown + JSON)
|
|
@@ -277,15 +277,22 @@ toward an exchange (directly, or to a detected deposit address); quieter notices
|
|
|
277
277
|
for other movement. It only alerts on activity *after* an address is added, and
|
|
278
278
|
never double-alerts.
|
|
279
279
|
|
|
280
|
+
It works on every supported chain and watches the native coin **and the
|
|
281
|
+
stablecoins** (USDT, USDC) — on Tron, stolen money is almost always USDT.
|
|
282
|
+
Counterfeit "USDT" and zero/dust transfers from address poisoners do not
|
|
283
|
+
alert; they are summed up in one line per address with a warning never to copy
|
|
284
|
+
those senders. Each check reads fresh data.
|
|
285
|
+
|
|
280
286
|
```bash
|
|
281
|
-
crypttrace watch add
|
|
287
|
+
crypttrace watch add TADDRESS --chain tron --note "victim funds"
|
|
282
288
|
crypttrace watch list
|
|
283
289
|
crypttrace watch run --interval 300 # continuous
|
|
284
290
|
crypttrace watch run --once # single check, for scheduled tasks
|
|
285
291
|
```
|
|
286
292
|
|
|
287
293
|
Optional Telegram alerts: set `CRYPTTRACE_TG_TOKEN` and `CRYPTTRACE_TG_CHAT`,
|
|
288
|
-
then pass `--telegram`.
|
|
294
|
+
then pass `--telegram`. Each alert carries the chain, amount, asset and a link
|
|
295
|
+
to the transaction.
|
|
289
296
|
|
|
290
297
|
### Stablecoin freezes
|
|
291
298
|
|
|
@@ -331,11 +338,16 @@ Tested on a documented Tron case from August 2026: from the victim's wallet it
|
|
|
331
338
|
finds the 2,527,862 USDT sent to `TDDDHi…rCr9Ps`, a look-alike of
|
|
332
339
|
`TDDD34…rCr9Ps`, an address the wallet had paid 8.4M USDT in all. From the
|
|
333
340
|
attacker's side it finds the same payment and two more victims of $2.4M and
|
|
334
|
-
$2.0M, each lured by a look-alike created three to twelve minutes earlier
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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.
|
|
343
|
+
|
|
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.
|
|
339
351
|
|
|
340
352
|
### Off-ramp detection
|
|
341
353
|
|
|
@@ -192,7 +192,7 @@ crypttrace timeline bc1qADDRESS --chain btc
|
|
|
192
192
|
crypttrace crosschain 0xADDRESS --window 48
|
|
193
193
|
|
|
194
194
|
# Watch addresses; alert loudly the moment funds head for an exchange
|
|
195
|
-
crypttrace watch add
|
|
195
|
+
crypttrace watch add TADDRESS --chain tron --note "my stolen USDT"
|
|
196
196
|
crypttrace watch run --interval 300 # or --once for cron / Task Scheduler
|
|
197
197
|
|
|
198
198
|
# Full investigation as a case file to send: one HTML page (plus Markdown + JSON)
|
|
@@ -243,15 +243,22 @@ toward an exchange (directly, or to a detected deposit address); quieter notices
|
|
|
243
243
|
for other movement. It only alerts on activity *after* an address is added, and
|
|
244
244
|
never double-alerts.
|
|
245
245
|
|
|
246
|
+
It works on every supported chain and watches the native coin **and the
|
|
247
|
+
stablecoins** (USDT, USDC) — on Tron, stolen money is almost always USDT.
|
|
248
|
+
Counterfeit "USDT" and zero/dust transfers from address poisoners do not
|
|
249
|
+
alert; they are summed up in one line per address with a warning never to copy
|
|
250
|
+
those senders. Each check reads fresh data.
|
|
251
|
+
|
|
246
252
|
```bash
|
|
247
|
-
crypttrace watch add
|
|
253
|
+
crypttrace watch add TADDRESS --chain tron --note "victim funds"
|
|
248
254
|
crypttrace watch list
|
|
249
255
|
crypttrace watch run --interval 300 # continuous
|
|
250
256
|
crypttrace watch run --once # single check, for scheduled tasks
|
|
251
257
|
```
|
|
252
258
|
|
|
253
259
|
Optional Telegram alerts: set `CRYPTTRACE_TG_TOKEN` and `CRYPTTRACE_TG_CHAT`,
|
|
254
|
-
then pass `--telegram`.
|
|
260
|
+
then pass `--telegram`. Each alert carries the chain, amount, asset and a link
|
|
261
|
+
to the transaction.
|
|
255
262
|
|
|
256
263
|
### Stablecoin freezes
|
|
257
264
|
|
|
@@ -297,11 +304,16 @@ Tested on a documented Tron case from August 2026: from the victim's wallet it
|
|
|
297
304
|
finds the 2,527,862 USDT sent to `TDDDHi…rCr9Ps`, a look-alike of
|
|
298
305
|
`TDDD34…rCr9Ps`, an address the wallet had paid 8.4M USDT in all. From the
|
|
299
306
|
attacker's side it finds the same payment and two more victims of $2.4M and
|
|
300
|
-
$2.0M, each lured by a look-alike created three to twelve minutes earlier
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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.
|
|
309
|
+
|
|
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.
|
|
305
317
|
|
|
306
318
|
### Off-ramp detection
|
|
307
319
|
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""crypttrace — OSINT crypto investigation CLI."""
|
|
2
|
-
__version__ = "0.
|
|
2
|
+
__version__ = "0.10.0"
|
|
@@ -836,12 +836,16 @@ app.add_typer(watch_app, name="watch")
|
|
|
836
836
|
|
|
837
837
|
@watch_app.command("add")
|
|
838
838
|
def watch_add(
|
|
839
|
-
address: str = typer.Argument(..., help="Address to watch
|
|
839
|
+
address: str = typer.Argument(..., help="Address to watch"),
|
|
840
840
|
chain: str = CHAIN_OPT,
|
|
841
|
-
note: str = typer.Option("", "--note", "-n", help="A label for this case, e.g. 'my stolen
|
|
841
|
+
note: str = typer.Option("", "--note", "-n", help="A label for this case, e.g. 'my stolen USDT'"),
|
|
842
842
|
):
|
|
843
843
|
"""Add an address to the watchlist (alerts only on activity from now on)."""
|
|
844
|
-
|
|
844
|
+
try:
|
|
845
|
+
watch_mod.add(address, chain, note)
|
|
846
|
+
except (watch_mod.WatchError, chains_mod.ChainError) as e:
|
|
847
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
848
|
+
raise typer.Exit(1)
|
|
845
849
|
console.print(f"[green]✓ Watching[/green] {address} ({chain})"
|
|
846
850
|
+ (f" — {note}" if note else ""))
|
|
847
851
|
|
|
@@ -851,7 +855,8 @@ def watch_list():
|
|
|
851
855
|
"""Show the watchlist."""
|
|
852
856
|
d = watch_mod.all_watched()
|
|
853
857
|
if not d:
|
|
854
|
-
console.print("[dim]Watchlist is empty. Add one with
|
|
858
|
+
console.print("[dim]Watchlist is empty. Add one with "
|
|
859
|
+
"`crypttrace watch add ADDRESS --chain tron`[/dim]")
|
|
855
860
|
return
|
|
856
861
|
for addr, m in d.items():
|
|
857
862
|
console.print(f" {addr} ({m.get('chain','eth')})"
|
|
@@ -873,7 +878,7 @@ def _render_alert(e: dict) -> None:
|
|
|
873
878
|
when = render._ts(str(e["timestamp"]))
|
|
874
879
|
line = (f"{icon} [{style}]{e['sev'].upper()}[/{style}] {e['address'][:12]}…"
|
|
875
880
|
+ (f" ({e['note']})" if e.get("note") else "")
|
|
876
|
-
+ f" {e['value']
|
|
881
|
+
+ f" {e['value']:,.4f} {e.get('symbol', '')} {e['reason']} [dim]{when}[/dim]")
|
|
877
882
|
if e["sev"] == "high":
|
|
878
883
|
console.bell() # audible bell for cash-out events
|
|
879
884
|
console.print(line)
|
|
@@ -888,7 +893,8 @@ def watch_run(
|
|
|
888
893
|
):
|
|
889
894
|
"""Poll the watchlist and alert on new activity. Loud alert on likely cash-out."""
|
|
890
895
|
if not watch_mod.all_watched():
|
|
891
|
-
console.print("[dim]Watchlist is empty. Add one with
|
|
896
|
+
console.print("[dim]Watchlist is empty. Add one with "
|
|
897
|
+
"`crypttrace watch add ADDRESS --chain tron`[/dim]")
|
|
892
898
|
raise typer.Exit(1)
|
|
893
899
|
|
|
894
900
|
def _pass():
|
|
@@ -896,11 +902,20 @@ def watch_run(
|
|
|
896
902
|
if not alerts:
|
|
897
903
|
console.print(f"[dim]{render._ts(str(int(time.time())))} — no new activity[/dim]")
|
|
898
904
|
return
|
|
905
|
+
dust = {}
|
|
899
906
|
for e in alerts:
|
|
907
|
+
if e["sev"] == "dust": # one line per address, not one per speck
|
|
908
|
+
dust[e["address"]] = dust.get(e["address"], 0) + 1
|
|
909
|
+
continue
|
|
900
910
|
_render_alert(e)
|
|
901
911
|
if telegram and e["sev"] in ("high", "move"):
|
|
902
|
-
msg = f"crypttrace {e['sev'].upper()}
|
|
912
|
+
msg = (f"crypttrace {e['sev'].upper()} ({e.get('chain', '')}): {e['address']} "
|
|
913
|
+
f"{e['value']:,.4f} {e.get('symbol', '')} {e['reason']}"
|
|
914
|
+
+ (f"\n{e['explorer']}" if e.get("explorer") else ""))
|
|
903
915
|
watch_mod.telegram_notify(msg)
|
|
916
|
+
for addr, n in dust.items():
|
|
917
|
+
console.print(f"[dim] {addr[:12]}… {n} zero/dust transfer(s) — likely address "
|
|
918
|
+
"poisoning; never copy an address from this wallet's history[/dim]")
|
|
904
919
|
|
|
905
920
|
if once:
|
|
906
921
|
_pass()
|
|
@@ -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]
|
|
@@ -0,0 +1,194 @@
|
|
|
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
|
+
It works on every supported chain and watches the native coin *and* the
|
|
10
|
+
stablecoins (USDT, USDC) — most thefts move stablecoins, and on Tron almost
|
|
11
|
+
nothing else. Each poll reads fresh data, never the local store's copy.
|
|
12
|
+
|
|
13
|
+
Honest limit: the tool tells you *when* to act; freezing funds still depends on
|
|
14
|
+
the exchange and law enforcement responding quickly.
|
|
15
|
+
"""
|
|
16
|
+
import json
|
|
17
|
+
from typing import Dict, List, Optional, Tuple
|
|
18
|
+
|
|
19
|
+
import requests
|
|
20
|
+
|
|
21
|
+
from crypttrace.labels import labels
|
|
22
|
+
from crypttrace import addresses, assets, chains, config, offramp, poisoning
|
|
23
|
+
|
|
24
|
+
WATCHLIST = config.DATA_DIR / "watchlist.json"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class WatchError(ValueError):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _load() -> Dict[str, dict]:
|
|
32
|
+
if WATCHLIST.exists():
|
|
33
|
+
try:
|
|
34
|
+
return json.loads(WATCHLIST.read_text(encoding="utf-8"))
|
|
35
|
+
except (ValueError, OSError):
|
|
36
|
+
return {}
|
|
37
|
+
return {}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _save(d: Dict[str, dict]) -> None:
|
|
41
|
+
WATCHLIST.parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
WATCHLIST.write_text(json.dumps(d, indent=2), encoding="utf-8")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
_ALL_TOKENS = {"contract": None, "symbol": "*"}
|
|
46
|
+
_SPL = {"contract": "spl", "symbol": "SPL"}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _key(row: dict) -> str:
|
|
50
|
+
return "|".join(str(row.get(k, "")) for k in ("hash", "from", "to", "value", "symbol"))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _rows(address: str, chain: str, limit: int) -> List[dict]:
|
|
54
|
+
"""Fresh native and stablecoin transfers, each tagged with the asset it belongs to.
|
|
55
|
+
|
|
56
|
+
Tokens are read in one request and kept only when their contract is the real
|
|
57
|
+
stablecoin's — counterfeit "USDT" sent by address poisoners must not alert."""
|
|
58
|
+
rows = [{**r, "_asset": None} for r in chains.transfers(address, chain, limit, fresh=True)]
|
|
59
|
+
if chain == "btc":
|
|
60
|
+
return rows
|
|
61
|
+
try:
|
|
62
|
+
tokens = chains.transfers(address, chain, limit, asset=_ALL_TOKENS, fresh=True)
|
|
63
|
+
except chains.ChainError:
|
|
64
|
+
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
|
+
stables = {t["contract"].lower(): dict(t) for t in assets.tokens_for(chain).values()
|
|
68
|
+
if t.get("stable")}
|
|
69
|
+
return rows + [{**r, "_asset": stables[(r.get("contract") or "").lower()]}
|
|
70
|
+
for r in tokens if (r.get("contract") or "").lower() in stables]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _latest(rows: List[dict]) -> Tuple[int, List[str]]:
|
|
74
|
+
"""Newest timestamp and the keys of every transfer at that second."""
|
|
75
|
+
last = max((int(r.get("timestamp") or 0) for r in rows), default=0)
|
|
76
|
+
return last, [_key(r) for r in rows if int(r.get("timestamp") or 0) == last]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def add(address: str, chain: str = "eth", note: str = "") -> int:
|
|
80
|
+
ok, why = addresses.validate(address, chain)
|
|
81
|
+
if not ok:
|
|
82
|
+
raise WatchError(f"not a valid {chain} address: {why}")
|
|
83
|
+
last, keys = _latest(_rows(address, chain, 50)) # only alert on activity after now
|
|
84
|
+
d = _load()
|
|
85
|
+
d[chains.norm_addr(address, chain)] = {"chain": chain, "note": note,
|
|
86
|
+
"last_ts": last, "last_keys": keys}
|
|
87
|
+
_save(d)
|
|
88
|
+
return last
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def remove(address: str) -> bool:
|
|
92
|
+
d = _load()
|
|
93
|
+
for k in (address, address.lower()):
|
|
94
|
+
if k in d:
|
|
95
|
+
del d[k]
|
|
96
|
+
_save(d)
|
|
97
|
+
return True
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def all_watched() -> Dict[str, dict]:
|
|
102
|
+
return _load()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _classify(me: str, row: dict, chain: str) -> dict:
|
|
106
|
+
frm, to = row.get("from") or "", row.get("to") or ""
|
|
107
|
+
val = row.get("value", 0) or 0
|
|
108
|
+
asset = row.get("_asset")
|
|
109
|
+
symbol = asset["symbol"] if asset else chains.symbol(chain)
|
|
110
|
+
direction = "out" if frm == me else "in"
|
|
111
|
+
other = to if direction == "out" else frm
|
|
112
|
+
otype = labels.type_of(other)
|
|
113
|
+
|
|
114
|
+
sev, reason = "info", "incoming funds" if direction == "in" else "funds moved out"
|
|
115
|
+
if poisoning._is_bait(row, chain):
|
|
116
|
+
# zero, dust or counterfeit: how poisoners plant look-alikes in a history
|
|
117
|
+
sev, reason = "dust", "zero/dust transfer — likely address poisoning; never copy this sender"
|
|
118
|
+
elif direction == "out" and otype == "exchange":
|
|
119
|
+
sev, reason = "high", f"→ EXCHANGE {labels.label_of(other)} — possible cash-out (freeze window)"
|
|
120
|
+
elif direction == "out" and otype in ("mixer", "sanctioned"):
|
|
121
|
+
sev, reason = "high", f"→ {otype} {labels.label_of(other)}"
|
|
122
|
+
elif direction == "out" and otype == "bridge":
|
|
123
|
+
sev, reason = "move", f"→ bridge {labels.label_of(other)} (funds may leave this chain)"
|
|
124
|
+
elif direction == "out" and val > 0:
|
|
125
|
+
try:
|
|
126
|
+
off = offramp.detect(other, chain, asset=asset, stablecoins=False)
|
|
127
|
+
except chains.ChainError:
|
|
128
|
+
off = None
|
|
129
|
+
if off:
|
|
130
|
+
sev, reason = "high", f"→ likely {off['company']} DEPOSIT address — possible cash-out"
|
|
131
|
+
else:
|
|
132
|
+
sev, reason = "move", "funds moved out"
|
|
133
|
+
|
|
134
|
+
return {"direction": direction, "other": other, "otype": otype, "value": val,
|
|
135
|
+
"symbol": symbol, "sev": sev, "reason": reason,
|
|
136
|
+
"timestamp": int(row.get("timestamp") or 0), "hash": row.get("hash", ""),
|
|
137
|
+
"explorer": chains.tx_url(row["hash"], chain) if row.get("hash") else ""}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def check(address: str, chain: str, since_ts: int, seen_keys: Optional[List[str]] = None,
|
|
141
|
+
limit: int = 100) -> List[dict]:
|
|
142
|
+
"""New events since the baseline, newest first, each classified. A transfer in
|
|
143
|
+
the same second as the baseline counts as new unless it was already seen."""
|
|
144
|
+
me = chains.norm_addr(address, chain)
|
|
145
|
+
seen = set(seen_keys or [])
|
|
146
|
+
rows = [r for r in _rows(address, chain, limit)
|
|
147
|
+
if int(r.get("timestamp") or 0) > since_ts
|
|
148
|
+
or (int(r.get("timestamp") or 0) == since_ts and _key(r) not in seen)]
|
|
149
|
+
rows.sort(key=lambda r: int(r.get("timestamp") or 0), reverse=True)
|
|
150
|
+
return [dict(_classify(me, r, chain), _key=_key(r)) for r in rows]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def poll_once() -> List[dict]:
|
|
154
|
+
"""Check every watched address, update baselines, return all new alerts."""
|
|
155
|
+
d = _load()
|
|
156
|
+
alerts = []
|
|
157
|
+
for addr, meta in d.items():
|
|
158
|
+
chain = meta.get("chain", "eth")
|
|
159
|
+
try:
|
|
160
|
+
events = check(addr, chain, meta.get("last_ts", 0), meta.get("last_keys"))
|
|
161
|
+
except chains.ChainError:
|
|
162
|
+
continue
|
|
163
|
+
if events:
|
|
164
|
+
last = max(e["timestamp"] for e in events)
|
|
165
|
+
keys = [e["_key"] for e in events if e["timestamp"] == last]
|
|
166
|
+
if last == meta.get("last_ts"):
|
|
167
|
+
keys += meta.get("last_keys", [])
|
|
168
|
+
meta["last_ts"], meta["last_keys"] = last, keys
|
|
169
|
+
for e in events:
|
|
170
|
+
e.pop("_key", None)
|
|
171
|
+
e["address"] = addr
|
|
172
|
+
e["chain"] = chain
|
|
173
|
+
e["note"] = meta.get("note", "")
|
|
174
|
+
alerts.append(e)
|
|
175
|
+
if alerts:
|
|
176
|
+
_save(d)
|
|
177
|
+
# highest severity first
|
|
178
|
+
order = {"high": 0, "move": 1, "info": 2, "dust": 3}
|
|
179
|
+
return sorted(alerts, key=lambda e: order.get(e["sev"], 3))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def telegram_notify(text: str) -> Optional[bool]:
|
|
183
|
+
"""Send an alert to Telegram if CRYPTTRACE_TG_TOKEN + CRYPTTRACE_TG_CHAT are set."""
|
|
184
|
+
import os
|
|
185
|
+
token = os.environ.get("CRYPTTRACE_TG_TOKEN")
|
|
186
|
+
chat = os.environ.get("CRYPTTRACE_TG_CHAT")
|
|
187
|
+
if not token or not chat:
|
|
188
|
+
return None
|
|
189
|
+
try:
|
|
190
|
+
r = requests.get(f"https://api.telegram.org/bot{token}/sendMessage",
|
|
191
|
+
params={"chat_id": chat, "text": text}, timeout=15)
|
|
192
|
+
return r.ok
|
|
193
|
+
except requests.RequestException:
|
|
194
|
+
return False
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: crypttrace
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.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
|
|
@@ -226,7 +226,7 @@ crypttrace timeline bc1qADDRESS --chain btc
|
|
|
226
226
|
crypttrace crosschain 0xADDRESS --window 48
|
|
227
227
|
|
|
228
228
|
# Watch addresses; alert loudly the moment funds head for an exchange
|
|
229
|
-
crypttrace watch add
|
|
229
|
+
crypttrace watch add TADDRESS --chain tron --note "my stolen USDT"
|
|
230
230
|
crypttrace watch run --interval 300 # or --once for cron / Task Scheduler
|
|
231
231
|
|
|
232
232
|
# Full investigation as a case file to send: one HTML page (plus Markdown + JSON)
|
|
@@ -277,15 +277,22 @@ toward an exchange (directly, or to a detected deposit address); quieter notices
|
|
|
277
277
|
for other movement. It only alerts on activity *after* an address is added, and
|
|
278
278
|
never double-alerts.
|
|
279
279
|
|
|
280
|
+
It works on every supported chain and watches the native coin **and the
|
|
281
|
+
stablecoins** (USDT, USDC) — on Tron, stolen money is almost always USDT.
|
|
282
|
+
Counterfeit "USDT" and zero/dust transfers from address poisoners do not
|
|
283
|
+
alert; they are summed up in one line per address with a warning never to copy
|
|
284
|
+
those senders. Each check reads fresh data.
|
|
285
|
+
|
|
280
286
|
```bash
|
|
281
|
-
crypttrace watch add
|
|
287
|
+
crypttrace watch add TADDRESS --chain tron --note "victim funds"
|
|
282
288
|
crypttrace watch list
|
|
283
289
|
crypttrace watch run --interval 300 # continuous
|
|
284
290
|
crypttrace watch run --once # single check, for scheduled tasks
|
|
285
291
|
```
|
|
286
292
|
|
|
287
293
|
Optional Telegram alerts: set `CRYPTTRACE_TG_TOKEN` and `CRYPTTRACE_TG_CHAT`,
|
|
288
|
-
then pass `--telegram`.
|
|
294
|
+
then pass `--telegram`. Each alert carries the chain, amount, asset and a link
|
|
295
|
+
to the transaction.
|
|
289
296
|
|
|
290
297
|
### Stablecoin freezes
|
|
291
298
|
|
|
@@ -331,11 +338,16 @@ Tested on a documented Tron case from August 2026: from the victim's wallet it
|
|
|
331
338
|
finds the 2,527,862 USDT sent to `TDDDHi…rCr9Ps`, a look-alike of
|
|
332
339
|
`TDDD34…rCr9Ps`, an address the wallet had paid 8.4M USDT in all. From the
|
|
333
340
|
attacker's side it finds the same payment and two more victims of $2.4M and
|
|
334
|
-
$2.0M, each lured by a look-alike created three to twelve minutes earlier
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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.
|
|
343
|
+
|
|
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.
|
|
339
351
|
|
|
340
352
|
### Off-ramp detection
|
|
341
353
|
|
|
@@ -1,138 +0,0 @@
|
|
|
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 chains, 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, chains.ChainError):
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|