Compare commits
21 Commits
8213f363c4
..
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| dabb96e1b3 | |||
| 2b5a154b99 | |||
| e475b0f47d | |||
| 8f81f8f1e2 | |||
| cd753a7e28 | |||
| 7ac1985508 | |||
| 0ecf2eb651 | |||
| 18c7095aaf | |||
| dcad276c59 | |||
| 06988d0ff0 | |||
| 69b84153b4 | |||
| df08a7c413 | |||
| 602464189f | |||
| 67f4cdc99e | |||
| f8c717db25 | |||
| 268abddb28 | |||
| c1119b03a8 | |||
| 0c273b758d | |||
| 6f98c478e8 | |||
| 875a6a9297 | |||
| 1dbfe3cd94 |
@@ -39,7 +39,7 @@ The control center is the **Hub** — a built-in panel that lets the operator la
|
|||||||
│ flake.nix │
|
│ flake.nix │
|
||||||
│ inputs: nixpkgs, │
|
│ inputs: nixpkgs, │
|
||||||
│ nix-bitcoin, nixvim, │
|
│ nix-bitcoin, nixvim, │
|
||||||
│ btc-clients, bip110 │
|
│ btc-clients │
|
||||||
└───────────┬─────────────┘
|
└───────────┬─────────────┘
|
||||||
│ nixosModules.Sovran_SystemsOS
|
│ nixosModules.Sovran_SystemsOS
|
||||||
▼
|
▼
|
||||||
@@ -78,10 +78,9 @@ Defaults follow the import order in `modules/modules.nix`. Toggles live in `cust
|
|||||||
| `bitcoinecosystem.nix` | **on** | bitcoind/electrs/LND/RTL/BTCPay (over Tor) |
|
| `bitcoinecosystem.nix` | **on** | bitcoind/electrs/LND/RTL/BTCPay (over Tor) |
|
||||||
| `wallet-autoconnect.nix` | **on** | Sparrow/Bisq ↔ node handshake |
|
| `wallet-autoconnect.nix` | **on** | Sparrow/Bisq ↔ node handshake |
|
||||||
| `haven.nix` | off | Nostr relay |
|
| `haven.nix` | off | Nostr relay |
|
||||||
| `bip110.nix` | off | Bitcoin Knots BIP-110 |
|
|
||||||
| `element-calling.nix` | off | LiveKit + JWT for E2E calling |
|
| `element-calling.nix` | off | LiveKit + JWT for E2E calling |
|
||||||
| `mempool.nix` | off | Mempool.space dashboard |
|
| `mempool.nix` | off | Mempool.space dashboard |
|
||||||
| `bitcoin-core.nix` | off | Standalone bitcoind |
|
| `bitcoin-core.nix` | off | Switch node to Bitcoin Core (replaces default Bitcoin Knots + BIP110) |
|
||||||
| `rdp.nix` | off | xrdp remote desktop |
|
| `rdp.nix` | off | xrdp remote desktop |
|
||||||
| `sshd.nix` | off | Public-facing OpenSSH |
|
| `sshd.nix` | off | Public-facing OpenSSH |
|
||||||
|
|
||||||
|
|||||||
@@ -222,28 +222,16 @@ FEATURE_REGISTRY = [
|
|||||||
"conflicts_with": [],
|
"conflicts_with": [],
|
||||||
"port_requirements": [],
|
"port_requirements": [],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "bip110",
|
|
||||||
"name": "Bitcoin Knots + BIP110",
|
|
||||||
"description": "Only one Bitcoin node implementation can be active at a time: Bitcoin Knots (default), Bitcoin Knots + BIP110, or Bitcoin Core. Enabling this option replaces the default Bitcoin Knots with Bitcoin Knots + BIP110 consensus changes. It will disable the currently active alternative.",
|
|
||||||
"category": "bitcoin",
|
|
||||||
"needs_domain": False,
|
|
||||||
"domain_name": None,
|
|
||||||
"needs_ddns": False,
|
|
||||||
"extra_fields": [],
|
|
||||||
"conflicts_with": ["bitcoin-core"],
|
|
||||||
"port_requirements": [],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "bitcoin-core",
|
"id": "bitcoin-core",
|
||||||
"name": "Bitcoin Core",
|
"name": "Bitcoin Core",
|
||||||
"description": "Only one Bitcoin node implementation can be active at a time: Bitcoin Knots (default), Bitcoin Knots + BIP110, or Bitcoin Core. Enabling this option replaces the default Bitcoin Knots with Bitcoin Core. It will disable the currently active alternative.",
|
"description": "Only one Bitcoin node implementation can be active: Bitcoin Knots + BIP110 (default) or Bitcoin Core. Enabling this replaces Knots + BIP110 with Bitcoin Core. Your timechain data is preserved.",
|
||||||
"category": "bitcoin",
|
"category": "bitcoin",
|
||||||
"needs_domain": False,
|
"needs_domain": False,
|
||||||
"domain_name": None,
|
"domain_name": None,
|
||||||
"needs_ddns": False,
|
"needs_ddns": False,
|
||||||
"extra_fields": [],
|
"extra_fields": [],
|
||||||
"conflicts_with": ["bip110"],
|
"conflicts_with": [],
|
||||||
"port_requirements": [],
|
"port_requirements": [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -277,13 +265,16 @@ FEATURE_REGISTRY = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Feature ids that have been removed/deprecated. The Hub must never write these
|
||||||
|
# back into custom.nix, and should strip any it finds (see startup migration).
|
||||||
|
DEPRECATED_FEATURE_IDS: set[str] = {"bip110"}
|
||||||
|
|
||||||
# Map feature IDs to their systemd units in config.json
|
# Map feature IDs to their systemd units in config.json
|
||||||
FEATURE_SERVICE_MAP = {
|
FEATURE_SERVICE_MAP = {
|
||||||
"rdp": "gnome-remote-desktop.service",
|
"rdp": "gnome-remote-desktop.service",
|
||||||
"haven": "haven-relay.service",
|
"haven": "haven-relay.service",
|
||||||
"element-calling": "livekit.service",
|
"element-calling": "livekit.service",
|
||||||
"mempool": "mempool.service",
|
"mempool": "mempool.service",
|
||||||
"bip110": None,
|
|
||||||
"bitcoin-core": None,
|
"bitcoin-core": None,
|
||||||
"btcpay-web": "btcpayserver.service",
|
"btcpay-web": "btcpayserver.service",
|
||||||
"sshd": "sshd.service",
|
"sshd": "sshd.service",
|
||||||
@@ -331,7 +322,6 @@ SERVICE_DOMAIN_MAP: dict[str, str] = {
|
|||||||
|
|
||||||
# For features that share a unit, disambiguate by icon field
|
# For features that share a unit, disambiguate by icon field
|
||||||
FEATURE_ICON_MAP = {
|
FEATURE_ICON_MAP = {
|
||||||
"bip110": "bip110",
|
|
||||||
"bitcoin-core": "bitcoin-core",
|
"bitcoin-core": "bitcoin-core",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,7 +342,7 @@ ROLE_CATEGORIES: dict[str, set[str] | None] = {
|
|||||||
ROLE_FEATURES: dict[str, set[str] | None] = {
|
ROLE_FEATURES: dict[str, set[str] | None] = {
|
||||||
"server_plus_desktop": None,
|
"server_plus_desktop": None,
|
||||||
"desktop": {"rdp", "sshd"},
|
"desktop": {"rdp", "sshd"},
|
||||||
"node": {"rdp", "bip110", "bitcoin-core", "mempool", "btcpay-web", "sshd"},
|
"node": {"rdp", "bitcoin-core", "mempool", "btcpay-web", "sshd"},
|
||||||
}
|
}
|
||||||
|
|
||||||
SERVICE_DESCRIPTIONS: dict[str, str] = {
|
SERVICE_DESCRIPTIONS: dict[str, str] = {
|
||||||
@@ -1519,7 +1509,9 @@ def _read_hub_overrides() -> tuple[dict, str | None, str | None, str | None]:
|
|||||||
r'sovran_systemsOS\.features\.([a-zA-Z0-9_-]+)\s*=\s*(?:lib\.mkForce\s+)?(true|false)\s*;',
|
r'sovran_systemsOS\.features\.([a-zA-Z0-9_-]+)\s*=\s*(?:lib\.mkForce\s+)?(true|false)\s*;',
|
||||||
section,
|
section,
|
||||||
):
|
):
|
||||||
features[m.group(1)] = m.group(2) == "true"
|
feat_id = m.group(1)
|
||||||
|
if feat_id not in DEPRECATED_FEATURE_IDS:
|
||||||
|
features[feat_id] = m.group(2) == "true"
|
||||||
for m in re.finditer(
|
for m in re.finditer(
|
||||||
r'sovran_systemsOS\.web\.btcpayserver\s*=\s*(?:lib\.mkForce\s+)?(true|false)\s*;',
|
r'sovran_systemsOS\.web\.btcpayserver\s*=\s*(?:lib\.mkForce\s+)?(true|false)\s*;',
|
||||||
section,
|
section,
|
||||||
@@ -1552,6 +1544,8 @@ def _write_hub_overrides(features: dict, nostr_npub: str | None, timezone: str |
|
|||||||
"""Write the Hub Managed section inside custom.nix."""
|
"""Write the Hub Managed section inside custom.nix."""
|
||||||
lines = []
|
lines = []
|
||||||
for feat_id, enabled in features.items():
|
for feat_id, enabled in features.items():
|
||||||
|
if feat_id in DEPRECATED_FEATURE_IDS:
|
||||||
|
continue
|
||||||
val = "true" if enabled else "false"
|
val = "true" if enabled else "false"
|
||||||
if feat_id == "btcpay-web":
|
if feat_id == "btcpay-web":
|
||||||
lines.append(f" sovran_systemsOS.web.btcpayserver = lib.mkForce {val};")
|
lines.append(f" sovran_systemsOS.web.btcpayserver = lib.mkForce {val};")
|
||||||
@@ -1597,6 +1591,40 @@ def _write_hub_overrides(features: dict, nostr_npub: str | None, timezone: str |
|
|||||||
f.write(content)
|
f.write(content)
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_strip_deprecated_features() -> None:
|
||||||
|
"""One-time migration: remove deprecated feature lines from the Hub Managed
|
||||||
|
section of custom.nix. Any feature id in DEPRECATED_FEATURE_IDS is dropped
|
||||||
|
while all other Hub-managed settings (other features, nostr_npub, timezone,
|
||||||
|
locale) are preserved byte-for-byte in meaning.
|
||||||
|
|
||||||
|
This is a no-op (and never raises) if CUSTOM_NIX is missing, unreadable, or
|
||||||
|
contains no deprecated lines.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(CUSTOM_NIX, "r") as f:
|
||||||
|
content = f.read()
|
||||||
|
except (FileNotFoundError, OSError):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Quick-exit: if none of the deprecated ids appear, nothing to do.
|
||||||
|
hub_begin = content.find(HUB_BEGIN)
|
||||||
|
hub_end = content.find(HUB_END)
|
||||||
|
if hub_begin == -1 or hub_end == -1:
|
||||||
|
return
|
||||||
|
section = content[hub_begin:hub_end]
|
||||||
|
if not any(f"features.{dep_id}" in section for dep_id in DEPRECATED_FEATURE_IDS):
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
features, nostr_npub, timezone, locale = _read_hub_overrides()
|
||||||
|
# _read_hub_overrides already excludes DEPRECATED_FEATURE_IDS, so
|
||||||
|
# calling _write_hub_overrides with its output drops the stale lines.
|
||||||
|
_write_hub_overrides(features, nostr_npub, timezone, locale)
|
||||||
|
except Exception:
|
||||||
|
# Never let a migration failure break startup.
|
||||||
|
logger.exception("_migrate_strip_deprecated_features: unexpected error (non-fatal)")
|
||||||
|
|
||||||
|
|
||||||
# ── Feature status helpers ─────────────────────────────────────────
|
# ── Feature status helpers ─────────────────────────────────────────
|
||||||
|
|
||||||
def _is_feature_enabled_in_config(feature_id: str) -> bool | None:
|
def _is_feature_enabled_in_config(feature_id: str) -> bool | None:
|
||||||
@@ -1606,7 +1634,7 @@ def _is_feature_enabled_in_config(feature_id: str) -> bool | None:
|
|||||||
return False # Default off in Node role; only on via explicit hub toggle
|
return False # Default off in Node role; only on via explicit hub toggle
|
||||||
unit = FEATURE_SERVICE_MAP.get(feature_id)
|
unit = FEATURE_SERVICE_MAP.get(feature_id)
|
||||||
if unit is None:
|
if unit is None:
|
||||||
return None # bip110, bitcoin-core — can't determine from config
|
return None # bitcoin-core — can't determine from config
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
for svc in cfg.get("services", []):
|
for svc in cfg.get("services", []):
|
||||||
if svc.get("unit") == unit:
|
if svc.get("unit") == unit:
|
||||||
@@ -2225,6 +2253,16 @@ _BTC_VERSION_CACHE_TTL = 60 # seconds — version doesn't change at runtime
|
|||||||
# Cache for ``bitcoind --version`` output (available even before RPC is ready)
|
# Cache for ``bitcoind --version`` output (available even before RPC is ready)
|
||||||
_btcd_version_cache: tuple[float, str | None] = (0.0, None)
|
_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) ─────────
|
# ── Generic service version detection (NixOS store path) ─────────
|
||||||
|
|
||||||
@@ -2339,12 +2377,160 @@ def _get_bitcoin_version_info() -> dict | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_bitcoin_deployment_info() -> dict | None:
|
||||||
|
"""Call bitcoin-cli getdeploymentinfo and return parsed JSON, or None on error.
|
||||||
|
|
||||||
|
Results are cached for _BTC_VERSION_CACHE_TTL seconds. Never raises.
|
||||||
|
"""
|
||||||
|
global _btc_deployment_cache
|
||||||
|
now = time.monotonic()
|
||||||
|
cached_at, cached_val = _btc_deployment_cache
|
||||||
|
if now - cached_at < _BTC_VERSION_CACHE_TTL:
|
||||||
|
return cached_val
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["bitcoin-cli", f"-datadir={BITCOIN_DATADIR}", "getdeploymentinfo"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
_btc_deployment_cache = (now, None)
|
||||||
|
return None
|
||||||
|
info = json.loads(result.stdout)
|
||||||
|
_btc_deployment_cache = (now, info)
|
||||||
|
return info
|
||||||
|
except Exception:
|
||||||
|
_btc_deployment_cache = (now, None)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_bip110_status() -> dict:
|
||||||
|
"""Return a dict describing the live BIP-110 deployment/signaling state.
|
||||||
|
|
||||||
|
The returned struct has four stable keys::
|
||||||
|
|
||||||
|
{
|
||||||
|
"supported": bool, # node build is BIP-110-capable
|
||||||
|
"signaling": bool, # node is actively signaling / locked-in / active
|
||||||
|
"state": str, # "active" | "locked_in" | "signaling" |
|
||||||
|
# "not_signaling" | "unsupported" | "unknown"
|
||||||
|
"source": str, # "getdeploymentinfo" | "subversion" | "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
Resolution order (authoritative → fallback → honest unknown):
|
||||||
|
|
||||||
|
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 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".
|
||||||
|
"""
|
||||||
|
_unknown: dict = {"supported": False, "signaling": False, "state": "unknown", "source": "none"}
|
||||||
|
|
||||||
|
def _deployment_bit(entry: dict) -> int | None:
|
||||||
|
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")
|
||||||
|
return bit
|
||||||
|
|
||||||
|
# ── 1. getdeploymentinfo (authoritative) ──────────────────────────
|
||||||
|
deploy_info = _get_bitcoin_deployment_info()
|
||||||
|
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 exact match)
|
||||||
|
for key, entry in deployments.items():
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
key_lower = key.lower()
|
||||||
|
if key_lower not 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
|
||||||
|
if _deployment_bit(entry) != 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 {}
|
||||||
|
bip8 = entry.get("bip8", {}) or {}
|
||||||
|
status = (
|
||||||
|
bip9.get("status")
|
||||||
|
or bip8.get("status")
|
||||||
|
or entry.get("status")
|
||||||
|
or ""
|
||||||
|
).lower()
|
||||||
|
active = entry.get("active", False)
|
||||||
|
|
||||||
|
if active or status == "active":
|
||||||
|
return {"supported": True, "signaling": True, "state": "active", "source": "getdeploymentinfo"}
|
||||||
|
if status == "locked_in":
|
||||||
|
return {"supported": True, "signaling": True, "state": "locked_in", "source": "getdeploymentinfo"}
|
||||||
|
if status in ("started", "defined"):
|
||||||
|
# Check whether deployment is currently signaling in this period.
|
||||||
|
stats = bip9.get("statistics") or bip8.get("statistics") or {}
|
||||||
|
# Some Knots outputs expose only ``count`` (not explicit signaling bool),
|
||||||
|
# so treat count>0 as a conservative signaling indicator for this period.
|
||||||
|
count = stats.get("count")
|
||||||
|
signaling = bool(
|
||||||
|
stats.get("signaling")
|
||||||
|
or stats.get("signalling")
|
||||||
|
or (isinstance(count, int) and count > 0)
|
||||||
|
)
|
||||||
|
if signaling:
|
||||||
|
return {"supported": True, "signaling": True, "state": "signaling", "source": "getdeploymentinfo"}
|
||||||
|
return {"supported": True, "signaling": False, "state": "not_signaling", "source": "getdeploymentinfo"}
|
||||||
|
if status == "failed":
|
||||||
|
return {"supported": True, "signaling": False, "state": "not_signaling", "source": "getdeploymentinfo"}
|
||||||
|
# Entry found but status unrecognised — node supports BIP-110 but state unclear
|
||||||
|
return {"supported": True, "signaling": False, "state": "unknown", "source": "getdeploymentinfo"}
|
||||||
|
|
||||||
|
# ── 2. Subversion fallback ─────────────────────────────────────────
|
||||||
|
net_info = _get_bitcoin_version_info()
|
||||||
|
if net_info is not None:
|
||||||
|
subversion = net_info.get("subversion", "") or ""
|
||||||
|
sv_lower = subversion.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"}
|
||||||
|
|
||||||
|
# ── 3. Node unreachable / RPC not ready ───────────────────────────
|
||||||
|
return _unknown
|
||||||
|
|
||||||
|
|
||||||
def _get_bitcoind_version() -> str | None:
|
def _get_bitcoind_version() -> str | None:
|
||||||
"""Run ``bitcoind --version`` and return the raw version string, or None on error.
|
"""Run ``bitcoind --version`` and return the raw version string, or None on error.
|
||||||
|
|
||||||
Parses the first output line to extract the token after "version ".
|
Parses the first output line to extract the token after "version ".
|
||||||
For example: "Bitcoin Knots daemon version v29.3.knots20260210+bip110-v0.4.1"
|
For example: "Bitcoin Knots daemon version v29.3.knots20260508"
|
||||||
returns "v29.3.knots20260210+bip110-v0.4.1".
|
returns "v29.3.knots20260508".
|
||||||
|
|
||||||
Works regardless of whether the RPC server is ready (IBD, warmup, etc.).
|
Works regardless of whether the RPC server is ready (IBD, warmup, etc.).
|
||||||
Results are cached for 60 seconds (_BTC_VERSION_CACHE_TTL).
|
Results are cached for 60 seconds (_BTC_VERSION_CACHE_TTL).
|
||||||
@@ -2379,26 +2565,13 @@ def _get_bitcoind_version() -> str | None:
|
|||||||
def _format_bitcoin_version(raw_version: str, icon: str = "") -> str:
|
def _format_bitcoin_version(raw_version: str, icon: str = "") -> str:
|
||||||
"""Format a raw version string from ``bitcoind --version`` for tile display.
|
"""Format a raw version string from ``bitcoind --version`` for tile display.
|
||||||
|
|
||||||
Strips the ``+bip110-vX.Y.Z`` patch suffix so the base version is shown
|
For the BIP110 tile (icon == "bip110") a " (bip110)" tag is appended,
|
||||||
cleanly (e.g. "v29.3.knots20260210+bip110-v0.4.1" → "v29.3.knots20260210").
|
since mainline Bitcoin Knots (29.3.knots20260508+) now includes BIP-110
|
||||||
For the BIP110 tile (icon == "bip110") a " (bip110 vX.Y.Z)" tag is appended
|
and no longer carries a separate ``+bip110-vX.Y.Z`` suffix.
|
||||||
including the patch version.
|
|
||||||
"""
|
"""
|
||||||
# Extract the BIP110 patch version before stripping the suffix
|
display = raw_version
|
||||||
bip110_ver = ""
|
if icon == "bip110" and "(bip110)" not in display.lower():
|
||||||
bip_match = re.search(r"\+bip110-v(\S+)", raw_version)
|
display += " (bip110)"
|
||||||
if bip_match:
|
|
||||||
bip110_ver = bip_match.group(1)
|
|
||||||
|
|
||||||
# Strip the +bip110... suffix for the base Knots version
|
|
||||||
display = re.sub(r"\+bip110\S*", "", raw_version)
|
|
||||||
|
|
||||||
# For BIP110 tile, append both the tag and the patch version
|
|
||||||
if icon == "bip110":
|
|
||||||
if bip110_ver:
|
|
||||||
display += f" (bip110 v{bip110_ver})"
|
|
||||||
elif "(bip110)" not in display.lower():
|
|
||||||
display += " (bip110)"
|
|
||||||
return display
|
return display
|
||||||
|
|
||||||
|
|
||||||
@@ -2466,6 +2639,19 @@ async def api_bitcoin_version():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/bitcoin/bip110")
|
||||||
|
async def api_bitcoin_bip110():
|
||||||
|
"""Return live BIP-110 deployment/signaling status from bitcoin-cli.
|
||||||
|
|
||||||
|
Always returns HTTP 200. When bitcoind is unreachable or the node is mid-IBD
|
||||||
|
the response will contain ``state = "unknown"`` so the UI can render a neutral
|
||||||
|
badge rather than an error toast.
|
||||||
|
"""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
status = await loop.run_in_executor(None, _get_bip110_status)
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/services")
|
@app.get("/api/services")
|
||||||
async def api_services():
|
async def api_services():
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
@@ -2646,6 +2832,8 @@ async def api_services():
|
|||||||
btc_ver = _format_bitcoin_version(raw_ver, icon=icon)
|
btc_ver = _format_bitcoin_version(raw_ver, icon=icon)
|
||||||
service_data["bitcoin_version"] = btc_ver # backwards compat
|
service_data["bitcoin_version"] = btc_ver # backwards compat
|
||||||
service_data["version"] = btc_ver
|
service_data["version"] = btc_ver
|
||||||
|
if icon == "bip110":
|
||||||
|
service_data["bip110"] = await loop.run_in_executor(None, _get_bip110_status)
|
||||||
return service_data
|
return service_data
|
||||||
|
|
||||||
results = await asyncio.gather(*[get_status(s) for s in services])
|
results = await asyncio.gather(*[get_status(s) for s in services])
|
||||||
@@ -2930,6 +3118,8 @@ async def api_service_detail(unit: str, icon: str | None = None):
|
|||||||
btc_ver = _format_bitcoin_version(raw_ver, icon=icon)
|
btc_ver = _format_bitcoin_version(raw_ver, icon=icon)
|
||||||
service_detail["bitcoin_version"] = btc_ver # backwards compat
|
service_detail["bitcoin_version"] = btc_ver # backwards compat
|
||||||
service_detail["version"] = btc_ver
|
service_detail["version"] = btc_ver
|
||||||
|
if icon == "bip110":
|
||||||
|
service_detail["bip110"] = await loop.run_in_executor(None, _get_bip110_status)
|
||||||
return service_detail
|
return service_detail
|
||||||
|
|
||||||
|
|
||||||
@@ -4597,6 +4787,14 @@ async def _startup_recover_stale_status():
|
|||||||
await loop.run_in_executor(None, _recover_stale_status, REBUILD_STATUS, REBUILD_LOG, REBUILD_UNIT)
|
await loop.run_in_executor(None, _recover_stale_status, REBUILD_STATUS, REBUILD_LOG, REBUILD_UNIT)
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def _startup_migrate_deprecated_features():
|
||||||
|
"""Strip deprecated feature lines (e.g. bip110) from the Hub Managed section
|
||||||
|
of custom.nix so they are never re-written and do not cause stale warnings."""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
await loop.run_in_executor(None, _migrate_strip_deprecated_features)
|
||||||
|
|
||||||
|
|
||||||
async def _background_domain_reachability_checker():
|
async def _background_domain_reachability_checker():
|
||||||
"""Periodically curl configured domains and cache reachability results."""
|
"""Periodically curl configured domains and cache reachability results."""
|
||||||
await asyncio.sleep(_DOMAIN_REACHABILITY_STARTUP_DELAY)
|
await asyncio.sleep(_DOMAIN_REACHABILITY_STARTUP_DELAY)
|
||||||
|
|||||||
@@ -155,6 +155,69 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── BIP-110 status badge (tile + detail modal) ───────────────────── */
|
||||||
|
|
||||||
|
.tile-bip110-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
font-size: 0.64rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
margin-top: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-bip110-badge--active {
|
||||||
|
background: rgba(109, 191, 139, 0.18);
|
||||||
|
color: var(--green);
|
||||||
|
border: 1px solid rgba(109, 191, 139, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-bip110-badge--locked_in {
|
||||||
|
background: rgba(94, 173, 138, 0.15);
|
||||||
|
color: var(--accent-color);
|
||||||
|
border: 1px solid rgba(94, 173, 138, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-bip110-badge--signaling {
|
||||||
|
background: rgba(94, 173, 138, 0.12);
|
||||||
|
color: var(--accent-color);
|
||||||
|
border: 1px solid rgba(94, 173, 138, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-bip110-badge--not_signaling {
|
||||||
|
background: rgba(229, 165, 10, 0.12);
|
||||||
|
color: var(--yellow);
|
||||||
|
border: 1px solid rgba(229, 165, 10, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-bip110-badge--unsupported {
|
||||||
|
background: rgba(94, 122, 106, 0.12);
|
||||||
|
color: var(--grey);
|
||||||
|
border: 1px solid rgba(94, 122, 106, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-bip110-badge--unknown {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-dim);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bip110-status-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bip110-source-label {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Service detail modal sections ───────────────────────────────── */
|
/* ── Service detail modal sections ───────────────────────────────── */
|
||||||
|
|
||||||
.svc-detail-section {
|
.svc-detail-section {
|
||||||
|
|||||||
@@ -413,16 +413,11 @@ function handleFeatureToggle(feat, newEnabled) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (conflictNames.length > 0) {
|
if (feat.id === "bitcoin-core") {
|
||||||
var confirmMsg;
|
var confirmMsg = "Only one Bitcoin node implementation can be active. Enabling Bitcoin Core will replace Bitcoin Knots + BIP110 as the active node. Your timechain data will be preserved — you will not need to re-download the timechain. Continue?";
|
||||||
if (feat.id === "bip110") {
|
|
||||||
confirmMsg = "Only one Bitcoin node implementation can be active. Enabling Bitcoin Knots + BIP110 will disable Bitcoin Core (if active). Your timechain data will be preserved — you will not need to re-download the timechain. Continue?";
|
|
||||||
} else if (feat.id === "bitcoin-core") {
|
|
||||||
confirmMsg = "Only one Bitcoin node implementation can be active. Enabling Bitcoin Core will disable Bitcoin Knots + BIP110 (if active). Your timechain data will be preserved — you will not need to re-download the timechain. Continue?";
|
|
||||||
} else {
|
|
||||||
confirmMsg = "This will disable " + conflictNames.join(", ") + ". Continue?";
|
|
||||||
}
|
|
||||||
openFeatureConfirm(confirmMsg, proceedAfterConflictCheck);
|
openFeatureConfirm(confirmMsg, proceedAfterConflictCheck);
|
||||||
|
} else if (conflictNames.length > 0) {
|
||||||
|
openFeatureConfirm("This will disable " + conflictNames.join(", ") + ". Continue?", proceedAfterConflictCheck);
|
||||||
} else {
|
} else {
|
||||||
proceedAfterConflictCheck();
|
proceedAfterConflictCheck();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,3 +60,17 @@ async function apiFetch(path, options) {
|
|||||||
}
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── BIP-110 badge state config ────────────────────────────────────
|
||||||
|
// Shared lookup used by tiles.js and service-detail.js.
|
||||||
|
// Keys match the "state" values returned by /api/bitcoin/bip110.
|
||||||
|
|
||||||
|
var BIP110_BADGE_CONFIG = {
|
||||||
|
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)' }
|
||||||
|
};
|
||||||
|
|||||||
@@ -107,6 +107,21 @@ async function openServiceDetailModal(unit, name, icon) {
|
|||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
|
|
||||||
|
// Section B2: BIP-110 live status (bip110 tile only)
|
||||||
|
if (icon === 'bip110' && data.bip110) {
|
||||||
|
var bip110 = data.bip110;
|
||||||
|
var bip110State = bip110.state || 'unknown';
|
||||||
|
var bip110Cfg = BIP110_BADGE_CONFIG[bip110State] || BIP110_BADGE_CONFIG.unknown;
|
||||||
|
var bip110Source = bip110.source ? ' <span class="bip110-source-label">(source: ' + escHtml(bip110.source) + ')</span>' : '';
|
||||||
|
html += '<div class="svc-detail-section">' +
|
||||||
|
'<div class="svc-detail-section-title">BIP-110 Deployment Status</div>' +
|
||||||
|
'<div class="bip110-status-row">' +
|
||||||
|
'<span class="tile-bip110-badge ' + bip110Cfg.cls + '" title="' + escHtml(bip110Cfg.title) + '">' + escHtml(bip110Cfg.label) + '</span>' +
|
||||||
|
bip110Source +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
// Section C: Domain diagnostics (domain services)
|
// Section C: Domain diagnostics (domain services)
|
||||||
if (data.needs_domain) {
|
if (data.needs_domain) {
|
||||||
var steps = data.domain_check_steps || [];
|
var steps = data.domain_check_steps || [];
|
||||||
@@ -242,7 +257,7 @@ async function openServiceDetailModal(unit, name, icon) {
|
|||||||
var addonBtnCls = feat.enabled ? "btn btn-close-modal" : "btn btn-primary";
|
var addonBtnCls = feat.enabled ? "btn btn-close-modal" : "btn btn-primary";
|
||||||
|
|
||||||
// Section title: use a more specific label for mutually-exclusive Bitcoin node features
|
// Section title: use a more specific label for mutually-exclusive Bitcoin node features
|
||||||
var addonSectionTitle = (feat.id === "bip110" || feat.id === "bitcoin-core")
|
var addonSectionTitle = (feat.id === "bitcoin-core")
|
||||||
? "\u20BF Bitcoin Node Selection"
|
? "\u20BF Bitcoin Node Selection"
|
||||||
: "\uD83D\uDD27 Addon Feature";
|
: "\uD83D\uDD27 Addon Feature";
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,21 @@
|
|||||||
// Keyed by tileId: { progress: float, timestamp: ms }
|
// Keyed by tileId: { progress: float, timestamp: ms }
|
||||||
var _btcSyncPrev = {};
|
var _btcSyncPrev = {};
|
||||||
|
|
||||||
|
// ── BIP-110 badge helper ──────────────────────────────────────────
|
||||||
|
|
||||||
|
function _renderBip110Badge(bip110) {
|
||||||
|
if (!bip110) return '';
|
||||||
|
var state = bip110.state || 'unknown';
|
||||||
|
var cfg = BIP110_BADGE_CONFIG[state] || BIP110_BADGE_CONFIG.unknown;
|
||||||
|
return '<div class="tile-bip110-badge ' + cfg.cls + '" title="' + escHtml(cfg.title) + '">' + escHtml(cfg.label) + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _firstElementFromHtml(html) {
|
||||||
|
var tmp = document.createElement("div");
|
||||||
|
tmp.innerHTML = html;
|
||||||
|
return tmp.firstElementChild || null;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Render: initial build ─────────────────────────────────────────
|
// ── Render: initial build ─────────────────────────────────────────
|
||||||
|
|
||||||
function buildTiles(services, categoryLabels) {
|
function buildTiles(services, categoryLabels) {
|
||||||
@@ -165,7 +180,8 @@ function buildTile(svc) {
|
|||||||
|
|
||||||
var ver = svc.version || svc.bitcoin_version || '';
|
var ver = svc.version || svc.bitcoin_version || '';
|
||||||
var versionLabel = ver ? '<div class="tile-version">' + escHtml(ver) + '</div>' : '';
|
var versionLabel = ver ? '<div class="tile-version">' + escHtml(ver) + '</div>' : '';
|
||||||
tile.innerHTML = '<img class="tile-icon" src="/static/icons/' + escHtml(svc.icon) + '.svg" alt="' + escHtml(svc.name) + '" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'flex\'"><div class="tile-icon-fallback" style="display:none">?</div><div class="tile-name">' + escHtml(svc.name) + '</div>' + versionLabel + '<div class="tile-status"><span class="status-dot ' + sc + '"></span><span class="status-text">' + st + '</span></div>';
|
var bip110Badge = (svc.icon === 'bip110') ? _renderBip110Badge(svc.bip110) : '';
|
||||||
|
tile.innerHTML = '<img class="tile-icon" src="/static/icons/' + escHtml(svc.icon) + '.svg" alt="' + escHtml(svc.name) + '" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'flex\'"><div class="tile-icon-fallback" style="display:none">?</div><div class="tile-name">' + escHtml(svc.name) + '</div>' + versionLabel + bip110Badge + '<div class="tile-status"><span class="status-dot ' + sc + '"></span><span class="status-text">' + st + '</span></div>';
|
||||||
|
|
||||||
tile.style.cursor = "pointer";
|
tile.style.cursor = "pointer";
|
||||||
tile.addEventListener("click", function() {
|
tile.addEventListener("click", function() {
|
||||||
@@ -265,6 +281,23 @@ function updateTiles(services) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Update BIP-110 badge for bip110 tiles
|
||||||
|
if (svc.icon === 'bip110') {
|
||||||
|
var badgeHtml = _renderBip110Badge(svc.bip110);
|
||||||
|
var badgeEl = tile.querySelector(".tile-bip110-badge");
|
||||||
|
if (badgeEl) {
|
||||||
|
// Replace existing badge in-place
|
||||||
|
var newBadge = _firstElementFromHtml(badgeHtml);
|
||||||
|
if (newBadge) { badgeEl.replaceWith(newBadge); } else { badgeEl.remove(); }
|
||||||
|
} else if (badgeHtml) {
|
||||||
|
// Insert badge after version label (or after tile-name if no version)
|
||||||
|
var anchorEl = tile.querySelector(".tile-version") || tile.querySelector(".tile-name");
|
||||||
|
if (anchorEl) {
|
||||||
|
var newBadgeEl = _firstElementFromHtml(badgeHtml);
|
||||||
|
if (newBadgeEl) anchorEl.insertAdjacentElement("afterend", newBadgeEl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
+2
-2
@@ -145,10 +145,10 @@
|
|||||||
ranger fastfetch gedit openssl pwgen
|
ranger fastfetch gedit openssl pwgen
|
||||||
aspell aspellDicts.en lm_sensors
|
aspell aspellDicts.en lm_sensors
|
||||||
hunspell hunspellDicts.en_US
|
hunspell hunspellDicts.en_US
|
||||||
synadm brave dua bitwarden-desktop
|
synadm brave dua
|
||||||
gparted pv unzip parted screen zenity
|
gparted pv unzip parted screen zenity
|
||||||
libargon2 gnome-terminal libreoffice-fresh
|
libargon2 gnome-terminal libreoffice-fresh
|
||||||
dig firefox element-desktop wp-cli axel
|
dig firefox wp-cli axel
|
||||||
lk-jwt-service livekit-libwebrtc livekit-cli livekit
|
lk-jwt-service livekit-libwebrtc livekit-cli livekit
|
||||||
matrix-synapse age
|
matrix-synapse age
|
||||||
];
|
];
|
||||||
|
|||||||
Generated
+19
-54
@@ -1,26 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"bip110": {
|
|
||||||
"inputs": {
|
|
||||||
"nixpkgs": "nixpkgs"
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1778967282,
|
|
||||||
"narHash": "sha256-0g9RvVCD6zxY2vy54GhbB1OeeEZdKuxTr9r0whcpRjQ=",
|
|
||||||
"owner": "emmanuelrosa",
|
|
||||||
"repo": "bitcoin-knots-bip-110-nix",
|
|
||||||
"rev": "8d23ed98940d70e42ee870d719677a073a0a5920",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "emmanuelrosa",
|
|
||||||
"repo": "bitcoin-knots-bip-110-nix",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"btc-clients": {
|
"btc-clients": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"nixpkgs": "nixpkgs_2"
|
"nixpkgs": "nixpkgs"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1780397635,
|
"lastModified": 1780397635,
|
||||||
@@ -105,7 +87,7 @@
|
|||||||
"inputs": {
|
"inputs": {
|
||||||
"extra-container": "extra-container",
|
"extra-container": "extra-container",
|
||||||
"flake-utils": "flake-utils",
|
"flake-utils": "flake-utils",
|
||||||
"nixpkgs": "nixpkgs_3",
|
"nixpkgs": "nixpkgs_2",
|
||||||
"nixpkgs-25_05": "nixpkgs-25_05",
|
"nixpkgs-25_05": "nixpkgs-25_05",
|
||||||
"nixpkgs-unstable": "nixpkgs-unstable"
|
"nixpkgs-unstable": "nixpkgs-unstable"
|
||||||
},
|
},
|
||||||
@@ -126,16 +108,15 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1777728799,
|
"lastModified": 1780218263,
|
||||||
"narHash": "sha256-z7jjYQqhkFKab92VQ3duB7QVO7f7Y62qTFrJYXO/lyo=",
|
"narHash": "sha256-T/f0pPDrH3Qc1VXyQXbK7yfHWRn90l3xwplc/nsxin4=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "4b2287113c2f9a2331c04899b2e2e5ab92dea9c5",
|
"rev": "7fc393d1b46fa000d48ff14e8b6a3c9985f03af0",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"ref": "master",
|
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
@@ -158,16 +139,16 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs-stable": {
|
"nixpkgs-stable": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1751274312,
|
"lastModified": 1780453794,
|
||||||
"narHash": "sha256-/bVBlRpECLVzjV19t5KMdMFWSwKLtb5RyXdjz3LJT+g=",
|
"narHash": "sha256-bXMRa9VTsHSPXL4Cw8R6JJLQeY3Y/IP4+YJCYVmQ7FY=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "50ab793786d9de88ee30ec4e4c24fb4236fc2674",
|
"rev": "6b316287bae2ee04c9b93c8c858d930fd07d7338",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"ref": "nixos-24.11",
|
"ref": "nixos-26.05",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
@@ -189,21 +170,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nixpkgs_2": {
|
"nixpkgs_2": {
|
||||||
"locked": {
|
|
||||||
"lastModified": 1780218263,
|
|
||||||
"narHash": "sha256-T/f0pPDrH3Qc1VXyQXbK7yfHWRn90l3xwplc/nsxin4=",
|
|
||||||
"owner": "NixOS",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"rev": "7fc393d1b46fa000d48ff14e8b6a3c9985f03af0",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "nixos",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nixpkgs_3": {
|
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1778737229,
|
"lastModified": 1778737229,
|
||||||
"narHash": "sha256-6xWoytx8jFW4PF1GjRm/i/53trbpKGfz6zjzQGBr4cI=",
|
"narHash": "sha256-6xWoytx8jFW4PF1GjRm/i/53trbpKGfz6zjzQGBr4cI=",
|
||||||
@@ -219,7 +185,7 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nixpkgs_4": {
|
"nixpkgs_3": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1780243769,
|
"lastModified": 1780243769,
|
||||||
"narHash": "sha256-x5UQuRsH3MqI0U9afaXSNqzTPSeZlRLvFAav2Ux1pNw=",
|
"narHash": "sha256-x5UQuRsH3MqI0U9afaXSNqzTPSeZlRLvFAav2Ux1pNw=",
|
||||||
@@ -235,13 +201,13 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nixpkgs_5": {
|
"nixpkgs_4": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1780030872,
|
"lastModified": 1780336545,
|
||||||
"narHash": "sha256-u6WU/yd/o8iYQrHX3RAwO1hYa3LkoSL+WNQD0rJfJZQ=",
|
"narHash": "sha256-vhVhuXzFrIOfcssC/9hDHx7MHzDKjF3keHuREOQqQiQ=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "e9a7635a57597d9754eccebdfc7045e6c8600e6b",
|
"rev": "4df1b885d76a54e1aa1a318f8d16fd6005b6401f",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -254,15 +220,15 @@
|
|||||||
"nixvim": {
|
"nixvim": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"flake-parts": "flake-parts",
|
"flake-parts": "flake-parts",
|
||||||
"nixpkgs": "nixpkgs_5",
|
"nixpkgs": "nixpkgs_4",
|
||||||
"systems": "systems_2"
|
"systems": "systems_2"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1780235872,
|
"lastModified": 1780646548,
|
||||||
"narHash": "sha256-/TTVFhJQ5StCOrRFO+5NfSkYPSbAAekwymovcQzxNgE=",
|
"narHash": "sha256-Ckyl/l1XBmEwnaHcHD8PvBZk1uph0NqwbJ//CAvB7iE=",
|
||||||
"owner": "nix-community",
|
"owner": "nix-community",
|
||||||
"repo": "nixvim",
|
"repo": "nixvim",
|
||||||
"rev": "e5c7b40dc569f5c97ba2182d409f0fb54c02d7c1",
|
"rev": "816a15282e58678dde831477964987d0262d4293",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -273,10 +239,9 @@
|
|||||||
},
|
},
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"bip110": "bip110",
|
|
||||||
"btc-clients": "btc-clients",
|
"btc-clients": "btc-clients",
|
||||||
"nix-bitcoin": "nix-bitcoin",
|
"nix-bitcoin": "nix-bitcoin",
|
||||||
"nixpkgs": "nixpkgs_4",
|
"nixpkgs": "nixpkgs_3",
|
||||||
"nixpkgs-stable": "nixpkgs-stable",
|
"nixpkgs-stable": "nixpkgs-stable",
|
||||||
"nixvim": "nixvim"
|
"nixvim": "nixvim"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,10 @@
|
|||||||
nix-bitcoin.url = "github:fort-nix/nix-bitcoin/release";
|
nix-bitcoin.url = "github:fort-nix/nix-bitcoin/release";
|
||||||
nixvim.url = "github:nix-community/nixvim";
|
nixvim.url = "github:nix-community/nixvim";
|
||||||
btc-clients.url = "github:emmanuelrosa/btc-clients-nix";
|
btc-clients.url = "github:emmanuelrosa/btc-clients-nix";
|
||||||
nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-24.11";
|
nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-26.05";
|
||||||
bip110.url = "github:emmanuelrosa/bitcoin-knots-bip-110-nix";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs = { self, nixpkgs, nix-bitcoin, nixvim, btc-clients, nixpkgs-stable, bip110, ... }:
|
outputs = { self, nixpkgs, nix-bitcoin, nixvim, btc-clients, nixpkgs-stable, ... }:
|
||||||
|
|
||||||
let
|
let
|
||||||
overlay-stable = final: prev: {
|
overlay-stable = final: prev: {
|
||||||
@@ -56,7 +55,6 @@
|
|||||||
btc-clients.packages.${pkgs.system}.bisq2
|
btc-clients.packages.${pkgs.system}.bisq2
|
||||||
btc-clients.packages.${pkgs.system}.sparrow
|
btc-clients.packages.${pkgs.system}.sparrow
|
||||||
];
|
];
|
||||||
sovran_systemsOS.packages.bip110 = bip110.packages.${pkgs.system}.bitcoind-knots-bip-110;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
{ config, lib, pkgs, ... }:
|
|
||||||
|
|
||||||
let
|
|
||||||
cfg = config.sovran_systemsOS;
|
|
||||||
in
|
|
||||||
{
|
|
||||||
options.sovran_systemsOS.packages.bip110 = lib.mkOption {
|
|
||||||
type = lib.types.nullOr lib.types.package;
|
|
||||||
default = null;
|
|
||||||
description = "BIP110 Bitcoin package";
|
|
||||||
};
|
|
||||||
|
|
||||||
config = lib.mkIf (
|
|
||||||
cfg.features.bip110 &&
|
|
||||||
cfg.packages.bip110 != null
|
|
||||||
) {
|
|
||||||
services.bitcoind.package = lib.mkForce cfg.packages.bip110;
|
|
||||||
|
|
||||||
environment.systemPackages = [
|
|
||||||
cfg.packages.bip110
|
|
||||||
];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ lib.mkIf config.sovran_systemsOS.services.bitcoin {
|
|||||||
|
|
||||||
services.bitcoind = {
|
services.bitcoind = {
|
||||||
enable = true;
|
enable = true;
|
||||||
package = config.nix-bitcoin.pkgs.bitcoind-knots;
|
package = pkgs.bitcoind-knots;
|
||||||
dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node";
|
dataDir = "/run/media/Second_Drive/BTCEcoandBackup/Bitcoin_Node";
|
||||||
txindex = true;
|
txindex = true;
|
||||||
tor.proxy = true;
|
tor.proxy = true;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
})
|
})
|
||||||
|
|
||||||
# ── Bitcoin Node Only Role ────────────────────────────────
|
# ── Bitcoin Node Only Role ────────────────────────────────
|
||||||
# Bitcoin ecosystem + mempool + bip110, BTCPay runs but not exposed via Caddy
|
# Bitcoin ecosystem + mempool, BTCPay runs but not exposed via Caddy
|
||||||
(lib.mkIf config.sovran_systemsOS.roles.node {
|
(lib.mkIf config.sovran_systemsOS.roles.node {
|
||||||
sovran_systemsOS.services = {
|
sovran_systemsOS.services = {
|
||||||
bitcoin = lib.mkDefault true;
|
bitcoin = lib.mkDefault true;
|
||||||
@@ -36,7 +36,6 @@
|
|||||||
|
|
||||||
sovran_systemsOS.features = {
|
sovran_systemsOS.features = {
|
||||||
mempool = lib.mkDefault true;
|
mempool = lib.mkDefault true;
|
||||||
bip110 = lib.mkDefault true;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
sovran_systemsOS.web.btcpayserver = lib.mkDefault false;
|
sovran_systemsOS.web.btcpayserver = lib.mkDefault false;
|
||||||
|
|||||||
+24
-1
@@ -43,12 +43,24 @@
|
|||||||
# ── Features (default OFF — user can enable in custom.nix) ──
|
# ── Features (default OFF — user can enable in custom.nix) ──
|
||||||
features = {
|
features = {
|
||||||
haven = lib.mkEnableOption "Haven NOSTR relay";
|
haven = lib.mkEnableOption "Haven NOSTR relay";
|
||||||
bip110 = lib.mkEnableOption "BIP-110 Bitcoin Better Money";
|
|
||||||
mempool = lib.mkEnableOption "Bitcoin Mempool Explorer";
|
mempool = lib.mkEnableOption "Bitcoin Mempool Explorer";
|
||||||
element-calling = lib.mkEnableOption "Element Video and Audio Calling";
|
element-calling = lib.mkEnableOption "Element Video and Audio Calling";
|
||||||
bitcoin-core = lib.mkEnableOption "Bitcoin Core";
|
bitcoin-core = lib.mkEnableOption "Bitcoin Core";
|
||||||
rdp = lib.mkEnableOption "Gnome Remote Desktop";
|
rdp = lib.mkEnableOption "Gnome Remote Desktop";
|
||||||
sshd = lib.mkEnableOption "SSH remote access";
|
sshd = lib.mkEnableOption "SSH remote access";
|
||||||
|
|
||||||
|
# Deprecated: BIP-110 is now built into mainline Bitcoin Knots and is the
|
||||||
|
# default node. This option is retained ONLY so that existing machines with
|
||||||
|
# `sovran_systemsOS.features.bip110 = lib.mkForce true;` left in their local
|
||||||
|
# custom.nix continue to evaluate. It has no effect and will be removed in a
|
||||||
|
# future release once the Hub has cleaned up old custom.nix files.
|
||||||
|
bip110 = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.bool;
|
||||||
|
default = null;
|
||||||
|
internal = true;
|
||||||
|
visible = false;
|
||||||
|
description = "(Deprecated, no-op) BIP-110 is now built into Bitcoin Knots.";
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
# ── Web exposure (controls Caddy vhosts) ──────────────────
|
# ── Web exposure (controls Caddy vhosts) ──────────────────
|
||||||
@@ -89,4 +101,15 @@
|
|||||||
description = "Nostr public key (npub1...) for Haven relay";
|
description = "Nostr public key (npub1...) for Haven relay";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf (config.sovran_systemsOS.features.bip110 != null) {
|
||||||
|
warnings = [
|
||||||
|
''
|
||||||
|
sovran_systemsOS.features.bip110 is deprecated and has no effect:
|
||||||
|
BIP-110 is now built into mainline Bitcoin Knots, which is the default node.
|
||||||
|
You can safely remove the `sovran_systemsOS.features.bip110` line from
|
||||||
|
/etc/nixos/custom.nix. The Sovran Hub will also remove it automatically.
|
||||||
|
''
|
||||||
|
];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,10 +29,7 @@ let
|
|||||||
]
|
]
|
||||||
# ── Bitcoin Base (node implementations) ────────────────────
|
# ── Bitcoin Base (node implementations) ────────────────────
|
||||||
++ lib.optionals cfg.services.bitcoin [
|
++ lib.optionals cfg.services.bitcoin [
|
||||||
{ name = "Bitcoin Knots + BIP110"; unit = "bitcoind.service"; type = "system"; icon = "bip110"; enabled = cfg.features.bip110; category = "bitcoin-base"; credentials = [
|
{ name = "Bitcoin Knots + BIP110"; unit = "bitcoind.service"; type = "system"; icon = "bip110"; enabled = cfg.services.bitcoin && !cfg.features.bitcoin-core; category = "bitcoin-base"; credentials = [
|
||||||
{ label = "Tor Address — Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/bitcoind/hostname"; prefix = "http://"; }
|
|
||||||
]; }
|
|
||||||
{ name = "Bitcoin Knots"; unit = "bitcoind.service"; type = "system"; icon = "bitcoind"; enabled = cfg.services.bitcoin && !cfg.features.bitcoin-core && !cfg.features.bip110; category = "bitcoin-base"; credentials = [
|
|
||||||
{ label = "Tor Address — Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/bitcoind/hostname"; prefix = "http://"; }
|
{ label = "Tor Address — Access from anywhere via Tor Browser"; file = "/var/lib/tor/onion/bitcoind/hostname"; prefix = "http://"; }
|
||||||
]; }
|
]; }
|
||||||
{ name = "Bitcoin Core"; unit = "bitcoind.service"; type = "system"; icon = "bitcoin-core"; enabled = cfg.features.bitcoin-core; category = "bitcoin-base"; credentials = [
|
{ name = "Bitcoin Core"; unit = "bitcoind.service"; type = "system"; icon = "bitcoin-core"; enabled = cfg.features.bitcoin-core; category = "bitcoin-base"; credentials = [
|
||||||
|
|||||||
@@ -31,7 +31,6 @@
|
|||||||
|
|
||||||
# ── Features (default OFF — enable in custom.nix) ─────────
|
# ── Features (default OFF — enable in custom.nix) ─────────
|
||||||
./haven.nix
|
./haven.nix
|
||||||
./bip110.nix
|
|
||||||
./element-calling.nix
|
./element-calling.nix
|
||||||
./mempool.nix
|
./mempool.nix
|
||||||
./bitcoin-core.nix
|
./bitcoin-core.nix
|
||||||
|
|||||||
Reference in New Issue
Block a user