crypttrace 0.30.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.30.0/src/crypttrace.egg-info → crypttrace-0.31.0}/PKG-INFO +6 -4
  2. {crypttrace-0.30.0 → crypttrace-0.31.0}/README.md +5 -3
  3. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace/__init__.py +1 -1
  4. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace/addresses.py +24 -0
  5. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace/analysis.py +1 -1
  6. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace/assets.py +2 -2
  7. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace/chains.py +18 -10
  8. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace/cli.py +6 -4
  9. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace/drain.py +1 -1
  10. crypttrace-0.31.0/src/crypttrace/fetchers/bitcoin.py +287 -0
  11. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace/fetchers/http.py +2 -0
  12. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace/labels/known.json +72 -0
  13. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace/poisoning.py +1 -1
  14. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace/prices.py +2 -1
  15. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace/store.py +7 -0
  16. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace/verify.py +20 -7
  17. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace/watch.py +1 -1
  18. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace/web/index.html +1 -1
  19. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace/webapp.py +6 -1
  20. {crypttrace-0.30.0 → crypttrace-0.31.0/src/crypttrace.egg-info}/PKG-INFO +6 -4
  21. crypttrace-0.30.0/src/crypttrace/fetchers/bitcoin.py +0 -186
  22. {crypttrace-0.30.0 → crypttrace-0.31.0}/LICENSE +0 -0
  23. {crypttrace-0.30.0 → crypttrace-0.31.0}/pyproject.toml +0 -0
  24. {crypttrace-0.30.0 → crypttrace-0.31.0}/setup.cfg +0 -0
  25. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace/assess.py +0 -0
  26. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace/bridges.py +0 -0
  27. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace/config.py +0 -0
  28. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace/evidence.py +0 -0
  29. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace/fetchers/__init__.py +0 -0
  30. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace/fetchers/etherscan.py +0 -0
  31. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace/fetchers/solana.py +0 -0
  32. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace/fetchers/ton.py +0 -0
  33. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace/fetchers/tron.py +0 -0
  34. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace/freeze.py +0 -0
  35. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace/funder.py +0 -0
  36. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace/gascluster.py +0 -0
  37. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace/investigate.py +0 -0
  38. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace/labels/__init__.py +0 -0
  39. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace/labels/audit.py +0 -0
  40. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace/labels/bulk.py +0 -0
  41. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace/labels/labels.py +0 -0
  42. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace/labels/names.py +0 -0
  43. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace/labels/partial.json +0 -0
  44. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace/labels/tonapi.py +0 -0
  45. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace/labels/tronscan.py +0 -0
  46. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace/offramp.py +0 -0
  47. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace/render.py +0 -0
  48. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace/report.py +0 -0
  49. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace/spam.py +0 -0
  50. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace/trace.py +0 -0
  51. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace.egg-info/SOURCES.txt +0 -0
  52. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace.egg-info/dependency_links.txt +0 -0
  53. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace.egg-info/entry_points.txt +0 -0
  54. {crypttrace-0.30.0 → crypttrace-0.31.0}/src/crypttrace.egg-info/requires.txt +0 -0
  55. {crypttrace-0.30.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.30.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
@@ -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 |
@@ -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?*
@@ -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
@@ -1,2 +1,2 @@
1
1
  """crypttrace — OSINT crypto investigation CLI."""
2
- __version__ = "0.30.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
@@ -711,6 +711,78 @@
711
711
  "TT1DyeqXaaJkt6UhVYFWUXBXknaXnBudTK": {"name": "Binance reserve wallet", "type": "exchange", "chain": "tron", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-11-07"},
712
712
  "TTU4bhRuPEtaSWPXvvRQ7CVnyHs6cGu24e": {"name": "Binance reserve wallet", "type": "exchange", "chain": "tron", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2026-11-01", "source_kind": "self-published", "added": "2026-11-07"},
713
713
  "TYLRgzUKgLHw5wCYox2xRUkCQaWo9koP6K": {"name": "Binance reserve wallet", "type": "exchange", "chain": "tron", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2026-11-01", "source_kind": "self-published", "added": "2026-11-07"},
714
+ "DGmzv39riELTuigZCUD6sWoHEHPdSbxdUB": {"name": "Binance reserve wallet", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-11-07"},
715
+ "MQSs17ECe51kqhkFPQY98wiyPvGuK5iCB6": {"name": "Binance reserve wallet", "type": "exchange", "chain": "ltc", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-11-07"},
716
+ "D59dre2oAFac8UYqwrSCC4rcQapcC2PQA5": {"name": "Binance reserve wallet", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2026-07-20", "source_kind": "self-published", "added": "2026-11-07"},
717
+ "D6e11vLvkgjnkvNbViSk8PBNSKVN4dupx8": {"name": "Binance reserve wallet", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-12-18", "source_kind": "self-published", "added": "2026-11-07"},
718
+ "D7BrtudMAdwz2U7vSGumVDuxZsZNibJuzw": {"name": "Binance reserve wallet", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-11-07"},
719
+ "D7D2f2WuEkwhTWqBkisZ5sM3JagaBcNWBs": {"name": "Binance reserve wallet", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-11-07"},
720
+ "D7LBW9Kk3Qe9iDQVGarCf8XSSs4U46rEWD": {"name": "Binance reserve wallet", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-12-19", "source_kind": "self-published", "added": "2026-11-07"},
721
+ "D7bg2iUG3UiHuTWjRqcjdJiEjHrsFASsjw": {"name": "Binance reserve wallet", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-11-07"},
722
+ "DA4kkg23oQ2wS4UPMfdL9jbDuoLgk7KiTW": {"name": "Binance reserve wallet", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2026-06-16", "source_kind": "self-published", "added": "2026-11-07"},
723
+ "DA9TU5Gtd4VM8petBvf8ecv9u13NSTYzzz": {"name": "Binance reserve wallet", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-11-07"},
724
+ "DAYthKnkrWUHRENvtBwjemi6kFzk4K9SMr": {"name": "Binance reserve wallet", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-11-07"},
725
+ "DBTrXhmzMbo1bkYAv6ZYodtwvTahhApf7y": {"name": "Binance reserve wallet", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2026-11-01", "source_kind": "self-published", "added": "2026-11-07"},
726
+ "DBrm7F6i8YEVX7STwYfe27RVZD9x1jGRDR": {"name": "Binance reserve wallet", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2026-11-02", "source_kind": "self-published", "added": "2026-11-07"},
727
+ "DCqD9gyq3qumbeEJZKc2Tm2R96JEd9dSDe": {"name": "Binance reserve wallet", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-11-07"},
728
+ "DD1h9ojoyAdAGLXaqgqZP3j86AtxZs6jCn": {"name": "Binance reserve wallet", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-11-07"},
729
+ "DD2AVpbD5aybXQC9iVRrzVyoszp5UQCRUJ": {"name": "Binance reserve wallet", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2026-11-04", "source_kind": "self-published", "added": "2026-11-07"},
730
+ "DD7W4ddD7wBeMVhJmyPAy9qcDp9ypwbxpD": {"name": "Binance reserve wallet", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-11-07"},
731
+ "DE5opaXjFgDhFBqL6tBDxTAQ56zkX6EToX": {"name": "Binance reserve wallet", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-11-07"},
732
+ "DEth4cF3doxmbcv9ofH3vm4j2NCozn8DL6": {"name": "Binance reserve wallet", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-11-07"},
733
+ "DF4snE9zVmwQ3nMLjLwKyVehoz9ZH7vBKj": {"name": "Binance reserve wallet", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-12-19", "source_kind": "self-published", "added": "2026-11-07"},
734
+ "DF6RgUzMsp7na9PX9rKjUQeXA7HEreBot6": {"name": "Binance reserve wallet", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-12-19", "source_kind": "self-published", "added": "2026-11-07"},
735
+ "DFoQ1qrgDM7D9tAhF7Mahbp6zLv8CzK3XH": {"name": "Binance reserve wallet", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-11-07"},
736
+ "DGmd2jn1D7oLXpdpcGkLaGLDMbnRt4qkH3": {"name": "Binance reserve wallet", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-11-07"},
737
+ "DHFu8WjwXzHVy9pknMrxdQpePFir2FmiuG": {"name": "Binance reserve wallet", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-12-19", "source_kind": "self-published", "added": "2026-11-07"},
738
+ "DHnhi38hJyTVP6hCtTeH2dkjLU7Acwfdbd": {"name": "Binance reserve wallet (custodied by Ceffu)", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-12-19", "source_kind": "self-published", "added": "2026-11-07"},
739
+ "DJfU2p6woQ9GiBdiXsWZWJnJ9uDdZfSSNC": {"name": "Binance reserve wallet", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-11-07"},
740
+ "DKjcdPpQLoVfkZsN4wJr2RFuyFCoAVvoRe": {"name": "Binance reserve wallet", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-11-07"},
741
+ "DL7jHHaHHF5z2ap2eVMhZe8fZqVvPdAkEZ": {"name": "Binance reserve wallet (custodied by Ceffu)", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-12-19", "source_kind": "self-published", "added": "2026-11-07"},
742
+ "DLqwvgk27ACJnHPEGxyDfspieHBVYJRZiC": {"name": "Binance reserve wallet", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-11-07"},
743
+ "DMURD7AGQwQhVLzz53QzQ8VsmmZShycpaz": {"name": "Binance reserve wallet", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-11-07"},
744
+ "DMcrq3jy8MVYTBM9tGCjFZerpKXpW91WXx": {"name": "Binance reserve wallet", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-11-07"},
745
+ "DP627ydcvYTgkTGboYh3xQhMpF6ph7F2b9": {"name": "Binance reserve wallet", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-23", "source_kind": "self-published", "added": "2026-11-07"},
746
+ "DPAMdZsSirVBGeDR9sE1LJXkwG6AQSNAMW": {"name": "Binance reserve wallet", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-11-07"},
747
+ "DPNqMsW1rotjhCzcbBEXzK3W73DiyzVLYc": {"name": "Binance reserve wallet", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-11-07"},
748
+ "DPcqvZT8SbQ2dZKzZe2VXTpaKGzGHqQWoy": {"name": "Binance reserve wallet", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2026-07-20", "source_kind": "self-published", "added": "2026-11-07"},
749
+ "DQ3VG1e7rJCGafx38cUVMez5nVpQPAdvna": {"name": "Binance reserve wallet", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-12-18", "source_kind": "self-published", "added": "2026-11-07"},
750
+ "DQYqMYmQCfL2eDLmMXV5uvCPkN6Vv4KtSx": {"name": "Binance reserve wallet", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-11-07"},
751
+ "DQkwksfYrAWAR9tRcqygA5pihmnURozxzo": {"name": "Binance reserve wallet", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-11-07"},
752
+ "DQtm9uQ9gKmUDk5Pdg8pocv1aiwgH949YX": {"name": "Binance reserve wallet", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-12-19", "source_kind": "self-published", "added": "2026-11-07"},
753
+ "DT3N6kG5ewsCp6zA7hUhXW7F3dzqsRTre8": {"name": "Binance reserve wallet", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2026-10-22", "source_kind": "self-published", "added": "2026-11-07"},
754
+ "DTGrjpvJXQU2zSpUDsnk9b4PAQW5Pwtg4K": {"name": "Binance reserve wallet", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-11-07"},
755
+ "DTSop4ycWkpmDqpncVZ3jwKp3z7B4kW4T3": {"name": "Binance reserve wallet", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-11-07"},
756
+ "DTXbttzfvPr4t6CPEAtKKUrHpm9hpfgahu": {"name": "Binance reserve wallet", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-11-07"},
757
+ "DTbG3RJRejnM99g8MSnhSbJxJVW8X7YXy9": {"name": "Binance reserve wallet", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-11-07"},
758
+ "DU8gPC5mh4KxWJARQRxoESFark2jAguBr5": {"name": "Binance reserve wallet", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-11-07"},
759
+ "DUMB7o1jqJvpriAAExmEFi1Y88s5q94fi9": {"name": "Binance reserve wallet", "type": "exchange", "chain": "doge", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-11-07"},
760
+ "LXTras9zXwkUvmsAF15hUu86by2UdNLSWk": {"name": "Binance reserve wallet", "type": "exchange", "chain": "ltc", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-11-07"},
761
+ "LZEjckteAtWrugbsy9zU8VHEZ4iUiXo9Nm": {"name": "Binance reserve wallet", "type": "exchange", "chain": "ltc", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-11-07"},
762
+ "LazqPDHyo1nqwaYLc8WSDtG1mRj3anFfbX": {"name": "Binance reserve wallet (custodied by Ceffu)", "type": "exchange", "chain": "ltc", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-11-07"},
763
+ "LbmGksLBwtwRXyxeazCZqKiAHX6cWN2AzN": {"name": "Binance reserve wallet", "type": "exchange", "chain": "ltc", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-11-07"},
764
+ "Le8Lyy1JdYY3JUTbaAUE5utF7aTP8rYRuS": {"name": "Binance reserve wallet", "type": "exchange", "chain": "ltc", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-12-18", "source_kind": "self-published", "added": "2026-11-07"},
765
+ "LhcTcrMrheBqbthwfit43jkT9qnge5wSs1": {"name": "Binance reserve wallet", "type": "exchange", "chain": "ltc", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-11-07"},
766
+ "LhzEoDXHXASi4hSMxrKeVoSGrED9QsBpPq": {"name": "Binance reserve wallet", "type": "exchange", "chain": "ltc", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-11-07"},
767
+ "LiDY79SxUJfjE6fyqyxXxh9kBpfi4sRoKf": {"name": "Binance reserve wallet", "type": "exchange", "chain": "ltc", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-11-07"},
768
+ "M8T1B2Z97gVdvmfkQcAtYbEepune1tzGua": {"name": "Binance reserve wallet", "type": "exchange", "chain": "ltc", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-11-07"},
769
+ "M9vsPE9LLEgtYduCr2gktfT35uu7eQeDgr": {"name": "Binance reserve wallet", "type": "exchange", "chain": "ltc", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2026-08-24", "source_kind": "self-published", "added": "2026-11-07"},
770
+ "MB6LrCx4qjEFpU9U1WBrgPrntkfMR5GtCA": {"name": "Binance reserve wallet", "type": "exchange", "chain": "ltc", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-11-07"},
771
+ "MF34ww6PuT3eRh8iTdwawJrqe6fd9QNZ9F": {"name": "Binance reserve wallet", "type": "exchange", "chain": "ltc", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2026-08-24", "source_kind": "self-published", "added": "2026-11-07"},
772
+ "MFWNdXE9Cz6MjVDcCLaA2MaXbkwRVUe3TZ": {"name": "Binance reserve wallet", "type": "exchange", "chain": "ltc", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-12-19", "source_kind": "self-published", "added": "2026-11-07"},
773
+ "MGfpYF7kPpoWmVXxSQCmvgb3SedtvDdV47": {"name": "Binance reserve wallet", "type": "exchange", "chain": "ltc", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2026-02-22", "source_kind": "self-published", "added": "2026-11-07"},
774
+ "MHS3wszxaspPAeJYC7p9fpDp5mwvcaUpZL": {"name": "Binance reserve wallet", "type": "exchange", "chain": "ltc", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2026-07-20", "source_kind": "self-published", "added": "2026-11-07"},
775
+ "MJnkrsBX2o7Hv1PU3PEbLJACFt6WvdaxRC": {"name": "Binance reserve wallet", "type": "exchange", "chain": "ltc", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2026-10-22", "source_kind": "self-published", "added": "2026-11-07"},
776
+ "MLj1bgnNs8iAmNxhZNnrymQaAZNqnXXV3E": {"name": "Binance reserve wallet", "type": "exchange", "chain": "ltc", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-11-07"},
777
+ "MNKgpjAqG2fq6WLkpc5mw6mavGNhwCx8tq": {"name": "Binance reserve wallet", "type": "exchange", "chain": "ltc", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2026-02-22", "source_kind": "self-published", "added": "2026-11-07"},
778
+ "MPYLuihDRvAr1YhoxP2tG29y38myBufRF2": {"name": "Binance reserve wallet", "type": "exchange", "chain": "ltc", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2026-07-01", "source_kind": "self-published", "added": "2026-11-07"},
779
+ "MQd1fJwqBJvwLuyhr17PhEFx1swiqDbPQS": {"name": "Binance reserve wallet", "type": "exchange", "chain": "ltc", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-11-07"},
780
+ "MRNXY6zo4j1rTqXmpzLaXWuF8sAuLjNUpY": {"name": "Binance reserve wallet", "type": "exchange", "chain": "ltc", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2026-10-22", "source_kind": "self-published", "added": "2026-11-07"},
781
+ "MS56eJAupdvN5bYo6ythw784qahCpTzZ5V": {"name": "Binance reserve wallet", "type": "exchange", "chain": "ltc", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-11-07"},
782
+ "MSyWrzBejZpuC6h8trjadG4Vk7XRky1Lhq": {"name": "Binance reserve wallet", "type": "exchange", "chain": "ltc", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2026-08-24", "source_kind": "self-published", "added": "2026-11-07"},
783
+ "MW87w56tHzzBYuHS4tfTQ1aYN7Kz6iuSra": {"name": "Binance reserve wallet", "type": "exchange", "chain": "ltc", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-11-07"},
784
+ "ltc1q32lyrhp9zpww22phqjwwmelta0c8a5q9pnjng2": {"name": "Binance reserve wallet (custodied by Ceffu)", "type": "exchange", "chain": "ltc", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-10-15", "source_kind": "self-published", "added": "2026-11-07"},
785
+ "ltc1qzphegzz94l3mr9w56yq6845wfcfllmrd89cd69": {"name": "Binance reserve wallet", "type": "exchange", "chain": "ltc", "source": "Binance proof-of-reserves address list (binance.com/bapi/apex/v1/public/apex/market/por/address), fetched 2026-11-07; listed since 2025-12-18", "source_kind": "self-published", "added": "2026-11-07"},
714
786
 
715
787
  "0x9b64203878f24eb0cdf55c8c6fa7d08ba0cf77e5": {"name": "MEXC reserve wallet", "type": "exchange", "source": "MEXC proof-of-reserves audit by Hacken, 'Verified wallets' table (ownership proven by a test transaction from each address); MEXC_PoR_Audit_20261009.pdf, listed at mexc.com/proof-of-reserve/audit-report; snapshot 2026-10-09; listed for Arbitrum", "source_kind": "self-published", "added": "2026-10-23"},
716
788
  "0xb86f1061e0d79e8319339d5fdbb187d4e7ad3300": {"name": "MEXC reserve wallet", "type": "exchange", "source": "MEXC proof-of-reserves audit by Hacken, 'Verified wallets' table (ownership proven by a test transaction from each address); MEXC_PoR_Audit_20261009.pdf, listed at mexc.com/proof-of-reserve/audit-report; snapshot 2026-10-09; listed for Arbitrum", "source_kind": "self-published", "added": "2026-10-23"},
@@ -94,7 +94,7 @@ def resemblance(a: str, b: str, chain: str) -> Optional[str]:
94
94
  def _history(address: str, chain: str, limit: int) -> List[dict]:
95
95
  """Native and every-token transfers, fakes included — they are the bait."""
96
96
  rows = list(chains.transfers(address, chain, limit))
97
- if chain != "btc":
97
+ if not chains.is_utxo(chain):
98
98
  try:
99
99
  rows += chains.transfers(address, chain, limit, asset=_ALL_TOKENS)
100
100
  except chains.ChainError:
@@ -19,7 +19,8 @@ _PLATFORM = {"eth": "ethereum", "bsc": "binance-smart-chain", "polygon": "polygo
19
19
  "tron": "tron", "sol": "solana", "ton": "the-open-network"}
20
20
  _COINGECKO_NATIVE = {"eth": "ethereum", "bsc": "binancecoin", "polygon": "matic-network",
21
21
  "arbitrum": "ethereum", "optimism": "ethereum", "base": "ethereum",
22
- "btc": "bitcoin", "tron": "tron", "sol": "solana", "ton": "the-open-network"}
22
+ "btc": "bitcoin", "ltc": "litecoin", "doge": "dogecoin",
23
+ "tron": "tron", "sol": "solana", "ton": "the-open-network"}
23
24
 
24
25
  _cache: Dict[str, Optional[float]] = {}
25
26
 
@@ -112,6 +112,13 @@ def _conn() -> sqlite3.Connection:
112
112
  c.execute("DELETE FROM fetched WHERE chain='sol'")
113
113
  c.execute("PRAGMA user_version=6")
114
114
  c.commit()
115
+ if c.execute("PRAGMA user_version").fetchone()[0] < 7:
116
+ # 0.31: equal outputs to one recipient in one Bitcoin transaction were
117
+ # identical rows, and only one was kept; they are one summed row now
118
+ c.execute("DELETE FROM transfers WHERE chain='btc'")
119
+ c.execute("DELETE FROM fetched WHERE chain='btc'")
120
+ c.execute("PRAGMA user_version=7")
121
+ c.commit()
115
122
  return c
116
123
 
117
124
 
@@ -60,13 +60,26 @@ def reconcile(address: str, chain: str = "btc", asset: Optional[dict] = None,
60
60
  ref_in = ref_out = ref_balance = None
61
61
  ref_tx = None
62
62
 
63
- if chain == "btc" and asset is None:
63
+ change = {"received": 0.0, "sent": 0.0}
64
+ read_txs = limit
65
+ if chains.is_utxo(chain) and asset is None:
66
+ read_txs = bitcoin._depth(limit, chain)
64
67
  try:
65
- s = bitcoin.stats(address)
68
+ s = bitcoin.stats(address, chain)
66
69
  ref_in, ref_out = s["received"], s["sent"]
67
70
  ref_balance, ref_tx = s["balance"], s["tx_count"]
68
71
  except bitcoin.BitcoinError as e:
69
72
  notes.append(f"chain totals unavailable: {e}")
73
+ if ref_in is not None:
74
+ # change back to itself is in both of the chain's totals and in neither of ours
75
+ try:
76
+ change = bitcoin.change(address, limit, chain)
77
+ except bitcoin.BitcoinError as e:
78
+ notes.append(f"change outputs unavailable: {e}")
79
+ if change["received"] > 0:
80
+ notes.append(f"{change['received']:.8f} came back to this address as change — the "
81
+ "chain counts it as received and as spent; it is added to both computed "
82
+ "totals (to the spent side in this address's share of the inputs)")
70
83
  else:
71
84
  try:
72
85
  ref_balance = chains.balance(address, chain)
@@ -77,10 +90,10 @@ def reconcile(address: str, chain: str = "btc", asset: Optional[dict] = None,
77
90
  complete = True
78
91
  if ref_tx is not None:
79
92
  # rows are per-transfer, a tx can produce several — compare loosely
80
- if ref_tx > limit:
93
+ if ref_tx > read_txs:
81
94
  complete = False
82
95
  notes.append(f"address has {ref_tx} transactions on chain but only "
83
- f"{limit} were fetched — this is a partial view")
96
+ f"{read_txs} were fetched — this is a partial view")
84
97
  elif len(rows) >= limit:
85
98
  complete = False
86
99
  notes.append(f"hit the {limit}-transfer fetch limit — history may be truncated")
@@ -88,11 +101,11 @@ def reconcile(address: str, chain: str = "btc", asset: Optional[dict] = None,
88
101
  # --- arithmetic ---------------------------------------------------
89
102
  checks = []
90
103
  if ref_in is not None:
91
- ok = _within(got_in, ref_in) or not complete
92
- checks.append(("received", got_in, ref_in, ok))
104
+ ok = _within(got_in + change["received"], ref_in) or not complete
105
+ checks.append(("received", got_in + change["received"], ref_in, ok))
93
106
  if ref_out is not None:
94
107
  # compare like with like: what recipients got, plus the fees we paid
95
- sent_incl_fees = got_out + fees
108
+ sent_incl_fees = got_out + fees + change["sent"]
96
109
  ok = _within(sent_incl_fees, ref_out) or not complete
97
110
  checks.append(("sent (incl. fees)", sent_incl_fees, ref_out, ok))
98
111
  if fees > 0:
@@ -55,7 +55,7 @@ def _rows(address: str, chain: str, limit: int) -> List[dict]:
55
55
  Tokens are read in one request and kept only when their contract is the real
56
56
  stablecoin's — counterfeit "USDT" sent by address poisoners must not alert."""
57
57
  rows = [{**r, "_asset": None} for r in chains.transfers(address, chain, limit, fresh=True)]
58
- if chain == "btc":
58
+ if chains.is_utxo(chain):
59
59
  return rows
60
60
  try:
61
61
  tokens = chains.transfers(address, chain, limit, asset=_ALL_TOKENS, fresh=True)
@@ -217,7 +217,7 @@
217
217
  <option value="eth">eth</option><option value="bsc">bsc</option>
218
218
  <option value="polygon">polygon</option><option value="arbitrum">arbitrum</option>
219
219
  <option value="optimism">optimism</option><option value="base">base</option>
220
- <option value="btc">btc</option><option value="tron">tron</option><option value="sol">sol</option><option value="ton">ton</option>
220
+ <option value="btc">btc</option><option value="ltc">ltc</option><option value="doge">doge</option><option value="tron">tron</option><option value="sol">sol</option><option value="ton">ton</option>
221
221
  </select>
222
222
  <select id="asset" title="Asset to trace">
223
223
  <option value="native">ETH (native)</option>
@@ -37,6 +37,11 @@ def validate(address: str, chain: str):
37
37
  elif chain == "btc":
38
38
  if not (a.startswith(("1", "3", "bc1", "tb1")) and 25 <= len(a) <= 62):
39
39
  return f"'{a[:14]}…' is not a Bitcoin address (expected 1…, 3… or bc1…)."
40
+ elif chains.is_utxo(chain):
41
+ from crypttrace import addresses as _a
42
+ ok, why = _a.validate(a, chain)
43
+ if not ok:
44
+ return f"'{a[:14]}…' is not a {chain.upper()} address: {why}."
40
45
  elif chain == "tron":
41
46
  if not (a.startswith("T") and len(a) == 34):
42
47
  return f"'{a[:14]}…' is not a Tron address (expected T…, 34 chars)."
@@ -80,7 +85,7 @@ def create_app() -> Flask:
80
85
  for sym, meta in assets.tokens_for(chain).items():
81
86
  opts.append({"value": sym, "label": meta["symbol"]})
82
87
  return jsonify({"chain": chain, "assets": opts,
83
- "tokens_supported": chain != "btc"})
88
+ "tokens_supported": not chains.is_utxo(chain)})
84
89
 
85
90
  @app.route("/api/label")
86
91
  def api_label():
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crypttrace
3
- Version: 0.30.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
@@ -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 |
@@ -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
@@ -1,186 +0,0 @@
1
- """Bitcoin fetcher via mempool.space — no API key required.
2
-
3
- Bitcoin uses the UTXO model, not accounts: a transaction consumes previous
4
- outputs and creates new ones, so there is no single "from" or "to". To fit the
5
- same tracing engine used for EVM chains, transactions are normalized into
6
- directional transfer rows:
7
-
8
- * if our address signed an input, each output that isn't ours is an outflow
9
- (outputs back to ourselves are change and are skipped);
10
- * otherwise, outputs paying us are inflows, attributed to the first input.
11
-
12
- Bitcoin also enables the strongest clustering heuristic in blockchain forensics:
13
- common-input-ownership. If several addresses sign inputs of the same
14
- transaction, one party almost certainly controls all of them.
15
- """
16
- from typing import List, Dict, Optional
17
-
18
- import requests
19
-
20
- BASE = "https://mempool.space/api"
21
- SATS = 100_000_000
22
-
23
-
24
- class BitcoinError(RuntimeError):
25
- pass
26
-
27
-
28
- def _get(path: str, timeout: int = 30):
29
- """Cached, throttled GET against mempool.space."""
30
- from crypttrace.fetchers import http
31
- try:
32
- data = http.request_json(f"{BASE}{path}", timeout=timeout)
33
- except http.RateLimited as e:
34
- raise BitcoinError(str(e))
35
- except requests.RequestException as e:
36
- raise BitcoinError(f"mempool.space request failed: {e}")
37
- except ValueError as e:
38
- raise BitcoinError(f"bad response from mempool.space: {e}")
39
- if isinstance(data, dict) and "__status__" in data:
40
- if data["__status__"] == 400:
41
- raise BitcoinError("Bitcoin address rejected by mempool.space — check it is "
42
- "exact (BTC addresses are case-sensitive).")
43
- raise BitcoinError("Address not found on the Bitcoin chain.")
44
- return data
45
-
46
-
47
- def stats(address: str) -> Dict:
48
- """Authoritative totals straight from the chain index.
49
-
50
- These are the numbers our own parsing must agree with — the reference for
51
- checking that a trace didn't miscount or silently miss history.
52
- """
53
- d = _get(f"/address/{address}")
54
- cs = d.get("chain_stats", {}) or {}
55
- ms = d.get("mempool_stats", {}) or {}
56
- recv = (cs.get("funded_txo_sum", 0) + ms.get("funded_txo_sum", 0)) / SATS
57
- sent = (cs.get("spent_txo_sum", 0) + ms.get("spent_txo_sum", 0)) / SATS
58
- return {
59
- "received": recv,
60
- "sent": sent,
61
- "balance": recv - sent,
62
- "tx_count": (cs.get("tx_count", 0) + ms.get("tx_count", 0)),
63
- "funded_outputs": (cs.get("funded_txo_count", 0) + ms.get("funded_txo_count", 0)),
64
- }
65
-
66
-
67
- def balance(address: str) -> float:
68
- return stats(address)["balance"]
69
-
70
-
71
- def raw_txs(address: str, max_txs: int = 500) -> List[dict]:
72
- """Transactions touching this address, paging back through history.
73
-
74
- mempool.space returns ~50 per call. A single page is not enough for
75
- investigations: famous addresses get spammed with dust, which pushes the
76
- transactions that actually matter out of the recent window. So we follow
77
- the /txs/chain/:last_txid cursor until we have enough history.
78
- """
79
- out: List[dict] = []
80
- last: Optional[str] = None
81
- while len(out) < max_txs:
82
- path = f"/address/{address}/txs" if last is None \
83
- else f"/address/{address}/txs/chain/{last}"
84
- batch = _get(path)
85
- if not isinstance(batch, list) or not batch:
86
- break
87
- out.extend(batch)
88
- if len(batch) < 25: # last page
89
- break
90
- nxt = batch[-1].get("txid")
91
- if not nxt or nxt == last:
92
- break
93
- last = nxt
94
- return out[:max_txs]
95
-
96
-
97
- def _in_addrs(tx: dict) -> List[str]:
98
- out = []
99
- for v in tx.get("vin", []) or []:
100
- a = (v.get("prevout") or {}).get("scriptpubkey_address")
101
- if a:
102
- out.append(a)
103
- return out
104
-
105
-
106
- def _in_pairs(tx: dict):
107
- """(address, value) for every input — a UTXO tx can be funded by many parties."""
108
- for v in tx.get("vin", []) or []:
109
- p = v.get("prevout") or {}
110
- a = p.get("scriptpubkey_address")
111
- if a:
112
- yield a, (p.get("value", 0) or 0) / SATS
113
-
114
-
115
- def _out_pairs(tx: dict):
116
- for o in tx.get("vout", []) or []:
117
- a = o.get("scriptpubkey_address")
118
- if a:
119
- yield a, o.get("value", 0) / SATS
120
-
121
-
122
- def transfers(address: str, limit: int = 1000) -> List[Dict]:
123
- """Normalized {from,to,value,timestamp,hash,symbol} rows.
124
-
125
- A Bitcoin transaction has no single sender: it can be funded by many inputs
126
- from different owners and pay many outputs. Attributing a whole transfer to
127
- the first input address — the naive shortcut — badly misreports
128
- consolidations, e.g. crediting one wallet with funds a hundred others
129
- supplied. So value is split in proportion to what each side actually
130
- contributed or received.
131
- """
132
- me = address
133
- rows: List[Dict] = []
134
- for tx in raw_txs(address, max_txs=max(limit, 200)):
135
- ts = int((tx.get("status") or {}).get("block_time") or 0)
136
- h = tx.get("txid", "")
137
- ins = list(_in_pairs(tx))
138
- outs = list(_out_pairs(tx))
139
- total_in = sum(v for _, v in ins)
140
- if total_in <= 0:
141
- continue
142
-
143
- if me in (a for a, _ in ins):
144
- # outgoing: our share of the inputs funds our share of each output
145
- mine_in = sum(v for a, v in ins if a == me)
146
- share = mine_in / total_in
147
- # The chain counts a spent output at full value; the recipient gets
148
- # less, because the miner fee comes out in between. Carry our share
149
- # of that fee so totals can be reconciled exactly rather than
150
- # hidden inside a tolerance.
151
- fee_share = (total_in - sum(v for _, v in outs)) * share
152
- for a, v in outs:
153
- if a == me or v <= 0:
154
- continue # change back to self
155
- rows.append({"from": me, "to": a, "value": v * share,
156
- "timestamp": ts, "hash": h, "symbol": "BTC",
157
- "fee_share": fee_share})
158
- else:
159
- # incoming: credit every funder in proportion to what it put in
160
- mine_out = sum(v for a, v in outs if a == me)
161
- if mine_out <= 0:
162
- continue
163
- merged: Dict[str, float] = {}
164
- for a, v in ins:
165
- merged[a] = merged.get(a, 0.0) + v
166
- for a, v in merged.items():
167
- rows.append({"from": a, "to": me, "value": mine_out * (v / total_in),
168
- "timestamp": ts, "hash": h, "symbol": "BTC"})
169
- if len(rows) >= limit:
170
- break
171
- return rows
172
-
173
-
174
- def cluster(address: str) -> List[tuple]:
175
- """Common-input-ownership: addresses that co-signed inputs with `address`.
176
-
177
- Returns [(address, times_seen_together)] — likely the same owner's wallets.
178
- """
179
- peers: Dict[str, int] = {}
180
- for tx in raw_txs(address):
181
- ins = _in_addrs(tx)
182
- if address in ins and len(set(ins)) > 1:
183
- for a in set(ins):
184
- if a != address:
185
- peers[a] = peers.get(a, 0) + 1
186
- return sorted(peers.items(), key=lambda kv: kv[1], reverse=True)
File without changes
File without changes
File without changes