crypttrace 0.13.0__tar.gz → 0.14.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {crypttrace-0.13.0/src/crypttrace.egg-info → crypttrace-0.14.0}/PKG-INFO +25 -1
- {crypttrace-0.13.0 → crypttrace-0.14.0}/README.md +24 -0
- {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/__init__.py +1 -1
- {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/chains.py +30 -0
- {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/cli.py +4 -1
- {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/fetchers/ton.py +14 -3
- {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/labels/labels.py +17 -2
- crypttrace-0.14.0/src/crypttrace/labels/tonapi.py +109 -0
- {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/offramp.py +4 -0
- {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/render.py +14 -0
- {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/report.py +8 -1
- {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/store.py +17 -5
- {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/trace.py +43 -13
- {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/web/index.html +7 -1
- {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/webapp.py +1 -0
- {crypttrace-0.13.0 → crypttrace-0.14.0/src/crypttrace.egg-info}/PKG-INFO +25 -1
- {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace.egg-info/SOURCES.txt +1 -0
- {crypttrace-0.13.0 → crypttrace-0.14.0}/LICENSE +0 -0
- {crypttrace-0.13.0 → crypttrace-0.14.0}/pyproject.toml +0 -0
- {crypttrace-0.13.0 → crypttrace-0.14.0}/setup.cfg +0 -0
- {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/addresses.py +0 -0
- {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/analysis.py +0 -0
- {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/assess.py +0 -0
- {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/assets.py +0 -0
- {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/bridges.py +0 -0
- {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/config.py +0 -0
- {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/fetchers/__init__.py +0 -0
- {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/fetchers/bitcoin.py +0 -0
- {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/fetchers/etherscan.py +0 -0
- {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/fetchers/http.py +0 -0
- {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/fetchers/solana.py +0 -0
- {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/fetchers/tron.py +0 -0
- {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/freeze.py +0 -0
- {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/funder.py +0 -0
- {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/investigate.py +0 -0
- {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/labels/__init__.py +0 -0
- {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/labels/audit.py +0 -0
- {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/labels/bulk.py +0 -0
- {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/labels/known.json +0 -0
- {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/labels/partial.json +0 -0
- {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/poisoning.py +0 -0
- {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/prices.py +0 -0
- {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/spam.py +0 -0
- {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/verify.py +0 -0
- {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace/watch.py +0 -0
- {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace.egg-info/dependency_links.txt +0 -0
- {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace.egg-info/entry_points.txt +0 -0
- {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace.egg-info/requires.txt +0 -0
- {crypttrace-0.13.0 → crypttrace-0.14.0}/src/crypttrace.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: crypttrace
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.14.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
|
|
@@ -103,6 +103,7 @@ own credentials (all optional):
|
|
|
103
103
|
export TRONGRID_API_KEY=xxxx # optional: higher TronGrid quota
|
|
104
104
|
export CRYPTTRACE_SOLANA_RPC=https://… # optional: your own (faster) Solana RPC
|
|
105
105
|
export TONCENTER_API_KEY=xxxx # optional: toncenter allows ~1 request/s without it
|
|
106
|
+
export TONAPI_KEY=xxxx # optional: faster tonapi name lookups on TON
|
|
106
107
|
```
|
|
107
108
|
|
|
108
109
|
(In PowerShell: `$env:TRONGRID_API_KEY = "xxxx"` and so on.)
|
|
@@ -481,6 +482,29 @@ wallets. That is what lets `offramp` flag deposit addresses on those chains. Fun
|
|
|
481
482
|
list still show as `unknown`. Every label records where it came from — see
|
|
482
483
|
`crypttrace labels audit`.
|
|
483
484
|
|
|
485
|
+
**TON: names from tonapi.** On TON, addresses the curated set does not know are
|
|
486
|
+
looked up on tonapi, which names exchange hot wallets ("Bybit 7", "Kucoin 1"),
|
|
487
|
+
DEX contracts (STON.fi, DeDust) and services (Fragment), and flags scam
|
|
488
|
+
accounts. These become labels of kind `explorer-tag`, cached for a week. A
|
|
489
|
+
`.ton` name an owner chose for himself — "crypto-exch-io.ton", or an impostor's
|
|
490
|
+
"kucoin-1.ton" — is not taken as a label. A trace stops at a DEX: the funds
|
|
491
|
+
come back to the sender in another token.
|
|
492
|
+
|
|
493
|
+
**Exchange deposits with a memo.** A TON exchange takes all customers' deposits
|
|
494
|
+
into one hot wallet and tells them apart by the transfer comment, the *memo*.
|
|
495
|
+
The memo is the lead: it names one account at the exchange, which holds that
|
|
496
|
+
customer's KYC. `trace`, `profile`, the web UI and the HTML report show every
|
|
497
|
+
deposit an address made to an exchange with its memo:
|
|
498
|
+
|
|
499
|
+
```
|
|
500
|
+
├── ──1280.0000 TON (1 tx)──▶ 🟢 UQCA1BI4QR…la6Yug [KuCoin hot wallet (memo required)]
|
|
501
|
+
│ └── ↳ deposit memo 2093842166 — names one account at KuCoin, which holds its KYC
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
In the fragment-api case this found the attacker's KuCoin account directly, and
|
|
505
|
+
the account of the exchange service he cashed out through (its relay deposits
|
|
506
|
+
under one memo every time).
|
|
507
|
+
|
|
484
508
|
**Deposit addresses, labelled outright.** OKX signs every address that holds
|
|
485
509
|
customer funds — over 300,000, nearly all of them per-customer deposit addresses
|
|
486
510
|
— and publishes the list with its proof of reserves. `crypttrace update-labels
|
|
@@ -69,6 +69,7 @@ own credentials (all optional):
|
|
|
69
69
|
export TRONGRID_API_KEY=xxxx # optional: higher TronGrid quota
|
|
70
70
|
export CRYPTTRACE_SOLANA_RPC=https://… # optional: your own (faster) Solana RPC
|
|
71
71
|
export TONCENTER_API_KEY=xxxx # optional: toncenter allows ~1 request/s without it
|
|
72
|
+
export TONAPI_KEY=xxxx # optional: faster tonapi name lookups on TON
|
|
72
73
|
```
|
|
73
74
|
|
|
74
75
|
(In PowerShell: `$env:TRONGRID_API_KEY = "xxxx"` and so on.)
|
|
@@ -447,6 +448,29 @@ wallets. That is what lets `offramp` flag deposit addresses on those chains. Fun
|
|
|
447
448
|
list still show as `unknown`. Every label records where it came from — see
|
|
448
449
|
`crypttrace labels audit`.
|
|
449
450
|
|
|
451
|
+
**TON: names from tonapi.** On TON, addresses the curated set does not know are
|
|
452
|
+
looked up on tonapi, which names exchange hot wallets ("Bybit 7", "Kucoin 1"),
|
|
453
|
+
DEX contracts (STON.fi, DeDust) and services (Fragment), and flags scam
|
|
454
|
+
accounts. These become labels of kind `explorer-tag`, cached for a week. A
|
|
455
|
+
`.ton` name an owner chose for himself — "crypto-exch-io.ton", or an impostor's
|
|
456
|
+
"kucoin-1.ton" — is not taken as a label. A trace stops at a DEX: the funds
|
|
457
|
+
come back to the sender in another token.
|
|
458
|
+
|
|
459
|
+
**Exchange deposits with a memo.** A TON exchange takes all customers' deposits
|
|
460
|
+
into one hot wallet and tells them apart by the transfer comment, the *memo*.
|
|
461
|
+
The memo is the lead: it names one account at the exchange, which holds that
|
|
462
|
+
customer's KYC. `trace`, `profile`, the web UI and the HTML report show every
|
|
463
|
+
deposit an address made to an exchange with its memo:
|
|
464
|
+
|
|
465
|
+
```
|
|
466
|
+
├── ──1280.0000 TON (1 tx)──▶ 🟢 UQCA1BI4QR…la6Yug [KuCoin hot wallet (memo required)]
|
|
467
|
+
│ └── ↳ deposit memo 2093842166 — names one account at KuCoin, which holds its KYC
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
In the fragment-api case this found the attacker's KuCoin account directly, and
|
|
471
|
+
the account of the exchange service he cashed out through (its relay deposits
|
|
472
|
+
under one memo every time).
|
|
473
|
+
|
|
450
474
|
**Deposit addresses, labelled outright.** OKX signs every address that holds
|
|
451
475
|
customer funds — over 300,000, nearly all of them per-customer deposit addresses
|
|
452
476
|
— and publishes the list with its proof of reserves. `crypttrace update-labels
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""crypttrace — OSINT crypto investigation CLI."""
|
|
2
|
-
__version__ = "0.
|
|
2
|
+
__version__ = "0.14.0"
|
|
@@ -15,6 +15,7 @@ from typing import List, Dict, Optional
|
|
|
15
15
|
from crypttrace import config
|
|
16
16
|
from crypttrace import addresses as _addresses
|
|
17
17
|
from crypttrace import spam as _spam
|
|
18
|
+
from crypttrace.labels import labels as _labels
|
|
18
19
|
from crypttrace.fetchers import etherscan, bitcoin, http as _http, tron, solana, ton
|
|
19
20
|
|
|
20
21
|
EVM_CHAINS = set(config.CHAINS)
|
|
@@ -250,6 +251,35 @@ def flows(address: str, chain: str, top: int, direction: str = "out",
|
|
|
250
251
|
return [(a, v, c) for a, (v, c) in ranked][:top]
|
|
251
252
|
|
|
252
253
|
|
|
254
|
+
def exchange_deposits(address: str, chain: str, limit: int = 1000,
|
|
255
|
+
asset: Optional[dict] = None, only_to: Optional[str] = None) -> List[Dict]:
|
|
256
|
+
"""Deposits this address made to exchanges with a memo, per (exchange wallet, memo).
|
|
257
|
+
|
|
258
|
+
On TON an exchange's hot wallet takes every customer's deposits and tells
|
|
259
|
+
them apart by the memo (the transfer comment). The memo is therefore the
|
|
260
|
+
lead: it names one account at the exchange, which holds that customer's KYC.
|
|
261
|
+
Returns [{exchange_address, exchange, memo, value, txs, symbol, last_ts, hash}].
|
|
262
|
+
"""
|
|
263
|
+
me = norm_addr(address, chain)
|
|
264
|
+
rows = [r for r in transfers(address, chain, limit, asset=asset)
|
|
265
|
+
if r.get("from") == me and (r.get("memo") or "").strip()
|
|
266
|
+
and (not only_to or r.get("to") == only_to)]
|
|
267
|
+
_labels.enrich({r.get("to") for r in rows}, chain)
|
|
268
|
+
agg: Dict[tuple, dict] = {}
|
|
269
|
+
for r in rows:
|
|
270
|
+
to, memo = r.get("to") or "", r["memo"].strip()
|
|
271
|
+
if _labels.type_of(to) != "exchange":
|
|
272
|
+
continue
|
|
273
|
+
rec = agg.setdefault((to, memo), {"exchange_address": to, "exchange": _labels.label_of(to),
|
|
274
|
+
"memo": memo, "value": 0.0, "txs": 0,
|
|
275
|
+
"symbol": r.get("symbol", ""), "last_ts": 0, "hash": ""})
|
|
276
|
+
rec["value"] += r.get("value", 0) or 0
|
|
277
|
+
rec["txs"] += 1
|
|
278
|
+
if (r.get("timestamp") or 0) >= rec["last_ts"]:
|
|
279
|
+
rec["last_ts"], rec["hash"] = r.get("timestamp") or 0, r.get("hash", "")
|
|
280
|
+
return sorted(agg.values(), key=lambda d: -d["value"])
|
|
281
|
+
|
|
282
|
+
|
|
253
283
|
def outflows(address: str, chain: str, top: int, limit: int = 1000, asset=None):
|
|
254
284
|
return flows(address, chain, top, "out", limit, asset)
|
|
255
285
|
|
|
@@ -159,6 +159,9 @@ def profile(
|
|
|
159
159
|
chains_mod.symbol(chain)))
|
|
160
160
|
if rows:
|
|
161
161
|
console.print(render.counterparties_rows_table(address, chain, rows))
|
|
162
|
+
deposits = chains_mod.exchange_deposits(address, chain)
|
|
163
|
+
if deposits:
|
|
164
|
+
console.print(render.deposits_table(deposits))
|
|
162
165
|
else:
|
|
163
166
|
console.print("[dim]No transactions found for this address on this chain.[/dim]")
|
|
164
167
|
|
|
@@ -191,7 +194,7 @@ def trace(
|
|
|
191
194
|
console.print(tree)
|
|
192
195
|
console.print(
|
|
193
196
|
"\n[dim]Legend: \U0001F7E2 exchange \U0001F7E3 mixer \U0001F534 sanctioned/scam"
|
|
194
|
-
" \U0001F309 bridge ⚪ unknown[/dim]"
|
|
197
|
+
" \U0001F309 bridge \U0001F504 DEX \U0001F537 service ⚪ unknown[/dim]"
|
|
195
198
|
)
|
|
196
199
|
|
|
197
200
|
|
|
@@ -70,6 +70,11 @@ def _addr(raw: Optional[str]) -> str:
|
|
|
70
70
|
return addresses.ton_friendly(raw or "") or (raw or "")
|
|
71
71
|
|
|
72
72
|
|
|
73
|
+
def _memo(text) -> str:
|
|
74
|
+
"""A transfer comment, cleaned — exchanges read deposit memos from it."""
|
|
75
|
+
return str(text or "").replace("\x00", "").strip()
|
|
76
|
+
|
|
77
|
+
|
|
73
78
|
def _hex(tx_hash: str) -> str:
|
|
74
79
|
"""toncenter gives base64 hashes; explorers link by hex."""
|
|
75
80
|
try:
|
|
@@ -100,8 +105,12 @@ def transfers(address: str, limit: int = 1000) -> List[Dict]:
|
|
|
100
105
|
continue
|
|
101
106
|
# the sender's and the receiver's transactions have different hashes;
|
|
102
107
|
# the message hash is the same on both sides (the store keys on it)
|
|
103
|
-
|
|
104
|
-
|
|
108
|
+
row = {"from": src, "to": dst, "value": value, "timestamp": ts,
|
|
109
|
+
"hash": h, "symbol": "TON", "msg_hash": _hex(m.get("hash") or "")}
|
|
110
|
+
memo = _memo(((m.get("message_content") or {}).get("decoded") or {}).get("comment"))
|
|
111
|
+
if memo:
|
|
112
|
+
row["memo"] = memo
|
|
113
|
+
rows.append(row)
|
|
105
114
|
return rows
|
|
106
115
|
|
|
107
116
|
|
|
@@ -140,9 +149,11 @@ def jetton_transfers(address: str, limit: int = 1000) -> List[Dict]:
|
|
|
140
149
|
value = int(t.get("amount") or 0) / (10 ** meta["decimals"])
|
|
141
150
|
except (ValueError, TypeError):
|
|
142
151
|
continue
|
|
152
|
+
payload = t.get("decoded_forward_payload") or {}
|
|
153
|
+
memo = _memo(payload.get("comment")) if isinstance(payload, dict) else ""
|
|
143
154
|
rows.append({"from": _addr(t.get("source")), "to": _addr(t.get("destination")),
|
|
144
155
|
"value": value, "timestamp": int(t.get("transaction_now") or 0),
|
|
145
156
|
"hash": _hex(t.get("transaction_hash", "")),
|
|
146
157
|
"symbol": meta["symbol"], "name": meta["name"], "contract": master,
|
|
147
|
-
"flagged": meta["scam"]})
|
|
158
|
+
"flagged": meta["scam"], **({"memo": memo} if memo else {})})
|
|
148
159
|
return rows
|
|
@@ -17,7 +17,7 @@ from typing import Optional, Dict, List
|
|
|
17
17
|
import requests
|
|
18
18
|
|
|
19
19
|
from crypttrace import addresses, config
|
|
20
|
-
from crypttrace.labels import bulk
|
|
20
|
+
from crypttrace.labels import bulk, tonapi
|
|
21
21
|
|
|
22
22
|
_HERE = Path(__file__).parent
|
|
23
23
|
_IMPORTED = config.DATA_DIR / "imported_labels.json"
|
|
@@ -162,6 +162,8 @@ RISK = {
|
|
|
162
162
|
"scam": 85,
|
|
163
163
|
"bridge": 40,
|
|
164
164
|
"exchange": 20,
|
|
165
|
+
"dex": 0,
|
|
166
|
+
"service": 0,
|
|
165
167
|
"unknown": 0,
|
|
166
168
|
}
|
|
167
169
|
|
|
@@ -171,6 +173,8 @@ TYPE_ICON = {
|
|
|
171
173
|
"scam": "\U0001F534",
|
|
172
174
|
"bridge": "\U0001F309", # bridge
|
|
173
175
|
"exchange": "\U0001F7E2", # green
|
|
176
|
+
"dex": "\U0001F504", # swap arrows
|
|
177
|
+
"service": "\U0001F537", # blue diamond
|
|
174
178
|
"unknown": "⚪", # white
|
|
175
179
|
}
|
|
176
180
|
|
|
@@ -182,7 +186,18 @@ def lookup(address: str) -> Optional[dict]:
|
|
|
182
186
|
hit = _KNOWN.get(address.lower())
|
|
183
187
|
if hit:
|
|
184
188
|
return hit
|
|
185
|
-
|
|
189
|
+
found = _match_partial(address) or bulk.lookup(address)
|
|
190
|
+
if not found and (len(address) == 48 or ":" in address):
|
|
191
|
+
found = tonapi.lookup(address) # cached explorer tag, no network here
|
|
192
|
+
return found
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def enrich(addrs, chain: str) -> None:
|
|
196
|
+
"""Fetch explorer tags the curated set lacks (TON: tonapi names). Network, cached."""
|
|
197
|
+
if chain == "ton":
|
|
198
|
+
_load()
|
|
199
|
+
tonapi.enrich(a for a in addrs
|
|
200
|
+
if a and not _KNOWN.get((addresses.ton_friendly(a) or a).lower()))
|
|
186
201
|
|
|
187
202
|
|
|
188
203
|
def is_deposit(address: str) -> bool:
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""TON account names from tonapi — exchanges, DEXes and known services.
|
|
2
|
+
|
|
3
|
+
tonapi names the hot wallets of exchanges ("Bybit 7", "Kucoin 1"), DEX routers
|
|
4
|
+
("STON.fi DEX") and services ("Fragment"), and flags scam accounts. That is
|
|
5
|
+
the TON equivalent of an explorer's address tags, so it is used the same way:
|
|
6
|
+
a label with its source, of kind "explorer-tag".
|
|
7
|
+
|
|
8
|
+
Two things are deliberately *not* trusted:
|
|
9
|
+
- names ending in .ton / .t.me — those are DNS names the owner gave himself
|
|
10
|
+
("crypto-exch-io.ton", or an impostor's "kucoin-1.ton");
|
|
11
|
+
- anything unknown to tonapi stays unknown — no guessing from interfaces alone,
|
|
12
|
+
except that STON.fi / DeDust contracts are DEXes whatever they are called.
|
|
13
|
+
|
|
14
|
+
Lookups are cached on disk for a week (positive and negative), so a trace pays
|
|
15
|
+
for each address once. Only `enrich()` touches the network; `lookup()` reads
|
|
16
|
+
the cache, which keeps label lookups cheap everywhere else.
|
|
17
|
+
"""
|
|
18
|
+
import datetime as dt
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import time
|
|
23
|
+
from typing import Dict, Iterable, Optional
|
|
24
|
+
|
|
25
|
+
import requests
|
|
26
|
+
|
|
27
|
+
from crypttrace import addresses, config
|
|
28
|
+
from crypttrace.fetchers import http
|
|
29
|
+
|
|
30
|
+
API = "https://tonapi.io/v2/accounts/{}"
|
|
31
|
+
CACHE = config.DATA_DIR / "tonapi_names.json"
|
|
32
|
+
MAX_AGE = 7 * 24 * 3600
|
|
33
|
+
_KEY = os.environ.get("TONAPI_KEY")
|
|
34
|
+
http.MIN_INTERVAL["tonapi.io"] = 0.12 if _KEY else 1.1
|
|
35
|
+
|
|
36
|
+
# custodial platforms that hold KYC and can freeze: a deposit there is a lead
|
|
37
|
+
_EXCHANGES = re.compile(
|
|
38
|
+
r"\b(binance|bybit|okx|okex|kucoin|htx|huobi|gate(\.io)?|mexc|bitget|kraken|coinbase|"
|
|
39
|
+
r"bitfinex|bingx|bitmart|lbank|whitebit|exmo|coinex|poloniex|crypto\.com|bitpanda|"
|
|
40
|
+
r"bithumb|upbit|cryptobot|crypto bot|xrocket|wallet in telegram|telegram wallet)\b", re.I)
|
|
41
|
+
|
|
42
|
+
_cache: Optional[Dict[str, dict]] = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _load() -> Dict[str, dict]:
|
|
46
|
+
global _cache
|
|
47
|
+
if _cache is None:
|
|
48
|
+
try:
|
|
49
|
+
_cache = json.loads(CACHE.read_text(encoding="utf-8"))
|
|
50
|
+
except (OSError, ValueError):
|
|
51
|
+
_cache = {}
|
|
52
|
+
return _cache
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _save() -> None:
|
|
56
|
+
try:
|
|
57
|
+
CACHE.parent.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
tmp = CACHE.with_suffix(".tmp")
|
|
59
|
+
tmp.write_text(json.dumps(_load(), ensure_ascii=False), encoding="utf-8")
|
|
60
|
+
tmp.replace(CACHE)
|
|
61
|
+
except OSError:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def classify(account: dict) -> Optional[dict]:
|
|
66
|
+
"""A label from one tonapi account record, or None if it names nothing we trust."""
|
|
67
|
+
name = (account.get("name") or "").strip()
|
|
68
|
+
ifaces = " ".join(account.get("interfaces") or [])
|
|
69
|
+
self_named = bool(re.search(r"\.(ton|t\.me)$", name, re.I))
|
|
70
|
+
if account.get("is_scam"):
|
|
71
|
+
return {"name": f"{name or 'account'} (flagged as scam by tonapi)", "type": "scam"}
|
|
72
|
+
if "stonfi" in ifaces or "dedust" in ifaces:
|
|
73
|
+
return {"name": name or "DEX contract", "type": "dex"}
|
|
74
|
+
if not name or self_named:
|
|
75
|
+
return None
|
|
76
|
+
if _EXCHANGES.search(name):
|
|
77
|
+
memo = " (memo required)" if account.get("memo_required") else ""
|
|
78
|
+
return {"name": f"{name}{memo}", "type": "exchange", "memo_required": bool(account.get("memo_required"))}
|
|
79
|
+
return {"name": name, "type": "service"}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def lookup(address: str) -> Optional[dict]:
|
|
83
|
+
"""Cached tonapi label for a TON address, without touching the network."""
|
|
84
|
+
key = addresses.ton_friendly(address) or address
|
|
85
|
+
entry = _load().get(key)
|
|
86
|
+
if not entry or not entry.get("label"):
|
|
87
|
+
return None
|
|
88
|
+
return {**entry["label"], "chain": "ton", "source_kind": "explorer-tag",
|
|
89
|
+
"source": f"tonapi account name, fetched {entry.get('day', '')}"}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def enrich(addrs: Iterable[str]) -> None:
|
|
93
|
+
"""Fetch tonapi names for addresses not in the cache (or stale)."""
|
|
94
|
+
cache, now, changed = _load(), time.time(), False
|
|
95
|
+
for a in dict.fromkeys(addrs):
|
|
96
|
+
key = addresses.ton_friendly(a or "")
|
|
97
|
+
if not key or now - cache.get(key, {}).get("at", 0) < MAX_AGE:
|
|
98
|
+
continue
|
|
99
|
+
try:
|
|
100
|
+
rec = http.request_json(API.format(key), {}, timeout=20,
|
|
101
|
+
headers={"Authorization": f"Bearer {_KEY}"} if _KEY else None)
|
|
102
|
+
except (http.RateLimited, requests.RequestException, ValueError):
|
|
103
|
+
continue
|
|
104
|
+
if not isinstance(rec, dict) or "__status__" in rec:
|
|
105
|
+
continue
|
|
106
|
+
cache[key] = {"at": now, "day": dt.date.today().isoformat(), "label": classify(rec)}
|
|
107
|
+
changed = True
|
|
108
|
+
if changed:
|
|
109
|
+
_save()
|
|
@@ -30,6 +30,10 @@ def _one(address: str, chain: str, threshold: float, asset: Optional[dict]) -> O
|
|
|
30
30
|
to = r.get("to") or ""
|
|
31
31
|
if r.get("from") != me or not to:
|
|
32
32
|
continue
|
|
33
|
+
# a deposit with a memo (TON) is a customer paying into their own exchange
|
|
34
|
+
# account — the memo is the lead (chains.exchange_deposits), not this address
|
|
35
|
+
if r.get("memo") and labels.type_of(to) == "exchange":
|
|
36
|
+
continue
|
|
33
37
|
val = r.get("value", 0) or 0
|
|
34
38
|
out_total += val
|
|
35
39
|
# sending *into* another customer's deposit address is a cash-out by
|
|
@@ -224,6 +224,20 @@ def funding_tree(address: str, hops: list, symbol: str = "ETH") -> Tree:
|
|
|
224
224
|
return root
|
|
225
225
|
|
|
226
226
|
|
|
227
|
+
def deposits_table(deposits: list) -> Table:
|
|
228
|
+
"""Exchange deposits made with a memo — each memo names one account at the exchange."""
|
|
229
|
+
t = Table(title="Exchange deposits — the memo names one account at the exchange (KYC)",
|
|
230
|
+
header_style="bold")
|
|
231
|
+
t.add_column("Exchange wallet")
|
|
232
|
+
t.add_column("Memo", style="bold green")
|
|
233
|
+
t.add_column("Amount", justify="right")
|
|
234
|
+
t.add_column("Txs", justify="right")
|
|
235
|
+
for d in deposits:
|
|
236
|
+
t.add_row(escape(d["exchange"]), escape(d["memo"]), f"{d['value']:,.4f} {escape(d['symbol'])}",
|
|
237
|
+
str(d["txs"]))
|
|
238
|
+
return t
|
|
239
|
+
|
|
240
|
+
|
|
227
241
|
def holdings_table(address: str, chain: str, holdings: list, show_all: bool = False) -> Table:
|
|
228
242
|
"""Token holdings with per-token and total USD value.
|
|
229
243
|
|
|
@@ -228,7 +228,8 @@ def _render_md(d: dict, json_name: str) -> str:
|
|
|
228
228
|
if d["key_findings"]:
|
|
229
229
|
L.append("| Entity | Type | Risk | Value reached | ≈ USD |\n|---|---|---|---|---|\n")
|
|
230
230
|
for f in d["key_findings"]:
|
|
231
|
-
|
|
231
|
+
memo = f" (deposit memo {f['memo']})" if f.get("memo") else ""
|
|
232
|
+
L.append(f"| {f['label']}{memo} | {f['type']} | {f['risk']}/100 | {f['value_reached']} "
|
|
232
233
|
f"{f.get('symbol', '')} | {prices.fmt_usd(f.get('usd_reached'))} |\n")
|
|
233
234
|
else:
|
|
234
235
|
L.append("_None within the traced depth._\n")
|
|
@@ -390,6 +391,12 @@ def render_html(d: dict, raw_json: str, digest: str, json_name: str) -> str:
|
|
|
390
391
|
if sig["name"] in ("address poisoning", "paid a look-alike"):
|
|
391
392
|
acts.append(f"<div class='box alert'><b>Address poisoning.</b> {_e(sig['observed'])} — "
|
|
392
393
|
f"{_e(sig['implication'])}.</div>")
|
|
394
|
+
for f in d["key_findings"]:
|
|
395
|
+
if f.get("memo"):
|
|
396
|
+
acts.append(f"<div class='box alert'><b>Deposit to {_e(labels.company(f['label']))} with memo "
|
|
397
|
+
f"{_e(f['memo'])}.</b> The memo names one account at the exchange, which holds "
|
|
398
|
+
"its owner's KYC and can freeze it. Quote the memo and the transaction hashes "
|
|
399
|
+
"below in the report to the exchange and the police.</div>")
|
|
393
400
|
exch = sorted({labels.company(f["label"]) for f in d["key_findings"]
|
|
394
401
|
if f["type"] in ("exchange", "offramp")})
|
|
395
402
|
if exch:
|
|
@@ -67,6 +67,16 @@ def _conn() -> sqlite3.Connection:
|
|
|
67
67
|
c.execute("DELETE FROM fetched WHERE chain='ton'")
|
|
68
68
|
c.execute("PRAGMA user_version=1")
|
|
69
69
|
c.commit()
|
|
70
|
+
if c.execute("PRAGMA user_version").fetchone()[0] < 2:
|
|
71
|
+
# 0.14: transfer comments — on TON an exchange deposit's memo names the account
|
|
72
|
+
cols = [r[1] for r in c.execute("PRAGMA table_info(transfers)")]
|
|
73
|
+
if "memo" not in cols:
|
|
74
|
+
c.execute("ALTER TABLE transfers ADD COLUMN memo TEXT")
|
|
75
|
+
# TON rows stored before had their memos dropped: read them again
|
|
76
|
+
c.execute("DELETE FROM transfers WHERE chain='ton'")
|
|
77
|
+
c.execute("DELETE FROM fetched WHERE chain='ton'")
|
|
78
|
+
c.execute("PRAGMA user_version=2")
|
|
79
|
+
c.commit()
|
|
70
80
|
return c
|
|
71
81
|
|
|
72
82
|
|
|
@@ -90,12 +100,12 @@ def save(chain: str, address: str, rows: List[dict], asset_key: str = "",
|
|
|
90
100
|
try:
|
|
91
101
|
c.executemany(
|
|
92
102
|
"INSERT OR REPLACE INTO transfers"
|
|
93
|
-
"(id,chain,tx_hash,from_addr,to_addr,value,symbol,contract,ts,fee_share)"
|
|
94
|
-
" VALUES (
|
|
103
|
+
"(id,chain,tx_hash,from_addr,to_addr,value,symbol,contract,ts,fee_share,memo)"
|
|
104
|
+
" VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
|
95
105
|
[(_row_id(chain, r), chain, r.get("hash", ""), r.get("from", ""),
|
|
96
106
|
r.get("to", ""), float(r.get("value", 0) or 0), r.get("symbol", ""),
|
|
97
107
|
r.get("contract", ""), int(r.get("timestamp", 0) or 0),
|
|
98
|
-
float(r.get("fee_share", 0) or 0)) for r in rows])
|
|
108
|
+
float(r.get("fee_share", 0) or 0), r.get("memo") or None) for r in rows])
|
|
99
109
|
c.execute("INSERT OR REPLACE INTO fetched(chain,address,asset,fetched_at,rows,complete)"
|
|
100
110
|
" VALUES (?,?,?,?,?,?)",
|
|
101
111
|
(chain, address, asset_key, int(time.time()), len(rows), int(complete)))
|
|
@@ -125,7 +135,7 @@ def is_fresh(chain: str, address: str, asset_key: str = "",
|
|
|
125
135
|
def load(chain: str, address: str, contract: str = "", native_symbol: str = "") -> List[dict]:
|
|
126
136
|
"""Every stored transfer touching this address, newest first."""
|
|
127
137
|
c = _conn()
|
|
128
|
-
cols = "SELECT from_addr,to_addr,value,symbol,contract,ts,tx_hash,fee_share FROM transfers"
|
|
138
|
+
cols = "SELECT from_addr,to_addr,value,symbol,contract,ts,tx_hash,fee_share,memo FROM transfers"
|
|
129
139
|
try:
|
|
130
140
|
if contract == "*":
|
|
131
141
|
# every token, whatever its contract (address-poisoning checks need fakes too)
|
|
@@ -151,13 +161,15 @@ def load(chain: str, address: str, contract: str = "", native_symbol: str = "")
|
|
|
151
161
|
finally:
|
|
152
162
|
c.close()
|
|
153
163
|
out = []
|
|
154
|
-
for f, t, v, sym, con, ts, h, fee in rows:
|
|
164
|
+
for f, t, v, sym, con, ts, h, fee, memo in rows:
|
|
155
165
|
r = {"from": f, "to": t, "value": v, "symbol": sym,
|
|
156
166
|
"timestamp": ts, "hash": h}
|
|
157
167
|
if con:
|
|
158
168
|
r["contract"] = con
|
|
159
169
|
if fee:
|
|
160
170
|
r["fee_share"] = fee
|
|
171
|
+
if memo:
|
|
172
|
+
r["memo"] = memo
|
|
161
173
|
out.append(r)
|
|
162
174
|
return out
|
|
163
175
|
|
|
@@ -33,16 +33,27 @@ def _edge_text(ctx, to, val, cnt):
|
|
|
33
33
|
return Text.assemble(Text(f"──{money} ({cnt} tx){tail}{arrow}"), render.addr_label(to))
|
|
34
34
|
|
|
35
35
|
|
|
36
|
-
def _record_finding(findings, ctx, to, val, cnt):
|
|
36
|
+
def _record_finding(findings, ctx, to, val, cnt, memos=()):
|
|
37
37
|
if findings is None:
|
|
38
38
|
return
|
|
39
39
|
hit = labels.lookup(to)
|
|
40
|
-
if not hit:
|
|
40
|
+
if not hit or hit["type"] in ("dex", "service"):
|
|
41
41
|
return
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
f = {"address": to, "type": hit["type"], "label": hit["name"],
|
|
43
|
+
"risk": labels.risk_score(to), "value_reached": round(val, 6),
|
|
44
|
+
"symbol": ctx.symbol, "usd_reached": prices.usd(val, ctx.price),
|
|
45
|
+
"tx_count": cnt}
|
|
46
|
+
if memos:
|
|
47
|
+
f["memo"] = ", ".join(memos)
|
|
48
|
+
findings.append(f)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _deposit_memos(sender, to, ctx):
|
|
52
|
+
"""Memos of `sender`'s deposits into exchange wallet `to` (TON) — each names an account."""
|
|
53
|
+
if ctx.direction != "out" or labels.type_of(to) != "exchange":
|
|
54
|
+
return []
|
|
55
|
+
from crypttrace import chains
|
|
56
|
+
return [d["memo"] for d in chains.exchange_deposits(sender, ctx.chain, asset=ctx.asset, only_to=to)]
|
|
46
57
|
|
|
47
58
|
|
|
48
59
|
def _expand(node, address, ctx, depth, seen, findings=None, is_root=False):
|
|
@@ -54,6 +65,10 @@ def _expand(node, address, ctx, depth, seen, findings=None, is_root=False):
|
|
|
54
65
|
if not is_root and labels.type_of(address) in ("exchange", "mixer", "sanctioned"):
|
|
55
66
|
node.add(Text("↳ trail ends here (identifiable entity — subpoena / off-chain)", style="dim"))
|
|
56
67
|
return
|
|
68
|
+
if not is_root and labels.type_of(address) == "dex":
|
|
69
|
+
node.add(Text("↳ DEX swap — the funds come back to the sender in another token; "
|
|
70
|
+
"trace the sender with --asset to follow them", style="cyan"))
|
|
71
|
+
return
|
|
57
72
|
if not is_root and labels.type_of(address) == "bridge":
|
|
58
73
|
node.add(Text("↳ bridge — funds leave this chain; run `crypttrace crosschain` "
|
|
59
74
|
"on the sender to find the destination chain", style="cyan"))
|
|
@@ -75,9 +90,15 @@ def _expand(node, address, ctx, depth, seen, findings=None, is_root=False):
|
|
|
75
90
|
"usd_reached": prices.usd(off["forwarded"], ctx.price),
|
|
76
91
|
"tx_count": 0})
|
|
77
92
|
return
|
|
78
|
-
|
|
79
|
-
|
|
93
|
+
outs = _outflows(address, ctx.chain, ctx.branching, ctx.asset, ctx.direction)
|
|
94
|
+
labels.enrich([to for to, _, _ in outs], ctx.chain)
|
|
95
|
+
for to, val, cnt in outs:
|
|
96
|
+
memos = _deposit_memos(address, to, ctx)
|
|
97
|
+
_record_finding(findings, ctx, to, val, cnt, memos)
|
|
80
98
|
child = node.add(_edge_text(ctx, to, val, cnt))
|
|
99
|
+
for memo in memos:
|
|
100
|
+
child.add(Text(f"↳ deposit memo {memo} — names one account at "
|
|
101
|
+
f"{labels.company(labels.label_of(to))}, which holds its KYC", style="bold green"))
|
|
81
102
|
_expand(child, to, ctx, depth - 1, seen, findings)
|
|
82
103
|
|
|
83
104
|
|
|
@@ -149,18 +170,27 @@ def build_graph(address, chain, depth, branching, asset=None, direction="out"):
|
|
|
149
170
|
if d <= 0 or key in seen:
|
|
150
171
|
return
|
|
151
172
|
seen.add(key)
|
|
152
|
-
if not is_root and labels.type_of(addr) in ("exchange", "mixer", "sanctioned", "bridge"):
|
|
173
|
+
if not is_root and labels.type_of(addr) in ("exchange", "mixer", "sanctioned", "bridge", "dex"):
|
|
153
174
|
nodes[key]["terminal"] = True
|
|
154
175
|
return
|
|
155
|
-
|
|
176
|
+
outs = _outflows(addr, chain, branching, asset, direction)
|
|
177
|
+
labels.enrich([to for to, _, _ in outs], chain)
|
|
178
|
+
for to, val, cnt in outs:
|
|
156
179
|
_node(to, hop + 1)
|
|
157
180
|
# arrows always point the way the money actually travelled
|
|
158
181
|
src, dst = (key, _norm(to)) if direction == "out" else (_norm(to), key)
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
182
|
+
edge = {"from": src, "to": dst, "value": round(val, 4), "tx": cnt,
|
|
183
|
+
"usd": prices.usd(val, price)}
|
|
184
|
+
if direction == "out" and labels.type_of(to) == "exchange":
|
|
185
|
+
from crypttrace import chains as _ch
|
|
186
|
+
memos = [m["memo"] for m in _ch.exchange_deposits(addr, chain, asset=asset, only_to=to)]
|
|
187
|
+
if memos:
|
|
188
|
+
edge["memo"] = ", ".join(memos)
|
|
189
|
+
edges.append(edge)
|
|
162
190
|
_walk(to, d - 1, seen, False, hop + 1)
|
|
163
191
|
|
|
164
192
|
_walk(address, depth, set(), True, 0)
|
|
193
|
+
for n in nodes.values(): # labels found by enrichment after the node was made
|
|
194
|
+
n["type"], n["label"] = labels.type_of(n["id"]), labels.label_of(n["id"])
|
|
165
195
|
return {"nodes": list(nodes.values()), "edges": edges,
|
|
166
196
|
"symbol": symbol, "direction": direction}
|
|
@@ -368,7 +368,7 @@ function renderGraph(g){
|
|
|
368
368
|
const edges=g.edges.map(e=>({
|
|
369
369
|
from:e.from,to:e.to,arrows:{to:{enabled:true,scaleFactor:.7}},
|
|
370
370
|
label:`${e.value} ${g.symbol}${e.usd!=null?' ≈'+fmtUsd(e.usd):''}`,
|
|
371
|
-
title:`${e.value} ${g.symbol} in ${e.tx} tx — click to open on explorer`,
|
|
371
|
+
title:`${e.value} ${g.symbol} in ${e.tx} tx${e.memo?' · deposit memo '+e.memo:''} — click to open on explorer`,
|
|
372
372
|
font:{color:'#93a1b5',size:10.5,strokeWidth:4,strokeColor:'rgba(8,11,18,.85)',align:'horizontal'},
|
|
373
373
|
color:{color:'rgba(255,255,255,.18)',highlight:'#4d9dff',hover:'#4d9dff'},
|
|
374
374
|
width:1.2, hoverWidth:2.4, selectionWidth:2.4,
|
|
@@ -444,6 +444,12 @@ async function inspect(addr){
|
|
|
444
444
|
kv('transfers',p.txs)+
|
|
445
445
|
kv('first seen',fmtTs(p.first_seen))+
|
|
446
446
|
kv('last seen',fmtTs(p.last_seen)));
|
|
447
|
+
if(p.deposits && p.deposits.length){
|
|
448
|
+
h+=card('🏦 Exchange deposits with a memo',
|
|
449
|
+
`<div class="muted">The memo names one account at the exchange, which holds its KYC — quote it
|
|
450
|
+
with the transaction in a report or freeze request.</div>`+
|
|
451
|
+
p.deposits.map(d=>kv(esc(d.exchange),`memo <b class="mono">${esc(d.memo)}</b> · ${esc(d.value.toLocaleString())} ${esc(d.symbol)} · ${d.txs} tx`)).join(''),'alert');
|
|
452
|
+
}
|
|
447
453
|
if(o.offramp){
|
|
448
454
|
h+=card('⚠ Off-ramp detected',
|
|
449
455
|
`<div class="muted">~${Math.round(o.offramp.fraction*100)}% of outgoing funds forwarded to
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: crypttrace
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.14.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
|
|
@@ -103,6 +103,7 @@ own credentials (all optional):
|
|
|
103
103
|
export TRONGRID_API_KEY=xxxx # optional: higher TronGrid quota
|
|
104
104
|
export CRYPTTRACE_SOLANA_RPC=https://… # optional: your own (faster) Solana RPC
|
|
105
105
|
export TONCENTER_API_KEY=xxxx # optional: toncenter allows ~1 request/s without it
|
|
106
|
+
export TONAPI_KEY=xxxx # optional: faster tonapi name lookups on TON
|
|
106
107
|
```
|
|
107
108
|
|
|
108
109
|
(In PowerShell: `$env:TRONGRID_API_KEY = "xxxx"` and so on.)
|
|
@@ -481,6 +482,29 @@ wallets. That is what lets `offramp` flag deposit addresses on those chains. Fun
|
|
|
481
482
|
list still show as `unknown`. Every label records where it came from — see
|
|
482
483
|
`crypttrace labels audit`.
|
|
483
484
|
|
|
485
|
+
**TON: names from tonapi.** On TON, addresses the curated set does not know are
|
|
486
|
+
looked up on tonapi, which names exchange hot wallets ("Bybit 7", "Kucoin 1"),
|
|
487
|
+
DEX contracts (STON.fi, DeDust) and services (Fragment), and flags scam
|
|
488
|
+
accounts. These become labels of kind `explorer-tag`, cached for a week. A
|
|
489
|
+
`.ton` name an owner chose for himself — "crypto-exch-io.ton", or an impostor's
|
|
490
|
+
"kucoin-1.ton" — is not taken as a label. A trace stops at a DEX: the funds
|
|
491
|
+
come back to the sender in another token.
|
|
492
|
+
|
|
493
|
+
**Exchange deposits with a memo.** A TON exchange takes all customers' deposits
|
|
494
|
+
into one hot wallet and tells them apart by the transfer comment, the *memo*.
|
|
495
|
+
The memo is the lead: it names one account at the exchange, which holds that
|
|
496
|
+
customer's KYC. `trace`, `profile`, the web UI and the HTML report show every
|
|
497
|
+
deposit an address made to an exchange with its memo:
|
|
498
|
+
|
|
499
|
+
```
|
|
500
|
+
├── ──1280.0000 TON (1 tx)──▶ 🟢 UQCA1BI4QR…la6Yug [KuCoin hot wallet (memo required)]
|
|
501
|
+
│ └── ↳ deposit memo 2093842166 — names one account at KuCoin, which holds its KYC
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
In the fragment-api case this found the attacker's KuCoin account directly, and
|
|
505
|
+
the account of the exchange service he cashed out through (its relay deposits
|
|
506
|
+
under one memo every time).
|
|
507
|
+
|
|
484
508
|
**Deposit addresses, labelled outright.** OKX signs every address that holds
|
|
485
509
|
customer funds — over 300,000, nearly all of them per-customer deposit addresses
|
|
486
510
|
— and publishes the list with its proof of reserves. `crypttrace update-labels
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|