diff --git a/PR_ORACLEVISION.md b/PR_ORACLEVISION.md new file mode 100644 index 0000000..fe86a07 --- /dev/null +++ b/PR_ORACLEVISION.md @@ -0,0 +1,78 @@ +# Add OracleVision integration: BIP-110 spam detection and block template analysis + +## Motivation + +Sovereign Bitcoin node operators — especially those running **Bitcoin Knots** with **BIP-110** (`reduced_data`) policy — need local visibility into L1 spam and consensus-rule violations. Third-party block explorers and dashboards require trust. PyBLOCK already talks to `bitcoin-cli`; this PR adds **Don't Trust, Verify** tooling so operators can audit blocks and mempool composition from their own node. + +## What was added + +### New module: `pybitblock/oraclevision/` + +A self-contained, zero-extra-dependency analysis engine ported from [OracleVision](https://github.com/MarcanoFilms/oraculovision): + +| File | Purpose | +|------|---------| +| `script_parser.py` | BIP-110 size limits, witness/script parsing, inscription & token heuristics | +| `bip110.py` | Per-transaction and per-block BIP-110 rule checks | +| `spam_score.py` | 0–100 spam score and CLEAN/SUSPICIOUS/VIOLATION classification | +| `mempool_compose.py` | `getblocktemplate` transaction categorization (economic / consolidation / coinjoin / spam) | +| `bitcoin_cli.py` | Thin `bitcoin-cli` wrapper using PyBLOCK's existing config | +| `config.py` | Settings loader (`config/oraclevision.conf`) | +| `ui.py` | Terminal menus matching PyBLOCK's Rich/cyberpunk aesthetic | + +### Menu integration + +- **Bitcoin → OV. OracleVision** in the MONITORING section +- Submenu: + - **A.** BIP-110 Block Scanner (recent blocks table) + - **B.** Mempool Glass (`getblocktemplate` categorization) + - **C.** Block Detail View (height or hash) + - **D.** Launch Full OracleVision TUI (if installed) + +### Configuration + +- `pybitblock/config/oraclevision.conf.example` — scan count, spam threshold, datadir, TUI command +- Environment overrides for Docker/Umbrel deployments + +### Documentation + +- README section explaining built-in vs. full OracleVision, configuration, and how to extend detection logic + +## Relationship to OracleVision + +This PR does **not** port the full Textual dashboard into PyBLOCK. Instead: + +1. **Built-in tools** give immediate value inside PyBLOCK's existing menu-driven workflow +2. **Launch option** promotes the standalone [OracleVision](https://github.com/MarcanoFilms/oraculovision) project for operators who want DATUM mining panels, Ocean account stats, live charts, and the full rich TUI + +The detection logic is shared in spirit with OracleVision and designed to be maintained in one place (`pybitblock/oraclevision/`) so the community can improve heuristics via PRs without touching UI code. + +## Design principles + +- **Low dependencies** — uses only `bitcoin-cli` (same as PyBLOCK) and existing Rich UI +- **Modular** — detection rules separated from terminal presentation +- **Community-extensible** — documented module boundaries for new BIP-110 checks and spam heuristics +- **Knots + BIP-110 aligned** — version bit 4 signaling, reduced_data rule checks, local verification framing + +## Testing notes + +1. Requires a synced Knots/Core node with RPC enabled +2. `getblocktemplate` needs mining RPC capability (standard on most node setups) +3. Block scanner needs `getblock` verbosity 2 (decoded transactions) +4. Full TUI launch requires separate OracleVision installation + +```bash +# Quick import check +cd pybitblock && python3 -c "from oraclevision.bip110 import analyze_block; print('ok')" + +# Manual test path +python3 PyBlock.py +# → B. Bitcoin → OV. OracleVision → A/B/C +``` + +## Files changed + +- `pybitblock/oraclevision/` (new package, 7 files) +- `pybitblock/config/oraclevision.conf.example` (new) +- `pybitblock/PyBlock.py` (menu entry + handler) +- `README.md` (OracleVision section) \ No newline at end of file diff --git a/README.md b/README.md index 13c5c97..8248e53 100644 --- a/README.md +++ b/README.md @@ -270,7 +270,63 @@ ## How to execute - python3 PyBlock.py - + +## OracleVision Integration (BIP-110 & Mempool Analysis) + +PyBLOCK includes a lightweight integration with [OracleVision](https://github.com/MarcanoFilms/oraculovision) for sovereign node operators running **Bitcoin Knots** with **BIP-110** policy enabled. Philosophy: **Don't Trust, Verify** — all analysis runs locally against your node via `bitcoin-cli`. + +### Built-in features (Bitcoin → OV. OracleVision) + +| Option | What it does | +|--------|----------------| +| **BIP-110 Block Scanner** | Scans recent blocks for consensus violations, spam score (0–100), and status (CLEAN / SUSPICIOUS / VIOLATION) | +| **Mempool Glass** | Categorizes your node's current `getblocktemplate` into economic, consolidation, coinjoin, and spam buckets | +| **Block Detail View** | Deep-dive into a single block: miner tag, witness %, violation flags, problematic transactions | +| **Launch Full OracleVision** | Opens the standalone Textual TUI if installed (DATUM mining, Ocean panels, live charts) | + +### Configuration + +Copy the example config and adjust for your node: + +```bash +cp pybitblock/config/oraclevision.conf.example pybitblock/config/oraclevision.conf +``` + +| Setting | Default | Description | +|---------|---------|-------------| +| `block_scan_count` | 10 | How many recent blocks to scan | +| `spam_score_threshold` | 45 | Score above this marks a block as VIOLATION | +| `bitcoin_datadir` | `""` | Optional `-datadir` for bitcoin-cli | +| `oraculovision_command` | `oraculovision` | Command to launch the full TUI | + +Environment overrides: `ORACULOVISION_BLOCK_SCAN_COUNT`, `ORACULOVISION_SPAM_THRESHOLD`, `ORACULOVISION_COMMAND`, `BITCOIN_DATADIR`. + +### Full OracleVision TUI (recommended for power users) + +The built-in tools cover the essentials. For the complete dashboard — DATUM solo mining panel, Ocean account stats, live mempool charts, and navigable BIP-110 tables — install the standalone project: + +```bash +git clone https://github.com/MarcanoFilms/oraculovision.git +cd oraculovision +python -m venv .venv +source .venv/bin/activate +pip install -e . +oraculovision +``` + +From PyBLOCK, use **Bitcoin → OV. OracleVision → D. Launch Full OracleVision TUI**. + +### Extending detection logic + +The analysis engine lives in `pybitblock/oraclevision/` and is intentionally modular: + +- `script_parser.py` — BIP-110 size limits and witness/script parsing +- `bip110.py` — per-transaction and per-block rule checks +- `spam_score.py` — heuristic scoring (community-tunable weights) +- `mempool_compose.py` — block template categorization + +Pull requests that improve heuristics or add new violation rules are welcome. Keep UI code in `oraclevision/ui.py` separate from detection logic. + ## Running PyBLOCK using Docker diff --git a/pybitblock/PyBlock.py b/pybitblock/PyBlock.py index 922c024..67a2db8 100644 --- a/pybitblock/PyBlock.py +++ b/pybitblock/PyBlock.py @@ -2060,6 +2060,8 @@ def bitcoincoremenuLocal(mode): #Unified Bitcoin Core menu for local/onchain_onl col2.append("Mempool Monitor\n", style="white") col2.append(" K. ", style="bold cyan") col2.append("Peers Monitor\n", style="white") + col2.append(" OV. ", style="bold cyan") + col2.append("OracleVision\n", style="white") # Tools section col3 = RText() @@ -6625,6 +6627,14 @@ def bitcoincoremenuLocalControl(bcore, mode=None): #Unified Bitcoin Core local c print(output) subprocess.run(["python3", "PyVanityGenerator.py"], cwd="SPV") input("\a\nContinue...") + elif bcore in ["OV", "ov"]: + try: + pathexec() + from oraclevision.ui import run_oraclevision_menu + run_oraclevision_menu(path) + except Exception as e: + show_error(str(e)) + logger.debug("Suppressed error: %s", e) else: if bcore.strip(): from shared.ui import YELLOW, RESET diff --git a/pybitblock/config/oraclevision.conf.example b/pybitblock/config/oraclevision.conf.example new file mode 100644 index 0000000..ae7bfd1 --- /dev/null +++ b/pybitblock/config/oraclevision.conf.example @@ -0,0 +1,7 @@ +{ + "block_scan_count": 10, + "spam_score_threshold": 45, + "bitcoin_datadir": "", + "oraculovision_command": "oraculovision", + "cli_timeout_seconds": 60 +} \ No newline at end of file diff --git a/pybitblock/oraclevision/__init__.py b/pybitblock/oraclevision/__init__.py new file mode 100644 index 0000000..2fdc5be --- /dev/null +++ b/pybitblock/oraclevision/__init__.py @@ -0,0 +1,22 @@ +""" +OracleVision analysis integration for PyBLOCK. + +Lightweight BIP-110 and mempool composition tooling for sovereign node +operators. Core detection logic is ported from OracleVision and kept +modular so the community can extend heuristics without touching the UI. + +Upstream: https://github.com/MarcanoFilms/oraculovision +""" + +from oraclevision.bip110 import BlockAnalysis, TxAnalysis, analyze_block, analyze_transaction +from oraclevision.mempool_compose import MempoolComposition, analyze_block_template, categorize_transaction + +__all__ = [ + "BlockAnalysis", + "TxAnalysis", + "MempoolComposition", + "analyze_block", + "analyze_transaction", + "analyze_block_template", + "categorize_transaction", +] \ No newline at end of file diff --git a/pybitblock/oraclevision/bip110.py b/pybitblock/oraclevision/bip110.py new file mode 100644 index 0000000..ae87c68 --- /dev/null +++ b/pybitblock/oraclevision/bip110.py @@ -0,0 +1,320 @@ +""" +BIP-110 block/transaction analysis engine. + +Checks reduced_data policy rules locally against decoded block data. +Extend _check_witness_rules() and analyze_transaction() for new rules. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from oraclevision.script_parser import ( + MAX_CONTROL_BLOCK_SIZE, + MAX_OPRETURN_SIZE, + MAX_PUSHDATA_SIZE, + MAX_SCRIPTPUBKEY_SIZE, + decode_coinbase_tag, + detect_inscription_in_witness, + detect_token_patterns, + has_annex, + infer_taproot_script_path, + is_op_return, + is_signaling_bip110, + is_valid_taproot_control_block, + scan_tapscript_violations, + script_has_large_push, + vout_script_size, + witness_total_bytes, +) +from oraclevision.spam_score import classify_status, compute_spam_score + + +@dataclass +class TxAnalysis: + txid: str + weight: int + vsize: int + bip110_flags: set[str] = field(default_factory=set) + signals: set[str] = field(default_factory=set) + witness_bytes: int = 0 + + @property + def has_bip110_violation(self) -> bool: + return bool(self.bip110_flags) + + @property + def is_spam_signal(self) -> bool: + return bool(self.signals - {"op_return"}) + + +@dataclass +class BlockAnalysis: + height: int + hash: str + miner_tag: str + version: int + weight: int + tx_count: int + bip110_signaling: bool + spam_score: int = 0 + status: str = "CLEAN" + violation_count: int = 0 + violation_weight: int = 0 + inscription_count: int = 0 + brc20_count: int = 0 + runes_count: int = 0 + op_return_count: int = 0 + large_witness_bytes: int = 0 + witness_pct: float = 0.0 + transactions: list[TxAnalysis] = field(default_factory=list) + + +def _prevout_type(vin: dict) -> str | None: + if isinstance(vin.get("prevout"), dict): + spk = vin["prevout"].get("scriptPubKey", {}) + return spk.get("type") + return None + + +def _check_witness_rules(vin: dict) -> set[str]: + flags: set[str] = set() + witness: list[str] = vin.get("txinwitness") or vin.get("witness") or [] + if not witness: + return flags + + annex = has_annex(witness) + prevout_type = _prevout_type(vin) + is_taproot_prevout = prevout_type == "witness_v1_taproot" or prevout_type == "v1_p2tr" + is_script_path = ( + (is_taproot_prevout and len(witness) > (2 if annex else 1)) + or infer_taproot_script_path(witness) + ) + + if annex and (is_taproot_prevout or is_script_path): + flags.add("taproot_annex") + + exempt: set[int] = set() + executing_scripts: list[int] = [] + + if annex: + exempt.add(len(witness) - 1) + + if is_script_path: + cb_idx = len(witness) - (2 if annex else 1) + tap_idx = cb_idx - 1 + exempt.add(cb_idx) + if tap_idx >= 0: + exempt.add(tap_idx) + executing_scripts.append(tap_idx) + elif is_taproot_prevout: + sig_idx = len(witness) - 1 - (1 if annex else 0) + if sig_idx >= 0: + exempt.add(sig_idx) + elif prevout_type in ("witness_v0_scripthash", "v0_p2wsh", "scripthash", "p2sh") or prevout_type is None: + ws_idx = len(witness) - 1 - (1 if annex else 0) + if ws_idx >= 0: + exempt.add(ws_idx) + executing_scripts.append(ws_idx) + + for i, item in enumerate(witness): + if i in exempt: + continue + if len(item) // 2 > MAX_PUSHDATA_SIZE: + flags.add("large_pushdata") + break + + if is_script_path: + cb_idx = len(witness) - (2 if annex else 1) + cb = witness[cb_idx] + if len(cb) // 2 > MAX_CONTROL_BLOCK_SIZE: + flags.add("large_control_block") + if is_valid_taproot_control_block(cb): + leaf_version = int(cb[:2], 16) & 0xFE + if leaf_version != 0xC0: + flags.add("undefined_witness") + tap_idx = cb_idx - 1 + if tap_idx >= 0: + tapscript = witness[tap_idx] + op_success, op_if = scan_tapscript_violations(tapscript) + if op_success: + flags.add("op_success") + if op_if: + flags.add("op_if_notif") + + if "large_pushdata" not in flags: + for idx in executing_scripts: + if script_has_large_push(witness[idx]): + flags.add("large_pushdata") + break + + return flags + + +def _check_scriptsig_rules(vin: dict) -> set[str]: + flags: set[str] = set() + scriptsig = vin.get("scriptSig", {}) + hex_sig = scriptsig.get("hex", "") if isinstance(scriptsig, dict) else "" + if not hex_sig: + return flags + + asm = scriptsig.get("asm", "") if isinstance(scriptsig, dict) else "" + if asm: + parts = asm.split() + prevout_type = _prevout_type(vin) + redeem_idx = len(parts) - 1 if prevout_type in ("scripthash", "p2sh") else -1 + for i, part in enumerate(parts): + if part.startswith("OP_"): + continue + if i == redeem_idx: + if script_has_large_push(part): + flags.add("large_pushdata") + continue + if len(part) // 2 > MAX_PUSHDATA_SIZE: + flags.add("large_pushdata") + break + elif script_has_large_push(hex_sig): + flags.add("large_pushdata") + return flags + + +def analyze_transaction(tx: dict[str, Any]) -> TxAnalysis: + txid = tx.get("txid", tx.get("hash", "")) + weight = int(tx.get("weight") or (tx.get("vsize", 0) * 4)) + vsize = int(tx.get("vsize") or weight // 4) + + analysis = TxAnalysis(txid=txid, weight=weight, vsize=vsize) + bip110: set[str] = set() + signals: set[str] = set() + + for vout in tx.get("vout", []): + size = vout_script_size(vout) + if is_op_return(vout): + signals.add("op_return") + if size > MAX_OPRETURN_SIZE: + bip110.add("large_scriptpubkey") + elif size > MAX_SCRIPTPUBKEY_SIZE: + bip110.add("large_scriptpubkey") + + witness_bytes = 0 + all_hex = txid + + for vin in tx.get("vin", []): + if vin.get("coinbase"): + continue + witness: list[str] = vin.get("txinwitness") or vin.get("witness") or [] + witness_bytes += witness_total_bytes(witness) + all_hex += "".join(witness) + + bip110 |= _check_witness_rules(vin) + bip110 |= _check_scriptsig_rules(vin) + + if detect_inscription_in_witness(witness): + signals.add("inscription") + + scriptsig = vin.get("scriptSig", {}) + if isinstance(scriptsig, dict): + all_hex += scriptsig.get("hex", "") + + for vout in tx.get("vout", []): + spk = vout.get("scriptPubKey", {}) + all_hex += spk.get("hex", "") + + token_hits = detect_token_patterns(all_hex) + signals |= token_hits + + analysis.bip110_flags = bip110 + analysis.signals = signals + analysis.witness_bytes = witness_bytes + return analysis + + +def analyze_block( + block: dict[str, Any], + *, + spam_threshold: int = 45, +) -> BlockAnalysis: + height = int(block.get("height", 0)) + block_hash = block.get("hash", "") + version = int(block.get("version", 0)) + weight = int(block.get("weight") or 0) + + txs = block.get("tx", []) + if txs and isinstance(txs[0], str): + return BlockAnalysis( + height=height, + hash=block_hash, + miner_tag="?", + version=version, + weight=weight, + tx_count=len(txs), + bip110_signaling=is_signaling_bip110(version), + ) + + miner_tag = "unknown" + tx_analyses: list[TxAnalysis] = [] + total_witness = 0 + + for tx in txs: + if not isinstance(tx, dict): + continue + vin0 = tx.get("vin", [{}])[0] + if vin0.get("coinbase"): + miner_tag = decode_coinbase_tag(vin0["coinbase"]) + continue + ta = analyze_transaction(tx) + tx_analyses.append(ta) + total_witness += ta.witness_bytes + + violation_count = sum(1 for t in tx_analyses if t.has_bip110_violation) + violation_weight = sum(t.weight for t in tx_analyses if t.has_bip110_violation) + inscription_count = sum(1 for t in tx_analyses if "inscription" in t.signals) + brc20_count = sum(1 for t in tx_analyses if "brc20" in t.signals) + runes_count = sum(1 for t in tx_analyses if "runes" in t.signals) + op_return_count = sum(1 for t in tx_analyses if "op_return" in t.signals) + + large_witness_bytes = sum( + t.witness_bytes for t in tx_analyses if t.witness_bytes > MAX_PUSHDATA_SIZE + ) + + spam_score = compute_spam_score( + block_weight=weight or 1, + total_txs=len(tx_analyses), + violation_weight=violation_weight, + inscription_count=inscription_count, + brc20_count=brc20_count, + runes_count=runes_count, + op_return_count=op_return_count, + large_witness_bytes=large_witness_bytes, + violation_count=violation_count, + ) + status = classify_status( + spam_score, + violation_count, + violation_weight, + weight or 1, + spam_threshold=spam_threshold, + ) + witness_pct = (total_witness / max(weight, 1)) * 100 if weight else 0.0 + + return BlockAnalysis( + height=height, + hash=block_hash, + miner_tag=miner_tag, + version=version, + weight=weight, + tx_count=len(tx_analyses), + bip110_signaling=is_signaling_bip110(version), + spam_score=spam_score, + status=status, + violation_count=violation_count, + violation_weight=violation_weight, + inscription_count=inscription_count, + brc20_count=brc20_count, + runes_count=runes_count, + op_return_count=op_return_count, + large_witness_bytes=large_witness_bytes, + witness_pct=witness_pct, + transactions=tx_analyses, + ) \ No newline at end of file diff --git a/pybitblock/oraclevision/bitcoin_cli.py b/pybitblock/oraclevision/bitcoin_cli.py new file mode 100644 index 0000000..d95fa52 --- /dev/null +++ b/pybitblock/oraclevision/bitcoin_cli.py @@ -0,0 +1,126 @@ +""" +bitcoin-cli wrapper for OracleVision analysis inside PyBLOCK. + +Uses the same bitcoin-cli path configured in bclock.conf. No extra deps. +""" + +from __future__ import annotations + +import json +import os +import subprocess +from typing import Any + +from oraclevision.security import resolve_bitcoin_cli, validate_rpc_method, validate_safe_path_token + + +class BitcoinCLIError(Exception): + """Raised when bitcoin-cli fails or is unavailable.""" + + def __init__(self, message: str, *, hint: str | None = None) -> None: + self.hint = hint + super().__init__(message) + + +class BitcoinCLI: + """Thin wrapper around bitcoin-cli JSON-RPC for analysis commands.""" + + def __init__( + self, + cli_path: str, + datadir: str | None = None, + timeout: float = 60.0, + ) -> None: + try: + self.cli_path = resolve_bitcoin_cli(cli_path or os.environ.get("BITCOIN_CLI", "bitcoin-cli")) + self.datadir = validate_safe_path_token( + datadir or os.environ.get("BITCOIN_DATADIR") or "", + name="datadir", + allow_empty=True, + ) + except ValueError as exc: + raise BitcoinCLIError(str(exc)) from exc + self.timeout = timeout + + def _base_cmd(self) -> list[str]: + cmd = [self.cli_path] + if self.datadir: + cmd.extend(["-datadir", self.datadir]) + return cmd + + def call(self, method: str, *params: Any) -> Any: + try: + method = validate_rpc_method(method) + except ValueError as exc: + raise BitcoinCLIError(str(exc)) from exc + + cmd = self._base_cmd() + [method] + for param in params: + if isinstance(param, (dict, list)): + cmd.append(json.dumps(param)) + elif isinstance(param, bool): + cmd.append("true" if param else "false") + else: + cmd.append(str(param)) + + try: + # nosemgrep: python.lang.security.audit.dangerous-subprocess-use-audit + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=self.timeout, + check=False, + ) + except subprocess.TimeoutExpired as exc: + raise BitcoinCLIError( + f"Timeout calling {method} ({self.timeout}s)", + hint="The node may be busy or unresponsive.", + ) from exc + except FileNotFoundError as exc: + raise BitcoinCLIError( + f"bitcoin-cli not found: {self.cli_path}", + hint="Check your Knots/Core installation.", + ) from exc + + if result.returncode != 0: + stderr = (result.stderr or result.stdout or "").strip() + hint = None + lower = stderr.lower() + if "could not connect" in lower or "connection refused" in lower: + hint = "Start bitcoind/knots and check RPC (bitcoin.conf)." + elif "verifying blocks" in lower or "initial block download" in lower: + hint = "Node still syncing. Wait for IBD to finish." + elif "not available" in lower and method == "getblocktemplate": + hint = "Enable mining RPC or use a node that supports getblocktemplate." + raise BitcoinCLIError(stderr or f"Error in {method}", hint=hint) + + stdout = result.stdout.strip() + if not stdout: + return None + try: + return json.loads(stdout) + except json.JSONDecodeError: + return stdout + + def get_block_count(self) -> int: + return int(self.call("getblockcount")) + + def get_block_hash(self, height: int) -> str: + return str(self.call("getblockhash", height)) + + def get_block(self, block_hash: str, verbosity: int = 2) -> dict[str, Any]: + return self.call("getblock", block_hash, verbosity) + + def get_mempool_info(self) -> dict[str, Any]: + return self.call("getmempoolinfo") + + def get_block_template(self) -> dict[str, Any]: + return self.call("getblocktemplate", {"rules": ["segwit"]}) + + def decode_raw_transaction(self, hex_data: str) -> dict[str, Any]: + return self.call("decoderawtransaction", hex_data) + + @classmethod + def from_path_config(cls, path: dict[str, str], datadir: str = "", timeout: float = 60.0) -> "BitcoinCLI": + return cls(path.get("bitcoincli", "bitcoin-cli"), datadir=datadir, timeout=timeout) \ No newline at end of file diff --git a/pybitblock/oraclevision/config.py b/pybitblock/oraclevision/config.py new file mode 100644 index 0000000..d46c015 --- /dev/null +++ b/pybitblock/oraclevision/config.py @@ -0,0 +1,107 @@ +""" +OracleVision settings for PyBLOCK. + +Stored in config/oraclevision.conf (JSON). Environment overrides: + ORACULOVISION_BLOCK_SCAN_COUNT + ORACULOVISION_SPAM_THRESHOLD + ORACULOVISION_COMMAND + BITCOIN_DATADIR +""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass + +from config import cfg +from oraclevision.security import validate_safe_path_token + + +_DEFAULTS = { + "block_scan_count": 10, + "spam_score_threshold": 45, + "bitcoin_datadir": "", + "oraculovision_command": "oraculovision", + "cli_timeout_seconds": 60, +} + + +@dataclass +class OracleVisionSettings: + block_scan_count: int = 10 + spam_score_threshold: int = 45 + bitcoin_datadir: str = "" + oraculovision_command: str = "oraculovision" + cli_timeout_seconds: int = 60 + load_error: str | None = None + + +def _safe_int(value: object, default: int, *, field: str, errors: list[str]) -> int: + try: + return int(value) # type: ignore[arg-type] + except (TypeError, ValueError): + errors.append(f"Invalid {field}; using default {default}") + return default + + +def load_settings() -> OracleVisionSettings: + """Load OracleVision config, merging defaults, file, and env vars.""" + data = dict(_DEFAULTS) + errors: list[str] = [] + filepath = os.path.join(cfg.config_dir, "oraclevision.conf") + + if os.path.isfile(filepath): + try: + with open(filepath, "r", encoding="utf-8") as f: + file_data = json.load(f) + if isinstance(file_data, dict): + data.update(file_data) + else: + errors.append("oraclevision.conf must be a JSON object; using defaults") + except json.JSONDecodeError as exc: + errors.append(f"Invalid JSON in oraclevision.conf: {exc}") + except OSError as exc: + errors.append(f"Could not read oraclevision.conf: {exc}") + + if env_count := os.environ.get("ORACULOVISION_BLOCK_SCAN_COUNT"): + data["block_scan_count"] = _safe_int(env_count, data["block_scan_count"], field="block_scan_count", errors=errors) + if env_threshold := os.environ.get("ORACULOVISION_SPAM_THRESHOLD"): + data["spam_score_threshold"] = _safe_int( + env_threshold, data["spam_score_threshold"], field="spam_score_threshold", errors=errors + ) + if env_cmd := os.environ.get("ORACULOVISION_COMMAND"): + data["oraculovision_command"] = env_cmd + if env_datadir := os.environ.get("BITCOIN_DATADIR"): + data["bitcoin_datadir"] = env_datadir + + bitcoin_datadir = "" + try: + bitcoin_datadir = validate_safe_path_token( + str(data.get("bitcoin_datadir", "")), name="bitcoin_datadir", allow_empty=True + ) + except ValueError as exc: + errors.append(str(exc)) + bitcoin_datadir = "" + + oraculovision_command = str(data.get("oraculovision_command", "oraculovision")) + try: + validate_safe_path_token(oraculovision_command, name="oraculovision_command", allow_empty=False) + except ValueError as exc: + errors.append(str(exc)) + oraculovision_command = _DEFAULTS["oraculovision_command"] + + return OracleVisionSettings( + block_scan_count=_safe_int( + data.get("block_scan_count", 10), 10, field="block_scan_count", errors=errors + ), + spam_score_threshold=_safe_int( + data.get("spam_score_threshold", 45), 45, field="spam_score_threshold", errors=errors + ), + bitcoin_datadir=bitcoin_datadir, + oraculovision_command=oraculovision_command, + cli_timeout_seconds=_safe_int( + data.get("cli_timeout_seconds", 60), 60, field="cli_timeout_seconds", errors=errors + ), + load_error="; ".join(errors) if errors else None, + ) \ No newline at end of file diff --git a/pybitblock/oraclevision/mempool_compose.py b/pybitblock/oraclevision/mempool_compose.py new file mode 100644 index 0000000..5796a79 --- /dev/null +++ b/pybitblock/oraclevision/mempool_compose.py @@ -0,0 +1,166 @@ +""" +Block template composition analysis for Mempool Glass. + +Classifies transactions from getblocktemplate into economic, consolidation, +coinjoin, and spam buckets. Extend categorize_transaction() to add categories. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable + +from oraclevision.bip110 import analyze_transaction +from oraclevision.script_parser import MAX_PUSHDATA_SIZE, witness_total_bytes + + +@dataclass +class MempoolComposition: + """Composition stats derived from the node's block template.""" + + total_tx: int = 0 + total_weight: int = 0 + analyzed_tx: int = 0 + analyzed_weight: int = 0 + economic_weight: int = 0 + consolidation_weight: int = 0 + coinjoin_weight: int = 0 + spam_weight: int = 0 + economic_count: int = 0 + consolidation_count: int = 0 + coinjoin_count: int = 0 + spam_count: int = 0 + template_height: int = 0 + weight_limit: int = 4_000_000 + fill_pct: float = 0.0 + mempool_size: int = 0 + source: str = "block_template" + error: str | None = None + + def pct(self, weight: int) -> float: + base = self.analyzed_weight or 1 + return (weight / base) * 100 + + +def _witness_has_oversized_item(tx: dict[str, Any]) -> bool: + for vin in tx.get("vin", []): + witness = vin.get("txinwitness") or vin.get("witness") or [] + for item in witness: + if len(item) // 2 > MAX_PUSHDATA_SIZE: + return True + return False + + +def _excess_witness_ratio(tx: dict[str, Any]) -> bool: + weight = int(tx.get("weight") or 1) + wbytes = 0 + for vin in tx.get("vin", []): + witness = vin.get("txinwitness") or vin.get("witness") or [] + wbytes += witness_total_bytes(witness) + return wbytes > 2000 and (wbytes / weight) > 0.45 + + +def _is_consolidation(tx: dict[str, Any]) -> bool: + vin = len(tx.get("vin", [])) + vout = len(tx.get("vout", [])) + return vout <= 2 and vin >= 5 + + +def _is_coinjoin(tx: dict[str, Any]) -> bool: + vin = tx.get("vin", []) + vout = tx.get("vout", []) + if len(vin) < 5 or len(vout) < 5: + return False + in_vals: dict[float, int] = {} + out_vals: dict[float, int] = {} + for i in vin: + v = (i.get("prevout") or {}).get("value") + if v is not None: + in_vals[v] = in_vals.get(v, 0) + 1 + for o in vout: + v = o.get("value") + if v is not None: + out_vals[v] = out_vals.get(v, 0) + 1 + if not in_vals and not out_vals: + return len(vin) >= 8 and len(vout) >= 8 and abs(len(vin) - len(vout)) <= 2 + unique = len(set(in_vals) | set(out_vals)) + total = len(vin) + len(vout) + return unique <= total // 2 + + +def _is_spam_tx(tx: dict[str, Any]) -> bool: + analysis = analyze_transaction(tx) + if analysis.has_bip110_violation or analysis.is_spam_signal: + return True + if _witness_has_oversized_item(tx): + return True + if _excess_witness_ratio(tx): + return True + return False + + +def categorize_transaction(tx: dict[str, Any]) -> str: + """Return category: spam, coinjoin, consolidation, economic.""" + if _is_spam_tx(tx): + return "spam" + if _is_coinjoin(tx): + return "coinjoin" + if _is_consolidation(tx): + return "consolidation" + return "economic" + + +def analyze_block_template( + template: dict[str, Any], + decode_tx: Callable[[str], dict[str, Any]], +) -> MempoolComposition: + """Classify all transactions in the node's current block template.""" + result = MempoolComposition() + txs = template.get("transactions", []) + result.template_height = int(template.get("height", 0)) + result.weight_limit = int(template.get("weightlimit", 4_000_000)) + result.total_tx = len(txs) + + if not txs: + result.error = "Block template is empty" + return result + + for entry in txs: + weight = int(entry.get("weight", 0)) + result.total_weight += weight + hex_data = entry.get("data", "") + if not hex_data: + continue + try: + tx = decode_tx(hex_data) + if entry.get("txid"): + tx["txid"] = entry["txid"] + if weight and not tx.get("weight"): + tx["weight"] = weight + except Exception: + continue + + result.analyzed_tx += 1 + w = int(tx.get("weight") or weight or 0) + result.analyzed_weight += w + + cat = categorize_transaction(tx) + if cat == "spam": + result.spam_weight += w + result.spam_count += 1 + elif cat == "coinjoin": + result.coinjoin_weight += w + result.coinjoin_count += 1 + elif cat == "consolidation": + result.consolidation_weight += w + result.consolidation_count += 1 + else: + result.economic_weight += w + result.economic_count += 1 + + if result.analyzed_tx == 0: + result.error = "Could not decode transactions from the template" + else: + result.fill_pct = (result.analyzed_weight / result.weight_limit * 100) if result.weight_limit else 0 + + return result \ No newline at end of file diff --git a/pybitblock/oraclevision/script_parser.py b/pybitblock/oraclevision/script_parser.py new file mode 100644 index 0000000..dab8ad4 --- /dev/null +++ b/pybitblock/oraclevision/script_parser.py @@ -0,0 +1,239 @@ +""" +Low-level Bitcoin script/witness parsing helpers. + +Ported from OracleVision (https://github.com/MarcanoFilms/oraculovision). +These functions implement BIP-110 size checks and spam heuristics. Extend +this module when adding new detection rules — keep UI code separate. +""" + +from __future__ import annotations + +import re +from typing import Iterable + +# BIP-110 size limits (reduced_data policy) +MAX_SCRIPTPUBKEY_SIZE = 34 +MAX_OPRETURN_SIZE = 83 +MAX_PUSHDATA_SIZE = 256 +MAX_CONTROL_BLOCK_SIZE = 257 + +OP_IF = 0x63 +OP_NOTIF = 0x64 +OP_FALSE = 0x00 + +_SPAM_HEX_PATTERNS = ( + b"6272632d3230", # brc-20 + b"2270223a22627263", # "p":"brc + b"7469636b", # tick + b"6f7264", # ord + b"52554e45", # RUNE + b"746578742f706c61696e", # text/plain +) + + +def hex_to_bytes(hex_str: str) -> bytes: + if not hex_str: + return b"" + try: + return bytes.fromhex(hex_str) + except ValueError: + return b"" + + +def witness_total_bytes(witness: Iterable[str] | None) -> int: + if not witness: + return 0 + return sum(len(w) // 2 for w in witness) + + +def has_annex(witness: list[str] | None) -> bool: + if not witness or len(witness) < 2: + return False + return witness[-1].startswith("50") + + +def is_valid_taproot_control_block(cb_hex: str) -> bool: + cb = hex_to_bytes(cb_hex) + if len(cb) < 33 or (len(cb) - 33) % 32 != 0: + return False + return (cb[0] & 0xFE) >= 0xC0 + + +def infer_taproot_script_path(witness: list[str]) -> bool: + """Infer taproot script-path spend from witness structure without prevout.""" + if not witness or len(witness) < 3: + return False + annex = has_annex(witness) + cb_idx = len(witness) - (2 if annex else 1) + if cb_idx < 1: + return False + return is_valid_taproot_control_block(witness[cb_idx]) + + +def script_has_large_push(script_hex: str) -> bool: + """BIP-110 Rule 2: OP_PUSHDATA payloads > 256 bytes inside executing scripts.""" + buf = hex_to_bytes(script_hex) + i = 0 + while i < len(buf): + op = buf[i] + if 0x01 <= op <= 0x4B: + header_len, data_len = 1, op + elif op == 0x4C: + if i + 2 > len(buf): + break + header_len, data_len = 2, buf[i + 1] + elif op == 0x4D: + if i + 3 > len(buf): + break + header_len, data_len = 3, int.from_bytes(buf[i + 1 : i + 3], "little") + elif op == 0x4E: + if i + 5 > len(buf): + break + header_len, data_len = 5, int.from_bytes(buf[i + 1 : i + 5], "little") + else: + i += 1 + continue + if i + header_len + data_len > len(buf): + break + if data_len > MAX_PUSHDATA_SIZE: + return True + i += header_len + data_len + return False + + +def scan_tapscript_violations(script_hex: str) -> tuple[bool, bool]: + """Return (op_success, op_if_notif) for BIP-110 rules 6 & 7.""" + buf = hex_to_bytes(script_hex) + op_success = False + op_if_notif = False + i = 0 + while i < len(buf): + op = buf[i] + if 0x01 <= op <= 0x4B: + i += 1 + op + continue + if op == 0x4C: + if i + 1 >= len(buf): + break + i += 2 + buf[i + 1] + continue + if op == 0x4D: + if i + 2 >= len(buf): + break + i += 3 + int.from_bytes(buf[i + 1 : i + 3], "little") + continue + if op == 0x4E: + if i + 4 >= len(buf): + break + i += 5 + int.from_bytes(buf[i + 1 : i + 5], "little") + continue + + if ( + op in (80, 98) + or (126 <= op <= 129) + or (131 <= op <= 134) + or (137 <= op <= 138) + or (141 <= op <= 142) + or (149 <= op <= 153) + or (187 <= op <= 254) + ): + op_success = True + elif op in (OP_IF, OP_NOTIF): + op_if_notif = True + + i += 1 + if op_success and op_if_notif: + break + return op_success, op_if_notif + + +def has_op_false_op_if_envelope(script_hex: str) -> bool: + """Detect Ordinals inscription envelope: OP_FALSE ... OP_IF.""" + buf = hex_to_bytes(script_hex) + if len(buf) < 3: + return False + for i in range(len(buf) - 1): + if buf[i] == OP_FALSE and buf[i + 1] == OP_IF: + return True + return False + + +def detect_inscription_in_witness(witness: list[str] | None) -> bool: + if not witness: + return False + annex = has_annex(witness) + if len(witness) > (2 if annex else 1): + script_idx = len(witness) - (3 if annex else 2) + if script_idx >= 0: + tapscript = witness[script_idx] + if has_op_false_op_if_envelope(tapscript): + return True + for item in witness: + if has_op_false_op_if_envelope(item): + return True + return False + + +def detect_token_patterns(hex_blob: str) -> set[str]: + """Heuristic detection of BRC-20, Runes, Ordinals content in hex.""" + found: set[str] = set() + raw = hex_to_bytes(hex_blob) + ascii_text = raw.decode("ascii", errors="ignore").lower() + + if b"6272632d3230" in raw or b'"p":"brc-20"' in raw or b'"p": "brc-20"' in raw: + found.add("brc20") + if b"7469636b" in raw and (b"627263" in raw or b"6f7264" in raw): + found.add("brc20") + if b"52554e45" in raw: + found.add("runes") + if b"6f7264" in raw or b"746578742f706c61696e" in raw: + found.add("ordinals") + if "ord" in ascii_text or "inscription" in ascii_text: + found.add("ordinals") + + for pat in _SPAM_HEX_PATTERNS: + if pat in raw: + if pat == b"52554e45": + found.add("runes") + elif pat in (b"6272632d3230", b"2270223a22627263", b"7469636b"): + found.add("brc20") + else: + found.add("ordinals") + return found + + +def decode_coinbase_tag(coinbase_hex: str) -> str: + """Extract readable miner/pool tag from coinbase hex.""" + raw = hex_to_bytes(coinbase_hex) + if len(raw) < 4: + return "unknown" + + text = raw.decode("ascii", errors="ignore") + runs = re.findall(r"[\x20-\x7e]{4,}", text) + if not runs: + return "unknown" + + pool_runs = [r.strip() for r in runs if "/" in r and len(r.strip()) >= 5] + if pool_runs: + return max(pool_runs, key=len)[:40] + + candidates = [r.strip() for r in runs if len(r.strip()) >= 6] + if candidates: + return max(candidates, key=len)[:40] + return runs[-1].strip()[:40] or "unknown" + + +def is_signaling_bip110(version: int) -> bool: + """BIP-110 reduced_data uses version bit 4.""" + return bool(version & (1 << 4)) + + +def vout_script_size(vout: dict) -> int: + spk = vout.get("scriptPubKey", {}) + hex_data = spk.get("hex", "") + return len(hex_data) // 2 + + +def is_op_return(vout: dict) -> bool: + spk = vout.get("scriptPubKey", {}) + return spk.get("type") == "nulldata" or spk.get("asm", "").startswith("OP_RETURN") \ No newline at end of file diff --git a/pybitblock/oraclevision/security.py b/pybitblock/oraclevision/security.py new file mode 100644 index 0000000..bae2c48 --- /dev/null +++ b/pybitblock/oraclevision/security.py @@ -0,0 +1,74 @@ +""" +Input validation helpers for OracleVision subprocess and config paths. + +Prevents command injection when launching external binaries configured by +the node operator (bitcoin-cli path, oraculovision command, datadir). +""" + +from __future__ import annotations + +import os +import re +import shlex +import shutil + +_SHELL_META = re.compile(r"[;|&$`<>\"'\n\\]") +_RPC_METHOD = re.compile(r"^[a-z][a-z0-9_]*$", re.I) + + +def validate_safe_path_token(value: str, *, name: str = "path", allow_empty: bool = True) -> str: + """Reject shell metacharacters in filesystem path tokens.""" + value = (value or "").strip() + if not value: + if allow_empty: + return "" + raise ValueError(f"{name} must not be empty") + if _SHELL_META.search(value): + raise ValueError(f"Invalid characters in {name}") + return value + + +def validate_rpc_method(method: str) -> str: + """Ensure bitcoin-cli RPC method names are safe tokens.""" + method = (method or "").strip() + if not _RPC_METHOD.fullmatch(method): + raise ValueError(f"Invalid RPC method: {method!r}") + return method + + +def resolve_executable(command: str) -> list[str]: + """Resolve a single executable name or absolute path for subprocess.run.""" + command = (command or "").strip() + if not command: + raise ValueError("Empty command") + + parts = shlex.split(command) + if len(parts) != 1: + raise ValueError("Command must be a single executable (no shell arguments)") + + exe = validate_safe_path_token(parts[0], name="command", allow_empty=False) + + if os.path.isabs(exe): + if not os.path.isfile(exe) or not os.access(exe, os.X_OK): + raise ValueError(f"Not executable: {exe}") + return [exe] + + resolved = shutil.which(exe) + if not resolved: + raise ValueError(f"Command not found: {exe}") + return [resolved] + + +def resolve_bitcoin_cli(cli_path: str) -> str: + """Resolve and validate bitcoin-cli executable path.""" + cli_path = validate_safe_path_token(cli_path or "bitcoin-cli", name="bitcoincli", allow_empty=False) + + if os.path.isabs(cli_path): + if not os.path.isfile(cli_path): + raise ValueError(f"bitcoin-cli not found: {cli_path}") + return cli_path + + resolved = shutil.which(cli_path) + if not resolved: + raise ValueError(f"bitcoin-cli not found: {cli_path}") + return resolved \ No newline at end of file diff --git a/pybitblock/oraclevision/spam_score.py b/pybitblock/oraclevision/spam_score.py new file mode 100644 index 0000000..f53d1a1 --- /dev/null +++ b/pybitblock/oraclevision/spam_score.py @@ -0,0 +1,74 @@ +""" +Spam score and BIP-110 status classification. + +Weights are heuristic — tune in oraclevision.conf or extend compute_spam_score() +for community-driven improvements. +""" + +from __future__ import annotations + + +def compute_spam_score( + *, + block_weight: int, + total_txs: int, + violation_weight: int, + inscription_count: int, + brc20_count: int, + runes_count: int, + op_return_count: int, + large_witness_bytes: int, + violation_count: int, +) -> int: + """Compute 0-100 spam score for a block.""" + if block_weight <= 0: + block_weight = 1 + if total_txs <= 0: + total_txs = 1 + + violation_ratio = violation_weight / block_weight + inscription_ratio = inscription_count / total_txs + token_ratio = (brc20_count + runes_count) / total_txs + witness_ratio = large_witness_bytes / block_weight + op_return_ratio = op_return_count / total_txs + + score = ( + 40 * violation_ratio + + 25 * inscription_ratio + + 15 * witness_ratio + + 10 * op_return_ratio + + 10 * token_ratio * 5 + ) + + if violation_count > 10: + score += min(20, violation_count) + + return min(100, int(round(score))) + + +def classify_status( + spam_score: int, + violation_count: int, + violation_weight: int, + block_weight: int, + *, + spam_threshold: int = 45, + violation_pct_threshold: float = 5.0, +) -> str: + """Return CLEAN, SUSPICIOUS, or VIOLATION.""" + violation_pct = (violation_weight / max(block_weight, 1)) * 100 + + if spam_score > spam_threshold or violation_pct > violation_pct_threshold: + return "VIOLATION" + if spam_score >= 15 or violation_count > 0: + return "SUSPICIOUS" + return "CLEAN" + + +def status_style(status: str) -> str: + """Rich style name for terminal display.""" + return { + "CLEAN": "bold green", + "SUSPICIOUS": "bold yellow", + "VIOLATION": "bold red", + }.get(status, "white") \ No newline at end of file diff --git a/pybitblock/oraclevision/ui.py b/pybitblock/oraclevision/ui.py new file mode 100644 index 0000000..b5c755b --- /dev/null +++ b/pybitblock/oraclevision/ui.py @@ -0,0 +1,334 @@ +""" +Terminal UI for OracleVision features inside PyBLOCK. + +Don't Trust, Verify — all analysis runs locally against your Knots node. +""" + +from __future__ import annotations + +import subprocess +import time as t + +from rich.panel import Panel +from rich.table import Table +from rich.text import Text + +from oraclevision.bip110 import BlockAnalysis, analyze_block +from oraclevision.bitcoin_cli import BitcoinCLI, BitcoinCLIError +from oraclevision.config import load_settings +from oraclevision.mempool_compose import analyze_block_template +from oraclevision.security import resolve_executable +from oraclevision.spam_score import status_style +from shared.display import clear +from shared.rich_ui import console, rich_error, rich_prompt +from shared.ui import show_error + + +def _header() -> None: + console.print() + console.print( + Panel( + Text.from_markup( + "[bold rgb(255,102,0)]OracleVision[/] · " + "[dim]Don't Trust, Verify[/]\n" + "[dim]Local BIP-110 & mempool analysis via bitcoin-cli[/]" + ), + border_style="rgb(255,102,0)", + ) + ) + console.print() + + +def _menu_items() -> None: + console.print(" [bold cyan]A.[/] BIP-110 Block Scanner") + console.print(" [bold cyan]B.[/] Mempool Glass (getblocktemplate)") + console.print(" [bold cyan]C.[/] Block Detail View") + console.print(" [bold cyan]D.[/] Launch Full OracleVision TUI") + console.print(" [bold yellow]R.[/] Return") + console.print() + + +def _cli_for(path: dict) -> BitcoinCLI: + settings = load_settings() + return BitcoinCLI.from_path_config( + path, + datadir=settings.bitcoin_datadir, + timeout=float(settings.cli_timeout_seconds), + ) + + +def _format_block_row(analysis: BlockAnalysis) -> tuple: + sig = "Y" if analysis.bip110_signaling else "n" + flags = [] + if analysis.violation_count: + flags.append(f"bip110:{analysis.violation_count}") + if analysis.inscription_count: + flags.append(f"insc:{analysis.inscription_count}") + if analysis.brc20_count: + flags.append(f"brc20:{analysis.brc20_count}") + if analysis.runes_count: + flags.append(f"runes:{analysis.runes_count}") + flag_text = ", ".join(flags) if flags else "—" + return ( + str(analysis.height), + analysis.miner_tag[:24], + str(analysis.spam_score), + f"[{status_style(analysis.status)}]{analysis.status}[/]", + sig, + flag_text, + ) + + +def scan_recent_blocks(path: dict, count: int | None = None) -> None: + """Scan recent blocks for BIP-110 violations and spam signals.""" + settings = load_settings() + count = count or settings.block_scan_count + cli = _cli_for(path) + + clear() + _header() + console.print(f"[dim]Scanning last {count} blocks from your node…[/]\n") + + try: + tip = cli.get_block_count() + table = Table(title="BIP-110 Block Scanner", show_lines=True) + table.add_column("Height", style="cyan", justify="right") + table.add_column("Miner", style="white") + table.add_column("Score", justify="right") + table.add_column("Status") + table.add_column("BIP110", justify="center") + table.add_column("Flags", style="dim") + + for height in range(tip, max(tip - count, -1), -1): + block_hash = cli.get_block_hash(height) + block = cli.get_block(block_hash, 2) + analysis = analyze_block(block, spam_threshold=settings.spam_score_threshold) + table.add_row(*_format_block_row(analysis)) + + console.print(table) + console.print( + "\n[dim]Score 0-100 · CLEAN / SUSPICIOUS / VIOLATION · " + "BIP110 = version bit 4 signaling[/]" + ) + except BitcoinCLIError as exc: + rich_error(str(exc)) + if exc.hint: + console.print(f" [dim]→ {exc.hint}[/]") + except Exception as exc: + show_error(str(exc)) + + input("\n\aContinue...") + + +def show_mempool_glass(path: dict) -> None: + """Show Mempool Glass composition from getblocktemplate.""" + cli = _cli_for(path) + + clear() + _header() + console.print("[dim]Fetching block template from your node…[/]\n") + + try: + template = cli.get_block_template() + composition = analyze_block_template(template, cli.decode_raw_transaction) + mempool = cli.get_mempool_info() + + if composition.error: + rich_error(composition.error) + input("\n\aContinue...") + return + + summary = Table(title="Mempool Glass — Block Template", show_header=False) + summary.add_column("Metric", style="cyan") + summary.add_column("Value", style="white") + summary.add_row("Template height", str(composition.template_height)) + summary.add_row("Mempool txs", str(mempool.get("size", "?"))) + summary.add_row("Template txs", str(composition.total_tx)) + summary.add_row("Analyzed txs", str(composition.analyzed_tx)) + summary.add_row("Template weight", f"{composition.analyzed_weight:,} / {composition.weight_limit:,}") + summary.add_row("Fill", f"{composition.fill_pct:.1f}%") + + cats = Table(title="Transaction Categories", show_lines=True) + cats.add_column("Category", style="bold") + cats.add_column("Count", justify="right") + cats.add_column("Weight", justify="right") + cats.add_column("% of template", justify="right") + + rows = [ + ("economic", composition.economic_count, composition.economic_weight, "green"), + ("consolidation", composition.consolidation_count, composition.consolidation_weight, "cyan"), + ("coinjoin", composition.coinjoin_count, composition.coinjoin_weight, "blue"), + ("spam", composition.spam_count, composition.spam_weight, "red"), + ] + for name, cnt, wt, color in rows: + cats.add_row( + f"[{color}]{name}[/]", + str(cnt), + f"{wt:,}", + f"{composition.pct(wt):.1f}%", + ) + + console.print(summary) + console.print() + console.print(cats) + console.print( + "\n[dim]Spam = BIP-110 violations, inscriptions, tokens, " + "oversized witness, or excess witness ratio[/]" + ) + except BitcoinCLIError as exc: + rich_error(str(exc)) + if exc.hint: + console.print(f" [dim]→ {exc.hint}[/]") + except Exception as exc: + show_error(str(exc)) + + input("\n\aContinue...") + + +def _render_block_detail(analysis: BlockAnalysis) -> None: + sig = "YES" if analysis.bip110_signaling else "no" + title = ( + f"Block #{analysis.height} · Spam {analysis.spam_score}/100 · " + f"{analysis.status} · BIP110 bit4: {sig}" + ) + + info = Table(show_header=False, title=title) + info.add_column("Field", style="cyan") + info.add_column("Value") + info.add_row("Hash", analysis.hash) + info.add_row("Miner", analysis.miner_tag) + info.add_row("Weight", f"{analysis.weight:,} ({analysis.tx_count} txs)") + info.add_row("Witness", f"{analysis.witness_pct:.1f}% of block weight") + info.add_row("Inscriptions", str(analysis.inscription_count)) + info.add_row("BRC-20", str(analysis.brc20_count)) + info.add_row("Runes", str(analysis.runes_count)) + info.add_row("OP_RETURN", str(analysis.op_return_count)) + info.add_row( + "BIP-110 violations", + f"{analysis.violation_count} txs ({analysis.violation_weight:,} wt)", + ) + + console.print(info) + console.print() + + bad = [tx for tx in analysis.transactions if tx.has_bip110_violation or tx.is_spam_signal] + bad.sort(key=lambda tx: tx.weight, reverse=True) + + if not bad: + console.print("[green]No problematic transactions detected.[/]") + return + + tx_table = Table(title="Problematic Transactions (top 25)", show_lines=True) + tx_table.add_column("TXID", style="red") + tx_table.add_column("Weight", justify="right") + tx_table.add_column("BIP-110 flags") + tx_table.add_column("Signals") + + for tx in bad[:25]: + flags = ", ".join(sorted(tx.bip110_flags)) or "—" + signals = ", ".join(sorted(tx.signals)) or "—" + tx_table.add_row(tx.txid[:20] + "…", f"{tx.weight:,}", flags, signals) + + console.print(tx_table) + if len(bad) > 25: + console.print(f"[dim]… and {len(bad) - 25} more[/]") + + +def show_block_detail(path: dict, target: str | None = None) -> None: + """Analyze a single block by height or hash.""" + cli = _cli_for(path) + settings = load_settings() + + clear() + _header() + + if not target: + target = input("\033[1;32;40mBlock height or hash: \033[0;37;40m").strip() + if not target: + return + + try: + if target.isdigit(): + block_hash = cli.get_block_hash(int(target)) + else: + block_hash = target + + console.print(f"[dim]Loading block {block_hash[:16]}…[/]\n") + block = cli.get_block(block_hash, 2) + analysis = analyze_block(block, spam_threshold=settings.spam_score_threshold) + _render_block_detail(analysis) + except BitcoinCLIError as exc: + rich_error(str(exc)) + if exc.hint: + console.print(f" [dim]→ {exc.hint}[/]") + except Exception as exc: + show_error(str(exc)) + + input("\n\aContinue...") + + +def launch_full_oraculovision(path: dict) -> None: + """Launch the standalone OracleVision Textual TUI if installed.""" + settings = load_settings() + command = settings.oraculovision_command + + clear() + _header() + + try: + launch_cmd = resolve_executable(command) + except ValueError as exc: + rich_error(str(exc)) + console.print( + " [dim]→ Install OracleVision: pip install -e . from " + "https://github.com/MarcanoFilms/oraculovision[/]" + ) + input("\n\aContinue...") + return + + console.print(f"[dim]Launching {launch_cmd[0]}…[/]\n") + console.print("[yellow]Press Ctrl+C in OracleVision to return to PyBLOCK.[/]\n") + t.sleep(1) + + env = dict(**{k: v for k, v in __import__("os").environ.items()}) + if settings.bitcoin_datadir: + env["BITCOIN_DATADIR"] = settings.bitcoin_datadir + if path.get("bitcoincli"): + env["BITCOIN_CLI"] = path["bitcoincli"] + + try: + # nosemgrep: python.lang.security.audit.dangerous-subprocess-use-audit + subprocess.run(launch_cmd, env=env, check=False) + except FileNotFoundError: + rich_error(f"Could not execute: {launch_cmd[0]}") + except KeyboardInterrupt: + pass + + input("\n\aContinue...") + + +def run_oraclevision_menu(path: dict) -> None: + """Main OracleVision submenu loop.""" + settings = load_settings() + if settings.load_error: + rich_error(f"Config warning: {settings.load_error}") + + while True: + clear() + _header() + _menu_items() + choice = rich_prompt("Select option").strip().upper() + + if choice in ("A",): + scan_recent_blocks(path) + elif choice in ("B",): + show_mempool_glass(path) + elif choice in ("C",): + show_block_detail(path) + elif choice in ("D",): + launch_full_oraculovision(path) + elif choice in ("R", ""): + break + else: + show_error(f"Invalid option '{choice}'") + t.sleep(1) \ No newline at end of file