From 0ecf2eb651c95889d88faf4edfa2a57863bf1d3d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Jun 2026 20:11:58 +0000 Subject: [PATCH] Fix BIP110 detection for reduced_data deployments --- app/sovran_systemsos_web/server.py | 64 +++++-- app/sovran_systemsos_web/static/js/helpers.js | 12 +- app/tests/test_bip110_status.py | 166 ++++++++++++++++++ 3 files changed, 221 insertions(+), 21 deletions(-) create mode 100644 app/tests/test_bip110_status.py diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index a8e043d..6d4b8d7 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -2256,6 +2256,13 @@ _btcd_version_cache: tuple[float, str | None] = (0.0, None) # Cache for ``bitcoin-cli getdeploymentinfo`` output (BIP-110 live status) _btc_deployment_cache: tuple[float, dict | None] = (0.0, None) +# Bitcoin Knots exposes BIP-110 as the `reduced_data` versionbits deployment +# (RDTS, bit 4) in getdeploymentinfo. See Knots src/deploymentinfo.cpp, +# src/kernel/chainparams.cpp, and doc/bips.md. +BIP110_DEPLOYMENT_NAMES = {"reduced_data", "rdts", "bip110", "uasf-bip110"} +BIP110_VERSIONBITS_BIT = 4 +BIP110_SUBVERSION_MARKERS = {"bip110", "uasf-bip110", "reduced_data", "rdts"} + # ── Generic service version detection (NixOS store path) ───────── @@ -2414,16 +2421,16 @@ def _get_bip110_status() -> dict: Resolution order (authoritative → fallback → honest unknown): - 1. ``getdeploymentinfo`` (authoritative) — scan the ``deployments`` dict for an - entry whose key (case-insensitive) contains "bip110". The exact - deployment key name is **not** hard-coded because it may vary across Knots - releases; detection is intentionally generic so that a name change degrades - to "unknown" rather than producing a false result. + 1. ``getdeploymentinfo`` (authoritative) — scan ``deployments`` for BIP-110. + Bitcoin Knots currently exposes BIP-110 as ``reduced_data`` (RDTS, bit 4; + see Knots deploymentinfo.cpp / chainparams.cpp / doc/bips.md), so matching + first uses known deployment names, then falls back to versionbits bit 4. 2. Subversion fallback — if getdeploymentinfo is unavailable or yields no recognisable BIP-110 entry, inspect the ``subversion`` field from - ``getnetworkinfo``. A case-insensitive match for "bip110" or "uasf-bip110" - in the subversion string is treated as "signaling". + ``getnetworkinfo``. A case-insensitive match for known BIP-110 markers + (including "bip110", "uasf-bip110", "reduced_data", "rdts") is treated as + "signaling". 3. Unknown — if the node is entirely unreachable or neither source is conclusive, return state="unknown", signaling=False, source="none". @@ -2435,14 +2442,37 @@ def _get_bip110_status() -> dict: if deploy_info is not None: deployments = deploy_info.get("deployments", {}) if isinstance(deployments, dict): + matched_entry: dict | None = None + + # Primary match: known deployment names (case-insensitive contains) for key, entry in deployments.items(): - # Generic scan: match key that contains "bip110" (case-insensitive). - # Deliberately not matching bare "110" to avoid false positives on - # unrelated deployments whose names happen to include that digit sequence. - if "bip110" not in key.lower(): - continue if not isinstance(entry, dict): continue + key_lower = key.lower() + if not any(name in key_lower for name in BIP110_DEPLOYMENT_NAMES): + continue + matched_entry = entry + break + + # Secondary match: versionbits bit (fallback only) + if matched_entry is None: + for _, entry in deployments.items(): + if not isinstance(entry, dict): + continue + bip9 = entry.get("bip9", {}) or {} + bip8 = entry.get("bip8", {}) or {} + bit = bip9.get("bit") + if bit is None: + bit = bip8.get("bit") + if bit is None: + bit = entry.get("bit") + if bit != BIP110_VERSIONBITS_BIT: + continue + matched_entry = entry + break + + if matched_entry is not None: + entry = matched_entry # bip9 / bip8 status field bip9 = entry.get("bip9", {}) or {} @@ -2460,9 +2490,13 @@ def _get_bip110_status() -> dict: if status == "locked_in": return {"supported": True, "signaling": True, "state": "locked_in", "source": "getdeploymentinfo"} if status in ("started", "defined"): - # Check whether the node is currently signaling this period + # Check whether deployment is currently signaling in this period. stats = bip9.get("statistics") or bip8.get("statistics") or {} - signaling = bool(stats.get("signaling", False)) + signaling = bool( + stats.get("signaling") + or stats.get("signalling") + or (isinstance(stats.get("count"), int) and stats.get("count", 0) > 0) + ) if signaling: return {"supported": True, "signaling": True, "state": "signaling", "source": "getdeploymentinfo"} return {"supported": True, "signaling": False, "state": "not_signaling", "source": "getdeploymentinfo"} @@ -2476,7 +2510,7 @@ def _get_bip110_status() -> dict: if net_info is not None: subversion = net_info.get("subversion", "") or "" sv_lower = subversion.lower() - if "bip110" in sv_lower or "uasf-bip110" in sv_lower: + if any(marker in sv_lower for marker in BIP110_SUBVERSION_MARKERS): return {"supported": True, "signaling": True, "state": "signaling", "source": "subversion"} # Node is reachable via RPC but no BIP-110 marker found anywhere return {"supported": False, "signaling": False, "state": "unsupported", "source": "subversion"} diff --git a/app/sovran_systemsos_web/static/js/helpers.js b/app/sovran_systemsos_web/static/js/helpers.js index d452fca..24f8caf 100644 --- a/app/sovran_systemsos_web/static/js/helpers.js +++ b/app/sovran_systemsos_web/static/js/helpers.js @@ -67,10 +67,10 @@ async function apiFetch(path, options) { // Keys match the "state" values returned by /api/bitcoin/bip110. var BIP110_BADGE_CONFIG = { - active: { cls: 'tile-bip110-badge--active', label: 'BIP\u2011110: Active \u2713', title: 'BIP-110 is active on this node' }, - locked_in: { cls: 'tile-bip110-badge--locked_in', label: 'BIP\u2011110: Locked In', title: 'BIP-110 is locked in and will activate shortly' }, - signaling: { cls: 'tile-bip110-badge--signaling', label: 'BIP\u2011110: Signaling', title: 'Node is signaling readiness for BIP-110' }, - not_signaling: { cls: 'tile-bip110-badge--not_signaling',label: 'BIP\u2011110: Not Signaling', title: 'Node supports BIP-110 but is not signaling this period' }, - unsupported: { cls: 'tile-bip110-badge--unsupported', label: 'BIP\u2011110: Not Supported', title: 'This node build does not include BIP-110' }, - unknown: { cls: 'tile-bip110-badge--unknown', label: 'BIP\u2011110: \u2014', title: 'Status unavailable (node syncing or RPC not ready)' } + active: { cls: 'tile-bip110-badge--active', label: 'Active', title: 'BIP-110 is active on this node' }, + locked_in: { cls: 'tile-bip110-badge--locked_in', label: 'Locked In', title: 'BIP-110 is locked in and will activate shortly' }, + signaling: { cls: 'tile-bip110-badge--signaling', label: 'Signaling', title: 'Node is signaling readiness for BIP-110' }, + not_signaling: { cls: 'tile-bip110-badge--not_signaling',label: 'Not Signaling', title: 'Node supports BIP-110 but is not signaling this period' }, + unsupported: { cls: 'tile-bip110-badge--unsupported', label: 'Not Supported', title: 'This node build does not include BIP-110' }, + unknown: { cls: 'tile-bip110-badge--unknown', label: '\u2014', title: 'Status unavailable (node syncing or RPC not ready)' } }; diff --git a/app/tests/test_bip110_status.py b/app/tests/test_bip110_status.py new file mode 100644 index 0000000..fafec4f --- /dev/null +++ b/app/tests/test_bip110_status.py @@ -0,0 +1,166 @@ +import unittest +from unittest.mock import patch +from pathlib import Path +import sys +import types + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + + +def _install_web_stubs(): + if "fastapi" in sys.modules: + return + + class _HTTPException(Exception): + def __init__(self, status_code=None, detail=None): + super().__init__(detail) + self.status_code = status_code + self.detail = detail + + class _FastAPI: + def __init__(self, *args, **kwargs): + pass + + def mount(self, *args, **kwargs): + return None + + def add_middleware(self, *args, **kwargs): + return None + + def __getattr__(self, _name): + def _decorator_factory(*args, **kwargs): + def _decorator(func): + return func + + return _decorator + + return _decorator_factory + + class _BaseModel: + pass + + class _StaticFiles: + def __init__(self, *args, **kwargs): + pass + + class _Jinja2Templates: + def __init__(self, *args, **kwargs): + pass + + class _BaseHTTPMiddleware: + pass + + fastapi_module = types.ModuleType("fastapi") + fastapi_module.FastAPI = _FastAPI + fastapi_module.HTTPException = _HTTPException + sys.modules["fastapi"] = fastapi_module + + responses_module = types.ModuleType("fastapi.responses") + responses_module.HTMLResponse = object + responses_module.JSONResponse = object + responses_module.RedirectResponse = object + sys.modules["fastapi.responses"] = responses_module + + staticfiles_module = types.ModuleType("fastapi.staticfiles") + staticfiles_module.StaticFiles = _StaticFiles + sys.modules["fastapi.staticfiles"] = staticfiles_module + + templating_module = types.ModuleType("fastapi.templating") + templating_module.Jinja2Templates = _Jinja2Templates + sys.modules["fastapi.templating"] = templating_module + + requests_module = types.ModuleType("fastapi.requests") + requests_module.Request = object + sys.modules["fastapi.requests"] = requests_module + + pydantic_module = types.ModuleType("pydantic") + pydantic_module.BaseModel = _BaseModel + sys.modules["pydantic"] = pydantic_module + + starlette_base_module = types.ModuleType("starlette.middleware.base") + starlette_base_module.BaseHTTPMiddleware = _BaseHTTPMiddleware + sys.modules["starlette.middleware.base"] = starlette_base_module + + starlette_middleware_module = types.ModuleType("starlette.middleware") + starlette_middleware_module.base = starlette_base_module + sys.modules["starlette.middleware"] = starlette_middleware_module + + starlette_module = types.ModuleType("starlette") + starlette_module.middleware = starlette_middleware_module + sys.modules["starlette"] = starlette_module + + +_install_web_stubs() +from sovran_systemsos_web import server + + +class Bip110StatusTests(unittest.TestCase): + def _status(self, deploy_info, net_info): + with patch.object(server, "_get_bitcoin_deployment_info", return_value=deploy_info), patch.object( + server, "_get_bitcoin_version_info", return_value=net_info + ): + return server._get_bip110_status() + + def test_started_reduced_data_reports_signaling(self): + deploy_info = { + "deployments": { + "reduced_data": { + "type": "bip9", + "active": False, + "bip9": { + "bit": 4, + "status": "started", + "statistics": {"elapsed": 833, "count": 4, "threshold": 1109}, + "signalling": "--#--", + }, + } + } + } + + result = self._status(deploy_info, {"subversion": "/Satoshi:29.0.0/"}) + self.assertEqual( + result, + {"supported": True, "signaling": True, "state": "signaling", "source": "getdeploymentinfo"}, + ) + + def test_active_reduced_data_reports_active(self): + deploy_info = { + "deployments": {"reduced_data": {"active": True, "bip9": {"bit": 4, "status": "active"}}} + } + + result = self._status(deploy_info, {"subversion": "/Satoshi:29.0.0/"}) + self.assertEqual(result["state"], "active") + self.assertTrue(result["supported"]) + self.assertTrue(result["signaling"]) + self.assertEqual(result["source"], "getdeploymentinfo") + + def test_locked_in_reduced_data_reports_locked_in(self): + deploy_info = { + "deployments": {"reduced_data": {"active": False, "bip9": {"bit": 4, "status": "locked_in"}}} + } + + result = self._status(deploy_info, {"subversion": "/Satoshi:29.0.0/"}) + self.assertEqual(result["state"], "locked_in") + self.assertTrue(result["supported"]) + self.assertTrue(result["signaling"]) + self.assertEqual(result["source"], "getdeploymentinfo") + + def test_no_bip110_deployment_and_plain_subversion_reports_unsupported(self): + deploy_info = { + "deployments": { + "taproot": {"type": "bip9", "active": True, "bip9": {"bit": 2, "status": "active"}}, + } + } + result = self._status(deploy_info, {"subversion": "/Satoshi:27.0.0/"}) + self.assertEqual( + result, + {"supported": False, "signaling": False, "state": "unsupported", "source": "subversion"}, + ) + + def test_node_unreachable_reports_unknown(self): + result = self._status(None, None) + self.assertEqual(result, {"supported": False, "signaling": False, "state": "unknown", "source": "none"}) + + +if __name__ == "__main__": + unittest.main()