crypttrace 0.29.0__tar.gz → 0.31.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 (55) hide show
  1. {crypttrace-0.29.0/src/crypttrace.egg-info → crypttrace-0.31.0}/PKG-INFO +9 -7
  2. {crypttrace-0.29.0 → crypttrace-0.31.0}/README.md +6 -4
  3. {crypttrace-0.29.0 → crypttrace-0.31.0}/pyproject.toml +2 -2
  4. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace/__init__.py +1 -1
  5. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace/addresses.py +24 -0
  6. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace/analysis.py +1 -1
  7. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace/assets.py +2 -2
  8. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace/chains.py +18 -10
  9. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace/cli.py +6 -4
  10. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace/drain.py +1 -1
  11. crypttrace-0.31.0/src/crypttrace/fetchers/bitcoin.py +287 -0
  12. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace/fetchers/http.py +2 -0
  13. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace/labels/known.json +146 -0
  14. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace/poisoning.py +1 -1
  15. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace/prices.py +2 -1
  16. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace/store.py +7 -0
  17. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace/verify.py +20 -7
  18. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace/watch.py +1 -1
  19. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace/web/index.html +1 -1
  20. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace/webapp.py +6 -1
  21. {crypttrace-0.29.0 → crypttrace-0.31.0/src/crypttrace.egg-info}/PKG-INFO +9 -7
  22. crypttrace-0.29.0/src/crypttrace/fetchers/bitcoin.py +0 -186
  23. {crypttrace-0.29.0 → crypttrace-0.31.0}/LICENSE +0 -0
  24. {crypttrace-0.29.0 → crypttrace-0.31.0}/setup.cfg +0 -0
  25. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace/assess.py +0 -0
  26. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace/bridges.py +0 -0
  27. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace/config.py +0 -0
  28. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace/evidence.py +0 -0
  29. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace/fetchers/__init__.py +0 -0
  30. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace/fetchers/etherscan.py +0 -0
  31. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace/fetchers/solana.py +0 -0
  32. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace/fetchers/ton.py +0 -0
  33. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace/fetchers/tron.py +0 -0
  34. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace/freeze.py +0 -0
  35. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace/funder.py +0 -0
  36. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace/gascluster.py +0 -0
  37. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace/investigate.py +0 -0
  38. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace/labels/__init__.py +0 -0
  39. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace/labels/audit.py +0 -0
  40. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace/labels/bulk.py +0 -0
  41. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace/labels/labels.py +0 -0
  42. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace/labels/names.py +0 -0
  43. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace/labels/partial.json +0 -0
  44. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace/labels/tonapi.py +0 -0
  45. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace/labels/tronscan.py +0 -0
  46. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace/offramp.py +0 -0
  47. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace/render.py +0 -0
  48. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace/report.py +0 -0
  49. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace/spam.py +0 -0
  50. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace/trace.py +0 -0
  51. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace.egg-info/SOURCES.txt +0 -0
  52. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace.egg-info/dependency_links.txt +0 -0
  53. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace.egg-info/entry_points.txt +0 -0
  54. {crypttrace-0.29.0 → crypttrace-0.31.0}/src/crypttrace.egg-info/requires.txt +0 -0
  55. {crypttrace-0.29.0 → crypttrace-0.31.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.29.0
3
+ Version: 0.31.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
@@ -15,14 +15,14 @@ Classifier: Intended Audience :: Information Technology
15
15
  Classifier: Intended Audience :: Legal Industry
16
16
  Classifier: Operating System :: OS Independent
17
17
  Classifier: Programming Language :: Python :: 3
18
- Classifier: Programming Language :: Python :: 3.9
19
18
  Classifier: Programming Language :: Python :: 3.10
20
19
  Classifier: Programming Language :: Python :: 3.11
21
20
  Classifier: Programming Language :: Python :: 3.12
22
21
  Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
23
  Classifier: Topic :: Security
24
24
  Classifier: Topic :: Utilities
25
- Requires-Python: >=3.9
25
+ Requires-Python: >=3.10
26
26
  Description-Content-Type: text/markdown
27
27
  License-File: LICENSE
28
28
  Requires-Dist: typer>=0.12
@@ -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?*
@@ -66,7 +66,7 @@ pipx install "crypttrace[web]" # or: pip install "crypttrace[web]"
66
66
  ```
67
67
 
68
68
  `[web]` also installs Flask for the web UI; leave it off for the terminal tool
69
- alone. Python 3.9 or newer. To work on the code instead:
69
+ alone. Python 3.10 or newer. To work on the code instead:
70
70
 
71
71
  ```bash
72
72
  git clone https://github.com/bobslayerX/crypttrace
@@ -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 |
@@ -472,7 +474,7 @@ with an `nfts` column in the CSV. Failed (aborted) NFT transfers are not counted
472
474
 
473
475
  ### Bitcoin clustering (common-input-ownership)
474
476
 
475
- Bitcoin's UTXO model enables the strongest clustering heuristic in blockchain
477
+ Bitcoin's UTXO model (Litecoin's and Dogecoin's too) enables the strongest clustering heuristic in blockchain
476
478
  forensics: if several addresses sign the inputs of one transaction, one party
477
479
  almost certainly controls all of them. `cluster` surfaces those co-signers,
478
480
  turning a single address into a set of wallets belonging to the same owner. A
@@ -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?*
@@ -32,7 +32,7 @@ pipx install "crypttrace[web]" # or: pip install "crypttrace[web]"
32
32
  ```
33
33
 
34
34
  `[web]` also installs Flask for the web UI; leave it off for the terminal tool
35
- alone. Python 3.9 or newer. To work on the code instead:
35
+ alone. Python 3.10 or newer. To work on the code instead:
36
36
 
37
37
  ```bash
38
38
  git clone https://github.com/bobslayerX/crypttrace
@@ -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 |
@@ -438,7 +440,7 @@ with an `nfts` column in the CSV. Failed (aborted) NFT transfers are not counted
438
440
 
439
441
  ### Bitcoin clustering (common-input-ownership)
440
442
 
441
- Bitcoin's UTXO model enables the strongest clustering heuristic in blockchain
443
+ Bitcoin's UTXO model (Litecoin's and Dogecoin's too) enables the strongest clustering heuristic in blockchain
442
444
  forensics: if several addresses sign the inputs of one transaction, one party
443
445
  almost certainly controls all of them. `cluster` surfaces those co-signers,
444
446
  turning a single address into a set of wallets belonging to the same owner. A
@@ -6,7 +6,7 @@ readme = "README.md"
6
6
  license = "MIT"
7
7
  license-files = ["LICENSE"]
8
8
  authors = [{ name = "bobslayerX" }]
9
- requires-python = ">=3.9"
9
+ requires-python = ">=3.10"
10
10
  keywords = ["osint", "blockchain", "forensics", "cryptocurrency", "bitcoin", "ethereum", "tron", "solana", "ton", "investigation"]
11
11
  classifiers = [
12
12
  "Development Status :: 4 - Beta",
@@ -16,11 +16,11 @@ classifiers = [
16
16
  "Intended Audience :: Legal Industry",
17
17
  "Operating System :: OS Independent",
18
18
  "Programming Language :: Python :: 3",
19
- "Programming Language :: Python :: 3.9",
20
19
  "Programming Language :: Python :: 3.10",
21
20
  "Programming Language :: Python :: 3.11",
22
21
  "Programming Language :: Python :: 3.12",
23
22
  "Programming Language :: Python :: 3.13",
23
+ "Programming Language :: Python :: 3.14",
24
24
  "Topic :: Security",
25
25
  "Topic :: Utilities",
26
26
  ]
@@ -1,2 +1,2 @@
1
1
  """crypttrace — OSINT crypto investigation CLI."""
2
- __version__ = "0.29.0"
2
+ __version__ = "0.31.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:
@@ -762,7 +762,7 @@ def verify(
762
762
  style = _verdict_style(v["status"])
763
763
  console.print(f"\n[{style}]{v['status'].upper()}[/{style}] {verify_mod.headline(v)}\n")
764
764
 
765
- if v["checks"]:
765
+ if v.get("checks"): # absent when the chain could not be read at all
766
766
  t = render.Table(title="Cross-check against the chain", header_style="bold")
767
767
  t.add_column("Figure"); t.add_column("Computed", justify="right")
768
768
  t.add_column("Chain", justify="right"); t.add_column("", justify="center")
@@ -803,12 +803,12 @@ def cluster(
803
803
  "funder set it up. Wallets set up by the same person's wallet are likely one party; a "
804
804
  "funding from an exchange, mixer, bridge or any contract links nothing. A lead, not proof.[/dim]")
805
805
  return
806
- if chain != "btc":
807
- console.print("[red]Error:[/red] cluster works on Bitcoin and EVM chains")
806
+ if not chains_mod.is_utxo(chain):
807
+ console.print("[red]Error:[/red] cluster works on Bitcoin, Litecoin, Dogecoin and EVM chains")
808
808
  raise typer.Exit(1)
809
809
  from crypttrace.fetchers import bitcoin
810
810
  try:
811
- peers = bitcoin.cluster(address)
811
+ peers = bitcoin.cluster(address, chain)
812
812
  except bitcoin.BitcoinError as e:
813
813
  console.print(f"[red]Error:[/red] {e}")
814
814
  raise typer.Exit(1)
@@ -884,6 +884,8 @@ def chains():
884
884
  console.print(f" {name:<10} chainid {cid}{paid}")
885
885
  console.print("\n[bold]Non-EVM[/bold] (no API key needed):")
886
886
  console.print(" btc Bitcoin — mempool.space (UTXO)")
887
+ console.print(" ltc Litecoin — litecoinspace.org (UTXO)")
888
+ console.print(" doge Dogecoin — BlockCypher (UTXO; free tier ~100 requests an hour)")
887
889
  console.print(" tron Tron — TronGrid (TRX + USDT-TRC20)")
888
890
  console.print(" sol Solana — public JSON-RPC")
889
891
  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,287 @@
1
+ """UTXO chains — Bitcoin, Litecoin, Dogecoin — with no API key required.
2
+
3
+ Bitcoin reads mempool.space and Litecoin its twin litecoinspace.org (both
4
+ Esplora). Dogecoin has no public Esplora, so it reads BlockCypher, whose
5
+ answers are turned into the same shape; its free tier allows about 100
6
+ requests an hour, which is enough for a check and a few hops of tracing.
7
+
8
+ These chains use the UTXO model, not accounts: a transaction consumes previous
9
+ outputs and creates new ones, so there is no single "from" or "to". To fit the
10
+ same tracing engine used for EVM chains, transactions are normalized into
11
+ directional transfer rows:
12
+
13
+ * if our address signed an input, each output that isn't ours is an outflow
14
+ (outputs back to ourselves are change and are skipped);
15
+ * otherwise, outputs paying us are inflows, attributed to the first input.
16
+
17
+ UTXO chains also enable the strongest clustering heuristic in blockchain
18
+ forensics: common-input-ownership. If several addresses sign inputs of the
19
+ same transaction, one party almost certainly controls all of them.
20
+ """
21
+ import datetime as dt
22
+ from typing import List, Dict, Optional
23
+
24
+ import requests
25
+
26
+ NETWORKS = {
27
+ "btc": {"name": "Bitcoin", "symbol": "BTC", "api": "esplora", "base": "https://mempool.space/api",
28
+ "host": "mempool.space"},
29
+ "ltc": {"name": "Litecoin", "symbol": "LTC", "api": "esplora", "base": "https://litecoinspace.org/api",
30
+ "host": "litecoinspace.org"},
31
+ "doge": {"name": "Dogecoin", "symbol": "DOGE", "api": "blockcypher",
32
+ "base": "https://api.blockcypher.com/v1/doge/main", "host": "BlockCypher"},
33
+ }
34
+ UTXO_CHAINS = set(NETWORKS)
35
+ BASE = NETWORKS["btc"]["base"]
36
+ SATS = 100_000_000 # satoshis, litoshis and koinus alike
37
+ BLOCKCYPHER_MAX_TXS = 200 # history read per Dogecoin address: the free tier is ~100 calls an hour
38
+
39
+
40
+ class BitcoinError(RuntimeError):
41
+ pass
42
+
43
+
44
+ def _net(chain: str) -> dict:
45
+ if chain not in NETWORKS:
46
+ raise BitcoinError(f"not a UTXO chain: {chain}")
47
+ return NETWORKS[chain]
48
+
49
+
50
+ def _get(path: str, timeout: int = 30, chain: str = "btc", params: Optional[dict] = None):
51
+ """Cached, throttled GET against the chain's explorer API."""
52
+ from crypttrace.fetchers import http
53
+ n = _net(chain)
54
+ try:
55
+ data = http.request_json(f"{n['base']}{path}", params or {}, timeout=timeout)
56
+ except http.RateLimited as e:
57
+ raise BitcoinError(str(e) + (" (BlockCypher's free tier allows about 100 requests an hour)"
58
+ if n["api"] == "blockcypher" else ""))
59
+ except requests.RequestException as e:
60
+ raise BitcoinError(f"{n['host']} request failed: {e}")
61
+ except ValueError as e:
62
+ raise BitcoinError(f"bad response from {n['host']}: {e}")
63
+ if isinstance(data, dict) and "__status__" in data:
64
+ if data["__status__"] == 400:
65
+ raise BitcoinError(f"{n['name']} address rejected by {n['host']} — check it is "
66
+ "exact (these addresses are case-sensitive).")
67
+ raise BitcoinError(f"Address not found on the {n['name']} chain.")
68
+ return data
69
+
70
+
71
+ def stats(address: str, chain: str = "btc") -> Dict:
72
+ """Authoritative totals straight from the chain index.
73
+
74
+ These are the numbers our own parsing must agree with — the reference for
75
+ checking that a trace didn't miscount or silently miss history.
76
+ """
77
+ if _net(chain)["api"] == "blockcypher":
78
+ d = _get(f"/addrs/{address}/balance", chain=chain)
79
+ recv, sent = (d.get("total_received") or 0) / SATS, (d.get("total_sent") or 0) / SATS
80
+ return {"received": recv, "sent": sent, "balance": recv - sent,
81
+ "tx_count": d.get("final_n_tx") or d.get("n_tx") or 0, "funded_outputs": None}
82
+ d = _get(f"/address/{address}", chain=chain)
83
+ cs = d.get("chain_stats", {}) or {}
84
+ ms = d.get("mempool_stats", {}) or {}
85
+ recv = (cs.get("funded_txo_sum", 0) + ms.get("funded_txo_sum", 0)) / SATS
86
+ sent = (cs.get("spent_txo_sum", 0) + ms.get("spent_txo_sum", 0)) / SATS
87
+ return {
88
+ "received": recv,
89
+ "sent": sent,
90
+ "balance": recv - sent,
91
+ "tx_count": (cs.get("tx_count", 0) + ms.get("tx_count", 0)),
92
+ "funded_outputs": (cs.get("funded_txo_count", 0) + ms.get("funded_txo_count", 0)),
93
+ }
94
+
95
+
96
+ def balance(address: str, chain: str = "btc") -> float:
97
+ return stats(address, chain)["balance"]
98
+
99
+
100
+ def _bc_time(tx: dict) -> int:
101
+ s = tx.get("confirmed") or tx.get("received") or ""
102
+ try:
103
+ return int(dt.datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp())
104
+ except ValueError:
105
+ return 0
106
+
107
+
108
+ def _from_blockcypher(tx: dict) -> dict:
109
+ """A BlockCypher transaction in Esplora's shape, so one parser serves every chain."""
110
+ first = lambda xs: (xs or [None])[0]
111
+ return {
112
+ "txid": tx.get("hash", ""),
113
+ "status": {"block_time": _bc_time(tx)},
114
+ "vin": [{"prevout": {"scriptpubkey_address": first(i.get("addresses")),
115
+ "value": i.get("output_value") or 0}} for i in tx.get("inputs") or []],
116
+ "vout": [{"scriptpubkey_address": first(o.get("addresses")), "value": o.get("value") or 0}
117
+ for o in tx.get("outputs") or []],
118
+ }
119
+
120
+
121
+ def raw_txs(address: str, max_txs: int = 500, chain: str = "btc") -> List[dict]:
122
+ """Transactions touching this address, paging back through history.
123
+
124
+ The explorers return ~50 per call. A single page is not enough for
125
+ investigations: famous addresses get spammed with dust, which pushes the
126
+ transactions that actually matter out of the recent window. So we follow
127
+ the cursor back until we have enough history.
128
+ """
129
+ out: List[dict] = []
130
+ if _net(chain)["api"] == "blockcypher":
131
+ seen, before = set(), None
132
+ while len(out) < max_txs:
133
+ params = {"limit": 50, "txlimit": 500} # txlimit: inputs/outputs per tx (default 20)
134
+ if before is not None:
135
+ params["before"] = before
136
+ d = _get(f"/addrs/{address}/full", chain=chain, params=params)
137
+ batch = [t for t in (d.get("txs") or []) if t.get("hash") not in seen]
138
+ if not batch:
139
+ break
140
+ for t in batch:
141
+ seen.add(t.get("hash"))
142
+ out.append(_from_blockcypher(t))
143
+ heights = [t.get("block_height") for t in batch if (t.get("block_height") or -1) > 0]
144
+ if not d.get("hasMore") or not heights:
145
+ break
146
+ before = min(heights)
147
+ return out[:max_txs]
148
+ last: Optional[str] = None
149
+ while len(out) < max_txs:
150
+ path = f"/address/{address}/txs" if last is None \
151
+ else f"/address/{address}/txs/chain/{last}"
152
+ batch = _get(path, chain=chain)
153
+ if not isinstance(batch, list) or not batch:
154
+ break
155
+ out.extend(batch)
156
+ if len(batch) < 25: # last page
157
+ break
158
+ nxt = batch[-1].get("txid")
159
+ if not nxt or nxt == last:
160
+ break
161
+ last = nxt
162
+ return out[:max_txs]
163
+
164
+
165
+ def _in_addrs(tx: dict) -> List[str]:
166
+ out = []
167
+ for v in tx.get("vin", []) or []:
168
+ a = (v.get("prevout") or {}).get("scriptpubkey_address")
169
+ if a:
170
+ out.append(a)
171
+ return out
172
+
173
+
174
+ def _in_pairs(tx: dict):
175
+ """(address, value) for every input — a UTXO tx can be funded by many parties."""
176
+ for v in tx.get("vin", []) or []:
177
+ p = v.get("prevout") or {}
178
+ a = p.get("scriptpubkey_address")
179
+ if a:
180
+ yield a, (p.get("value", 0) or 0) / SATS
181
+
182
+
183
+ def _out_pairs(tx: dict):
184
+ for o in tx.get("vout", []) or []:
185
+ a = o.get("scriptpubkey_address")
186
+ if a:
187
+ yield a, o.get("value", 0) / SATS
188
+
189
+
190
+ def _depth(limit: int, chain: str) -> int:
191
+ """How many transactions of history to read for `limit` transfer rows."""
192
+ depth = max(limit, 200)
193
+ if _net(chain)["api"] == "blockcypher":
194
+ depth = min(depth, BLOCKCYPHER_MAX_TXS) # 4 calls of the ~100 an hour
195
+ return depth
196
+
197
+
198
+ def change(address: str, limit: int = 1000, chain: str = "btc") -> Dict[str, float]:
199
+ """What the address paid back to itself as change, over the same history
200
+ `transfers` reads. Transfers leave change out — it is not money moving — but
201
+ the chain's totals count it twice, so a check against them adds it back:
202
+ in full to "received" (the change output is the address's), and in the
203
+ address's share of the inputs to "sent" (when others funded the transaction
204
+ too, only that share of what came back was the address's own spending)."""
205
+ received = sent = 0.0
206
+ for tx in raw_txs(address, max_txs=_depth(limit, chain), chain=chain):
207
+ ins = list(_in_pairs(tx))
208
+ mine_in = sum(v for a, v in ins if a == address)
209
+ if mine_in <= 0:
210
+ continue
211
+ back = sum(v for a, v in _out_pairs(tx) if a == address)
212
+ received += back
213
+ sent += back * mine_in / sum(v for _, v in ins)
214
+ return {"received": received, "sent": sent}
215
+
216
+
217
+ def transfers(address: str, limit: int = 1000, chain: str = "btc") -> List[Dict]:
218
+ """Normalized {from,to,value,timestamp,hash,symbol} rows.
219
+
220
+ A UTXO transaction has no single sender: it can be funded by many inputs
221
+ from different owners and pay many outputs. Attributing a whole transfer to
222
+ the first input address — the naive shortcut — badly misreports
223
+ consolidations, e.g. crediting one wallet with funds a hundred others
224
+ supplied. So value is split in proportion to what each side actually
225
+ contributed or received.
226
+ """
227
+ me = address
228
+ sym = _net(chain)["symbol"]
229
+ rows: List[Dict] = []
230
+ for tx in raw_txs(address, max_txs=_depth(limit, chain), chain=chain):
231
+ ts = int((tx.get("status") or {}).get("block_time") or 0)
232
+ h = tx.get("txid", "")
233
+ ins = list(_in_pairs(tx))
234
+ outs = list(_out_pairs(tx))
235
+ total_in = sum(v for _, v in ins)
236
+ if total_in <= 0:
237
+ continue
238
+
239
+ if me in (a for a, _ in ins):
240
+ # outgoing: our share of the inputs funds our share of each output
241
+ mine_in = sum(v for a, v in ins if a == me)
242
+ share = mine_in / total_in
243
+ # The chain counts a spent output at full value; the recipient gets
244
+ # less, because the miner fee comes out in between. Carry our share
245
+ # of that fee so totals can be reconciled exactly rather than
246
+ # hidden inside a tolerance.
247
+ fee_share = (total_in - sum(v for _, v in outs)) * share
248
+ # one row per recipient: several equal outputs to one address in one
249
+ # transaction would otherwise be identical rows, and the store keeps one
250
+ paid: Dict[str, float] = {}
251
+ for a, v in outs:
252
+ if a == me or v <= 0:
253
+ continue # change back to self
254
+ paid[a] = paid.get(a, 0.0) + v
255
+ for a, v in paid.items():
256
+ rows.append({"from": me, "to": a, "value": v * share,
257
+ "timestamp": ts, "hash": h, "symbol": sym,
258
+ "fee_share": fee_share})
259
+ else:
260
+ # incoming: credit every funder in proportion to what it put in
261
+ mine_out = sum(v for a, v in outs if a == me)
262
+ if mine_out <= 0:
263
+ continue
264
+ merged: Dict[str, float] = {}
265
+ for a, v in ins:
266
+ merged[a] = merged.get(a, 0.0) + v
267
+ for a, v in merged.items():
268
+ rows.append({"from": a, "to": me, "value": mine_out * (v / total_in),
269
+ "timestamp": ts, "hash": h, "symbol": sym})
270
+ if len(rows) >= limit:
271
+ break
272
+ return rows
273
+
274
+
275
+ def cluster(address: str, chain: str = "btc") -> List[tuple]:
276
+ """Common-input-ownership: addresses that co-signed inputs with `address`.
277
+
278
+ Returns [(address, times_seen_together)] — likely the same owner's wallets.
279
+ """
280
+ peers: Dict[str, int] = {}
281
+ for tx in raw_txs(address, chain=chain):
282
+ ins = _in_addrs(tx)
283
+ if address in ins and len(set(ins)) > 1:
284
+ for a in set(ins):
285
+ if a != address:
286
+ peers[a] = peers.get(a, 0) + 1
287
+ return sorted(peers.items(), key=lambda kv: kv[1], reverse=True)
@@ -24,6 +24,8 @@ from crypttrace import config
24
24
  MIN_INTERVAL = {
25
25
  "api.trongrid.io": 0.4,
26
26
  "mempool.space": 0.25,
27
+ "litecoinspace.org": 0.25,
28
+ "api.blockcypher.com": 0.4, # free tier: 3 a second, ~100 an hour
27
29
  "api.mainnet-beta.solana.com": 0.3,
28
30
  }
29
31
  DEFAULT_INTERVAL = 0.25