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.
Files changed (56) hide show
  1. {crypttrace-0.30.0/src/crypttrace.egg-info → crypttrace-0.32.0}/PKG-INFO +11 -4
  2. {crypttrace-0.30.0 → crypttrace-0.32.0}/README.md +10 -3
  3. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/__init__.py +1 -1
  4. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/addresses.py +24 -0
  5. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/analysis.py +1 -1
  6. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/assets.py +2 -2
  7. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/chains.py +18 -10
  8. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/cli.py +25 -4
  9. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/drain.py +1 -1
  10. crypttrace-0.32.0/src/crypttrace/export.py +94 -0
  11. crypttrace-0.32.0/src/crypttrace/fetchers/bitcoin.py +287 -0
  12. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/fetchers/http.py +2 -0
  13. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/labels/known.json +72 -0
  14. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/poisoning.py +1 -1
  15. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/prices.py +2 -1
  16. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/store.py +7 -0
  17. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/verify.py +20 -7
  18. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/watch.py +1 -1
  19. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/web/index.html +21 -1
  20. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/webapp.py +14 -1
  21. {crypttrace-0.30.0 → crypttrace-0.32.0/src/crypttrace.egg-info}/PKG-INFO +11 -4
  22. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace.egg-info/SOURCES.txt +1 -0
  23. crypttrace-0.30.0/src/crypttrace/fetchers/bitcoin.py +0 -186
  24. {crypttrace-0.30.0 → crypttrace-0.32.0}/LICENSE +0 -0
  25. {crypttrace-0.30.0 → crypttrace-0.32.0}/pyproject.toml +0 -0
  26. {crypttrace-0.30.0 → crypttrace-0.32.0}/setup.cfg +0 -0
  27. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/assess.py +0 -0
  28. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/bridges.py +0 -0
  29. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/config.py +0 -0
  30. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/evidence.py +0 -0
  31. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/fetchers/__init__.py +0 -0
  32. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/fetchers/etherscan.py +0 -0
  33. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/fetchers/solana.py +0 -0
  34. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/fetchers/ton.py +0 -0
  35. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/fetchers/tron.py +0 -0
  36. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/freeze.py +0 -0
  37. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/funder.py +0 -0
  38. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/gascluster.py +0 -0
  39. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/investigate.py +0 -0
  40. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/labels/__init__.py +0 -0
  41. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/labels/audit.py +0 -0
  42. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/labels/bulk.py +0 -0
  43. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/labels/labels.py +0 -0
  44. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/labels/names.py +0 -0
  45. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/labels/partial.json +0 -0
  46. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/labels/tonapi.py +0 -0
  47. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/labels/tronscan.py +0 -0
  48. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/offramp.py +0 -0
  49. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/render.py +0 -0
  50. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/report.py +0 -0
  51. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/spam.py +0 -0
  52. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace/trace.py +0 -0
  53. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace.egg-info/dependency_links.txt +0 -0
  54. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace.egg-info/entry_points.txt +0 -0
  55. {crypttrace-0.30.0 → crypttrace-0.32.0}/src/crypttrace.egg-info/requires.txt +0 -0
  56. {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.30.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.30.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 == "btc":
73
- raise ValueError("Bitcoin has no tokens — omit --asset (or use --asset btc).")
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", "tron": "TRX", "sol": "SOL", "ton": "TON"}
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/{}", "tron": "https://tronscan.org/#/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/{}", "tron": "https://tronscan.org/#/transaction/{}",
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 == "btc":
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 == "btc":
232
+ elif is_utxo(chain):
225
233
  if asset:
226
- raise ChainError("Bitcoin has no tokens.")
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 == "btc":
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["checks"]:
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 != "btc":
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 != "btc":
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)