crypttrace 0.6.0__tar.gz → 0.7.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.6.0/src/crypttrace.egg-info → crypttrace-0.7.0}/PKG-INFO +20 -9
- {crypttrace-0.6.0 → crypttrace-0.7.0}/README.md +19 -8
- {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/__init__.py +1 -1
- {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/assess.py +1 -1
- {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/chains.py +12 -0
- {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/cli.py +12 -9
- {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/freeze.py +13 -2
- {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/investigate.py +9 -7
- crypttrace-0.7.0/src/crypttrace/report.py +470 -0
- {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/store.py +3 -1
- {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/web/index.html +3 -3
- {crypttrace-0.6.0 → crypttrace-0.7.0/src/crypttrace.egg-info}/PKG-INFO +20 -9
- crypttrace-0.6.0/src/crypttrace/report.py +0 -175
- {crypttrace-0.6.0 → crypttrace-0.7.0}/LICENSE +0 -0
- {crypttrace-0.6.0 → crypttrace-0.7.0}/pyproject.toml +0 -0
- {crypttrace-0.6.0 → crypttrace-0.7.0}/setup.cfg +0 -0
- {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/addresses.py +0 -0
- {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/analysis.py +0 -0
- {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/assets.py +0 -0
- {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/bridges.py +0 -0
- {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/config.py +0 -0
- {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/fetchers/__init__.py +0 -0
- {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/fetchers/bitcoin.py +0 -0
- {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/fetchers/etherscan.py +0 -0
- {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/fetchers/http.py +0 -0
- {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/fetchers/solana.py +0 -0
- {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/fetchers/tron.py +0 -0
- {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/funder.py +0 -0
- {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/labels/__init__.py +0 -0
- {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/labels/audit.py +0 -0
- {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/labels/bulk.py +0 -0
- {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/labels/known.json +0 -0
- {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/labels/labels.py +0 -0
- {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/labels/partial.json +0 -0
- {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/offramp.py +0 -0
- {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/poisoning.py +0 -0
- {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/prices.py +0 -0
- {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/render.py +0 -0
- {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/trace.py +0 -0
- {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/verify.py +0 -0
- {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/watch.py +0 -0
- {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace/webapp.py +0 -0
- {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace.egg-info/SOURCES.txt +0 -0
- {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace.egg-info/dependency_links.txt +0 -0
- {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace.egg-info/entry_points.txt +0 -0
- {crypttrace-0.6.0 → crypttrace-0.7.0}/src/crypttrace.egg-info/requires.txt +0 -0
- {crypttrace-0.6.0 → crypttrace-0.7.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.7.0
|
|
4
4
|
Summary: OSINT toolkit for crypto investigations: trace stolen funds across Ethereum, Bitcoin, Tron and Solana
|
|
5
5
|
Author: bobslayerX
|
|
6
6
|
License-Expression: MIT
|
|
@@ -225,7 +225,7 @@ crypttrace crosschain 0xADDRESS --window 48
|
|
|
225
225
|
crypttrace watch add 0xADDRESS --note "my stolen ETH"
|
|
226
226
|
crypttrace watch run --interval 300 # or --once for cron / Task Scheduler
|
|
227
227
|
|
|
228
|
-
# Full investigation
|
|
228
|
+
# Full investigation as a case file to send: one HTML page (plus Markdown + JSON)
|
|
229
229
|
crypttrace report 0xADDRESS --depth 3
|
|
230
230
|
|
|
231
231
|
# Refresh label lists (OFAC sanctions, …)
|
|
@@ -460,10 +460,24 @@ are ignored, and every address is checked before it is stored.
|
|
|
460
460
|
|
|
461
461
|
### Reports
|
|
462
462
|
|
|
463
|
-
`report` runs the full analysis and writes
|
|
464
|
-
with `--out`)
|
|
465
|
-
|
|
466
|
-
the
|
|
463
|
+
`report` runs the full analysis and writes a **case file** to
|
|
464
|
+
`~/crypttrace-reports/` (override with `--out`); `investigate` saves the same
|
|
465
|
+
file with the victim's next steps in it. The case file is one HTML page, made
|
|
466
|
+
for whoever receives it — an exchange's compliance team, the police, a lawyer:
|
|
467
|
+
|
|
468
|
+
- the conclusion in plain words, and what to do now: stablecoins still at the
|
|
469
|
+
address and who can freeze them, the exchanges the money reached, address
|
|
470
|
+
poisoning;
|
|
471
|
+
- the fund-flow graph, each address linking to a block explorer;
|
|
472
|
+
- every transfer along the trace with its transaction hashes — the first thing
|
|
473
|
+
an exchange asks for;
|
|
474
|
+
- where each label comes from, how the tool checked its own arithmetic, and
|
|
475
|
+
what the method cannot tell you.
|
|
476
|
+
|
|
477
|
+
It has no scripts and loads nothing from the internet, so it opens in any
|
|
478
|
+
browser or mail client, prints (or saves as PDF) cleanly, and opening it tells
|
|
479
|
+
no one. The raw data is embedded for analysts and written next to it as JSON,
|
|
480
|
+
with its SHA-256 printed on the page; a Markdown version is written too.
|
|
467
481
|
|
|
468
482
|
### Self-verification
|
|
469
483
|
|
|
@@ -561,9 +575,6 @@ cases/ # worked investigations with their data
|
|
|
561
575
|
|
|
562
576
|
- More exchanges on Bitcoin, Tron and Solana (now: Binance, OKX, HTX, Bybit),
|
|
563
577
|
and refreshing these lists as the exchanges republish them
|
|
564
|
-
- `report --html`: one self-contained file with the interactive graph, to
|
|
565
|
-
send to an exchange or attach to a police report
|
|
566
|
-
- `report --pdf` for exchange and law-enforcement filings
|
|
567
578
|
- Internal transactions (completes `funder` and contract-mediated transfers)
|
|
568
579
|
- Per-mint filtering for Solana SPL tokens
|
|
569
580
|
- More label sources: Chainabuse, CryptoScamDB, exchange deposit-address sets
|
|
@@ -191,7 +191,7 @@ crypttrace crosschain 0xADDRESS --window 48
|
|
|
191
191
|
crypttrace watch add 0xADDRESS --note "my stolen ETH"
|
|
192
192
|
crypttrace watch run --interval 300 # or --once for cron / Task Scheduler
|
|
193
193
|
|
|
194
|
-
# Full investigation
|
|
194
|
+
# Full investigation as a case file to send: one HTML page (plus Markdown + JSON)
|
|
195
195
|
crypttrace report 0xADDRESS --depth 3
|
|
196
196
|
|
|
197
197
|
# Refresh label lists (OFAC sanctions, …)
|
|
@@ -426,10 +426,24 @@ are ignored, and every address is checked before it is stored.
|
|
|
426
426
|
|
|
427
427
|
### Reports
|
|
428
428
|
|
|
429
|
-
`report` runs the full analysis and writes
|
|
430
|
-
with `--out`)
|
|
431
|
-
|
|
432
|
-
the
|
|
429
|
+
`report` runs the full analysis and writes a **case file** to
|
|
430
|
+
`~/crypttrace-reports/` (override with `--out`); `investigate` saves the same
|
|
431
|
+
file with the victim's next steps in it. The case file is one HTML page, made
|
|
432
|
+
for whoever receives it — an exchange's compliance team, the police, a lawyer:
|
|
433
|
+
|
|
434
|
+
- the conclusion in plain words, and what to do now: stablecoins still at the
|
|
435
|
+
address and who can freeze them, the exchanges the money reached, address
|
|
436
|
+
poisoning;
|
|
437
|
+
- the fund-flow graph, each address linking to a block explorer;
|
|
438
|
+
- every transfer along the trace with its transaction hashes — the first thing
|
|
439
|
+
an exchange asks for;
|
|
440
|
+
- where each label comes from, how the tool checked its own arithmetic, and
|
|
441
|
+
what the method cannot tell you.
|
|
442
|
+
|
|
443
|
+
It has no scripts and loads nothing from the internet, so it opens in any
|
|
444
|
+
browser or mail client, prints (or saves as PDF) cleanly, and opening it tells
|
|
445
|
+
no one. The raw data is embedded for analysts and written next to it as JSON,
|
|
446
|
+
with its SHA-256 printed on the page; a Markdown version is written too.
|
|
433
447
|
|
|
434
448
|
### Self-verification
|
|
435
449
|
|
|
@@ -527,9 +541,6 @@ cases/ # worked investigations with their data
|
|
|
527
541
|
|
|
528
542
|
- More exchanges on Bitcoin, Tron and Solana (now: Binance, OKX, HTX, Bybit),
|
|
529
543
|
and refreshing these lists as the exchanges republish them
|
|
530
|
-
- `report --html`: one self-contained file with the interactive graph, to
|
|
531
|
-
send to an exchange or attach to a police report
|
|
532
|
-
- `report --pdf` for exchange and law-enforcement filings
|
|
533
544
|
- Internal transactions (completes `funder` and contract-mediated transfers)
|
|
534
545
|
- Per-mint filtering for Solana SPL tokens
|
|
535
546
|
- More label sources: Chainabuse, CryptoScamDB, exchange deposit-address sets
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""crypttrace — OSINT crypto investigation CLI."""
|
|
2
|
-
__version__ = "0.
|
|
2
|
+
__version__ = "0.7.0"
|
|
@@ -240,7 +240,7 @@ def assess(address: str, chain: str = "eth", asset: Optional[dict] = None,
|
|
|
240
240
|
e = frozen[0]
|
|
241
241
|
signals.append(Signal(
|
|
242
242
|
name="frozen by issuer",
|
|
243
|
-
observed=
|
|
243
|
+
observed=freeze.describe_frozen(e),
|
|
244
244
|
implication="the issuer blocked it — usually at the request of law enforcement "
|
|
245
245
|
"or under sanctions",
|
|
246
246
|
confidence="high", weight=40, evidence={"freezes": frozen}))
|
|
@@ -30,6 +30,14 @@ EXPLORER = {
|
|
|
30
30
|
"sol": "https://solscan.io/account/{}",
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
TX_EXPLORER = {
|
|
34
|
+
"eth": "https://etherscan.io/tx/{}", "bsc": "https://bscscan.com/tx/{}",
|
|
35
|
+
"polygon": "https://polygonscan.com/tx/{}", "arbitrum": "https://arbiscan.io/tx/{}",
|
|
36
|
+
"optimism": "https://optimistic.etherscan.io/tx/{}", "base": "https://basescan.org/tx/{}",
|
|
37
|
+
"btc": "https://mempool.space/tx/{}", "tron": "https://tronscan.org/#/transaction/{}",
|
|
38
|
+
"sol": "https://solscan.io/tx/{}",
|
|
39
|
+
}
|
|
40
|
+
|
|
33
41
|
_UPSTREAM_ERRORS = (etherscan.EtherscanError, bitcoin.BitcoinError,
|
|
34
42
|
tron.TronError, solana.SolanaError)
|
|
35
43
|
|
|
@@ -59,6 +67,10 @@ def explorer_url(address: str, chain: str) -> str:
|
|
|
59
67
|
return EXPLORER.get(chain, EXPLORER["eth"]).format(address)
|
|
60
68
|
|
|
61
69
|
|
|
70
|
+
def tx_url(tx_hash: str, chain: str) -> str:
|
|
71
|
+
return TX_EXPLORER.get(chain, TX_EXPLORER["eth"]).format(tx_hash)
|
|
72
|
+
|
|
73
|
+
|
|
62
74
|
def check(chain: str) -> None:
|
|
63
75
|
if chain not in ALL_CHAINS:
|
|
64
76
|
raise ChainError(f"unsupported chain '{chain}'. Options: {ALL_CHAINS}")
|
|
@@ -224,7 +224,7 @@ def report(
|
|
|
224
224
|
help="Folder to save the report in",
|
|
225
225
|
),
|
|
226
226
|
):
|
|
227
|
-
"""Run a full investigation and save
|
|
227
|
+
"""Run a full investigation and save it as an HTML case file (plus Markdown and JSON)."""
|
|
228
228
|
try:
|
|
229
229
|
asset_desc = assets.resolve_asset(asset, chain)
|
|
230
230
|
except ValueError as e:
|
|
@@ -232,12 +232,14 @@ def report(
|
|
|
232
232
|
raise typer.Exit(1)
|
|
233
233
|
try:
|
|
234
234
|
with console.status("Gathering on-chain data and tracing funds…"):
|
|
235
|
-
|
|
236
|
-
except etherscan.EtherscanError as e:
|
|
235
|
+
paths = report_mod.generate(address, chain, depth, branching, out, asset_desc)
|
|
236
|
+
except (chains_mod.ChainError, etherscan.EtherscanError) as e:
|
|
237
237
|
console.print(f"[red]Error:[/red] {e}")
|
|
238
238
|
raise typer.Exit(1)
|
|
239
|
-
console.print(f"[green]✓
|
|
240
|
-
console.print(
|
|
239
|
+
console.print(f"[green]✓ Case file saved:[/green] {paths['html']}")
|
|
240
|
+
console.print("[dim] One self-contained page: open it in a browser, print it to PDF, or "
|
|
241
|
+
"send it as it is.[/dim]")
|
|
242
|
+
console.print("[dim] Markdown and raw JSON are saved next to it.[/dim]")
|
|
241
243
|
|
|
242
244
|
|
|
243
245
|
@app.command()
|
|
@@ -279,14 +281,15 @@ def freeze(
|
|
|
279
281
|
if e.get("error"):
|
|
280
282
|
console.print(f"{head}: [yellow]could not be read[/yellow] — {e['error']}")
|
|
281
283
|
elif e["frozen"]:
|
|
282
|
-
console.print(f"{head}: [bold green]FROZEN[/bold green] — {e
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
elif e["balance"]:
|
|
284
|
+
console.print(f"{head}: [bold green]FROZEN[/bold green] — {freeze_mod.describe_frozen(e)}"
|
|
285
|
+
+ (f"; {e['movable']:,.2f} still can move" if e["actionable"] else ""))
|
|
286
|
+
elif e["actionable"]:
|
|
286
287
|
console.print(f"{head}: [bold red]{e['balance']:,.2f} {e['token']} NOT frozen[/bold red]"
|
|
287
288
|
" — it can still be moved")
|
|
288
289
|
console.print(f" {e['how']}")
|
|
289
290
|
console.print(f" [dim]{e['url']}[/dim]")
|
|
291
|
+
elif e["balance"]:
|
|
292
|
+
console.print(f"{head}: only dust ({e['balance']:.6f}) — nothing to freeze")
|
|
290
293
|
else:
|
|
291
294
|
console.print(f"{head}: none at this address")
|
|
292
295
|
if labels.type_of(address) == "exchange":
|
|
@@ -48,6 +48,7 @@ ISSUERS = {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
SUPPORTED = ("eth", "tron", "sol")
|
|
51
|
+
MIN_ACTIONABLE = 1.0 # USDT/USDC below this is dust
|
|
51
52
|
|
|
52
53
|
|
|
53
54
|
class FreezeError(RuntimeError):
|
|
@@ -117,8 +118,18 @@ def _one(address: str, chain: str, symbol: str, contract: str) -> Dict:
|
|
|
117
118
|
balance = sum(a["amount"] for a in accts)
|
|
118
119
|
frozen_amount = sum(a["amount"] for a in accts if a["state"] == "frozen")
|
|
119
120
|
frozen = any(a["state"] == "frozen" for a in accts)
|
|
121
|
+
movable = balance - frozen_amount
|
|
120
122
|
return {"token": symbol, "balance": balance, "frozen": frozen,
|
|
121
|
-
"frozen_amount": frozen_amount, "movable":
|
|
123
|
+
"frozen_amount": frozen_amount, "movable": movable,
|
|
124
|
+
# leftover dust is not worth a freeze request, or a victim's panic
|
|
125
|
+
"actionable": movable >= MIN_ACTIONABLE}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def describe_frozen(e: Dict) -> str:
|
|
129
|
+
"""'4,021.97 USDT here is frozen by Tether', or the blacklisting alone when empty."""
|
|
130
|
+
if (e.get("frozen_amount") or 0) >= MIN_ACTIONABLE:
|
|
131
|
+
return f"{e['frozen_amount']:,.2f} {e['token']} here is frozen by {e['issuer']}"
|
|
132
|
+
return f"This address is blacklisted by {e['issuer']} (no {e['token']} left on it)"
|
|
122
133
|
|
|
123
134
|
|
|
124
135
|
def check(address: str, chain: str) -> List[Dict]:
|
|
@@ -144,7 +155,7 @@ def check(address: str, chain: str) -> List[Dict]:
|
|
|
144
155
|
entry.update(_one(address, chain, symbol, tok["contract"]))
|
|
145
156
|
except (FreezeError, KeyError, TypeError, ValueError) as e:
|
|
146
157
|
entry.update({"balance": None, "frozen": None, "frozen_amount": None,
|
|
147
|
-
"movable": None, "error": str(e)})
|
|
158
|
+
"movable": None, "actionable": None, "error": str(e)})
|
|
148
159
|
out.append(entry)
|
|
149
160
|
return out
|
|
150
161
|
|
|
@@ -32,7 +32,7 @@ def analyse(address: str, chain: str = "eth", asset: Optional[dict] = None,
|
|
|
32
32
|
"""Run every check and return a structured result (no printing)."""
|
|
33
33
|
result = {
|
|
34
34
|
"address": address, "chain": chain,
|
|
35
|
-
"asset": asset["symbol"] if asset else chains.symbol(chain),
|
|
35
|
+
"asset": asset["symbol"] if asset else chains.symbol(chain), "depth": depth,
|
|
36
36
|
"generated": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
|
|
37
37
|
"errors": [],
|
|
38
38
|
}
|
|
@@ -154,13 +154,13 @@ def build_guidance(r: dict) -> dict:
|
|
|
154
154
|
for e in r.get("freeze") or []:
|
|
155
155
|
if e.get("frozen"):
|
|
156
156
|
steps.insert(0, {
|
|
157
|
-
"title":
|
|
157
|
+
"title": freeze_mod.describe_frozen(e),
|
|
158
158
|
"body": ("It cannot be moved. Frozen funds can be returned to victims, but only "
|
|
159
159
|
f"through a legal process: tell the police and {e['issuer']} that you "
|
|
160
160
|
"are a victim of this address, and include this report."),
|
|
161
161
|
"urgent": True,
|
|
162
162
|
})
|
|
163
|
-
elif e.get("
|
|
163
|
+
elif e.get("actionable"):
|
|
164
164
|
steps.insert(0, {
|
|
165
165
|
"title": f"Ask for a freeze: {e['movable']:,.2f} {e['token']} is still here",
|
|
166
166
|
"body": (f"{e['how']} It can be moved at any moment, so this is the most "
|
|
@@ -226,11 +226,13 @@ def guidance_markdown(r: dict) -> str:
|
|
|
226
226
|
|
|
227
227
|
|
|
228
228
|
def save_case(r: dict, out_dir: Path, asset: Optional[dict] = None) -> Path:
|
|
229
|
-
"""Write the
|
|
230
|
-
|
|
229
|
+
"""Write the case file (HTML, plus Markdown and JSON) with the victim's next steps;
|
|
230
|
+
returns the HTML one — the file to send."""
|
|
231
|
+
paths = report_mod.generate(r["address"], r["chain"], r.get("depth", 3), 3, out_dir, asset,
|
|
232
|
+
guidance=r.get("guidance"))
|
|
231
233
|
try:
|
|
232
|
-
with open(
|
|
234
|
+
with open(paths["md"], "a", encoding="utf-8") as fh:
|
|
233
235
|
fh.write(guidance_markdown(r))
|
|
234
236
|
except OSError:
|
|
235
237
|
pass
|
|
236
|
-
return
|
|
238
|
+
return paths["html"]
|
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
"""Investigation reports: an HTML case file to hand over, plus Markdown and JSON.
|
|
2
|
+
|
|
3
|
+
The HTML file is the one a victim sends to an exchange or attaches to a police
|
|
4
|
+
report, so it is built for the person receiving it:
|
|
5
|
+
|
|
6
|
+
* one file, nothing external — no scripts, fonts or CDN, so it opens in a
|
|
7
|
+
locked-down browser or mail client, prints, and "opening it" tells nobody;
|
|
8
|
+
* the fund-flow graph as a static SVG, each address linking to a block
|
|
9
|
+
explorer, so every line can be checked independently;
|
|
10
|
+
* the transfers along the trace with their transaction hashes — what an
|
|
11
|
+
exchange's compliance team asks for first;
|
|
12
|
+
* where every label comes from, how the tool checked its own arithmetic, and
|
|
13
|
+
what the method cannot tell you.
|
|
14
|
+
|
|
15
|
+
The same data is written as JSON (for analysts) and Markdown. Everything is read
|
|
16
|
+
through the shared chain layer, so reports work on every supported chain.
|
|
17
|
+
"""
|
|
18
|
+
import hashlib
|
|
19
|
+
import html
|
|
20
|
+
import json
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Dict, List, Optional
|
|
24
|
+
|
|
25
|
+
from rich.console import Console
|
|
26
|
+
|
|
27
|
+
from crypttrace import __version__, chains, prices
|
|
28
|
+
from crypttrace import freeze as freeze_mod
|
|
29
|
+
from crypttrace import trace as trace_mod
|
|
30
|
+
from crypttrace.labels import labels
|
|
31
|
+
|
|
32
|
+
SOURCES = {"btc": "mempool.space", "tron": "TronGrid", "sol": "Solana JSON-RPC"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _now() -> str:
|
|
36
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _ts(unix) -> str:
|
|
40
|
+
try:
|
|
41
|
+
return datetime.fromtimestamp(int(unix), tz=timezone.utc).strftime("%Y-%m-%d %H:%M")
|
|
42
|
+
except (ValueError, TypeError, OSError):
|
|
43
|
+
return "?"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _tree_text(tree) -> str:
|
|
47
|
+
"""Render the rich trace tree to plain text (ANSI stripped)."""
|
|
48
|
+
con = Console(record=True, width=100, file=None)
|
|
49
|
+
with con.capture() as cap:
|
|
50
|
+
con.print(tree)
|
|
51
|
+
return cap.get()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _headline(findings: list) -> str:
|
|
55
|
+
types = {f["type"] for f in findings}
|
|
56
|
+
if "sanctioned" in types:
|
|
57
|
+
return ("Funds from this address reach a **sanctioned / known-criminal wallet** — "
|
|
58
|
+
"escalate to law enforcement.")
|
|
59
|
+
if "mixer" in types:
|
|
60
|
+
return ("Funds from this address flow into a **mixer** (privacy pool), where on-chain "
|
|
61
|
+
"tracing terminates. Recovery from here requires timing/amount heuristics or "
|
|
62
|
+
"off-chain data.")
|
|
63
|
+
if types & {"exchange", "offramp"}:
|
|
64
|
+
return ("Funds from this address reach a **centralised exchange** — a KYC handoff point. "
|
|
65
|
+
"Identifying the owner requires a legal request to that exchange.")
|
|
66
|
+
return ("No labelled entities were reached within the traced depth. Increase --depth or "
|
|
67
|
+
"extend the label database, then re-run.")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _counterparties(me: str, rows: List[dict], top: int = 15) -> List[dict]:
|
|
71
|
+
agg: Dict[str, list] = {}
|
|
72
|
+
for r in rows:
|
|
73
|
+
frm, to = r.get("from") or "", r.get("to") or ""
|
|
74
|
+
other = to if frm == me else frm if to == me else ""
|
|
75
|
+
if not other or other == me:
|
|
76
|
+
continue
|
|
77
|
+
rec = agg.setdefault(other, [0.0, 0.0, 0])
|
|
78
|
+
rec[1 if frm == me else 0] += r.get("value", 0) or 0
|
|
79
|
+
rec[2] += 1
|
|
80
|
+
ranked = sorted(agg.items(), key=lambda kv: kv[1][0] + kv[1][1], reverse=True)[:top]
|
|
81
|
+
return [{"address": a, "in": round(i, 6), "out": round(o, 6), "txs": n,
|
|
82
|
+
"label": labels.label_of(a), "type": labels.type_of(a)}
|
|
83
|
+
for a, (i, o, n) in ranked]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _edge_evidence(graph: dict, chain: str, asset) -> None:
|
|
87
|
+
"""Attach the transactions behind each graph edge (hashes, first/last time).
|
|
88
|
+
|
|
89
|
+
The history is already in the local store from the trace, so this costs no
|
|
90
|
+
requests."""
|
|
91
|
+
for e in graph["edges"]:
|
|
92
|
+
try:
|
|
93
|
+
rows = chains.transfers(e["from"], chain, 1000, asset=asset)
|
|
94
|
+
except chains.ChainError:
|
|
95
|
+
rows = []
|
|
96
|
+
hits = sorted((r for r in rows if r.get("from") == e["from"] and r.get("to") == e["to"]),
|
|
97
|
+
key=lambda r: r.get("timestamp", 0))
|
|
98
|
+
e["hashes"] = [r["hash"] for r in hits if r.get("hash")][:10]
|
|
99
|
+
e["first_ts"] = hits[0].get("timestamp") if hits else None
|
|
100
|
+
e["last_ts"] = hits[-1].get("timestamp") if hits else None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def collect(address: str, chain: str, depth: int, branching: int, asset=None) -> dict:
|
|
104
|
+
"""Everything a report shows, as plain data (also the JSON file)."""
|
|
105
|
+
from crypttrace import assess as assess_mod, freeze as freeze_mod
|
|
106
|
+
from crypttrace.labels import audit
|
|
107
|
+
|
|
108
|
+
me = chains.norm_addr(address, chain)
|
|
109
|
+
symbol = asset["symbol"] if asset else chains.symbol(chain)
|
|
110
|
+
errors: List[str] = []
|
|
111
|
+
|
|
112
|
+
balance, rows = None, []
|
|
113
|
+
try:
|
|
114
|
+
balance = chains.balance(address, chain)
|
|
115
|
+
except chains.ChainError as e:
|
|
116
|
+
errors.append(f"balance: {e}")
|
|
117
|
+
try:
|
|
118
|
+
rows = chains.transfers(address, chain, 1000, asset=asset)
|
|
119
|
+
except chains.ChainError as e:
|
|
120
|
+
errors.append(f"history: {e}")
|
|
121
|
+
|
|
122
|
+
tree_text, findings, graph = "", [], {"nodes": [], "edges": [], "symbol": symbol}
|
|
123
|
+
try:
|
|
124
|
+
tree, found = trace_mod.build(address, chain, depth, branching, asset)
|
|
125
|
+
tree_text = _tree_text(tree)
|
|
126
|
+
best: Dict[str, dict] = {}
|
|
127
|
+
for f in found:
|
|
128
|
+
if f["address"] not in best or f["value_reached"] > best[f["address"]]["value_reached"]:
|
|
129
|
+
best[f["address"]] = f
|
|
130
|
+
findings = sorted(best.values(), key=lambda f: f["risk"], reverse=True)
|
|
131
|
+
graph = trace_mod.build_graph(address, chain, depth, branching, asset)
|
|
132
|
+
_edge_evidence(graph, chain, asset)
|
|
133
|
+
except chains.ChainError as e:
|
|
134
|
+
errors.append(f"trace: {e}")
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
assessment = assess_mod.assess(address, chain, asset)
|
|
138
|
+
except Exception as e: # a report must still be written without it
|
|
139
|
+
assessment = {"error": str(e)}
|
|
140
|
+
errors.append(f"assessment: {e}")
|
|
141
|
+
|
|
142
|
+
hit = labels.lookup(address)
|
|
143
|
+
evidence = {n["id"]: audit.evidence(n["id"]) for n in graph["nodes"] if n.get("label")}
|
|
144
|
+
if hit and me not in evidence:
|
|
145
|
+
evidence[me] = audit.evidence(address)
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
"tool": f"crypttrace v{__version__}",
|
|
149
|
+
"generated": _now(),
|
|
150
|
+
"subject": address, "chain": chain, "traced_asset": symbol,
|
|
151
|
+
"trace_depth": depth, "trace_branching": branching,
|
|
152
|
+
"data_source": SOURCES.get(chain, "Etherscan v2"),
|
|
153
|
+
"summary": {
|
|
154
|
+
"balance_native": None if balance is None else round(balance, 8),
|
|
155
|
+
"native_symbol": chains.symbol(chain),
|
|
156
|
+
"transfers_analysed": len(rows),
|
|
157
|
+
"first_seen": _ts(rows[-1]["timestamp"]) if rows else None,
|
|
158
|
+
"last_seen": _ts(rows[0]["timestamp"]) if rows else None,
|
|
159
|
+
"label": hit["name"] if hit else None,
|
|
160
|
+
"type": labels.type_of(address),
|
|
161
|
+
"risk_score": labels.risk_score(address),
|
|
162
|
+
},
|
|
163
|
+
"assessment": assessment,
|
|
164
|
+
"freeze": [] if labels.type_of(address) == "exchange" else freeze_mod.check(address, chain),
|
|
165
|
+
"key_findings": findings,
|
|
166
|
+
"graph": graph,
|
|
167
|
+
"label_evidence": evidence,
|
|
168
|
+
"top_counterparties": _counterparties(me, rows),
|
|
169
|
+
"tree_text": tree_text,
|
|
170
|
+
"errors": errors,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def generate(address: str, chain: str, depth: int, branching: int,
|
|
175
|
+
out_dir: Path, asset=None, guidance: Optional[dict] = None) -> Dict[str, Path]:
|
|
176
|
+
"""Write <case>.html, <case>.md and <case>.json; returns their paths."""
|
|
177
|
+
data = collect(address, chain, depth, branching, asset)
|
|
178
|
+
if guidance:
|
|
179
|
+
data["guidance"] = guidance
|
|
180
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
181
|
+
base = f"{chain}_{address[:10]}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
|
182
|
+
paths = {k: out_dir / f"{base}.{k}" for k in ("html", "md", "json")}
|
|
183
|
+
raw = json.dumps(data, indent=2, ensure_ascii=False, default=str)
|
|
184
|
+
# bytes, not text mode: on Windows text mode turns LF into CRLF, and the hash
|
|
185
|
+
# printed in the HTML would no longer match the file next to it
|
|
186
|
+
paths["json"].write_bytes(raw.encode("utf-8"))
|
|
187
|
+
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
188
|
+
paths["md"].write_text(_render_md(data, paths["json"].name), encoding="utf-8")
|
|
189
|
+
paths["html"].write_text(render_html(data, raw, digest, paths["json"].name), encoding="utf-8")
|
|
190
|
+
return paths
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# ------------------------------------------------------------------ markdown
|
|
194
|
+
|
|
195
|
+
def _render_md(d: dict, json_name: str) -> str:
|
|
196
|
+
s = d["summary"]
|
|
197
|
+
a = d.get("assessment") or {}
|
|
198
|
+
L = [f"# crypttrace investigation report\n\n",
|
|
199
|
+
f"**Generated:** {d['generated']} • **Tool:** {d['tool']} \n",
|
|
200
|
+
f"**Subject:** `{d['subject']}` • **Chain:** {d['chain']} • "
|
|
201
|
+
f"**Traced asset:** {d['traced_asset']}\n",
|
|
202
|
+
"\n## Assessment\n", (a.get("assessment") or _headline(d["key_findings"])) + "\n"]
|
|
203
|
+
if a.get("risk") is not None:
|
|
204
|
+
L.append(f"\nRisk {a['risk']}/100, confidence {a.get('confidence')}.\n")
|
|
205
|
+
for f in d.get("freeze") or []:
|
|
206
|
+
if f.get("frozen"):
|
|
207
|
+
L.append(f"\n**{freeze_mod.describe_frozen(f)}.**\n")
|
|
208
|
+
elif f.get("actionable"):
|
|
209
|
+
L.append(f"\n**{f['movable']:,.2f} {f['token']} is still at this address and not "
|
|
210
|
+
f"frozen.** {f['how']}\n")
|
|
211
|
+
L += ["\n## Summary\n", "| Field | Value |\n|---|---|\n",
|
|
212
|
+
f"| Balance | {s['balance_native']} {s['native_symbol']} |\n",
|
|
213
|
+
f"| Transfers analysed | {s['transfers_analysed']} |\n",
|
|
214
|
+
f"| First seen | {s['first_seen']} |\n", f"| Last seen | {s['last_seen']} |\n",
|
|
215
|
+
f"| Label | {s['label'] or '—'} |\n", f"| Risk score | {s['risk_score']}/100 |\n",
|
|
216
|
+
"\n## Key findings\n"]
|
|
217
|
+
if d["key_findings"]:
|
|
218
|
+
L.append("| Entity | Type | Risk | Value reached | ≈ USD |\n|---|---|---|---|---|\n")
|
|
219
|
+
for f in d["key_findings"]:
|
|
220
|
+
L.append(f"| {f['label']} | {f['type']} | {f['risk']}/100 | {f['value_reached']} "
|
|
221
|
+
f"{f.get('symbol', '')} | {prices.fmt_usd(f.get('usd_reached'))} |\n")
|
|
222
|
+
else:
|
|
223
|
+
L.append("_None within the traced depth._\n")
|
|
224
|
+
L += ["\n## Top counterparties\n", "| Address | Label | In | Out | Txs |\n|---|---|---|---|---|\n"]
|
|
225
|
+
for c in d["top_counterparties"]:
|
|
226
|
+
L.append(f"| `{c['address']}` | {c['label'] or '—'} | {c['in']} | {c['out']} | {c['txs']} |\n")
|
|
227
|
+
L += [f"\n## Fund-flow trace (depth {d['trace_depth']})\n",
|
|
228
|
+
"```\n" + (d["tree_text"] or "").rstrip() + "\n```\n",
|
|
229
|
+
"\n## Methodology & limitations\n", _METHOD + "\n",
|
|
230
|
+
f"\n---\n_Raw structured data: `{json_name}` (same folder)._\n"]
|
|
231
|
+
return "".join(L)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
_METHOD = (
|
|
235
|
+
"Data comes from the public blockchain. The trace follows the largest outgoing transfers "
|
|
236
|
+
"from each address and stops at identifiable entities (exchanges, mixers, sanctioned "
|
|
237
|
+
"wallets). Exchange deposit addresses are recognised either from lists exchanges publish "
|
|
238
|
+
"themselves or by behaviour (forwarding most funds to one exchange), which is a strong "
|
|
239
|
+
"lead, not proof. The blockchain is pseudonymous: reaching an address does not identify "
|
|
240
|
+
"its owner — an exchange can, on a legal request. Mixers sever the trail. This report is "
|
|
241
|
+
"an investigative aid, not proof of wrongdoing.")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ---------------------------------------------------------------------- html
|
|
245
|
+
|
|
246
|
+
_TYPE_COLOUR = {"exchange": "#1f9d55", "offramp": "#1f9d55", "mixer": "#7c3aed",
|
|
247
|
+
"sanctioned": "#dc2626", "scam": "#dc2626", "bridge": "#0891b2",
|
|
248
|
+
"unknown": "#64748b"}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _e(v) -> str:
|
|
252
|
+
return html.escape("" if v is None else str(v), quote=True)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _link(url: str, text: str, mono: bool = True) -> str:
|
|
256
|
+
cls = ' class="mono"' if mono else ""
|
|
257
|
+
return f'<a href="{_e(url)}" target="_blank" rel="noopener"{cls}>{_e(text)}</a>'
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _amount(v, symbol="") -> str:
|
|
261
|
+
if v is None:
|
|
262
|
+
return "—"
|
|
263
|
+
return f"{v:,.4f}".rstrip("0").rstrip(".") + (f" {symbol}" if symbol else "")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _svg(graph: dict, chain: str) -> str:
|
|
267
|
+
"""Left-to-right layered drawing: one column per hop from the subject."""
|
|
268
|
+
nodes = {n["id"]: n for n in graph["nodes"]}
|
|
269
|
+
if not nodes:
|
|
270
|
+
return '<p class="muted">No transfers to draw at this depth.</p>'
|
|
271
|
+
W, H, COL, ROW, PAD = 210, 46, 290, 74, 20
|
|
272
|
+
levels: Dict[int, List[str]] = {}
|
|
273
|
+
for n in graph["nodes"]:
|
|
274
|
+
levels.setdefault(n.get("level", 0), []).append(n["id"])
|
|
275
|
+
# order each column by where its parents sit, which keeps lines from crossing
|
|
276
|
+
ypos: Dict[str, float] = {}
|
|
277
|
+
for lvl in sorted(levels):
|
|
278
|
+
def parent_y(nid):
|
|
279
|
+
ys = [ypos[e["from"]] for e in graph["edges"] if e["to"] == nid and e["from"] in ypos]
|
|
280
|
+
return sum(ys) / len(ys) if ys else 0
|
|
281
|
+
levels[lvl].sort(key=parent_y)
|
|
282
|
+
for i, nid in enumerate(levels[lvl]):
|
|
283
|
+
ypos[nid] = i
|
|
284
|
+
tallest = max(len(v) for v in levels.values())
|
|
285
|
+
width = PAD * 2 + (max(levels) + 1) * COL - (COL - W)
|
|
286
|
+
height = PAD * 2 + tallest * ROW
|
|
287
|
+
xy = {}
|
|
288
|
+
for lvl, ids in levels.items():
|
|
289
|
+
top = PAD + (tallest - len(ids)) * ROW / 2
|
|
290
|
+
for i, nid in enumerate(ids):
|
|
291
|
+
xy[nid] = (PAD + lvl * COL, top + i * ROW)
|
|
292
|
+
|
|
293
|
+
sym = graph.get("symbol", "")
|
|
294
|
+
out = [f'<svg viewBox="0 0 {width} {height}" width="{width}" role="img" '
|
|
295
|
+
f'aria-label="Fund-flow graph" xmlns="http://www.w3.org/2000/svg">',
|
|
296
|
+
'<defs><marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" '
|
|
297
|
+
'markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" '
|
|
298
|
+
'fill="#94a3b8"/></marker></defs>']
|
|
299
|
+
for e in graph["edges"]:
|
|
300
|
+
if e["from"] not in xy or e["to"] not in xy:
|
|
301
|
+
continue
|
|
302
|
+
(x1, y1), (x2, y2) = xy[e["from"]], xy[e["to"]]
|
|
303
|
+
sx, sy, tx, ty = x1 + W, y1 + H / 2, x2, y2 + H / 2
|
|
304
|
+
mid = (sx + tx) / 2
|
|
305
|
+
out.append(f'<path d="M{sx},{sy} C{mid},{sy} {mid},{ty} {tx - 2},{ty}" fill="none" '
|
|
306
|
+
f'stroke="#94a3b8" stroke-width="1.6" marker-end="url(#arr)"/>')
|
|
307
|
+
label = f"{_amount(e['value'])} {sym} · {e['tx']} tx"
|
|
308
|
+
out.append(f'<text x="{mid}" y="{(sy + ty) / 2 - 5}" text-anchor="middle" '
|
|
309
|
+
f'class="edge">{_e(label)}</text>')
|
|
310
|
+
for nid, (x, y) in xy.items():
|
|
311
|
+
n = nodes[nid]
|
|
312
|
+
colour = _TYPE_COLOUR.get(n.get("type"), _TYPE_COLOUR["unknown"])
|
|
313
|
+
title = (n.get("label") + "\n" if n.get("label") else "") + nid
|
|
314
|
+
name = n.get("label") or ("subject" if n.get("root") else "unlabelled")
|
|
315
|
+
out.append(f'<a href="{_e(chains.explorer_url(nid, chain))}" target="_blank" rel="noopener">'
|
|
316
|
+
f'<title>{_e(title)}</title>'
|
|
317
|
+
f'<rect x="{x}" y="{y}" width="{W}" height="{H}" rx="8" fill="{colour}" '
|
|
318
|
+
f'fill-opacity="0.12" stroke="{colour}" stroke-width="{3 if n.get("root") else 1.4}"/>'
|
|
319
|
+
f'<text x="{x + 10}" y="{y + 19}" class="nname">{_e(name[:30])}</text>'
|
|
320
|
+
f'<text x="{x + 10}" y="{y + 36}" class="naddr">{_e(n["short"])}</text></a>')
|
|
321
|
+
out.append("</svg>")
|
|
322
|
+
return "".join(out)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
_CSS = """
|
|
326
|
+
:root{--fg:#0f172a;--muted:#64748b;--line:#e2e8f0;--bg:#fff;--card:#f8fafc;--red:#b91c1c;--green:#15803d}
|
|
327
|
+
@media (prefers-color-scheme:dark){:root{--fg:#e2e8f0;--muted:#94a3b8;--line:#334155;--bg:#0b1120;--card:#111827}}
|
|
328
|
+
body{font:15px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:var(--fg);background:var(--bg);
|
|
329
|
+
max-width:1100px;margin:0 auto;padding:24px 16px}
|
|
330
|
+
h1{font-size:24px;margin:0 0 4px}h2{font-size:18px;margin:28px 0 10px;border-bottom:1px solid var(--line);padding-bottom:4px}
|
|
331
|
+
.muted{color:var(--muted)}.mono{font-family:ui-monospace,Consolas,monospace;font-size:13px;word-break:break-all}
|
|
332
|
+
.box{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:14px 16px;margin:10px 0}
|
|
333
|
+
.alert{border-color:var(--red)}.ok{border-color:var(--green)}
|
|
334
|
+
table{border-collapse:collapse;width:100%;font-size:13px}th,td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line);vertical-align:top}
|
|
335
|
+
th{color:var(--muted);font-weight:600}td.num{text-align:right;white-space:nowrap}
|
|
336
|
+
.graph{overflow-x:auto;border:1px solid var(--line);border-radius:10px;padding:8px;background:var(--card)}
|
|
337
|
+
svg text{fill:var(--fg);font-family:system-ui,sans-serif}svg .edge{font-size:11px;fill:var(--muted)}
|
|
338
|
+
svg .nname{font-size:12px;font-weight:600}svg .naddr{font-size:11px;font-family:ui-monospace,Consolas,monospace}
|
|
339
|
+
a{color:#2563eb}ol li{margin-bottom:8px}.pill{display:inline-block;padding:1px 8px;border-radius:99px;font-size:12px;border:1px solid var(--line)}
|
|
340
|
+
@media print{:root{--fg:#000;--muted:#444;--line:#bbb;--bg:#fff;--card:#fff}
|
|
341
|
+
body{max-width:none;padding:0}.graph{overflow:visible}a{color:inherit;text-decoration:none}}
|
|
342
|
+
"""
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def render_html(d: dict, raw_json: str, digest: str, json_name: str) -> str:
|
|
346
|
+
s, a, chain = d["summary"], d.get("assessment") or {}, d["chain"]
|
|
347
|
+
sym = d["traced_asset"]
|
|
348
|
+
P: List[str] = [
|
|
349
|
+
"<!doctype html><html lang='en'><head><meta charset='utf-8'>",
|
|
350
|
+
"<meta name='viewport' content='width=device-width,initial-scale=1'>",
|
|
351
|
+
f"<title>Case file — {_e(d['subject'][:12])}…</title><style>{_CSS}</style></head><body>",
|
|
352
|
+
"<h1>Crypto investigation case file</h1>",
|
|
353
|
+
f"<p class='muted'>Generated {_e(d['generated'])} by {_e(d['tool'])} · chain {_e(chain)} · "
|
|
354
|
+
f"traced asset {_e(sym)} · data from {_e(d['data_source'])}</p>",
|
|
355
|
+
f"<div class='box'><b>Subject address</b><br>{_link(chains.explorer_url(d['subject'], chain), d['subject'])}"
|
|
356
|
+
+ (f"<br>Known as: <b>{_e(s['label'])}</b>" if s["label"] else "") + "</div>",
|
|
357
|
+
]
|
|
358
|
+
|
|
359
|
+
# in short
|
|
360
|
+
P.append("<h2>In short</h2>")
|
|
361
|
+
P.append(f"<p>{_e(a.get('assessment') or _headline(d['key_findings']).replace('**', ''))}</p>")
|
|
362
|
+
if a.get("risk") is not None:
|
|
363
|
+
ver = (a.get("verification") or {}).get("status", "—")
|
|
364
|
+
P.append(f"<p><span class='pill'>risk {a['risk']}/100</span> <span class='pill'>confidence "
|
|
365
|
+
f"{_e(a.get('confidence'))}</span> <span class='pill'>own arithmetic: {_e(ver)}</span></p>")
|
|
366
|
+
|
|
367
|
+
# act now
|
|
368
|
+
acts = []
|
|
369
|
+
for f in d.get("freeze") or []:
|
|
370
|
+
if f.get("frozen"):
|
|
371
|
+
acts.append(f"<div class='box ok'><b>{_e(freeze_mod.describe_frozen(f))}.</b> Frozen "
|
|
372
|
+
"funds cannot be moved and can be returned to victims through a legal "
|
|
373
|
+
"process.</div>")
|
|
374
|
+
elif f.get("actionable"):
|
|
375
|
+
acts.append(f"<div class='box alert'><b>{f['movable']:,.2f} {_e(f['token'])} is still at this "
|
|
376
|
+
f"address and not frozen.</b> {_e(f['how'])} "
|
|
377
|
+
f"{_link(f['url'], f['issuer'] + ' policy', mono=False)}</div>")
|
|
378
|
+
for sig in a.get("signals") or []:
|
|
379
|
+
if sig["name"] in ("address poisoning", "paid a look-alike"):
|
|
380
|
+
acts.append(f"<div class='box alert'><b>Address poisoning.</b> {_e(sig['observed'])} — "
|
|
381
|
+
f"{_e(sig['implication'])}.</div>")
|
|
382
|
+
exch = sorted({labels.company(f["label"]) for f in d["key_findings"]
|
|
383
|
+
if f["type"] in ("exchange", "offramp")})
|
|
384
|
+
if exch:
|
|
385
|
+
acts.append(f"<div class='box'><b>The funds reach {_e(', '.join(exch))}.</b> An exchange knows "
|
|
386
|
+
"who owns the receiving account and can freeze it on request. Give its compliance "
|
|
387
|
+
"team the transaction hashes below.</div>")
|
|
388
|
+
if acts:
|
|
389
|
+
P.append("<h2>Act now</h2>" + "".join(acts))
|
|
390
|
+
|
|
391
|
+
guidance = d.get("guidance")
|
|
392
|
+
if guidance and guidance.get("steps"):
|
|
393
|
+
P.append("<h2>What to do next</h2><ol>")
|
|
394
|
+
for st in guidance["steps"]:
|
|
395
|
+
P.append(f"<li><b>{_e(st['title'])}</b>{' <span class=pill>do this first</span>' if st.get('urgent') else ''}"
|
|
396
|
+
f"<br>{_e(st['body'])}</li>")
|
|
397
|
+
P.append("</ol>")
|
|
398
|
+
|
|
399
|
+
# graph
|
|
400
|
+
P.append(f"<h2>Where the money went (depth {d['trace_depth']})</h2>")
|
|
401
|
+
P.append("<p class='muted'>Each box links to the address on a block explorer; hover for the full "
|
|
402
|
+
"address. Green: exchange · purple: mixer · red: sanctioned or scam · teal: bridge · "
|
|
403
|
+
"grey: unlabelled.</p>")
|
|
404
|
+
P.append(f"<div class='graph'>{_svg(d['graph'], chain)}</div>")
|
|
405
|
+
|
|
406
|
+
# transfers with hashes
|
|
407
|
+
edges = d["graph"]["edges"]
|
|
408
|
+
if edges:
|
|
409
|
+
P.append("<h2>Transfers along the trace</h2><table><tr><th>From</th><th>To</th>"
|
|
410
|
+
"<th>Amount</th><th>Txs</th><th>First / last (UTC)</th><th>Transactions</th></tr>")
|
|
411
|
+
for e in edges:
|
|
412
|
+
to_lbl = labels.label_of(e["to"])
|
|
413
|
+
hashes = " ".join(_link(chains.tx_url(h, chain), h[:10] + "…") for h in e.get("hashes", []))
|
|
414
|
+
P.append(f"<tr><td>{_link(chains.explorer_url(e['from'], chain), e['from'])}</td>"
|
|
415
|
+
f"<td>{_link(chains.explorer_url(e['to'], chain), e['to'])}"
|
|
416
|
+
+ (f"<br><b>{_e(to_lbl)}</b>" if to_lbl else "") + "</td>"
|
|
417
|
+
f"<td class='num'>{_e(_amount(e['value'], sym))}</td><td class='num'>{e['tx']}</td>"
|
|
418
|
+
f"<td>{_e(_ts(e.get('first_ts')))}<br>{_e(_ts(e.get('last_ts')))}</td>"
|
|
419
|
+
f"<td>{hashes or '—'}</td></tr>")
|
|
420
|
+
P.append("</table>")
|
|
421
|
+
|
|
422
|
+
# signals
|
|
423
|
+
if a.get("signals"):
|
|
424
|
+
P.append("<h2>What the assessment rests on</h2><table><tr><th>Signal</th><th>Observed</th>"
|
|
425
|
+
"<th>Means</th><th>Confidence</th></tr>")
|
|
426
|
+
for sig in a["signals"]:
|
|
427
|
+
P.append(f"<tr><td>{_e(sig['name'])}</td><td>{_e(sig['observed'])}</td>"
|
|
428
|
+
f"<td>{_e(sig['implication'])}</td><td>{_e(sig['confidence'])}</td></tr>")
|
|
429
|
+
P.append("</table>")
|
|
430
|
+
for c in a.get("caveats") or []:
|
|
431
|
+
P.append(f"<p class='muted'>Caveat: {_e(c)}</p>")
|
|
432
|
+
|
|
433
|
+
# label evidence
|
|
434
|
+
ev = [v for v in (d.get("label_evidence") or {}).values() if v.get("known")]
|
|
435
|
+
if ev:
|
|
436
|
+
P.append("<h2>Where each label comes from</h2><table><tr><th>Address</th><th>Label</th>"
|
|
437
|
+
"<th>Source</th><th>Kind</th></tr>")
|
|
438
|
+
for v in ev:
|
|
439
|
+
P.append(f"<tr><td>{_link(chains.explorer_url(v['address'], chain), v['address'])}</td>"
|
|
440
|
+
f"<td>{_e(v['name'])}</td><td>{_e(v['source']) or '<i>none recorded</i>'}</td>"
|
|
441
|
+
f"<td>{_e(v['source_kind']) or '—'}</td></tr>")
|
|
442
|
+
P.append("</table>")
|
|
443
|
+
|
|
444
|
+
# summary + counterparties
|
|
445
|
+
P.append("<h2>Subject address</h2><table>")
|
|
446
|
+
for k, v in [("Balance", f"{s['balance_native']} {s['native_symbol']}"),
|
|
447
|
+
("Transfers analysed", s["transfers_analysed"]), ("First seen", s["first_seen"]),
|
|
448
|
+
("Last seen", s["last_seen"]), ("Label", s["label"] or "—")]:
|
|
449
|
+
P.append(f"<tr><th>{_e(k)}</th><td>{_e(v)}</td></tr>")
|
|
450
|
+
P.append("</table>")
|
|
451
|
+
if d["top_counterparties"]:
|
|
452
|
+
P.append(f"<h2>Largest counterparties</h2><table><tr><th>Address</th><th>Label</th>"
|
|
453
|
+
f"<th>In ({_e(sym)})</th><th>Out ({_e(sym)})</th><th>Txs</th></tr>")
|
|
454
|
+
for c in d["top_counterparties"]:
|
|
455
|
+
P.append(f"<tr><td>{_link(chains.explorer_url(c['address'], chain), c['address'])}</td>"
|
|
456
|
+
f"<td>{_e(c['label']) or '—'}</td><td class='num'>{_e(_amount(c['in']))}</td>"
|
|
457
|
+
f"<td class='num'>{_e(_amount(c['out']))}</td><td class='num'>{c['txs']}</td></tr>")
|
|
458
|
+
P.append("</table>")
|
|
459
|
+
|
|
460
|
+
P.append(f"<h2>Method and limits</h2><p>{_e(_METHOD)}</p>")
|
|
461
|
+
if d.get("errors"):
|
|
462
|
+
P.append("<p class='muted'>Parts that could not be read: " + _e("; ".join(d["errors"])) + "</p>")
|
|
463
|
+
P.append(f"<p class='muted'>The raw data is saved next to this file as <span class='mono'>{_e(json_name)}"
|
|
464
|
+
f"</span> (SHA-256 <span class='mono'>{digest}</span>) and embedded below for analysts.</p>")
|
|
465
|
+
# JSON inside <script type=application/json> is data, never executed. Every "<" is
|
|
466
|
+
# written as < (still valid JSON), so a crafted token or label name can neither
|
|
467
|
+
# close the element nor push the parser into its "<!--<script" escaping states.
|
|
468
|
+
P.append("<script type='application/json' id='case-data'>" + raw_json.replace("<", "\\u003c")
|
|
469
|
+
+ "</script></body></html>")
|
|
470
|
+
return "".join(P)
|
|
@@ -132,7 +132,9 @@ def load(chain: str, address: str, contract: str = "", native_symbol: str = "")
|
|
|
132
132
|
" AND (contract IS NULL OR contract='')"
|
|
133
133
|
+ (" AND symbol=?" if native_symbol else "") + " ORDER BY ts DESC")
|
|
134
134
|
args = (chain, address, address) + ((native_symbol,) if native_symbol else ())
|
|
135
|
-
|
|
135
|
+
# Stores written before 0.5.0 hold Tron Approval events read as transfers of
|
|
136
|
+
# ~1e59 tokens (an unlimited allowance). No real transfer comes near 1e30.
|
|
137
|
+
rows = [r for r in c.execute(q, args).fetchall() if (r[2] or 0) < 1e30]
|
|
136
138
|
finally:
|
|
137
139
|
c.close()
|
|
138
140
|
out = []
|
|
@@ -447,13 +447,13 @@ async function inspect(addr){
|
|
|
447
447
|
<b>KYC identification point</b>.</div>`,'alert');
|
|
448
448
|
}
|
|
449
449
|
// Tether/Circle can freeze USDT/USDC in place — the one thing that stops them
|
|
450
|
-
const fzs=(fz.freeze||[]).filter(e=>e.error||e.frozen||e.
|
|
450
|
+
const fzs=(fz.freeze||[]).filter(e=>e.error||e.frozen||e.actionable);
|
|
451
451
|
if(fzs.length&&!fz.exchange){
|
|
452
452
|
const amt=v=>Number(v).toLocaleString(undefined,{maximumFractionDigits:2});
|
|
453
|
-
const open=fzs.filter(e=>!e.frozen&&e.
|
|
453
|
+
const open=fzs.filter(e=>!e.frozen&&e.actionable);
|
|
454
454
|
h+=card('Stablecoin freeze',
|
|
455
455
|
fzs.map(e=>kv(esc(e.token), e.error ? '<span class="muted">could not be read</span>'
|
|
456
|
-
: e.frozen ? `<b style="color:var(--green)">${amt(e.frozen_amount)
|
|
456
|
+
: e.frozen ? `<b style="color:var(--green)">${e.frozen_amount>=1 ? amt(e.frozen_amount)+' frozen' : 'address blacklisted'} by ${esc(e.issuer)}</b>`
|
|
457
457
|
: `<b style="color:#ff6b6b">${amt(e.balance)} not frozen</b>`)).join('')+
|
|
458
458
|
(open.length?`<div class="vfy-note">${esc(open[0].how)}
|
|
459
459
|
<a href="${safeUrl(open[0].url)}" target="_blank" rel="noopener">${esc(open[0].issuer)} policy ↗</a></div>`:''),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: crypttrace
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: OSINT toolkit for crypto investigations: trace stolen funds across Ethereum, Bitcoin, Tron and Solana
|
|
5
5
|
Author: bobslayerX
|
|
6
6
|
License-Expression: MIT
|
|
@@ -225,7 +225,7 @@ crypttrace crosschain 0xADDRESS --window 48
|
|
|
225
225
|
crypttrace watch add 0xADDRESS --note "my stolen ETH"
|
|
226
226
|
crypttrace watch run --interval 300 # or --once for cron / Task Scheduler
|
|
227
227
|
|
|
228
|
-
# Full investigation
|
|
228
|
+
# Full investigation as a case file to send: one HTML page (plus Markdown + JSON)
|
|
229
229
|
crypttrace report 0xADDRESS --depth 3
|
|
230
230
|
|
|
231
231
|
# Refresh label lists (OFAC sanctions, …)
|
|
@@ -460,10 +460,24 @@ are ignored, and every address is checked before it is stored.
|
|
|
460
460
|
|
|
461
461
|
### Reports
|
|
462
462
|
|
|
463
|
-
`report` runs the full analysis and writes
|
|
464
|
-
with `--out`)
|
|
465
|
-
|
|
466
|
-
the
|
|
463
|
+
`report` runs the full analysis and writes a **case file** to
|
|
464
|
+
`~/crypttrace-reports/` (override with `--out`); `investigate` saves the same
|
|
465
|
+
file with the victim's next steps in it. The case file is one HTML page, made
|
|
466
|
+
for whoever receives it — an exchange's compliance team, the police, a lawyer:
|
|
467
|
+
|
|
468
|
+
- the conclusion in plain words, and what to do now: stablecoins still at the
|
|
469
|
+
address and who can freeze them, the exchanges the money reached, address
|
|
470
|
+
poisoning;
|
|
471
|
+
- the fund-flow graph, each address linking to a block explorer;
|
|
472
|
+
- every transfer along the trace with its transaction hashes — the first thing
|
|
473
|
+
an exchange asks for;
|
|
474
|
+
- where each label comes from, how the tool checked its own arithmetic, and
|
|
475
|
+
what the method cannot tell you.
|
|
476
|
+
|
|
477
|
+
It has no scripts and loads nothing from the internet, so it opens in any
|
|
478
|
+
browser or mail client, prints (or saves as PDF) cleanly, and opening it tells
|
|
479
|
+
no one. The raw data is embedded for analysts and written next to it as JSON,
|
|
480
|
+
with its SHA-256 printed on the page; a Markdown version is written too.
|
|
467
481
|
|
|
468
482
|
### Self-verification
|
|
469
483
|
|
|
@@ -561,9 +575,6 @@ cases/ # worked investigations with their data
|
|
|
561
575
|
|
|
562
576
|
- More exchanges on Bitcoin, Tron and Solana (now: Binance, OKX, HTX, Bybit),
|
|
563
577
|
and refreshing these lists as the exchanges republish them
|
|
564
|
-
- `report --html`: one self-contained file with the interactive graph, to
|
|
565
|
-
send to an exchange or attach to a police report
|
|
566
|
-
- `report --pdf` for exchange and law-enforcement filings
|
|
567
578
|
- Internal transactions (completes `funder` and contract-mediated transfers)
|
|
568
579
|
- Per-mint filtering for Solana SPL tokens
|
|
569
580
|
- More label sources: Chainabuse, CryptoScamDB, exchange deposit-address sets
|
|
@@ -1,175 +0,0 @@
|
|
|
1
|
-
"""Generate a full investigation report (Markdown + JSON) for an address.
|
|
2
|
-
|
|
3
|
-
Runs the same analysis as `profile` + `trace`, then writes a self-contained
|
|
4
|
-
report to a folder the user can easily find (default: ~/crypttrace-reports).
|
|
5
|
-
"""
|
|
6
|
-
import json
|
|
7
|
-
from datetime import datetime, timezone
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
|
|
10
|
-
from rich.console import Console
|
|
11
|
-
|
|
12
|
-
from crypttrace import __version__, config, prices
|
|
13
|
-
from crypttrace.fetchers import etherscan
|
|
14
|
-
from crypttrace.labels import labels
|
|
15
|
-
from crypttrace import trace as trace_mod
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def _now() -> str:
|
|
19
|
-
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def _ts(unix: str) -> str:
|
|
23
|
-
try:
|
|
24
|
-
return datetime.fromtimestamp(int(unix), tz=timezone.utc).strftime("%Y-%m-%d %H:%M")
|
|
25
|
-
except (ValueError, TypeError):
|
|
26
|
-
return "?"
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def _counterparties(address: str, txs: list, top: int = 15):
|
|
30
|
-
me = address.lower()
|
|
31
|
-
agg = {}
|
|
32
|
-
for tx in txs:
|
|
33
|
-
frm, to = tx.get("from", "").lower(), tx.get("to", "").lower()
|
|
34
|
-
val = int(tx.get("value", 0)) / config.WEI
|
|
35
|
-
other = to if frm == me else frm
|
|
36
|
-
if not other:
|
|
37
|
-
continue
|
|
38
|
-
rec = agg.setdefault(other, [0.0, 0.0, 0])
|
|
39
|
-
if frm == me:
|
|
40
|
-
rec[1] += val
|
|
41
|
-
else:
|
|
42
|
-
rec[0] += val
|
|
43
|
-
rec[2] += 1
|
|
44
|
-
rows = sorted(agg.items(), key=lambda kv: kv[1][0] + kv[1][1], reverse=True)[:top]
|
|
45
|
-
return [
|
|
46
|
-
{"address": a, "in": round(vin, 6), "out": round(vout, 6), "txs": cnt,
|
|
47
|
-
"label": labels.label_of(a), "type": labels.type_of(a)}
|
|
48
|
-
for a, (vin, vout, cnt) in rows
|
|
49
|
-
]
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
def _tree_text(tree) -> str:
|
|
53
|
-
"""Render the rich trace tree to plain text (ANSI stripped)."""
|
|
54
|
-
con = Console(record=True, width=100, file=None)
|
|
55
|
-
with con.capture() as cap:
|
|
56
|
-
con.print(tree)
|
|
57
|
-
return cap.get()
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def _headline(findings: list) -> str:
|
|
61
|
-
types = {f["type"] for f in findings}
|
|
62
|
-
if "sanctioned" in types:
|
|
63
|
-
return ("Funds from this address reach a **sanctioned / known-criminal wallet** — "
|
|
64
|
-
"escalate to law enforcement.")
|
|
65
|
-
if "mixer" in types:
|
|
66
|
-
return ("Funds from this address flow into a **mixer** (privacy pool), where on-chain "
|
|
67
|
-
"tracing terminates. Recovery from here requires timing/amount heuristics or "
|
|
68
|
-
"off-chain data.")
|
|
69
|
-
if "exchange" in types:
|
|
70
|
-
return ("Funds from this address reach a **centralised exchange** — a KYC handoff point. "
|
|
71
|
-
"Identifying the owner requires a legal request to that exchange.")
|
|
72
|
-
return ("No labelled entities were reached within the traced depth. Increase --depth or "
|
|
73
|
-
"extend the label database, then re-run.")
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def generate(address: str, chain: str, depth: int, branching: int,
|
|
77
|
-
out_dir: Path, asset=None) -> Path:
|
|
78
|
-
balance = etherscan.get_balance(address, chain)
|
|
79
|
-
txs = etherscan.get_txs(address, chain, limit=1000)
|
|
80
|
-
tree, findings = trace_mod.build(address, chain, depth, branching, asset)
|
|
81
|
-
|
|
82
|
-
# de-duplicate findings by address, keep highest value_reached
|
|
83
|
-
uniq = {}
|
|
84
|
-
for f in findings:
|
|
85
|
-
cur = uniq.get(f["address"])
|
|
86
|
-
if cur is None or f["value_reached"] > cur["value_reached"]:
|
|
87
|
-
uniq[f["address"]] = f
|
|
88
|
-
findings = sorted(uniq.values(), key=lambda f: f["risk"], reverse=True)
|
|
89
|
-
|
|
90
|
-
counterparties = _counterparties(address, txs)
|
|
91
|
-
hit = labels.lookup(address)
|
|
92
|
-
|
|
93
|
-
data = {
|
|
94
|
-
"tool": f"crypttrace v{__version__}",
|
|
95
|
-
"generated": _now(),
|
|
96
|
-
"subject": address,
|
|
97
|
-
"chain": chain,
|
|
98
|
-
"trace_depth": depth,
|
|
99
|
-
"trace_branching": branching,
|
|
100
|
-
"summary": {
|
|
101
|
-
"balance_native": round(balance, 6),
|
|
102
|
-
"txs_analysed": len(txs),
|
|
103
|
-
"first_seen": _ts(txs[-1]["timeStamp"]) if txs else None,
|
|
104
|
-
"last_seen": _ts(txs[0]["timeStamp"]) if txs else None,
|
|
105
|
-
"label": hit["name"] if hit else None,
|
|
106
|
-
"type": labels.type_of(address),
|
|
107
|
-
"risk_score": labels.risk_score(address),
|
|
108
|
-
},
|
|
109
|
-
"traced_asset": asset["symbol"] if asset else "ETH (native)",
|
|
110
|
-
"key_findings": findings,
|
|
111
|
-
"top_counterparties": counterparties,
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
out_dir.mkdir(parents=True, exist_ok=True)
|
|
115
|
-
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
116
|
-
base = f"{chain}_{address[:10]}_{stamp}"
|
|
117
|
-
json_path = out_dir / f"{base}.json"
|
|
118
|
-
md_path = out_dir / f"{base}.md"
|
|
119
|
-
|
|
120
|
-
json_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
121
|
-
md_path.write_text(_render_md(data, _tree_text(tree), json_path.name), encoding="utf-8")
|
|
122
|
-
return md_path
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
def _render_md(d: dict, tree_text: str, json_name: str) -> str:
|
|
126
|
-
s = d["summary"]
|
|
127
|
-
L = []
|
|
128
|
-
L.append(f"# crypttrace investigation report\n")
|
|
129
|
-
L.append(f"**Generated:** {d['generated']} • **Tool:** {d['tool']} \n")
|
|
130
|
-
L.append(f"**Subject:** `{d['subject']}` • **Chain:** {d['chain']} • "
|
|
131
|
-
f"**Traced asset:** {d.get('traced_asset', 'ETH (native)')}\n")
|
|
132
|
-
|
|
133
|
-
L.append(f"\n## Assessment\n")
|
|
134
|
-
L.append(_headline(d["key_findings"]) + "\n")
|
|
135
|
-
|
|
136
|
-
L.append(f"\n## Summary\n")
|
|
137
|
-
L.append(f"| Field | Value |\n|---|---|\n")
|
|
138
|
-
L.append(f"| Balance | {s['balance_native']} (native) |\n")
|
|
139
|
-
L.append(f"| Transactions analysed | {s['txs_analysed']} |\n")
|
|
140
|
-
L.append(f"| First seen | {s['first_seen']} |\n")
|
|
141
|
-
L.append(f"| Last seen | {s['last_seen']} |\n")
|
|
142
|
-
L.append(f"| Label | {s['label'] or '—'} |\n")
|
|
143
|
-
L.append(f"| Risk score | {s['risk_score']}/100 |\n")
|
|
144
|
-
|
|
145
|
-
L.append(f"\n## Key findings\n")
|
|
146
|
-
if d["key_findings"]:
|
|
147
|
-
L.append("Labelled entities reached while tracing funds outward from the subject:\n\n")
|
|
148
|
-
L.append("| Entity | Type | Risk | Value reached | ≈ USD |\n|---|---|---|---|---|\n")
|
|
149
|
-
for f in d["key_findings"]:
|
|
150
|
-
sym = f.get("symbol", "")
|
|
151
|
-
usd = prices.fmt_usd(f.get("usd_reached"))
|
|
152
|
-
L.append(f"| {f['label']} | {f['type']} | {f['risk']}/100 | "
|
|
153
|
-
f"{f['value_reached']} {sym} | {usd} |\n")
|
|
154
|
-
else:
|
|
155
|
-
L.append("_None within the traced depth._\n")
|
|
156
|
-
|
|
157
|
-
L.append(f"\n## Top counterparties\n")
|
|
158
|
-
L.append("| Address | Label | In | Out | Txs |\n|---|---|---|---|---|\n")
|
|
159
|
-
for c in d["top_counterparties"]:
|
|
160
|
-
L.append(f"| `{c['address']}` | {c['label'] or '—'} | {c['in']} | {c['out']} | {c['txs']} |\n")
|
|
161
|
-
|
|
162
|
-
L.append(f"\n## Fund-flow trace (depth {d['trace_depth']})\n")
|
|
163
|
-
L.append("```\n" + tree_text.rstrip() + "\n```\n")
|
|
164
|
-
|
|
165
|
-
L.append(f"\n## Methodology & limitations\n")
|
|
166
|
-
L.append(
|
|
167
|
-
"Data is sourced from the public blockchain via Etherscan. Tracing follows the largest "
|
|
168
|
-
"outgoing transfers from each address and stops at identifiable entities (exchanges, "
|
|
169
|
-
"mixers, sanctioned wallets). Note: the blockchain is pseudonymous — reaching an address "
|
|
170
|
-
"does not identify its owner. Mixers sever the on-chain trail; exchanges require a legal "
|
|
171
|
-
"request to attribute ownership. This report is an investigative aid, not proof of "
|
|
172
|
-
"wrongdoing.\n")
|
|
173
|
-
|
|
174
|
-
L.append(f"\n---\n_Raw structured data: `{json_name}` (same folder)._\n")
|
|
175
|
-
return "".join(L)
|
|
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
|
|
File without changes
|
|
File without changes
|