crypttrace 0.30.0__tar.gz → 0.32.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.30.0/src/crypttrace.egg-info → crypttrace-0.32.0}/PKG-INFO +11 -4
- {crypttrace-0.30.0 → crypttrace-0.32.0}/README.md +10 -3
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/__init__.py +1 -1
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/addresses.py +24 -0
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/analysis.py +1 -1
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/assets.py +2 -2
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/chains.py +18 -10
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/cli.py +25 -4
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/drain.py +1 -1
- crypttrace-0.32.0/src/crypttrace/export.py +94 -0
- crypttrace-0.32.0/src/crypttrace/fetchers/bitcoin.py +287 -0
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/fetchers/http.py +2 -0
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/labels/known.json +72 -0
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/poisoning.py +1 -1
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/prices.py +2 -1
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/store.py +7 -0
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/verify.py +20 -7
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/watch.py +1 -1
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/web/index.html +21 -1
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/webapp.py +14 -1
- {crypttrace-0.30.0 → crypttrace-0.32.0/src/crypttrace.egg-info}/PKG-INFO +11 -4
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace.egg-info/SOURCES.txt +1 -0
- crypttrace-0.30.0/src/crypttrace/fetchers/bitcoin.py +0 -186
- {crypttrace-0.30.0 → crypttrace-0.32.0}/LICENSE +0 -0
- {crypttrace-0.30.0 → crypttrace-0.32.0}/pyproject.toml +0 -0
- {crypttrace-0.30.0 → crypttrace-0.32.0}/setup.cfg +0 -0
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/assess.py +0 -0
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/bridges.py +0 -0
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/config.py +0 -0
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/evidence.py +0 -0
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/fetchers/__init__.py +0 -0
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/fetchers/etherscan.py +0 -0
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/fetchers/solana.py +0 -0
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/fetchers/ton.py +0 -0
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/fetchers/tron.py +0 -0
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/freeze.py +0 -0
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/funder.py +0 -0
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/gascluster.py +0 -0
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/investigate.py +0 -0
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/labels/__init__.py +0 -0
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/labels/audit.py +0 -0
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/labels/bulk.py +0 -0
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/labels/labels.py +0 -0
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/labels/names.py +0 -0
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/labels/partial.json +0 -0
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/labels/tonapi.py +0 -0
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/labels/tronscan.py +0 -0
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/offramp.py +0 -0
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/render.py +0 -0
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/report.py +0 -0
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/spam.py +0 -0
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/trace.py +0 -0
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace.egg-info/dependency_links.txt +0 -0
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace.egg-info/entry_points.txt +0 -0
- {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace.egg-info/requires.txt +0 -0
- {crypttrace-0.30.0 → crypttrace-0.32.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.32.0
|
|
4
4
|
Summary: OSINT toolkit for crypto investigations: trace stolen funds across Ethereum, Bitcoin, Tron, Solana and TON
|
|
5
5
|
Author: bobslayerX
|
|
6
6
|
License-Expression: MIT
|
|
@@ -42,7 +42,7 @@ Dynamic: license-file
|
|
|
42
42
|
Open-source OSINT toolkit for crypto investigations. Give it a suspicious
|
|
43
43
|
address; it pulls the public on-chain history, labels known entities (exchanges,
|
|
44
44
|
mixers, sanctioned wallets), scores risk, and maps where the funds moved —
|
|
45
|
-
across Ethereum, Bitcoin, Tron, Solana, TON and more.
|
|
45
|
+
across Ethereum, Bitcoin, Tron, Solana, TON, Litecoin, Dogecoin and more.
|
|
46
46
|
|
|
47
47
|
Built for the investigation that actually happens: *someone's crypto was stolen,
|
|
48
48
|
here's the wallet — where did the money go, and where can it still be stopped?*
|
|
@@ -80,7 +80,7 @@ python selftest.py # offline checks, no API keys needed
|
|
|
80
80
|
| | Needed? | How to get it |
|
|
81
81
|
|---|---|---|
|
|
82
82
|
| **EVM chains** (Ethereum, BSC, Polygon, Arbitrum, Optimism, Base) | **Required** | Free key at [etherscan.io/myapikey](https://etherscan.io/myapikey) covers Ethereum, Polygon and Arbitrum. BSC, Optimism and Base need a paid Etherscan plan; on a free key crypttrace says so instead of showing an empty wallet. |
|
|
83
|
-
| **Bitcoin, Tron, Solana, TON** | **Not required** | Work out of the box via public endpoints. |
|
|
83
|
+
| **Bitcoin, Litecoin, Dogecoin, Tron, Solana, TON** | **Not required** | Work out of the box via public endpoints. Dogecoin reads BlockCypher's free tier, about 100 requests an hour, so it reads the latest 200 transactions of an address. |
|
|
84
84
|
|
|
85
85
|
```bash
|
|
86
86
|
export ETHERSCAN_API_KEY=xxxx # required for EVM chains only
|
|
@@ -122,6 +122,8 @@ the number either way.
|
|
|
122
122
|
|---|---|---|---|
|
|
123
123
|
| Ethereum, BSC, Polygon, Arbitrum, Optimism, Base | `eth` `bsc` `polygon` `arbitrum` `optimism` `base` | Etherscan v2 (BSC, Optimism, Base: paid plan) | account |
|
|
124
124
|
| Bitcoin | `btc` | mempool.space | UTXO |
|
|
125
|
+
| Litecoin | `ltc` | litecoinspace.org | UTXO |
|
|
126
|
+
| Dogecoin | `doge` | BlockCypher | UTXO |
|
|
125
127
|
| Tron (TRX + USDT-TRC20) | `tron` | TronGrid | account |
|
|
126
128
|
| Solana (SOL + SPL) | `sol` | public JSON-RPC | account |
|
|
127
129
|
| TON (TON + jettons, e.g. USDT) | `ton` | toncenter v3 | account |
|
|
@@ -253,6 +255,10 @@ crypttrace trace 0xADDRESS --direction in
|
|
|
253
255
|
# Trace a token rather than the native coin (most thefts are stablecoins)
|
|
254
256
|
crypttrace trace 0xADDRESS --asset usdt --depth 4
|
|
255
257
|
|
|
258
|
+
# Save the traced graph for Gephi / yEd / Cytoscape, or for Maltego / Neo4j
|
|
259
|
+
crypttrace trace 0xADDRESS --depth 4 --export case.graphml
|
|
260
|
+
crypttrace trace 0xADDRESS --depth 4 --export case.csv
|
|
261
|
+
|
|
256
262
|
# Token holdings, valued in USD
|
|
257
263
|
crypttrace tokens 0xADDRESS
|
|
258
264
|
|
|
@@ -472,7 +478,7 @@ with an `nfts` column in the CSV. Failed (aborted) NFT transfers are not counted
|
|
|
472
478
|
|
|
473
479
|
### Bitcoin clustering (common-input-ownership)
|
|
474
480
|
|
|
475
|
-
Bitcoin's UTXO model enables the strongest clustering heuristic in blockchain
|
|
481
|
+
Bitcoin's UTXO model (Litecoin's and Dogecoin's too) enables the strongest clustering heuristic in blockchain
|
|
476
482
|
forensics: if several addresses sign the inputs of one transaction, one party
|
|
477
483
|
almost certainly controls all of them. `cluster` surfaces those co-signers,
|
|
478
484
|
turning a single address into a set of wallets belonging to the same owner. A
|
|
@@ -722,6 +728,7 @@ src/crypttrace/
|
|
|
722
728
|
bridges.py # cross-chain matching
|
|
723
729
|
watch.py # watchlist + alerts
|
|
724
730
|
report.py # Markdown + JSON reports
|
|
731
|
+
export.py # traced graph as GraphML (Gephi) and edge-list CSV (Maltego)
|
|
725
732
|
prices.py # USD valuation
|
|
726
733
|
render.py # rich terminal rendering
|
|
727
734
|
webapp.py # Flask backend for the web UI
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
Open-source OSINT toolkit for crypto investigations. Give it a suspicious
|
|
9
9
|
address; it pulls the public on-chain history, labels known entities (exchanges,
|
|
10
10
|
mixers, sanctioned wallets), scores risk, and maps where the funds moved —
|
|
11
|
-
across Ethereum, Bitcoin, Tron, Solana, TON and more.
|
|
11
|
+
across Ethereum, Bitcoin, Tron, Solana, TON, Litecoin, Dogecoin and more.
|
|
12
12
|
|
|
13
13
|
Built for the investigation that actually happens: *someone's crypto was stolen,
|
|
14
14
|
here's the wallet — where did the money go, and where can it still be stopped?*
|
|
@@ -46,7 +46,7 @@ python selftest.py # offline checks, no API keys needed
|
|
|
46
46
|
| | Needed? | How to get it |
|
|
47
47
|
|---|---|---|
|
|
48
48
|
| **EVM chains** (Ethereum, BSC, Polygon, Arbitrum, Optimism, Base) | **Required** | Free key at [etherscan.io/myapikey](https://etherscan.io/myapikey) covers Ethereum, Polygon and Arbitrum. BSC, Optimism and Base need a paid Etherscan plan; on a free key crypttrace says so instead of showing an empty wallet. |
|
|
49
|
-
| **Bitcoin, Tron, Solana, TON** | **Not required** | Work out of the box via public endpoints. |
|
|
49
|
+
| **Bitcoin, Litecoin, Dogecoin, Tron, Solana, TON** | **Not required** | Work out of the box via public endpoints. Dogecoin reads BlockCypher's free tier, about 100 requests an hour, so it reads the latest 200 transactions of an address. |
|
|
50
50
|
|
|
51
51
|
```bash
|
|
52
52
|
export ETHERSCAN_API_KEY=xxxx # required for EVM chains only
|
|
@@ -88,6 +88,8 @@ the number either way.
|
|
|
88
88
|
|---|---|---|---|
|
|
89
89
|
| Ethereum, BSC, Polygon, Arbitrum, Optimism, Base | `eth` `bsc` `polygon` `arbitrum` `optimism` `base` | Etherscan v2 (BSC, Optimism, Base: paid plan) | account |
|
|
90
90
|
| Bitcoin | `btc` | mempool.space | UTXO |
|
|
91
|
+
| Litecoin | `ltc` | litecoinspace.org | UTXO |
|
|
92
|
+
| Dogecoin | `doge` | BlockCypher | UTXO |
|
|
91
93
|
| Tron (TRX + USDT-TRC20) | `tron` | TronGrid | account |
|
|
92
94
|
| Solana (SOL + SPL) | `sol` | public JSON-RPC | account |
|
|
93
95
|
| TON (TON + jettons, e.g. USDT) | `ton` | toncenter v3 | account |
|
|
@@ -219,6 +221,10 @@ crypttrace trace 0xADDRESS --direction in
|
|
|
219
221
|
# Trace a token rather than the native coin (most thefts are stablecoins)
|
|
220
222
|
crypttrace trace 0xADDRESS --asset usdt --depth 4
|
|
221
223
|
|
|
224
|
+
# Save the traced graph for Gephi / yEd / Cytoscape, or for Maltego / Neo4j
|
|
225
|
+
crypttrace trace 0xADDRESS --depth 4 --export case.graphml
|
|
226
|
+
crypttrace trace 0xADDRESS --depth 4 --export case.csv
|
|
227
|
+
|
|
222
228
|
# Token holdings, valued in USD
|
|
223
229
|
crypttrace tokens 0xADDRESS
|
|
224
230
|
|
|
@@ -438,7 +444,7 @@ with an `nfts` column in the CSV. Failed (aborted) NFT transfers are not counted
|
|
|
438
444
|
|
|
439
445
|
### Bitcoin clustering (common-input-ownership)
|
|
440
446
|
|
|
441
|
-
Bitcoin's UTXO model enables the strongest clustering heuristic in blockchain
|
|
447
|
+
Bitcoin's UTXO model (Litecoin's and Dogecoin's too) enables the strongest clustering heuristic in blockchain
|
|
442
448
|
forensics: if several addresses sign the inputs of one transaction, one party
|
|
443
449
|
almost certainly controls all of them. `cluster` surfaces those co-signers,
|
|
444
450
|
turning a single address into a set of wallets belonging to the same owner. A
|
|
@@ -688,6 +694,7 @@ src/crypttrace/
|
|
|
688
694
|
bridges.py # cross-chain matching
|
|
689
695
|
watch.py # watchlist + alerts
|
|
690
696
|
report.py # Markdown + JSON reports
|
|
697
|
+
export.py # traced graph as GraphML (Gephi) and edge-list CSV (Maltego)
|
|
691
698
|
prices.py # USD valuation
|
|
692
699
|
render.py # rich terminal rendering
|
|
693
700
|
webapp.py # Flask backend for the web UI
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""crypttrace — OSINT crypto investigation CLI."""
|
|
2
|
-
__version__ = "0.
|
|
2
|
+
__version__ = "0.32.0"
|
|
@@ -82,6 +82,18 @@ def check_base58check(addr: str, expect_prefix: Optional[bytes] = None) -> Tuple
|
|
|
82
82
|
return True, "base58check checksum valid"
|
|
83
83
|
|
|
84
84
|
|
|
85
|
+
def _versioned(addr: str, versions: dict, name: str) -> Tuple[bool, str]:
|
|
86
|
+
"""base58check plus the chain's version byte — a Bitcoin '3…' address passes the
|
|
87
|
+
checksum too, so the version is what tells the chains apart."""
|
|
88
|
+
ok, why = check_base58check(addr)
|
|
89
|
+
if not ok:
|
|
90
|
+
return ok, why
|
|
91
|
+
v = _b58_decode(addr)[0]
|
|
92
|
+
if v not in versions:
|
|
93
|
+
return False, f"version byte {v:#04x} is not a {name} address"
|
|
94
|
+
return True, "base58check checksum valid"
|
|
95
|
+
|
|
96
|
+
|
|
85
97
|
# ---------------------------------------------------------------- per chain
|
|
86
98
|
|
|
87
99
|
def validate(address: str, chain: str) -> Tuple[bool, str]:
|
|
@@ -108,6 +120,18 @@ def validate(address: str, chain: str) -> Tuple[bool, str]:
|
|
|
108
120
|
return check_base58check(a)
|
|
109
121
|
return False, "not a recognised Bitcoin address format"
|
|
110
122
|
|
|
123
|
+
if chain == "ltc":
|
|
124
|
+
if a.lower().startswith("ltc1"):
|
|
125
|
+
return check_bech32(a, "ltc")
|
|
126
|
+
if a.startswith(("L", "M", "3")):
|
|
127
|
+
return _versioned(a, {0x30: "L", 0x32: "M", 0x05: "3"}, "Litecoin")
|
|
128
|
+
return False, "not a recognised Litecoin address format (L…, M…, 3… or ltc1…)"
|
|
129
|
+
|
|
130
|
+
if chain == "doge":
|
|
131
|
+
if a.startswith(("D", "A", "9")):
|
|
132
|
+
return _versioned(a, {0x1E: "D", 0x16: "A/9"}, "Dogecoin")
|
|
133
|
+
return False, "not a recognised Dogecoin address format (D…, A… or 9…)"
|
|
134
|
+
|
|
111
135
|
if chain == "tron":
|
|
112
136
|
if not a.startswith("T") or len(a) != 34:
|
|
113
137
|
return False, "Tron addresses start with T and are 34 characters"
|
|
@@ -22,7 +22,7 @@ from crypttrace.labels import labels
|
|
|
22
22
|
# Amounts below these are treated as dust: spam sent to well-known addresses to
|
|
23
23
|
# pollute their history (and sometimes to deanonymise the owner). Left in, dust
|
|
24
24
|
# drowns out the transfers an investigation is actually about.
|
|
25
|
-
DUST = {"btc": 0.0005, "eth": 0.002, "bsc": 0.005, "polygon": 5.0,
|
|
25
|
+
DUST = {"btc": 0.0005, "ltc": 0.001, "doge": 5.0, "eth": 0.002, "bsc": 0.005, "polygon": 5.0,
|
|
26
26
|
"arbitrum": 0.002, "optimism": 0.002, "base": 0.002,
|
|
27
27
|
"tron": 5.0, "sol": 0.01, "ton": 0.01}
|
|
28
28
|
|
|
@@ -69,8 +69,8 @@ def resolve_asset(asset: Optional[str], chain: str = "eth") -> Optional[dict]:
|
|
|
69
69
|
elif a.startswith("0x") and len(a) == 42:
|
|
70
70
|
return {"contract": a, "symbol": asset.upper()[:8], "stable": False}
|
|
71
71
|
|
|
72
|
-
if chain
|
|
73
|
-
raise ValueError("
|
|
72
|
+
if chain in ("btc", "ltc", "doge"):
|
|
73
|
+
raise ValueError(f"{chain.upper()} has no tokens — omit --asset (or use --asset {chain}).")
|
|
74
74
|
|
|
75
75
|
opts = ", ".join(known) or "none registered"
|
|
76
76
|
raise ValueError(f"Unknown asset '{asset}' on {chain}. Known symbols: {opts}. "
|
|
@@ -19,17 +19,19 @@ from crypttrace.labels import labels as _labels
|
|
|
19
19
|
from crypttrace.fetchers import etherscan, bitcoin, http as _http, tron, solana, ton
|
|
20
20
|
|
|
21
21
|
EVM_CHAINS = set(config.CHAINS)
|
|
22
|
-
NON_EVM = {"btc", "tron", "sol", "ton"}
|
|
22
|
+
NON_EVM = {"btc", "ltc", "doge", "tron", "sol", "ton"}
|
|
23
23
|
ALL_CHAINS = sorted(EVM_CHAINS | NON_EVM)
|
|
24
24
|
|
|
25
25
|
SYMBOL = {"eth": "ETH", "bsc": "BNB", "polygon": "MATIC", "arbitrum": "ETH",
|
|
26
|
-
"optimism": "ETH", "base": "ETH", "btc": "BTC", "
|
|
26
|
+
"optimism": "ETH", "base": "ETH", "btc": "BTC", "ltc": "LTC", "doge": "DOGE", "tron": "TRX", "sol": "SOL",
|
|
27
|
+
"ton": "TON"}
|
|
27
28
|
|
|
28
29
|
EXPLORER = {
|
|
29
30
|
"eth": "https://etherscan.io/address/{}", "bsc": "https://bscscan.com/address/{}",
|
|
30
31
|
"polygon": "https://polygonscan.com/address/{}", "arbitrum": "https://arbiscan.io/address/{}",
|
|
31
32
|
"optimism": "https://optimistic.etherscan.io/address/{}", "base": "https://basescan.org/address/{}",
|
|
32
|
-
"btc": "https://mempool.space/address/{}", "
|
|
33
|
+
"btc": "https://mempool.space/address/{}", "ltc": "https://litecoinspace.org/address/{}",
|
|
34
|
+
"doge": "https://blockchair.com/dogecoin/address/{}", "tron": "https://tronscan.org/#/address/{}",
|
|
33
35
|
"sol": "https://solscan.io/account/{}", "ton": "https://tonviewer.com/{}",
|
|
34
36
|
}
|
|
35
37
|
|
|
@@ -37,7 +39,8 @@ TX_EXPLORER = {
|
|
|
37
39
|
"eth": "https://etherscan.io/tx/{}", "bsc": "https://bscscan.com/tx/{}",
|
|
38
40
|
"polygon": "https://polygonscan.com/tx/{}", "arbitrum": "https://arbiscan.io/tx/{}",
|
|
39
41
|
"optimism": "https://optimistic.etherscan.io/tx/{}", "base": "https://basescan.org/tx/{}",
|
|
40
|
-
"btc": "https://mempool.space/tx/{}", "
|
|
42
|
+
"btc": "https://mempool.space/tx/{}", "ltc": "https://litecoinspace.org/tx/{}",
|
|
43
|
+
"doge": "https://blockchair.com/dogecoin/transaction/{}", "tron": "https://tronscan.org/#/transaction/{}",
|
|
41
44
|
"sol": "https://solscan.io/tx/{}", "ton": "https://tonviewer.com/transaction/{}",
|
|
42
45
|
}
|
|
43
46
|
|
|
@@ -53,6 +56,11 @@ def is_evm(chain: str) -> bool:
|
|
|
53
56
|
return chain in EVM_CHAINS
|
|
54
57
|
|
|
55
58
|
|
|
59
|
+
def is_utxo(chain: str) -> bool:
|
|
60
|
+
"""Bitcoin, Litecoin, Dogecoin: outputs and inputs, no tokens."""
|
|
61
|
+
return chain in bitcoin.UTXO_CHAINS
|
|
62
|
+
|
|
63
|
+
|
|
56
64
|
def case_sensitive(chain: str) -> bool:
|
|
57
65
|
"""Bitcoin/Tron/Solana use base58 — address case is significant."""
|
|
58
66
|
return chain in NON_EVM
|
|
@@ -86,8 +94,8 @@ def balance(address: str, chain: str = "eth") -> float:
|
|
|
86
94
|
try:
|
|
87
95
|
if is_evm(chain):
|
|
88
96
|
return etherscan.get_balance(address, chain)
|
|
89
|
-
if chain
|
|
90
|
-
return bitcoin.balance(address)
|
|
97
|
+
if is_utxo(chain):
|
|
98
|
+
return bitcoin.balance(address, chain)
|
|
91
99
|
if chain == "tron":
|
|
92
100
|
return tron.balance(address)
|
|
93
101
|
if chain == "sol":
|
|
@@ -221,10 +229,10 @@ def transfers(address: str, chain: str = "eth", limit: int = 1000,
|
|
|
221
229
|
if is_evm(chain):
|
|
222
230
|
rows = _evm_token(address, chain, contract, limit) if asset \
|
|
223
231
|
else _evm_native(address, chain, limit)
|
|
224
|
-
elif chain
|
|
232
|
+
elif is_utxo(chain):
|
|
225
233
|
if asset:
|
|
226
|
-
raise ChainError("
|
|
227
|
-
rows = bitcoin.transfers(address, limit)
|
|
234
|
+
raise ChainError(f"{bitcoin.NETWORKS[chain]['name']} has no tokens.")
|
|
235
|
+
rows = bitcoin.transfers(address, limit, chain=chain)
|
|
228
236
|
elif chain == "tron":
|
|
229
237
|
# keep TRX and TRC20 strictly separate
|
|
230
238
|
rows = _tron_token(address, contract, limit) if asset \
|
|
@@ -337,7 +345,7 @@ def inflows(address: str, chain: str, top: int, limit: int = 1000, asset=None):
|
|
|
337
345
|
def token_holdings(address: str, chain: str = "eth", limit: int = 1000) -> List[Dict]:
|
|
338
346
|
"""Approximate token holdings from transfer history (net in − out per token)."""
|
|
339
347
|
check(chain)
|
|
340
|
-
if chain
|
|
348
|
+
if is_utxo(chain):
|
|
341
349
|
return []
|
|
342
350
|
me = norm_addr(address, chain)
|
|
343
351
|
try:
|
|
@@ -222,6 +222,9 @@ def trace(
|
|
|
222
222
|
help="Top-N outflows to follow per address"),
|
|
223
223
|
direction: str = typer.Option("out", "--direction", "-D",
|
|
224
224
|
help="'out' = where funds went, 'in' = where they came from"),
|
|
225
|
+
export: Optional[Path] = typer.Option(None, "--export", "-o",
|
|
226
|
+
help="Also save the graph: .graphml (Gephi, yEd, Cytoscape) "
|
|
227
|
+
"or .csv (Maltego, Neo4j, spreadsheets)"),
|
|
225
228
|
):
|
|
226
229
|
"""Trace where funds moved, hop by hop, as a coloured tree (ETH or a token)."""
|
|
227
230
|
try:
|
|
@@ -232,6 +235,10 @@ def trace(
|
|
|
232
235
|
if direction not in ("out", "in"):
|
|
233
236
|
console.print("[red]Error:[/red] --direction must be 'out' or 'in'")
|
|
234
237
|
raise typer.Exit(1)
|
|
238
|
+
fmt = export.suffix.lower().lstrip(".") if export else None
|
|
239
|
+
if export and fmt not in ("graphml", "csv"):
|
|
240
|
+
console.print("[red]Error:[/red] --export takes a .graphml or .csv file name")
|
|
241
|
+
raise typer.Exit(1)
|
|
235
242
|
try:
|
|
236
243
|
tree = trace_mod.build_tree(address, chain, depth, branching, asset_desc, direction)
|
|
237
244
|
except (chains_mod.ChainError, etherscan.EtherscanError) as e:
|
|
@@ -242,6 +249,18 @@ def trace(
|
|
|
242
249
|
"\n[dim]Legend: \U0001F7E2 exchange \U0001F7E3 mixer \U0001F534 sanctioned/scam"
|
|
243
250
|
" \U0001F309 bridge \U0001F504 DEX \U0001F537 service ⚪ unknown[/dim]"
|
|
244
251
|
)
|
|
252
|
+
if export:
|
|
253
|
+
from crypttrace import export as export_mod
|
|
254
|
+
try:
|
|
255
|
+
graph = trace_mod.build_graph(address, chain, depth, branching, asset_desc, direction)
|
|
256
|
+
except (chains_mod.ChainError, etherscan.EtherscanError) as e:
|
|
257
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
258
|
+
raise typer.Exit(1)
|
|
259
|
+
export.write_text(export_mod.render(graph, chain, fmt), encoding="utf-8", newline="")
|
|
260
|
+
console.print(f"\n[green]Graph saved:[/green] {export} — {len(graph['nodes'])} addresses, "
|
|
261
|
+
f"{len(graph['edges'])} transfers"
|
|
262
|
+
+ (" [dim](Maltego: Import Graph from Table, map from/to to an address entity)[/dim]"
|
|
263
|
+
if fmt == "csv" else " [dim](open in Gephi, yEd or Cytoscape)[/dim]"))
|
|
245
264
|
|
|
246
265
|
|
|
247
266
|
@app.command()
|
|
@@ -762,7 +781,7 @@ def verify(
|
|
|
762
781
|
style = _verdict_style(v["status"])
|
|
763
782
|
console.print(f"\n[{style}]{v['status'].upper()}[/{style}] {verify_mod.headline(v)}\n")
|
|
764
783
|
|
|
765
|
-
if v
|
|
784
|
+
if v.get("checks"): # absent when the chain could not be read at all
|
|
766
785
|
t = render.Table(title="Cross-check against the chain", header_style="bold")
|
|
767
786
|
t.add_column("Figure"); t.add_column("Computed", justify="right")
|
|
768
787
|
t.add_column("Chain", justify="right"); t.add_column("", justify="center")
|
|
@@ -803,12 +822,12 @@ def cluster(
|
|
|
803
822
|
"funder set it up. Wallets set up by the same person's wallet are likely one party; a "
|
|
804
823
|
"funding from an exchange, mixer, bridge or any contract links nothing. A lead, not proof.[/dim]")
|
|
805
824
|
return
|
|
806
|
-
if chain
|
|
807
|
-
console.print("[red]Error:[/red] cluster works on Bitcoin and EVM chains")
|
|
825
|
+
if not chains_mod.is_utxo(chain):
|
|
826
|
+
console.print("[red]Error:[/red] cluster works on Bitcoin, Litecoin, Dogecoin and EVM chains")
|
|
808
827
|
raise typer.Exit(1)
|
|
809
828
|
from crypttrace.fetchers import bitcoin
|
|
810
829
|
try:
|
|
811
|
-
peers = bitcoin.cluster(address)
|
|
830
|
+
peers = bitcoin.cluster(address, chain)
|
|
812
831
|
except bitcoin.BitcoinError as e:
|
|
813
832
|
console.print(f"[red]Error:[/red] {e}")
|
|
814
833
|
raise typer.Exit(1)
|
|
@@ -884,6 +903,8 @@ def chains():
|
|
|
884
903
|
console.print(f" {name:<10} chainid {cid}{paid}")
|
|
885
904
|
console.print("\n[bold]Non-EVM[/bold] (no API key needed):")
|
|
886
905
|
console.print(" btc Bitcoin — mempool.space (UTXO)")
|
|
906
|
+
console.print(" ltc Litecoin — litecoinspace.org (UTXO)")
|
|
907
|
+
console.print(" doge Dogecoin — BlockCypher (UTXO; free tier ~100 requests an hour)")
|
|
887
908
|
console.print(" tron Tron — TronGrid (TRX + USDT-TRC20)")
|
|
888
909
|
console.print(" sol Solana — public JSON-RPC")
|
|
889
910
|
console.print(" ton TON — toncenter (TON + jettons such as USDT)")
|
|
@@ -29,7 +29,7 @@ _ALL_TOKENS = {"contract": None, "symbol": "*"}
|
|
|
29
29
|
|
|
30
30
|
def _history(address: str, chain: str, limit: int) -> List[dict]:
|
|
31
31
|
rows = list(chains.transfers(address, chain, limit))
|
|
32
|
-
if chain
|
|
32
|
+
if not chains.is_utxo(chain):
|
|
33
33
|
try:
|
|
34
34
|
rows += [r for r in chains.transfers(address, chain, limit, asset=_ALL_TOKENS)
|
|
35
35
|
if not spam.verdict(chain, r.get("contract", ""), r.get("symbol", ""),
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Export a traced fund-flow graph for the tools analysts already use.
|
|
2
|
+
|
|
3
|
+
GraphML — Gephi, yEd, Cytoscape: nodes keep their label, kind, hop and
|
|
4
|
+
explorer link; edges their amount, USD value, transfer count and
|
|
5
|
+
exchange memo, so a layout can be coloured and sized by them.
|
|
6
|
+
CSV — one row per edge with both ends' labels and kinds: Maltego's
|
|
7
|
+
"Import Graph from Table" (map `from` and `to` to a
|
|
8
|
+
cryptocurrency-address entity), Neo4j's LOAD CSV, a spreadsheet.
|
|
9
|
+
|
|
10
|
+
Both take the dict `trace.build_graph` returns, the same graph the web UI draws.
|
|
11
|
+
"""
|
|
12
|
+
import csv
|
|
13
|
+
import io
|
|
14
|
+
from xml.sax.saxutils import escape, quoteattr
|
|
15
|
+
|
|
16
|
+
from crypttrace import chains
|
|
17
|
+
|
|
18
|
+
NODE_KEYS = [("label", "string"), ("kind", "string"), ("hop", "int"), ("root", "boolean"),
|
|
19
|
+
("trail_ends", "boolean"), ("chain", "string"), ("explorer", "string")]
|
|
20
|
+
EDGE_KEYS = [("amount", "double"), ("symbol", "string"), ("usd", "double"), ("transfers", "int"),
|
|
21
|
+
("memo", "string")]
|
|
22
|
+
CSV_HEAD = ["from", "from_label", "from_kind", "to", "to_label", "to_kind", "amount", "symbol",
|
|
23
|
+
"usd", "transfers", "memo", "chain", "from_explorer", "to_explorer"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _node_attrs(n: dict, chain: str) -> dict:
|
|
27
|
+
return {"label": n.get("label") or n.get("short") or n["id"], "kind": n.get("type") or "unknown",
|
|
28
|
+
"hop": n.get("level", 0), "root": bool(n.get("root")), "trail_ends": bool(n.get("terminal")),
|
|
29
|
+
"chain": chain, "explorer": chains.explorer_url(n["id"], chain)}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _edge_attrs(e: dict, symbol: str) -> dict:
|
|
33
|
+
return {"amount": e.get("value") or 0, "symbol": symbol, "usd": e.get("usd"),
|
|
34
|
+
"transfers": e.get("tx") or 0, "memo": e.get("memo") or ""}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _val(v, kind: str) -> str:
|
|
38
|
+
if v is None:
|
|
39
|
+
return ""
|
|
40
|
+
if kind == "boolean":
|
|
41
|
+
return "true" if v else "false"
|
|
42
|
+
return escape(str(v))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def to_graphml(graph: dict, chain: str) -> str:
|
|
46
|
+
"""GraphML 1.0, directed: money flows along the edges."""
|
|
47
|
+
symbol = graph.get("symbol", "")
|
|
48
|
+
out = ['<?xml version="1.0" encoding="UTF-8"?>',
|
|
49
|
+
'<graphml xmlns="http://graphml.graphdrawing.org/xmlns">']
|
|
50
|
+
for k, t in NODE_KEYS:
|
|
51
|
+
out.append(f' <key id="n_{k}" for="node" attr.name="{k}" attr.type="{t}"/>')
|
|
52
|
+
for k, t in EDGE_KEYS:
|
|
53
|
+
out.append(f' <key id="e_{k}" for="edge" attr.name="{k}" attr.type="{t}"/>')
|
|
54
|
+
out.append(f' <graph id={quoteattr("crypttrace " + chain)} edgedefault="directed">')
|
|
55
|
+
for n in graph.get("nodes", []):
|
|
56
|
+
out.append(f' <node id={quoteattr(n["id"])}>')
|
|
57
|
+
for (k, t), v in zip(NODE_KEYS, _node_attrs(n, chain).values()):
|
|
58
|
+
if v not in (None, ""):
|
|
59
|
+
out.append(f' <data key="n_{k}">{_val(v, t)}</data>')
|
|
60
|
+
out.append(" </node>")
|
|
61
|
+
for i, e in enumerate(graph.get("edges", [])):
|
|
62
|
+
out.append(f' <edge id="e{i}" source={quoteattr(e["from"])} target={quoteattr(e["to"])}>')
|
|
63
|
+
for (k, t), v in zip(EDGE_KEYS, _edge_attrs(e, symbol).values()):
|
|
64
|
+
if v not in (None, ""):
|
|
65
|
+
out.append(f' <data key="e_{k}">{_val(v, t)}</data>')
|
|
66
|
+
out.append(" </edge>")
|
|
67
|
+
out += [" </graph>", "</graphml>", ""]
|
|
68
|
+
return "\n".join(out)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def to_csv(graph: dict, chain: str) -> str:
|
|
72
|
+
"""One row per edge, both ends described — what a link-analysis import wants."""
|
|
73
|
+
symbol = graph.get("symbol", "")
|
|
74
|
+
nodes = {n["id"]: n for n in graph.get("nodes", [])}
|
|
75
|
+
buf = io.StringIO()
|
|
76
|
+
w = csv.writer(buf, lineterminator="\n")
|
|
77
|
+
w.writerow(CSV_HEAD)
|
|
78
|
+
for e in graph.get("edges", []):
|
|
79
|
+
a, b = nodes.get(e["from"], {"id": e["from"]}), nodes.get(e["to"], {"id": e["to"]})
|
|
80
|
+
ea = _edge_attrs(e, symbol)
|
|
81
|
+
w.writerow([e["from"], a.get("label") or "", a.get("type") or "unknown",
|
|
82
|
+
e["to"], b.get("label") or "", b.get("type") or "unknown",
|
|
83
|
+
ea["amount"], symbol, "" if ea["usd"] is None else ea["usd"], ea["transfers"],
|
|
84
|
+
ea["memo"], chain, chains.explorer_url(e["from"], chain), chains.explorer_url(e["to"], chain)])
|
|
85
|
+
return buf.getvalue()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
FORMATS = {"graphml": (to_graphml, "application/graphml+xml"), "csv": (to_csv, "text/csv")}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def render(graph: dict, chain: str, fmt: str) -> str:
|
|
92
|
+
if fmt not in FORMATS:
|
|
93
|
+
raise ValueError(f"unknown export format '{fmt}' — use one of: {', '.join(FORMATS)}")
|
|
94
|
+
return FORMATS[fmt][0](graph, chain)
|