crypttrace 0.7.0__tar.gz → 0.8.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. {crypttrace-0.7.0/src/crypttrace.egg-info → crypttrace-0.8.0}/PKG-INFO +6 -2
  2. {crypttrace-0.7.0 → crypttrace-0.8.0}/README.md +5 -1
  3. {crypttrace-0.7.0 → crypttrace-0.8.0}/src/crypttrace/__init__.py +1 -1
  4. {crypttrace-0.7.0 → crypttrace-0.8.0}/src/crypttrace/report.py +20 -8
  5. {crypttrace-0.7.0 → crypttrace-0.8.0}/src/crypttrace/web/index.html +67 -1
  6. {crypttrace-0.7.0 → crypttrace-0.8.0}/src/crypttrace/webapp.py +35 -1
  7. {crypttrace-0.7.0 → crypttrace-0.8.0/src/crypttrace.egg-info}/PKG-INFO +6 -2
  8. {crypttrace-0.7.0 → crypttrace-0.8.0}/LICENSE +0 -0
  9. {crypttrace-0.7.0 → crypttrace-0.8.0}/pyproject.toml +0 -0
  10. {crypttrace-0.7.0 → crypttrace-0.8.0}/setup.cfg +0 -0
  11. {crypttrace-0.7.0 → crypttrace-0.8.0}/src/crypttrace/addresses.py +0 -0
  12. {crypttrace-0.7.0 → crypttrace-0.8.0}/src/crypttrace/analysis.py +0 -0
  13. {crypttrace-0.7.0 → crypttrace-0.8.0}/src/crypttrace/assess.py +0 -0
  14. {crypttrace-0.7.0 → crypttrace-0.8.0}/src/crypttrace/assets.py +0 -0
  15. {crypttrace-0.7.0 → crypttrace-0.8.0}/src/crypttrace/bridges.py +0 -0
  16. {crypttrace-0.7.0 → crypttrace-0.8.0}/src/crypttrace/chains.py +0 -0
  17. {crypttrace-0.7.0 → crypttrace-0.8.0}/src/crypttrace/cli.py +0 -0
  18. {crypttrace-0.7.0 → crypttrace-0.8.0}/src/crypttrace/config.py +0 -0
  19. {crypttrace-0.7.0 → crypttrace-0.8.0}/src/crypttrace/fetchers/__init__.py +0 -0
  20. {crypttrace-0.7.0 → crypttrace-0.8.0}/src/crypttrace/fetchers/bitcoin.py +0 -0
  21. {crypttrace-0.7.0 → crypttrace-0.8.0}/src/crypttrace/fetchers/etherscan.py +0 -0
  22. {crypttrace-0.7.0 → crypttrace-0.8.0}/src/crypttrace/fetchers/http.py +0 -0
  23. {crypttrace-0.7.0 → crypttrace-0.8.0}/src/crypttrace/fetchers/solana.py +0 -0
  24. {crypttrace-0.7.0 → crypttrace-0.8.0}/src/crypttrace/fetchers/tron.py +0 -0
  25. {crypttrace-0.7.0 → crypttrace-0.8.0}/src/crypttrace/freeze.py +0 -0
  26. {crypttrace-0.7.0 → crypttrace-0.8.0}/src/crypttrace/funder.py +0 -0
  27. {crypttrace-0.7.0 → crypttrace-0.8.0}/src/crypttrace/investigate.py +0 -0
  28. {crypttrace-0.7.0 → crypttrace-0.8.0}/src/crypttrace/labels/__init__.py +0 -0
  29. {crypttrace-0.7.0 → crypttrace-0.8.0}/src/crypttrace/labels/audit.py +0 -0
  30. {crypttrace-0.7.0 → crypttrace-0.8.0}/src/crypttrace/labels/bulk.py +0 -0
  31. {crypttrace-0.7.0 → crypttrace-0.8.0}/src/crypttrace/labels/known.json +0 -0
  32. {crypttrace-0.7.0 → crypttrace-0.8.0}/src/crypttrace/labels/labels.py +0 -0
  33. {crypttrace-0.7.0 → crypttrace-0.8.0}/src/crypttrace/labels/partial.json +0 -0
  34. {crypttrace-0.7.0 → crypttrace-0.8.0}/src/crypttrace/offramp.py +0 -0
  35. {crypttrace-0.7.0 → crypttrace-0.8.0}/src/crypttrace/poisoning.py +0 -0
  36. {crypttrace-0.7.0 → crypttrace-0.8.0}/src/crypttrace/prices.py +0 -0
  37. {crypttrace-0.7.0 → crypttrace-0.8.0}/src/crypttrace/render.py +0 -0
  38. {crypttrace-0.7.0 → crypttrace-0.8.0}/src/crypttrace/store.py +0 -0
  39. {crypttrace-0.7.0 → crypttrace-0.8.0}/src/crypttrace/trace.py +0 -0
  40. {crypttrace-0.7.0 → crypttrace-0.8.0}/src/crypttrace/verify.py +0 -0
  41. {crypttrace-0.7.0 → crypttrace-0.8.0}/src/crypttrace/watch.py +0 -0
  42. {crypttrace-0.7.0 → crypttrace-0.8.0}/src/crypttrace.egg-info/SOURCES.txt +0 -0
  43. {crypttrace-0.7.0 → crypttrace-0.8.0}/src/crypttrace.egg-info/dependency_links.txt +0 -0
  44. {crypttrace-0.7.0 → crypttrace-0.8.0}/src/crypttrace.egg-info/entry_points.txt +0 -0
  45. {crypttrace-0.7.0 → crypttrace-0.8.0}/src/crypttrace.egg-info/requires.txt +0 -0
  46. {crypttrace-0.7.0 → crypttrace-0.8.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.7.0
3
+ Version: 0.8.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
@@ -131,7 +131,11 @@ crypttrace serve # then open http://127.0.0.1:8000
131
131
  Paste an address, pick a chain and direction, and get an interactive fund-flow
132
132
  graph: nodes coloured by what they are, arrows showing where value went, click a
133
133
  node for its full profile, click a transfer line to open it on the block
134
- explorer. A side panel shows the profile, first-funder chain and off-ramp check.
134
+ explorer. A side panel shows the profile, first-funder chain, off-ramp check
135
+ and whether USDT/USDC at the address is frozen. Tabs add the assessment, the
136
+ timeline, the list of who sent funds here, and address poisoning (look-alikes
137
+ planted in the wallet's history, and payments lured by this address). **Case
138
+ file** downloads the same one-page HTML report `crypttrace report` writes.
135
139
  Everything runs on your machine — nothing is uploaded anywhere.
136
140
 
137
141
  This exists so non-technical victims can use the tool at all: a form and a
@@ -97,7 +97,11 @@ crypttrace serve # then open http://127.0.0.1:8000
97
97
  Paste an address, pick a chain and direction, and get an interactive fund-flow
98
98
  graph: nodes coloured by what they are, arrows showing where value went, click a
99
99
  node for its full profile, click a transfer line to open it on the block
100
- explorer. A side panel shows the profile, first-funder chain and off-ramp check.
100
+ explorer. A side panel shows the profile, first-funder chain, off-ramp check
101
+ and whether USDT/USDC at the address is frozen. Tabs add the assessment, the
102
+ timeline, the list of who sent funds here, and address poisoning (look-alikes
103
+ planted in the wallet's history, and payments lured by this address). **Case
104
+ file** downloads the same one-page HTML report `crypttrace report` writes.
101
105
  Everything runs on your machine — nothing is uploaded anywhere.
102
106
 
103
107
  This exists so non-technical victims can use the tool at all: a form and a
@@ -1,2 +1,2 @@
1
1
  """crypttrace — OSINT crypto investigation CLI."""
2
- __version__ = "0.7.0"
2
+ __version__ = "0.8.0"
@@ -174,22 +174,32 @@ def collect(address: str, chain: str, depth: int, branching: int, asset=None) ->
174
174
  def generate(address: str, chain: str, depth: int, branching: int,
175
175
  out_dir: Path, asset=None, guidance: Optional[dict] = None) -> Dict[str, Path]:
176
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
177
+ data, raw, digest = build(address, chain, depth, branching, asset, guidance)
180
178
  out_dir.mkdir(parents=True, exist_ok=True)
181
- base = f"{chain}_{address[:10]}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
179
+ base = case_name(address, chain)
182
180
  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
181
  # bytes, not text mode: on Windows text mode turns LF into CRLF, and the hash
185
182
  # printed in the HTML would no longer match the file next to it
186
183
  paths["json"].write_bytes(raw.encode("utf-8"))
187
- digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()
188
184
  paths["md"].write_text(_render_md(data, paths["json"].name), encoding="utf-8")
189
185
  paths["html"].write_text(render_html(data, raw, digest, paths["json"].name), encoding="utf-8")
190
186
  return paths
191
187
 
192
188
 
189
+ def case_name(address: str, chain: str) -> str:
190
+ return f"{chain}_{address[:10]}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
191
+
192
+
193
+ def build(address: str, chain: str, depth: int, branching: int, asset=None,
194
+ guidance: Optional[dict] = None):
195
+ """(data, raw JSON, its SHA-256) — the web UI renders the page from this in memory."""
196
+ data = collect(address, chain, depth, branching, asset)
197
+ if guidance:
198
+ data["guidance"] = guidance
199
+ raw = json.dumps(data, indent=2, ensure_ascii=False, default=str)
200
+ return data, raw, hashlib.sha256(raw.encode("utf-8")).hexdigest()
201
+
202
+
193
203
  # ------------------------------------------------------------------ markdown
194
204
 
195
205
  def _render_md(d: dict, json_name: str) -> str:
@@ -460,8 +470,10 @@ def render_html(d: dict, raw_json: str, digest: str, json_name: str) -> str:
460
470
  P.append(f"<h2>Method and limits</h2><p>{_e(_METHOD)}</p>")
461
471
  if d.get("errors"):
462
472
  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>")
473
+ where = (f"saved next to this file as <span class='mono'>{_e(json_name)}</span>"
474
+ if json_name else "embedded in this file")
475
+ P.append(f"<p class='muted'>The raw data is {where} (SHA-256 of the JSON: "
476
+ f"<span class='mono'>{digest}</span>) and embedded below for analysts.</p>")
465
477
  # JSON inside <script type=application/json> is data, never executed. Every "<" is
466
478
  # written as < (still valid JSON), so a crafted token or label name can neither
467
479
  # close the element nor push the parser into its "<!--<script" escaping states.
@@ -228,6 +228,7 @@
228
228
  <select id="depth" title="Depth"><option>2</option><option selected>3</option><option>4</option><option>5</option></select>
229
229
  <button id="go">Investigate</button>
230
230
  <button id="fit" class="ghost" title="Fit graph to view">Fit</button>
231
+ <button id="casefile" class="ghost" title="Download a one-page case file for an exchange or the police">Case file</button>
231
232
  </div>
232
233
 
233
234
  <div class="legend">
@@ -246,6 +247,7 @@
246
247
  <button class="tab" data-view="assess">Assessment</button>
247
248
  <button class="tab" data-view="timeline">Timeline</button>
248
249
  <button class="tab" data-view="victims">Who sent funds here</button>
250
+ <button class="tab" data-view="poison">Poisoning</button>
249
251
  </div>
250
252
  <div class="stage">
251
253
  <div class="loader" id="loader"></div>
@@ -255,6 +257,7 @@
255
257
  <div class="view" id="v-assess"></div>
256
258
  <div class="view" id="v-timeline"></div>
257
259
  <div class="view" id="v-victims"></div>
260
+ <div class="view" id="v-poison"></div>
258
261
  <div class="scaleview" id="scaleview" style="display:none">100%</div>
259
262
  <div class="zoomctl" id="zoomctl">
260
263
  <button id="zin" title="Zoom in">+</button>
@@ -474,7 +477,7 @@ async function run(){
474
477
  setStatus('tracing the chain…',false,true); loader(35);
475
478
  $('#hint').classList.add('hide');
476
479
  loaded={}; // new address -> other views must reload
477
- $('#v-timeline').innerHTML=''; $('#v-victims').innerHTML=''; $('#v-assess').innerHTML='';
480
+ $('#v-timeline').innerHTML=''; $('#v-victims').innerHTML=''; $('#v-assess').innerHTML=''; $('#v-poison').innerHTML='';
478
481
  try{
479
482
  const dir=$('#direction').value;
480
483
  const g=await api(`/api/trace?address=${q(a)}&chain=${chain()}&depth=${$('#depth').value}&asset=${q($('#asset').value)}&direction=${dir}`);
@@ -524,12 +527,14 @@ function showView(v){
524
527
  $('#v-assess').classList.toggle('on',v==='assess');
525
528
  $('#v-timeline').classList.toggle('on',v==='timeline');
526
529
  $('#v-victims').classList.toggle('on',v==='victims');
530
+ $('#v-poison').classList.toggle('on',v==='poison');
527
531
  const a=$('#addr').value.trim();
528
532
  if(a && !loaded[v+a]) {
529
533
  loaded[v+a]=true;
530
534
  if(v==='timeline') loadTimeline(a);
531
535
  if(v==='victims') loadVictims(a);
532
536
  if(v==='assess') loadAssessment(a);
537
+ if(v==='poison') loadPoisoning(a);
533
538
  }
534
539
  }
535
540
  document.querySelectorAll('.tab').forEach(t=>t.onclick=()=>showView(t.dataset.view));
@@ -647,6 +652,67 @@ async function verifyInto(container, addr){
647
652
  }catch(e){ slot.innerHTML=`<span class="vfy vfy-unavailable">verification unavailable</span>`; }
648
653
  }
649
654
 
655
+ // Address poisoning: look-alikes planted in this wallet's history, and — seen from
656
+ // the other side — payments this address received right after luring the payer.
657
+ async function loadPoisoning(a){
658
+ const el=$('#v-poison');
659
+ el.innerHTML='<div class="muted"><span class="spin"></span>Comparing every address in this history…</div>';
660
+ try{
661
+ const r=await api(`/api/poisoning?address=${q(a)}&chain=${chain()}`);
662
+ const amt=v=>Number(v).toLocaleString(undefined,{maximumFractionDigits:2});
663
+ const addr=x=>`<a class="mono" href="${safeUrl(addrUrl(x))}" target="_blank" rel="noopener">${esc(x)}</a>`;
664
+ let h='';
665
+ for(const x of r.lured||[]){
666
+ h+=`<div class="verdict">Lured payment: ${addr(x.payer)} paid <b>${amt(x.paid)} ${esc(x.symbol)}</b>
667
+ on ${dstamp(x.paid_ts)} UTC, right after this address lured it (${esc(x.lure)}).
668
+ ${x.imitates?`It imitates ${addr(x.imitates)}, an address the payer had really used.`
669
+ :'<span class="muted">The imitated address was not found in the payer\'s recent history.</span>'}</div>`;
670
+ }
671
+ const paid=(r.lookalikes||[]).filter(p=>p.sent_to_lookalike>0);
672
+ for(const p of paid){
673
+ h+=`<div class="verdict">Money went to a look-alike: <b>${amt(p.sent_to_lookalike)} ${esc(p.symbols.join('/'))}</b>
674
+ <div class="kv"><span class="k">real address</span><span class="v">${addr(p.genuine)}</span></div>
675
+ <div class="kv"><span class="k">look-alike</span><span class="v">${addr(p.lookalike)}</span></div>
676
+ <div class="muted">Same first ${p.matching.prefix} and last ${p.matching.suffix} characters;
677
+ the look-alike first appeared ${dstamp(p.lookalike_first_seen)} UTC.</div></div>`;
678
+ }
679
+ if(r.campaign){
680
+ h+=`<div class="verdict">Poisoning campaign: this address sent ${r.campaign.bait_transfers} zero, dust or
681
+ counterfeit transfers to ${r.campaign.targets} different wallets.</div>`;
682
+ }
683
+ const planted=(r.lookalikes||[]).filter(p=>!(p.sent_to_lookalike>0));
684
+ if(planted.length){
685
+ h+=`<h3 style="font-size:11px;letter-spacing:.09em;color:var(--dim);margin:16px 0 8px">
686
+ ${planted.length} LOOK-ALIKE ADDRESS(ES) PLANTED IN THIS HISTORY — NEVER COPY AN ADDRESS FROM HERE</h3>
687
+ <table class="grid"><thead><tr><th>Look-alike</th><th>Imitates</th><th>Match</th>
688
+ <th style="text-align:right">Bait</th><th>First seen (UTC)</th></tr></thead><tbody>`+
689
+ planted.map(p=>`<tr><td>${addr(p.lookalike)}</td><td>${addr(p.genuine)}</td>
690
+ <td>${esc(p.resemblance)} (${p.matching.prefix}+${p.matching.suffix})</td>
691
+ <td class="num">${p.bait_transfers}</td><td>${dstamp(p.lookalike_first_seen)}</td></tr>`).join('')+
692
+ '</tbody></table>';
693
+ }
694
+ el.innerHTML=h||'<div class="muted">No look-alike addresses or poisoning pattern in this history.</div>';
695
+ }catch(e){ el.innerHTML=`<div class="err">${esc(e.message)}</div>`; }
696
+ }
697
+
698
+ // The case file: the same one-page HTML `crypttrace report` writes, built by the
699
+ // backend and handed to the browser as a download.
700
+ async function downloadCaseFile(){
701
+ const a=$('#addr').value.trim(), b=$('#casefile');
702
+ if(!a){ setStatus('enter an address',true); return; }
703
+ b.disabled=true; setStatus('building the case file…',false,true);
704
+ try{
705
+ const r=await fetch(`/api/report?address=${q(a)}&chain=${chain()}&asset=${q($('#asset').value)}&depth=${q($('#depth').value)}`);
706
+ if(!r.ok){ const j=await r.json().catch(()=>({})); throw new Error(j.error||`HTTP ${r.status}`); }
707
+ const name=((r.headers.get('Content-Disposition')||'').match(/filename="([^"]+)"/)||[])[1]||'case-file.html';
708
+ const u=URL.createObjectURL(await r.blob()), l=document.createElement('a');
709
+ l.href=u; l.download=name; l.click(); URL.revokeObjectURL(u);
710
+ setStatus(`case file saved: ${name}`);
711
+ }catch(e){ setStatus(e.message,true); }
712
+ finally{ b.disabled=false; }
713
+ }
714
+ $('#casefile').addEventListener('click',downloadCaseFile);
715
+
650
716
  function downloadCsv(){
651
717
  const head=['address','hop',`amount_sent_${victimSym}`,'transactions','first_seen_utc','last_seen_utc','label','explorer'];
652
718
  const body=victimRows.map(r=>[r.address,r.hop,r.value,r.txs,dstamp(r.first_seen),dstamp(r.last_seen),
@@ -10,7 +10,7 @@ Launch with: crypttrace serve (then open http://127.0.0.1:8000)
10
10
  from datetime import datetime
11
11
  from pathlib import Path
12
12
 
13
- from flask import Flask, jsonify, request, send_from_directory
13
+ from flask import Flask, Response, jsonify, request, send_from_directory
14
14
 
15
15
  from crypttrace.fetchers import etherscan
16
16
  from crypttrace.labels import labels
@@ -255,6 +255,40 @@ def create_app() -> Flask:
255
255
  return jsonify({"error": str(e)}), 400
256
256
  return jsonify({"hops": hops})
257
257
 
258
+ @app.route("/api/poisoning")
259
+ def api_poisoning():
260
+ addr = request.args.get("address", "")
261
+ chain = request.args.get("chain", "eth")
262
+ bad = validate(addr, chain)
263
+ if bad:
264
+ return jsonify({"error": bad}), 400
265
+ from crypttrace import poisoning
266
+ try:
267
+ return jsonify({"lookalikes": poisoning.lookalikes(addr, chain),
268
+ "lured": poisoning.baited_payments(addr, chain),
269
+ "campaign": poisoning.campaign(addr, chain)})
270
+ except _KNOWN_ERRORS as e:
271
+ return jsonify({"error": str(e)}), 400
272
+
273
+ @app.route("/api/report")
274
+ def api_report():
275
+ """The HTML case file, built in memory and handed over as a download."""
276
+ addr = request.args.get("address", "")
277
+ chain = request.args.get("chain", "eth")
278
+ bad = validate(addr, chain)
279
+ if bad:
280
+ return jsonify({"error": bad}), 400
281
+ from crypttrace import report as report_mod
282
+ try:
283
+ asset = assets.resolve_asset(request.args.get("asset") or None, chain)
284
+ depth = max(1, min(5, int(request.args.get("depth", 3))))
285
+ data, raw, digest = report_mod.build(addr, chain, depth, 3, asset)
286
+ except _KNOWN_ERRORS as e:
287
+ return jsonify({"error": str(e)}), 400
288
+ name = report_mod.case_name(addr, chain) + ".html"
289
+ return Response(report_mod.render_html(data, raw, digest, None), mimetype="text/html",
290
+ headers={"Content-Disposition": f'attachment; filename="{name}"'})
291
+
258
292
  @app.route("/api/freeze")
259
293
  def api_freeze():
260
294
  addr = request.args.get("address", "")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crypttrace
3
- Version: 0.7.0
3
+ Version: 0.8.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
@@ -131,7 +131,11 @@ crypttrace serve # then open http://127.0.0.1:8000
131
131
  Paste an address, pick a chain and direction, and get an interactive fund-flow
132
132
  graph: nodes coloured by what they are, arrows showing where value went, click a
133
133
  node for its full profile, click a transfer line to open it on the block
134
- explorer. A side panel shows the profile, first-funder chain and off-ramp check.
134
+ explorer. A side panel shows the profile, first-funder chain, off-ramp check
135
+ and whether USDT/USDC at the address is frozen. Tabs add the assessment, the
136
+ timeline, the list of who sent funds here, and address poisoning (look-alikes
137
+ planted in the wallet's history, and payments lured by this address). **Case
138
+ file** downloads the same one-page HTML report `crypttrace report` writes.
135
139
  Everything runs on your machine — nothing is uploaded anywhere.
136
140
 
137
141
  This exists so non-technical victims can use the tool at all: a form and a
File without changes
File without changes
File without changes