From 61ff6e2ca487bbd3e4237b5093287326eaa48450 Mon Sep 17 00:00:00 2001 From: lucasdbr05 Date: Wed, 8 Apr 2026 00:36:56 -0300 Subject: [PATCH 1/9] feat(charts): add btcd Helm chart Add a new Helm chart for btcd nodes, including: - Chart.yaml with chart metadata and dependencies - values.yaml with default configuration values - Configmap template for btcd node configuration - Pod template for running the btcd container - PVC template for persistent volume claims - Service template for exposing the btcd node - ServiceMonitor template for Prometheus scraping - _helpers.tpl with reusable template helpers - NOTES.txt with post-install instructions --- resources/charts/btcd/.helmignore | 23 +++ resources/charts/btcd/Chart.yaml | 9 ++ resources/charts/btcd/templates/NOTES.txt | 1 + resources/charts/btcd/templates/_helpers.tpl | 59 ++++++++ .../charts/btcd/templates/configmap.yaml | 20 +++ resources/charts/btcd/templates/pod.yaml | 133 ++++++++++++++++++ resources/charts/btcd/templates/pvc.yaml | 19 +++ resources/charts/btcd/templates/service.yaml | 24 ++++ .../charts/btcd/templates/servicemonitor.yaml | 16 +++ resources/charts/btcd/values.yaml | 107 ++++++++++++++ 10 files changed, 411 insertions(+) create mode 100644 resources/charts/btcd/.helmignore create mode 100644 resources/charts/btcd/Chart.yaml create mode 100644 resources/charts/btcd/templates/NOTES.txt create mode 100644 resources/charts/btcd/templates/_helpers.tpl create mode 100644 resources/charts/btcd/templates/configmap.yaml create mode 100644 resources/charts/btcd/templates/pod.yaml create mode 100644 resources/charts/btcd/templates/pvc.yaml create mode 100644 resources/charts/btcd/templates/service.yaml create mode 100644 resources/charts/btcd/templates/servicemonitor.yaml create mode 100644 resources/charts/btcd/values.yaml diff --git a/resources/charts/btcd/.helmignore b/resources/charts/btcd/.helmignore new file mode 100644 index 000000000..0e8a0eb36 --- /dev/null +++ b/resources/charts/btcd/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/resources/charts/btcd/Chart.yaml b/resources/charts/btcd/Chart.yaml new file mode 100644 index 000000000..7b157e8b3 --- /dev/null +++ b/resources/charts/btcd/Chart.yaml @@ -0,0 +1,9 @@ +apiVersion: v2 +name: btcd +description: A Helm chart for btcd (alternative Bitcoin full node in Go) + +# Application chart +type: application + +version: 0.1.0 +appVersion: 0.1.0 diff --git a/resources/charts/btcd/templates/NOTES.txt b/resources/charts/btcd/templates/NOTES.txt new file mode 100644 index 000000000..89ab6feba --- /dev/null +++ b/resources/charts/btcd/templates/NOTES.txt @@ -0,0 +1 @@ +Thank you for installing {{ include "btcd.fullname" . }}. diff --git a/resources/charts/btcd/templates/_helpers.tpl b/resources/charts/btcd/templates/_helpers.tpl new file mode 100644 index 000000000..ca53cbbad --- /dev/null +++ b/resources/charts/btcd/templates/_helpers.tpl @@ -0,0 +1,59 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "btcd.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "btcd.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s" .Release.Name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "btcd.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "btcd.labels" -}} +helm.sh/chart: {{ include "btcd.chart" . }} +{{ include "btcd.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "btcd.selectorLabels" -}} +app.kubernetes.io/name: {{ include "btcd.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Map chain name to btcd config file flag +*/}} +{{- define "btcd.chainFlag" -}} +{{- if eq .Values.global.chain "regtest" -}} +simnet=1 +{{- else if eq .Values.global.chain "signet" -}} +signet=1 +{{- else if eq .Values.global.chain "testnet" -}} +testnet=1 +{{- else -}} +{{/* mainnet: no flag needed */}} +{{- end -}} +{{- end -}} diff --git a/resources/charts/btcd/templates/configmap.yaml b/resources/charts/btcd/templates/configmap.yaml new file mode 100644 index 000000000..fb2180a80 --- /dev/null +++ b/resources/charts/btcd/templates/configmap.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "btcd.fullname" . }} + labels: + {{- include "btcd.labels" . | nindent 4 }} +data: + btcd.conf: | + {{ include "btcd.chainFlag" . }} + rpclisten=0.0.0.0:{{ index .Values.global .Values.global.chain "RPCListenPort" }} + rpcuser={{ .Values.global.rpcuser }} + rpcpass={{ .Values.global.rpcpassword }} + listen=0.0.0.0:{{ index .Values.global .Values.global.chain "P2PPort" }} + debuglevel=info + txindex=1 + {{- $p2pPort := index .Values.global .Values.global.chain "P2PPort" }} + {{- range .Values.addnode }} + {{- printf "addpeer=%s:%v" . $p2pPort | nindent 4 }} + {{- end }} + {{- .Values.config | nindent 4 }} diff --git a/resources/charts/btcd/templates/pod.yaml b/resources/charts/btcd/templates/pod.yaml new file mode 100644 index 000000000..499581d6a --- /dev/null +++ b/resources/charts/btcd/templates/pod.yaml @@ -0,0 +1,133 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{ include "btcd.fullname" . }} + labels: + {{- include "btcd.labels" . | nindent 4 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 4 }} + {{- end }} + chain: {{ .Values.global.chain }} + P2PPort: "{{ index .Values.global .Values.global.chain "P2PPort" }}" + RPCPort: "{{ index .Values.global .Values.global.chain "RPCListenPort" }}" + rpcpassword: {{ .Values.global.rpcpassword }} + app: {{ include "btcd.fullname" . }} + implementation: btcd + {{- if .Values.collectLogs }} + collect_logs: "true" + {{- end }} + annotations: + init_peers: "{{ .Values.addnode | len }}" +spec: + restartPolicy: "{{ .Values.restartPolicy }}" + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 4 }} + {{- end }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 4 }} + {{- if .Values.loadSnapshot.enabled }} + initContainers: + - name: download-blocks + image: alpine:latest + command: ["/bin/sh", "-c"] + args: + - | + apk add --no-cache curl + mkdir -p /root/.btcd/data + curl -L {{ .Values.loadSnapshot.url }} | tar -xz -C /root/.btcd/data + volumeMounts: + - name: data + mountPath: /root/.btcd + {{- end }} + containers: + - name: btcd + securityContext: + {{- toYaml .Values.securityContext | nindent 8 }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + command: + - "/bin/linux_amd64/btcd" + args: + - "--configfile=/root/.btcd/btcd.conf" + {{- if .Values.extraArgs }} + {{- range (split " " .Values.extraArgs) }} + - {{ . | quote }} + {{- end }} + {{- end }} + ports: + - name: rpc + containerPort: {{ index .Values.global .Values.global.chain "RPCListenPort" }} + protocol: TCP + - name: p2p + containerPort: {{ index .Values.global .Values.global.chain "P2PPort" }} + protocol: TCP + livenessProbe: + {{- toYaml .Values.livenessProbe | nindent 8 }} + readinessProbe: + {{- toYaml .Values.readinessProbe | nindent 8 }} + tcpSocket: + port: {{ index .Values.global .Values.global.chain "RPCListenPort" }} + resources: + {{- toYaml .Values.resources | nindent 8 }} + volumeMounts: + {{- with .Values.volumeMounts }} + {{- toYaml . | nindent 8 }} + {{- end }} + - mountPath: /root/.btcd + name: data + - mountPath: /root/.btcd/btcd.conf + name: config + subPath: btcd.conf + {{- if .Values.metricsExport }} + - name: prometheus + image: bitcoindevproject/bitcoin-exporter:latest + imagePullPolicy: IfNotPresent + ports: + - name: prom-metrics + containerPort: {{ .Values.prometheusMetricsPort }} + protocol: TCP + env: + - name: BITCOIN_RPC_HOST + value: "127.0.0.1" + - name: BITCOIN_RPC_PORT + value: "{{ index .Values.global .Values.global.chain "RPCListenPort" }}" + - name: BITCOIN_RPC_USER + value: {{ .Values.global.rpcuser }} + - name: BITCOIN_RPC_PASSWORD + value: {{ .Values.global.rpcpassword }} + {{- end }} + {{- with .Values.extraContainers }} + {{- toYaml . | nindent 4 }} + {{- end }} + volumes: + {{- with .Values.volumes }} + {{- toYaml . | nindent 4 }} + {{- end }} + - name: data + {{- if .Values.persistence.enabled }} + {{- if .Values.persistence.existingClaim }} + persistentVolumeClaim: + claimName: {{ .Values.persistence.existingClaim }} + {{- else }} + persistentVolumeClaim: + claimName: {{ include "btcd.fullname" . }}.{{ .Release.Namespace }}-btcd-data + {{- end }} + {{- else }} + emptyDir: {} + {{- end }} + - name: config + configMap: + name: {{ include "btcd.fullname" . }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 4 }} + {{- end }} diff --git a/resources/charts/btcd/templates/pvc.yaml b/resources/charts/btcd/templates/pvc.yaml new file mode 100644 index 000000000..f1c6cd463 --- /dev/null +++ b/resources/charts/btcd/templates/pvc.yaml @@ -0,0 +1,19 @@ +{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "btcd.fullname" . }}.{{ .Release.Namespace }}-btcd-data + labels: + {{- include "btcd.labels" . | nindent 4 }} + annotations: + "helm.sh/resource-policy": keep +spec: + accessModes: + - {{ .Values.persistence.accessMode }} + resources: + requests: + storage: {{ .Values.persistence.size }} + {{- if .Values.persistence.storageClass }} + storageClassName: {{ .Values.persistence.storageClass }} + {{- end }} +{{- end }} diff --git a/resources/charts/btcd/templates/service.yaml b/resources/charts/btcd/templates/service.yaml new file mode 100644 index 000000000..43fe8713a --- /dev/null +++ b/resources/charts/btcd/templates/service.yaml @@ -0,0 +1,24 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "btcd.fullname" . }} + labels: + {{- include "btcd.labels" . | nindent 4 }} + app: {{ include "btcd.fullname" . }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ index .Values.global .Values.global.chain "RPCListenPort" }} + targetPort: rpc + protocol: TCP + name: rpc + - port: {{ index .Values.global .Values.global.chain "P2PPort" }} + targetPort: p2p + protocol: TCP + name: p2p + - port: {{ .Values.prometheusMetricsPort }} + targetPort: prom-metrics + protocol: TCP + name: prometheus-metrics + selector: + {{- include "btcd.selectorLabels" . | nindent 4 }} diff --git a/resources/charts/btcd/templates/servicemonitor.yaml b/resources/charts/btcd/templates/servicemonitor.yaml new file mode 100644 index 000000000..a6342b1f1 --- /dev/null +++ b/resources/charts/btcd/templates/servicemonitor.yaml @@ -0,0 +1,16 @@ +{{- if .Values.metricsExport }} +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ include "btcd.fullname" . }} + labels: + app.kubernetes.io/name: btcd-metrics + release: prometheus +spec: + endpoints: + - port: prometheus-metrics + interval: {{ .Values.metricsScrapeInterval | default "15s" }} + selector: + matchLabels: + app: {{ include "btcd.fullname" . }} +{{- end }} diff --git a/resources/charts/btcd/values.yaml b/resources/charts/btcd/values.yaml new file mode 100644 index 000000000..be4c3dd34 --- /dev/null +++ b/resources/charts/btcd/values.yaml @@ -0,0 +1,107 @@ +# Default values for btcd. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. +namespace: warnet + +restartPolicy: Never + +image: + repository: lucasdbr05/btcd + pullPolicy: IfNotPresent + tag: "latest" + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +podLabels: + app: "warnet" + mission: "tank" + +podSecurityContext: {} + +securityContext: {} + +service: + type: ClusterIP + +ingress: + enabled: false + className: "" + annotations: {} + hosts: + - host: chart-example.local + paths: + - path: / + pathType: ImplementationSpecific + tls: [] + +resources: {} + +livenessProbe: + exec: + command: + - pidof + - btcd + failureThreshold: 12 + initialDelaySeconds: 5 + periodSeconds: 5 + successThreshold: 1 + timeoutSeconds: 10 +readinessProbe: + failureThreshold: 12 + periodSeconds: 5 + successThreshold: 1 + timeoutSeconds: 10 + +# Node data persistence configuration +persistence: + enabled: false + storageClass: "" + accessMode: ReadWriteOncePod + size: 20Gi + existingClaim: "" + +volumes: [] +volumeMounts: [] + +nodeSelector: {} +tolerations: [] +affinity: {} + +collectLogs: false +metricsExport: false +prometheusMetricsPort: 9332 + +global: + chain: regtest + regtest: + RPCPort: 18443 + RPCListenPort: 18334 + P2PPort: 18444 + signet: + RPCPort: 38332 + RPCListenPort: 38334 + P2PPort: 38333 + testnet: + RPCPort: 18332 + RPCListenPort: 18334 + P2PPort: 18333 + mainnet: + RPCPort: 8332 + RPCListenPort: 8334 + P2PPort: 8333 + rpcuser: user + rpcpassword: gn0cchi + +# btcd configuration flags (passed as CLI args) +# btcd does not use a config file in the same way as Bitcoin Core +extraArgs: "" + +config: "" + +addnode: [] + +loadSnapshot: + enabled: false + url: "" From 4a83c49bc823c6650718ae5d9c49df04252541d5 Mon Sep 17 00:00:00 2001 From: lucasdbr05 Date: Wed, 8 Apr 2026 00:38:02 -0300 Subject: [PATCH 2/9] feat(examples): add btcd example Add example network configurations for btcd nodes, including: - 6_node_btcd: a 6-node btcd-only network topology --- examples/networks/6_node_btcd/network.yaml | 47 +++++++++++++++++++ .../networks/6_node_btcd/node-defaults.yaml | 13 +++++ 2 files changed, 60 insertions(+) create mode 100644 examples/networks/6_node_btcd/network.yaml create mode 100644 examples/networks/6_node_btcd/node-defaults.yaml diff --git a/examples/networks/6_node_btcd/network.yaml b/examples/networks/6_node_btcd/network.yaml new file mode 100644 index 000000000..57848a3b9 --- /dev/null +++ b/examples/networks/6_node_btcd/network.yaml @@ -0,0 +1,47 @@ +nodes: + - name: tank-0001 + implementation: btcd + image: + repository: lucasdbr05/btcd + tag: "latest" + addnode: + - tank-0002 + - tank-0003 + - name: tank-0002 + implementation: btcd + image: + repository: lucasdbr05/btcd + tag: "latest" + addnode: + - tank-0003 + - tank-0004 + - name: tank-0003 + implementation: btcd + image: + repository: lucasdbr05/btcd + tag: "latest" + addnode: + - tank-0004 + - tank-0005 + - name: tank-0004 + implementation: btcd + image: + repository: lucasdbr05/btcd + tag: "latest" + addnode: + - tank-0005 + - tank-0006 + - name: tank-0005 + implementation: btcd + image: + repository: lucasdbr05/btcd + tag: "latest" + addnode: + - tank-0006 + - name: tank-0006 + implementation: btcd + image: + repository: lucasdbr05/btcd + tag: "latest" +caddy: + enabled: true diff --git a/examples/networks/6_node_btcd/node-defaults.yaml b/examples/networks/6_node_btcd/node-defaults.yaml new file mode 100644 index 000000000..5064f7dca --- /dev/null +++ b/examples/networks/6_node_btcd/node-defaults.yaml @@ -0,0 +1,13 @@ +chain: regtest + +implementation: btcd + +collectLogs: true +metricsExport: false + +resources: {} + +image: + repository: lucasdbr05/btcd + pullPolicy: IfNotPresent + tag: "latest" From e199ba79657c2fe15a9117a775835a33e62e1649 Mon Sep 17 00:00:00 2001 From: lucasdbr05 Date: Wed, 8 Apr 2026 08:57:37 -0300 Subject: [PATCH 3/9] feat(btcd): add btcd support to core warnet modules Extend warnet to support btcd nodes alongside Bitcoin Core --- .../charts/bitcoincore/templates/pod.yaml | 1 + src/warnet/bitcoin.py | 31 ++++++++++++++++--- src/warnet/constants.py | 9 ++++++ src/warnet/control.py | 3 +- src/warnet/deploy.py | 18 +++++++++-- 5 files changed, 54 insertions(+), 8 deletions(-) diff --git a/resources/charts/bitcoincore/templates/pod.yaml b/resources/charts/bitcoincore/templates/pod.yaml index 2577468f9..c26e7a4e4 100644 --- a/resources/charts/bitcoincore/templates/pod.yaml +++ b/resources/charts/bitcoincore/templates/pod.yaml @@ -14,6 +14,7 @@ metadata: ZMQBlockPort: "{{ .Values.global.ZMQBlockPort }}" rpcpassword: {{ .Values.global.rpcpassword }} app: {{ include "bitcoincore.fullname" . }} + implementation: bitcoincore {{- if .Values.collectLogs }} collect_logs: "true" {{- end }} diff --git a/src/warnet/bitcoin.py b/src/warnet/bitcoin.py index 3bb8bc779..dbf4c68f8 100644 --- a/src/warnet/bitcoin.py +++ b/src/warnet/bitcoin.py @@ -12,11 +12,21 @@ from test_framework.p2p import MESSAGEMAP from urllib3.exceptions import MaxRetryError -from .constants import BITCOINCORE_CONTAINER -from .k8s import get_default_namespace_or, get_mission, pod_log +from .constants import BITCOINCORE_CONTAINER, BTCD_CONTAINER +from .k8s import get_default_namespace_or, get_mission, get_pod, pod_log from .process import run_command +def _get_node_container(tank: str, namespace: str) -> str: + """Determine which container name is running in the tank pod (bitcoincore or btcd).""" + try: + pod = get_pod(tank, namespace=namespace) + impl = pod.metadata.labels.get("implementation", BITCOINCORE_CONTAINER) + return impl if impl in (BTCD_CONTAINER, BITCOINCORE_CONTAINER) else BITCOINCORE_CONTAINER + except Exception: + return BITCOINCORE_CONTAINER + + @click.group(name="bitcoin") def bitcoin(): """Control running bitcoin nodes""" @@ -42,6 +52,9 @@ def rpc(tank: str, method: str, params: list[str], namespace: Optional[str]): def _rpc(tank: str, method: str, params: list[str], namespace: Optional[str] = None): namespace = get_default_namespace_or(namespace) + container = _get_node_container(tank, namespace) + is_btcd = container == BTCD_CONTAINER + if params: # First, try to join all parameters into a single string. full_param_str = " ".join(params) @@ -65,10 +78,16 @@ def _rpc(tank: str, method: str, params: list[str], namespace: Optional[str] = N # Quote each parameter individually to preserve them as separate arguments. param_str = " ".join(shlex.quote(p) for p in params) - cmd = f"kubectl -n {namespace} exec {tank} --container {BITCOINCORE_CONTAINER} -- bitcoin-cli {method} {param_str}" + if is_btcd: + cmd = f"kubectl -n {namespace} exec {tank} --container {container} -- /bin/linux_amd64/btcctl --rpcuser=user --rpcpass=gn0cchi --rpccert=/root/.btcd/rpc.cert --rpcserver=127.0.0.1:18334 --simnet {method} {param_str}" + else: + cmd = f"kubectl -n {namespace} exec {tank} --container {container} -- bitcoin-cli {method} {param_str}" else: # Handle commands with no parameters - cmd = f"kubectl -n {namespace} exec {tank} --container {BITCOINCORE_CONTAINER} -- bitcoin-cli {method}" + if is_btcd: + cmd = f"kubectl -n {namespace} exec {tank} --container {container} -- /bin/linux_amd64/btcctl --rpcuser=user --rpcpass=gn0cchi --rpccert=/root/.btcd/rpc.cert --rpcserver=127.0.0.1:18334 --simnet {method}" + else: + cmd = f"kubectl -n {namespace} exec {tank} --container {container} -- bitcoin-cli {method}" return run_command(cmd) @@ -111,7 +130,9 @@ def grep_logs(pattern: str, show_k8s_timestamps: bool, no_sort: bool): longest_namespace_len = len(tank.metadata.namespace) pod_name = tank.metadata.name - logs = pod_log(pod_name, BITCOINCORE_CONTAINER) + impl = (tank.metadata.labels or {}).get("implementation", BITCOINCORE_CONTAINER) + log_container = impl if impl in (BTCD_CONTAINER, BITCOINCORE_CONTAINER) else BITCOINCORE_CONTAINER + logs = pod_log(pod_name, log_container) if logs is not False: try: diff --git a/src/warnet/constants.py b/src/warnet/constants.py index d23e1518a..70c0b22bb 100644 --- a/src/warnet/constants.py +++ b/src/warnet/constants.py @@ -21,8 +21,15 @@ LIGHTNING_MISSION = "lightning" BITCOINCORE_CONTAINER = "bitcoincore" +BTCD_CONTAINER = "btcd" COMMANDER_CONTAINER = "commander" +# Supported node implementations +IMPLEMENTATION_BITCOINCORE = "bitcoincore" +IMPLEMENTATION_BTCD = "btcd" +SUPPORTED_IMPLEMENTATIONS = [IMPLEMENTATION_BITCOINCORE, IMPLEMENTATION_BTCD] +DEFAULT_IMPLEMENTATION = IMPLEMENTATION_BITCOINCORE + class HookValue(Enum): PRE_DEPLOY = "preDeploy" @@ -46,6 +53,7 @@ class AnnexMember(Enum): PLUGIN_ANNEX = "annex" DEFAULT_IMAGE_REPO = "bitcoindevproject/bitcoin" +DEFAULT_BTCD_IMAGE_REPO = "lucasdbr05/btcd" # Bitcoin Core config FORK_OBSERVER_RPCAUTH = "forkobserver:1418183465eecbd407010cf60811c6a0$d4e5f0647a63429c218da1302d7f19fe627302aeb0a71a74de55346a25d8057c" @@ -69,6 +77,7 @@ class AnnexMember(Enum): # Helm charts BITCOIN_CHART_LOCATION = str(CHARTS_DIR.joinpath("bitcoincore")) +BTCD_CHART_LOCATION = str(CHARTS_DIR.joinpath("btcd")) FORK_OBSERVER_CHART = str(CHARTS_DIR.joinpath("fork-observer")) COMMANDER_CHART = str(CHARTS_DIR.joinpath("commander")) NAMESPACES_CHART_LOCATION = CHARTS_DIR.joinpath("namespaces") diff --git a/src/warnet/control.py b/src/warnet/control.py index adf080e18..d2ece13c2 100644 --- a/src/warnet/control.py +++ b/src/warnet/control.py @@ -21,6 +21,7 @@ from .constants import ( BITCOINCORE_CONTAINER, + BTCD_CONTAINER, COMMANDER_CHART, COMMANDER_CONTAINER, COMMANDER_MISSION, @@ -515,7 +516,7 @@ def format_pods(pods: list[V1Pod]) -> list[str]: try: pod = get_pod(pod_name, namespace=namespace) - eligible_container_names = [BITCOINCORE_CONTAINER, COMMANDER_CONTAINER] + eligible_container_names = [BITCOINCORE_CONTAINER, BTCD_CONTAINER, COMMANDER_CONTAINER] available_container_names = [container.name for container in pod.spec.containers] container_name = next( ( diff --git a/src/warnet/deploy.py b/src/warnet/deploy.py index 9b9f3329f..51a826470 100644 --- a/src/warnet/deploy.py +++ b/src/warnet/deploy.py @@ -11,6 +11,7 @@ from .constants import ( BITCOIN_CHART_LOCATION, + BTCD_CHART_LOCATION, CADDY_CHART, DEFAULTS_FILE, DEFAULTS_NAMESPACE_FILE, @@ -18,6 +19,7 @@ FORK_OBSERVER_RPC_PASSWORD, FORK_OBSERVER_RPC_USER, HELM_COMMAND, + IMPLEMENTATION_BTCD, INGRESS_HELM_COMMANDS, LOGGING_CRD_COMMANDS, LOGGING_HELM_COMMANDS, @@ -348,7 +350,7 @@ def deploy_fork_observer(directory: Path, debug: bool) -> bool: for i, tank in enumerate(get_mission("tank")): node_name = tank.metadata.name for container in tank.spec.containers: - if container.name == "bitcoincore": + if container.name in ("bitcoincore", "btcd"): for port in container.ports: if port.name == "rpc": rpcport = port.container_port @@ -439,8 +441,20 @@ def deploy_single_node(node, directory: Path, debug: bool, namespace: str): node_name = node.get("name") node_config_override = {k: v for k, v in node.items() if k != "name"} + implementation = node.get("implementation", None) + + if implementation is None: + with open(directory / DEFAULTS_FILE) as f: + default_file = yaml.safe_load(f) or {} + implementation = default_file.get("implementation", None) + + if implementation == IMPLEMENTATION_BTCD: + chart_location = BTCD_CHART_LOCATION + else: + chart_location = BITCOIN_CHART_LOCATION + defaults_file_path = directory / DEFAULTS_FILE - cmd = f"{HELM_COMMAND} {node_name} {BITCOIN_CHART_LOCATION} --namespace {namespace} -f {defaults_file_path}" + cmd = f"{HELM_COMMAND} {node_name} {chart_location} --namespace {namespace} -f {defaults_file_path}" if debug: cmd += " --debug" From 6d3ead27b45f6f5e7558f0f2608dd069b826b01d Mon Sep 17 00:00:00 2001 From: lucasdbr05 Date: Wed, 22 Apr 2026 21:47:37 -0300 Subject: [PATCH 4/9] refactor: restart policy on failure for 6_node_btcd example --- examples/networks/6_node_btcd/node-defaults.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/networks/6_node_btcd/node-defaults.yaml b/examples/networks/6_node_btcd/node-defaults.yaml index 5064f7dca..6e16e0043 100644 --- a/examples/networks/6_node_btcd/node-defaults.yaml +++ b/examples/networks/6_node_btcd/node-defaults.yaml @@ -1,6 +1,7 @@ chain: regtest implementation: btcd +restartPolicy: OnFailure collectLogs: true metricsExport: false From 3708b3f151b9fcae75073416daeccd1d374a53b3 Mon Sep 17 00:00:00 2001 From: lucasdbr05 Date: Thu, 23 Apr 2026 14:46:56 -0300 Subject: [PATCH 5/9] feat(btcd): add BtcdRPC framework to interact with btcd nodes Add a lightweight JSON-RPC client for btcd nodes. It handles HTTPS communication with self-signed TLS certificates. It exposes standard Bitcoin RPC methods alongside btcd-specific extensions. --- .../scenarios/btcd_framework/__init__.py | 3 + resources/scenarios/btcd_framework/btcd.py | 169 ++++++++++++++++++ resources/scenarios/commander.py | 1 + src/warnet/control.py | 1 + 4 files changed, 174 insertions(+) create mode 100644 resources/scenarios/btcd_framework/__init__.py create mode 100644 resources/scenarios/btcd_framework/btcd.py diff --git a/resources/scenarios/btcd_framework/__init__.py b/resources/scenarios/btcd_framework/__init__.py new file mode 100644 index 000000000..66942aabe --- /dev/null +++ b/resources/scenarios/btcd_framework/__init__.py @@ -0,0 +1,3 @@ +from .btcd import BtcdRPC, BtcdRPCError + +__all__ = ["BtcdRPC", "BtcdRPCError"] diff --git a/resources/scenarios/btcd_framework/btcd.py b/resources/scenarios/btcd_framework/btcd.py new file mode 100644 index 000000000..76883ae96 --- /dev/null +++ b/resources/scenarios/btcd_framework/btcd.py @@ -0,0 +1,169 @@ +import http.client +import json +import logging +import ssl +import time +from base64 import b64encode +from typing import Any + +def _self_signed_context() -> ssl.SSLContext: + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + return ctx + + +class BtcdRPCError(Exception): + def __init__(self, code: int, message: str): + self.code = code + self.message = message + self.error = {"code": code, "message": message} + super().__init__(f"RPC error {code}: {message}") + + +class BtcdRPC: + def __init__( + self, + host: str, + port: int, + user: str, + password: str, + timeout: int = 60, + ): + self.host = host + self.port = port + self.timeout = timeout + self._auth_header = "Basic " + b64encode( + f"{user}:{password}".encode() + ).decode() + self._request_id = 0 + self.log = logging.getLogger(f"BtcdRPC({host}:{port})") + + + def _new_connection(self) -> http.client.HTTPSConnection: + return http.client.HTTPSConnection( + host=self.host, + port=self.port, + timeout=self.timeout, + context=_self_signed_context(), + ) + + def _build_payload(self, method: str, params: list) -> bytes: + self._request_id += 1 + return json.dumps( + { + "jsonrpc": "1.0", + "id": str(self._request_id), + "method": method, + "params": params, + } + ).encode() + + def _build_headers(self, payload: bytes) -> dict: + return { + "Authorization": self._auth_header, + "Content-Type": "application/json", + "Content-Length": str(len(payload)), + } + + def _send_with_retry(self, method: str, payload: bytes, headers: dict, max_attempts: int = 5) -> tuple[int, str]: + last_exc = RuntimeError("unreachable") + + for attempt in range(max_attempts): + if attempt > 0: + backoff = 2 ** attempt + self.log.debug("Retry %d for %s (backoff %ds)", attempt, method, backoff) + time.sleep(backoff) + + conn = self._new_connection() + + try: + conn.request("POST", "/", body=payload, headers=headers) + response = conn.getresponse() + return response.status, response.read().decode("utf-8") + except (BrokenPipeError, ConnectionResetError, OSError) as exc: + last_exc = exc + self.log.warning("Connection error on attempt %d for %s: %s", attempt + 1, method, exc) + finally: + conn.close() + + raise ConnectionError(f"btcd {method} failed after {max_attempts} attempts: {last_exc}") + + def _parse_response(self, method: str, status: int, raw: str): + body = json.loads(raw) + + if status != 200: + try: + err = body.get("error") or {} + raise BtcdRPCError( + code=err.get("code", status), + message=err.get("message", raw), + ) + except (json.JSONDecodeError, KeyError): + raise ConnectionError(f"btcd returned HTTP {status}: {raw[:200]}") + + + if body.get("error") is not None: + err = body["error"] + raise BtcdRPCError(code=err["code"], message=err["message"]) + + return body["result"] + + def _call(self, method: str, *params): + """Execute a JSON-RPC call and return the result field.""" + payload = self._build_payload(method, list(params)) + headers = self._build_headers(payload) + status, raw = self._send_with_retry(method, payload, headers) + return self._parse_response(method, status, raw) + + def __getattr__(self, name: str): + """Dispatch any unknown attribute as a JSON-RPC call.""" + if name.startswith("_"): + raise AttributeError(name) + + def method(*args): + return self._call(name, *args) + + method.__name__ = name + return method + + + def node(self, command: str, peer: str, connection_type: str = "") -> None: + if connection_type: + return self._call("node", command, peer, connection_type) + return self._call("node", command, peer) + + def searchrawtransactions( + self, + address: str, + verbose: int = 1, + skip: int = 0, + count: int = 100, + vin_extra: int = 0, + reverse: bool = False, + ) -> list: + return self._call( + "searchrawtransactions", address, verbose, skip, count, vin_extra, reverse + ) + + + # helpers + + def force_sync_from(self, source: "BtcdRPC") -> None: + p2p_port = getattr(source, "_p2p_port", 18444) + peer_addr = f"{source.host}:{p2p_port}" + + self.log.info("force_sync_from: disconnecting then reconnecting to %s", peer_addr) + try: + self.node("disconnect", peer_addr) + except Exception as e: + self.log.debug("disconnect %s (expected if not connected): %s", peer_addr, e) + time.sleep(1) + try: + self.node("connect", peer_addr, "perm") + except Exception as e: + self.log.debug("connect %s: %s", peer_addr, e) + + + def __repr__(self) -> str: + return f"BtcdRPC(host={self.host!r}, port={self.port})" diff --git a/resources/scenarios/commander.py b/resources/scenarios/commander.py index edc9b132c..67abd1ce7 100644 --- a/resources/scenarios/commander.py +++ b/resources/scenarios/commander.py @@ -102,6 +102,7 @@ "tank": pod.metadata.name, "namespace": pod.metadata.namespace, "chain": pod.metadata.labels["chain"], + "implementation": pod.metadata.labels.get("implementation", "bitcoincore"), "p2pport": int(pod.metadata.labels["P2PPort"]), "rpc_host": pod_ip, "rpc_port": int(pod.metadata.labels["RPCPort"]), diff --git a/src/warnet/control.py b/src/warnet/control.py index d2ece13c2..a60bbcf43 100644 --- a/src/warnet/control.py +++ b/src/warnet/control.py @@ -380,6 +380,7 @@ def filter(path): "commander.py", "test_framework", "ln_framework", + "btcd_framework", scenario_path.name, ] ): From 9cc2a8560c0554a90bf4a53770d7a21db6757641 Mon Sep 17 00:00:00 2001 From: lucasdbr05 Date: Mon, 27 Apr 2026 17:21:10 -0300 Subject: [PATCH 6/9] test: add scenarios related to btcd nodes --- .../networks/6_node_btcd/node-defaults.yaml | 4 + resources/scenarios/btcd_miner.py | 176 +++++++++++++ .../scenarios/test_scenarios/btcd_rpc_test.py | 240 ++++++++++++++++++ 3 files changed, 420 insertions(+) create mode 100644 resources/scenarios/btcd_miner.py create mode 100644 resources/scenarios/test_scenarios/btcd_rpc_test.py diff --git a/examples/networks/6_node_btcd/node-defaults.yaml b/examples/networks/6_node_btcd/node-defaults.yaml index 6e16e0043..117bae886 100644 --- a/examples/networks/6_node_btcd/node-defaults.yaml +++ b/examples/networks/6_node_btcd/node-defaults.yaml @@ -3,6 +3,10 @@ chain: regtest implementation: btcd restartPolicy: OnFailure +# TODO: find a better approach to can mine blocks (this address is just for tests) +config: | + miningaddr=Sh6VJ4TabWtfBm9kvLWPzj8WGNsMmtyaGF + collectLogs: true metricsExport: false diff --git a/resources/scenarios/btcd_miner.py b/resources/scenarios/btcd_miner.py new file mode 100644 index 000000000..b288e9792 --- /dev/null +++ b/resources/scenarios/btcd_miner.py @@ -0,0 +1,176 @@ +import time + +from btcd_framework import BtcdRPC, BtcdRPCError +from commander import Commander, WARNET + + +class BtcdMiner(Commander): + def set_test_params(self): + self.num_nodes = 1 + + def add_options(self, parser): + parser.description = "Mine blocks on a btcd network and log network status" + parser.usage = "warnet run /path/to/btcd_miner.py [options]" + parser.add_argument( + "--blocks", + dest="blocks", + default=5, + type=int, + help="Blocks to generate per round (default: 5)", + ) + parser.add_argument( + "--interval", + dest="interval", + default=30, + type=int, + help="Seconds between rounds (default: 30)", + ) + parser.add_argument( + "--rounds", + dest="rounds", + default=3, + type=int, + help="Number of mining rounds, 0 = infinite (default: 3)", + ) + + def _btcd_nodes(self) -> list[BtcdRPC]: + nodes = [] + for tank in WARNET["tanks"]: + impl = tank.get("implementation", "bitcoincore") + if impl != "btcd": + self.log.warning( + f"Skipping tank {tank['tank']} (implementation={impl})" + ) + continue + node = BtcdRPC( + host=tank["rpc_host"], + port=tank["rpc_port"], + user=tank["rpc_user"], + password=tank["rpc_password"], + ) + node._tank_name = tank["tank"] + node._p2p_port = tank.get("p2p_port", 18444) + nodes.append(node) + return nodes + + def _network_status(self, nodes: list[BtcdRPC]) -> dict: + status = {} + for node in nodes: + try: + height = node.getblockcount() + peers = len(node.getpeerinfo()) + mempool = node.getmempoolinfo().get("size", 0) + status[node._tank_name] = { + "height": height, + "peers": peers, + "mempool": mempool, + } + except Exception as exc: + status[node._tank_name] = {"error": str(exc)} + return status + + def _log_status(self, round_num: int, status: dict): + self.log.info("┌────────────────┬──────────┬─────────┬─────────────┐") + self.log.info(f"│ Round {round_num:>3} Network Status │") + self.log.info("├────────────────┬──────────┬─────────┬─────────────┤") + self.log.info("│ Node │ Height │ Peers │ Mempool │") + self.log.info("├────────────────┼──────────┼─────────┼─────────────┤") + for name, data in status.items(): + if "error" in data: + self.log.info(f"│ {name:<14}│ ERROR │ │ {data['error'][:11]:<11} │") + else: + self.log.info( + f"│ {name:<14}│ {data['height']:>6} │ {data['peers']:>5} │ {data['mempool']:>5} txs │" + ) + self.log.info("└────────────────┴──────────┴─────────┴─────────────┘") + + def _propagate(self, nodes: list[BtcdRPC], miner: BtcdRPC, target_height: int): + time.sleep(3) + + for node in nodes: + if node is miner: + continue + if node.getblockcount() < target_height: + node.force_sync_from(miner) + + timeout = 60 + for elapsed in range(timeout): + heights = {n._tank_name: n.getblockcount() for n in nodes} + if all(h >= target_height for h in heights.values()): + self.log.info( + f"All nodes synced to height {target_height} " + f"in ~{elapsed}s" + ) + return + if elapsed % 10 == 0 and elapsed > 0: + behind = {k: v for k, v in heights.items() if v < target_height} + self.log.info(f" Waiting for sync ({elapsed}s): {behind}") + time.sleep(1) + + heights = {n._tank_name: n.getblockcount() for n in nodes} + behind = {k: v for k, v in heights.items() if v < target_height} + if behind: + self.log.warning( + f" Sync timeout after {timeout}s. Still behind: {behind}" + ) + + + def run_test(self): + nodes = self._btcd_nodes() + if not nodes: + self.log.error("No btcd nodes found in the network. Aborting.") + return + + miner = nodes[0] + self.log.info( + f"btcd_miner: {len(nodes)} node(s) found — " + f"miner={miner._tank_name} " + f"[blocks={self.options.blocks}, interval={self.options.interval}s, " + f"rounds={'inf' if self.options.rounds == 0 else self.options.rounds}]" + ) + + self.log.info("=== Initial network status ===") + self._log_status(0, self._network_status(nodes)) + + round_num = 0 + while True: + round_num += 1 + if self.options.rounds > 0 and round_num > self.options.rounds: + break + + self.log.info( + f"=== Round {round_num}" + + (f"/{self.options.rounds}" if self.options.rounds > 0 else "") + + f": generating {self.options.blocks} block(s) on {miner._tank_name} ===" + ) + + try: + before = miner.getblockcount() + hashes = miner.generate(self.options.blocks) + after = miner.getblockcount() + self.log.info( + f" Mined {len(hashes)} block(s) — " + f"height {before} → {after}" + ) + for h in hashes: + self.log.info(f" {h}") + except BtcdRPCError as exc: + self.log.error(f" generate() failed: {exc}") + break + + self._propagate(nodes, miner, after) + + self._log_status(round_num, self._network_status(nodes)) + + if self.options.rounds == 0 or round_num < self.options.rounds: + time.sleep(self.options.interval) + + self.log.info("===>> btcd_miner finished") + + +def main(): + BtcdMiner("").main() + + +if __name__ == "__main__": + main() diff --git a/resources/scenarios/test_scenarios/btcd_rpc_test.py b/resources/scenarios/test_scenarios/btcd_rpc_test.py new file mode 100644 index 000000000..241ea20b4 --- /dev/null +++ b/resources/scenarios/test_scenarios/btcd_rpc_test.py @@ -0,0 +1,240 @@ +import time + +from commander import Commander, WARNET +from btcd_framework import BtcdRPC, BtcdRPCError + + +class BtcdRpcTest(Commander): + def set_test_params(self): + self.num_nodes = 1 + + def add_options(self, parser): + parser.description = ( + "Validate the BtcdRPC JSON-RPC interface against a live btcd network" + ) + parser.usage = "warnet run /path/to/btcd_rpc_test.py" + + + def _btcd_nodes(self) -> list[BtcdRPC]: + nodes = [] + for tank in WARNET["tanks"]: + impl = tank.get("implementation", "bitcoincore") + if impl != "btcd": + self.log.warning( + f"Skipping tank {tank['tank']} (implementation={impl})" + ) + continue + + node = BtcdRPC( + host=tank["rpc_host"], + port=tank["rpc_port"], + user=tank["rpc_user"], + password=tank["rpc_password"], + ) + node._tank_name = tank["tank"] + + node._p2p_port = tank.get("p2p_port", 18444) + nodes.append(node) + return nodes + + def _assert(self, condition: bool, msg: str): + if not condition: + self.log.error(f"FAIL: {msg}") + raise AssertionError(msg) + self.log.info(f"PASS: {msg}") + + + def test_connection_and_basic_info(self, nodes: list[BtcdRPC]): + self.log.info("=== Test 1: Connection & basic info ===") + for node in nodes: + count = node.getblockcount() + self._assert( + isinstance(count, int) and count >= 0, + f"{node._tank_name}: getblockcount() = {count}", + ) + + info = node.getinfo() + self._assert( + isinstance(info, dict) and "version" in info, + f"{node._tank_name}: getinfo() has 'version' field", + ) + self.log.info( + f" {node._tank_name}: height={count}, version={info['version']}" + ) + + def test_peer_connectivity(self, nodes: list[BtcdRPC]): + self.log.info("=== Test 2: Peer connectivity ===") + for node in nodes: + peers = node.getpeerinfo() + self._assert( + isinstance(peers, list) and len(peers) > 0, + f"{node._tank_name}: getpeerinfo() has {len(peers)} peer(s)", + ) + for peer in peers: + self.log.info( + f" {node._tank_name} ← peer addr={peer.get('addr')} " + f"version={peer.get('version')}" + ) + + def test_block_generation(self, nodes: list[BtcdRPC]): + self.log.info("=== Test 3: Block generation ===") + miner = nodes[0] + height_before = miner.getblockcount() + self.log.info(f" Height before generate: {height_before}") + + NUM_BLOCKS = 5 + hashes = miner.generate(NUM_BLOCKS) + self._assert( + isinstance(hashes, list) and len(hashes) == NUM_BLOCKS, + f"generate({NUM_BLOCKS}) returned a list of {NUM_BLOCKS} block hash(es)", + ) + self.log.info(f" First new block: {hashes[0]}") + + height_after = miner.getblockcount() + self._assert( + height_after == height_before + NUM_BLOCKS, + f"Block height increased from {height_before} to {height_after} (+{NUM_BLOCKS})", + ) + + self.log.info(" Waiting for all nodes to sync...") + + time.sleep(3) + for node in nodes[1:]: + if node.getblockcount() < height_after: + self.log.info(f" Triggering sync on {node._tank_name} from {miner._tank_name}") + node.force_sync_from(miner) + + SYNC_TIMEOUT = 30 + for i in range(SYNC_TIMEOUT): + heights = {n._tank_name: n.getblockcount() for n in nodes} + if all(h == height_after for h in heights.values()): + break + time.sleep(1) + + for node in nodes: + synced_height = node.getblockcount() + self._assert( + synced_height == height_after, + f"{node._tank_name}: synced to height {synced_height} (expected {height_after})", + ) + + def test_getblock_and_getblockhash(self, nodes: list[BtcdRPC]): + self.log.info("=== Test 4: getblock / getblockhash ===") + node = nodes[0] + height = node.getblockcount() + + block_hash = node.getblockhash(height) + self._assert( + isinstance(block_hash, str) and len(block_hash) == 64, + f"getblockhash({height}) returned a valid 64-char hex string", + ) + + block = node.getblock(block_hash, 1) + self._assert( + isinstance(block, dict) and block.get("hash") == block_hash, + "getblock(hash, 1) returned object whose 'hash' matches", + ) + self._assert( + block.get("height") == height, + f"Block height field matches ({block.get('height')} == {height})", + ) + self.log.info( + f" Block {height}: txns={len(block.get('tx', []))}, " + f"size={block.get('size')} bytes" + ) + + def test_raw_transaction_roundtrip(self, nodes: list[BtcdRPC]): + self.log.info("=== Test 5: Raw transaction round-trip ===") + node = nodes[0] + + genesis_hash = node.getblockhash(0) + genesis_block = node.getblock(genesis_hash, 1) + coinbase_txid = genesis_block["tx"][0] + + raw = node.getrawtransaction(coinbase_txid, 0) + self._assert( + isinstance(raw, str) and len(raw) > 0, + "getrawtransaction(txid, verbose=0) returned a hex string", + ) + + decoded = node.decoderawtransaction(raw) + self._assert( + isinstance(decoded, dict) and decoded.get("txid") == coinbase_txid, + "decoderawtransaction round-trip: decoded txid matches original", + ) + self.log.info( + f" Coinbase tx: {coinbase_txid[:16]}… " + f"vin={len(decoded.get('vin', []))} " + f"vout={len(decoded.get('vout', []))}" + ) + + def test_mempool(self, nodes: list[BtcdRPC]): + self.log.info("=== Test 6: Mempool ===") + node = nodes[0] + + info = node.getmempoolinfo() + self._assert( + isinstance(info, dict) and "size" in info and "bytes" in info, + "getmempoolinfo() has 'size' and 'bytes' fields", + ) + self.log.info(f" Mempool: {info['size']} txns, {info['bytes']} bytes") + + txns = node.getrawmempool(False) + self._assert( + isinstance(txns, list), + f"getrawmempool(verbose=False) returned a list ({len(txns)} items)", + ) + + def test_btcd_extensions(self, nodes: list[BtcdRPC]): + self.log.info("=== Test 7: btcd extension methods ===") + node = nodes[0] + + best = node.getbestblock() + self._assert( + isinstance(best, dict) and "hash" in best and "height" in best, + "getbestblock() has 'hash' and 'height'", + ) + self.log.info( + f" getbestblock: height={best['height']} hash={best['hash'][:16]}…" + ) + + net_id = node.getcurrentnet() + self._assert( + isinstance(net_id, int), + f"getcurrentnet() returned numeric network ID: {net_id}", + ) + + ver = node.version() + self._assert( + isinstance(ver, dict) and "btcdjsonrpcapi" in ver, + "version() has 'btcdjsonrpcapi' key", + ) + self.log.info( + f" API version: {ver['btcdjsonrpcapi'].get('versionstring')}" + ) + + def run_test(self): + nodes = self._btcd_nodes() + self._assert(len(nodes) > 0, f"Found {len(nodes)} btcd node(s) in the network") + self.log.info( + f"Running tests against {len(nodes)} btcd node(s): " + + ", ".join(n._tank_name for n in nodes) + ) + + self.test_connection_and_basic_info(nodes) + self.test_peer_connectivity(nodes) + self.test_block_generation(nodes) + self.test_getblock_and_getblockhash(nodes) + self.test_raw_transaction_roundtrip(nodes) + self.test_mempool(nodes) + self.test_btcd_extensions(nodes) + + self.log.info("=== All tests passed ===") + + +def main(): + BtcdRpcTest("").main() + + +if __name__ == "__main__": + main() From a170ff791291540c8665363379c8714b9c2ed16a Mon Sep 17 00:00:00 2001 From: lucasdbr05 Date: Thu, 11 Jun 2026 02:49:56 -0300 Subject: [PATCH 7/9] refactor(btcd): move btcd logic to a dedicated module --- src/warnet/bitcoin.py | 7 +++++-- src/warnet/btcd.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 src/warnet/btcd.py diff --git a/src/warnet/bitcoin.py b/src/warnet/bitcoin.py index dbf4c68f8..04ae84df2 100644 --- a/src/warnet/bitcoin.py +++ b/src/warnet/bitcoin.py @@ -12,6 +12,7 @@ from test_framework.p2p import MESSAGEMAP from urllib3.exceptions import MaxRetryError +from .btcd import get_btcctl_flags from .constants import BITCOINCORE_CONTAINER, BTCD_CONTAINER from .k8s import get_default_namespace_or, get_mission, get_pod, pod_log from .process import run_command @@ -79,13 +80,15 @@ def _rpc(tank: str, method: str, params: list[str], namespace: Optional[str] = N param_str = " ".join(shlex.quote(p) for p in params) if is_btcd: - cmd = f"kubectl -n {namespace} exec {tank} --container {container} -- /bin/linux_amd64/btcctl --rpcuser=user --rpcpass=gn0cchi --rpccert=/root/.btcd/rpc.cert --rpcserver=127.0.0.1:18334 --simnet {method} {param_str}" + btcctl_flags = get_btcctl_flags(tank, namespace) + cmd = f"kubectl -n {namespace} exec {tank} --container {container} -- /bin/linux_amd64/btcctl {btcctl_flags} {method} {param_str}" else: cmd = f"kubectl -n {namespace} exec {tank} --container {container} -- bitcoin-cli {method} {param_str}" else: # Handle commands with no parameters if is_btcd: - cmd = f"kubectl -n {namespace} exec {tank} --container {container} -- /bin/linux_amd64/btcctl --rpcuser=user --rpcpass=gn0cchi --rpccert=/root/.btcd/rpc.cert --rpcserver=127.0.0.1:18334 --simnet {method}" + btcctl_flags = get_btcctl_flags(tank, namespace) + cmd = f"kubectl -n {namespace} exec {tank} --container {container} -- /bin/linux_amd64/btcctl {btcctl_flags} {method}" else: cmd = f"kubectl -n {namespace} exec {tank} --container {container} -- bitcoin-cli {method}" diff --git a/src/warnet/btcd.py b/src/warnet/btcd.py new file mode 100644 index 000000000..20c3612f2 --- /dev/null +++ b/src/warnet/btcd.py @@ -0,0 +1,33 @@ +from .k8s import get_pod + +_BTCD_CHAIN_FLAGS = { + "regtest": "--simnet", + "signet": "--signet", + "testnet": "--testnet", + "mainnet": "", +} + +_DEFAULT_RPC_USER = "user" +_DEFAULT_RPC_PASS = "gn0cchi" + + +def get_btcd_rpc_info(tank: str, namespace: str) -> tuple[str, str, str, str]: + """Return (chain_flag, rpc_port, rpc_user, rpc_password) from pod labels""" + default_flag = "--simnet" + default_port = "18334" + try: + pod = get_pod(tank, namespace=namespace) + labels = pod.metadata.labels or {} + chain = labels.get("chain", "regtest") + rpc_port = labels.get("RPCPort", default_port) + rpc_user = labels.get("rpcuser", _DEFAULT_RPC_USER) + rpc_pass = labels.get("rpcpassword", _DEFAULT_RPC_PASS) + chain_flag = _BTCD_CHAIN_FLAGS.get(chain, default_flag) + return chain_flag, rpc_port, rpc_user, rpc_pass + except Exception: + return default_flag, default_port, _DEFAULT_RPC_USER, _DEFAULT_RPC_PASS + + +def get_btcctl_flags(tank: str, namespace: str) -> str: + chain_flag, rpc_port, rpc_user, rpc_pass = get_btcd_rpc_info(tank, namespace) + return f"--rpcuser={rpc_user} --rpcpass={rpc_pass} --rpccert=/root/.btcd/rpc.cert --rpcserver=127.0.0.1:{rpc_port} {chain_flag}".strip() From b537fc752e95d797fccab9e861192e665b7f3099 Mon Sep 17 00:00:00 2001 From: lucasdbr05 Date: Thu, 11 Jun 2026 12:56:10 -0300 Subject: [PATCH 8/9] style(btcd): apply ruff lint and format fixes to btcd_rpc_test --- .../scenarios/test_scenarios/btcd_rpc_test.py | 47 +++++++------------ 1 file changed, 17 insertions(+), 30 deletions(-) diff --git a/resources/scenarios/test_scenarios/btcd_rpc_test.py b/resources/scenarios/test_scenarios/btcd_rpc_test.py index 241ea20b4..bc1d42b7f 100644 --- a/resources/scenarios/test_scenarios/btcd_rpc_test.py +++ b/resources/scenarios/test_scenarios/btcd_rpc_test.py @@ -1,7 +1,7 @@ import time -from commander import Commander, WARNET -from btcd_framework import BtcdRPC, BtcdRPCError +from btcd_framework import BtcdRPC +from commander import WARNET, Commander class BtcdRpcTest(Commander): @@ -9,20 +9,15 @@ def set_test_params(self): self.num_nodes = 1 def add_options(self, parser): - parser.description = ( - "Validate the BtcdRPC JSON-RPC interface against a live btcd network" - ) + parser.description = "Validate the BtcdRPC JSON-RPC interface against a live btcd network" parser.usage = "warnet run /path/to/btcd_rpc_test.py" - def _btcd_nodes(self) -> list[BtcdRPC]: nodes = [] for tank in WARNET["tanks"]: impl = tank.get("implementation", "bitcoincore") if impl != "btcd": - self.log.warning( - f"Skipping tank {tank['tank']} (implementation={impl})" - ) + self.log.warning(f"Skipping tank {tank['tank']} (implementation={impl})") continue node = BtcdRPC( @@ -43,9 +38,8 @@ def _assert(self, condition: bool, msg: str): raise AssertionError(msg) self.log.info(f"PASS: {msg}") - def test_connection_and_basic_info(self, nodes: list[BtcdRPC]): - self.log.info("=== Test 1: Connection & basic info ===") + self.log.info("Test 1: Connection & basic info") for node in nodes: count = node.getblockcount() self._assert( @@ -58,12 +52,10 @@ def test_connection_and_basic_info(self, nodes: list[BtcdRPC]): isinstance(info, dict) and "version" in info, f"{node._tank_name}: getinfo() has 'version' field", ) - self.log.info( - f" {node._tank_name}: height={count}, version={info['version']}" - ) + self.log.info(f" {node._tank_name}: height={count}, version={info['version']}") def test_peer_connectivity(self, nodes: list[BtcdRPC]): - self.log.info("=== Test 2: Peer connectivity ===") + self.log.info("Test 2: Peer connectivity") for node in nodes: peers = node.getpeerinfo() self._assert( @@ -77,7 +69,7 @@ def test_peer_connectivity(self, nodes: list[BtcdRPC]): ) def test_block_generation(self, nodes: list[BtcdRPC]): - self.log.info("=== Test 3: Block generation ===") + self.log.info("Test 3: Block generation") miner = nodes[0] height_before = miner.getblockcount() self.log.info(f" Height before generate: {height_before}") @@ -105,7 +97,7 @@ def test_block_generation(self, nodes: list[BtcdRPC]): node.force_sync_from(miner) SYNC_TIMEOUT = 30 - for i in range(SYNC_TIMEOUT): + for _ in range(SYNC_TIMEOUT): heights = {n._tank_name: n.getblockcount() for n in nodes} if all(h == height_after for h in heights.values()): break @@ -119,7 +111,7 @@ def test_block_generation(self, nodes: list[BtcdRPC]): ) def test_getblock_and_getblockhash(self, nodes: list[BtcdRPC]): - self.log.info("=== Test 4: getblock / getblockhash ===") + self.log.info("Test 4: getblock / getblockhash") node = nodes[0] height = node.getblockcount() @@ -139,12 +131,11 @@ def test_getblock_and_getblockhash(self, nodes: list[BtcdRPC]): f"Block height field matches ({block.get('height')} == {height})", ) self.log.info( - f" Block {height}: txns={len(block.get('tx', []))}, " - f"size={block.get('size')} bytes" + f" Block {height}: txns={len(block.get('tx', []))}, size={block.get('size')} bytes" ) def test_raw_transaction_roundtrip(self, nodes: list[BtcdRPC]): - self.log.info("=== Test 5: Raw transaction round-trip ===") + self.log.info("Test 5: Raw transaction round-trip") node = nodes[0] genesis_hash = node.getblockhash(0) @@ -169,7 +160,7 @@ def test_raw_transaction_roundtrip(self, nodes: list[BtcdRPC]): ) def test_mempool(self, nodes: list[BtcdRPC]): - self.log.info("=== Test 6: Mempool ===") + self.log.info("Test 6: Mempool") node = nodes[0] info = node.getmempoolinfo() @@ -186,7 +177,7 @@ def test_mempool(self, nodes: list[BtcdRPC]): ) def test_btcd_extensions(self, nodes: list[BtcdRPC]): - self.log.info("=== Test 7: btcd extension methods ===") + self.log.info("Test 7: btcd extension methods") node = nodes[0] best = node.getbestblock() @@ -194,9 +185,7 @@ def test_btcd_extensions(self, nodes: list[BtcdRPC]): isinstance(best, dict) and "hash" in best and "height" in best, "getbestblock() has 'hash' and 'height'", ) - self.log.info( - f" getbestblock: height={best['height']} hash={best['hash'][:16]}…" - ) + self.log.info(f" getbestblock: height={best['height']} hash={best['hash'][:16]}…") net_id = node.getcurrentnet() self._assert( @@ -209,9 +198,7 @@ def test_btcd_extensions(self, nodes: list[BtcdRPC]): isinstance(ver, dict) and "btcdjsonrpcapi" in ver, "version() has 'btcdjsonrpcapi' key", ) - self.log.info( - f" API version: {ver['btcdjsonrpcapi'].get('versionstring')}" - ) + self.log.info(f" API version: {ver['btcdjsonrpcapi'].get('versionstring')}") def run_test(self): nodes = self._btcd_nodes() @@ -229,7 +216,7 @@ def run_test(self): self.test_mempool(nodes) self.test_btcd_extensions(nodes) - self.log.info("=== All tests passed ===") + self.log.info("All tests passed") def main(): From ba751a7277de27fb834a3ddede5d91733c508fa6 Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Mon, 29 Jun 2026 13:48:17 -0400 Subject: [PATCH 9/9] lint --- resources/scenarios/btcd_framework/btcd.py | 27 ++++++-------- resources/scenarios/btcd_miner.py | 43 ++++++++-------------- src/warnet/bitcoin.py | 4 +- 3 files changed, 31 insertions(+), 43 deletions(-) diff --git a/resources/scenarios/btcd_framework/btcd.py b/resources/scenarios/btcd_framework/btcd.py index 76883ae96..d51d782ac 100644 --- a/resources/scenarios/btcd_framework/btcd.py +++ b/resources/scenarios/btcd_framework/btcd.py @@ -4,7 +4,7 @@ import ssl import time from base64 import b64encode -from typing import Any + def _self_signed_context() -> ssl.SSLContext: ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) @@ -33,13 +33,10 @@ def __init__( self.host = host self.port = port self.timeout = timeout - self._auth_header = "Basic " + b64encode( - f"{user}:{password}".encode() - ).decode() + self._auth_header = "Basic " + b64encode(f"{user}:{password}".encode()).decode() self._request_id = 0 self.log = logging.getLogger(f"BtcdRPC({host}:{port})") - def _new_connection(self) -> http.client.HTTPSConnection: return http.client.HTTPSConnection( host=self.host, @@ -66,24 +63,28 @@ def _build_headers(self, payload: bytes) -> dict: "Content-Length": str(len(payload)), } - def _send_with_retry(self, method: str, payload: bytes, headers: dict, max_attempts: int = 5) -> tuple[int, str]: + def _send_with_retry( + self, method: str, payload: bytes, headers: dict, max_attempts: int = 5 + ) -> tuple[int, str]: last_exc = RuntimeError("unreachable") for attempt in range(max_attempts): if attempt > 0: - backoff = 2 ** attempt + backoff = 2**attempt self.log.debug("Retry %d for %s (backoff %ds)", attempt, method, backoff) time.sleep(backoff) conn = self._new_connection() - + try: conn.request("POST", "/", body=payload, headers=headers) response = conn.getresponse() return response.status, response.read().decode("utf-8") except (BrokenPipeError, ConnectionResetError, OSError) as exc: last_exc = exc - self.log.warning("Connection error on attempt %d for %s: %s", attempt + 1, method, exc) + self.log.warning( + "Connection error on attempt %d for %s: %s", attempt + 1, method, exc + ) finally: conn.close() @@ -100,8 +101,7 @@ def _parse_response(self, method: str, status: int, raw: str): message=err.get("message", raw), ) except (json.JSONDecodeError, KeyError): - raise ConnectionError(f"btcd returned HTTP {status}: {raw[:200]}") - + raise ConnectionError(f"btcd returned HTTP {status}: {raw[:200]}") from None if body.get("error") is not None: err = body["error"] @@ -127,7 +127,6 @@ def method(*args): method.__name__ = name return method - def node(self, command: str, peer: str, connection_type: str = "") -> None: if connection_type: return self._call("node", command, peer, connection_type) @@ -146,9 +145,8 @@ def searchrawtransactions( "searchrawtransactions", address, verbose, skip, count, vin_extra, reverse ) - # helpers - + def force_sync_from(self, source: "BtcdRPC") -> None: p2p_port = getattr(source, "_p2p_port", 18444) peer_addr = f"{source.host}:{p2p_port}" @@ -164,6 +162,5 @@ def force_sync_from(self, source: "BtcdRPC") -> None: except Exception as e: self.log.debug("connect %s: %s", peer_addr, e) - def __repr__(self) -> str: return f"BtcdRPC(host={self.host!r}, port={self.port})" diff --git a/resources/scenarios/btcd_miner.py b/resources/scenarios/btcd_miner.py index b288e9792..16ead33b3 100644 --- a/resources/scenarios/btcd_miner.py +++ b/resources/scenarios/btcd_miner.py @@ -1,12 +1,12 @@ import time from btcd_framework import BtcdRPC, BtcdRPCError -from commander import Commander, WARNET +from commander import WARNET, Commander class BtcdMiner(Commander): def set_test_params(self): - self.num_nodes = 1 + self.num_nodes = 1 def add_options(self, parser): parser.description = "Mine blocks on a btcd network and log network status" @@ -38,9 +38,7 @@ def _btcd_nodes(self) -> list[BtcdRPC]: for tank in WARNET["tanks"]: impl = tank.get("implementation", "bitcoincore") if impl != "btcd": - self.log.warning( - f"Skipping tank {tank['tank']} (implementation={impl})" - ) + self.log.warning(f"Skipping tank {tank['tank']} (implementation={impl})") continue node = BtcdRPC( host=tank["rpc_host"], @@ -58,11 +56,11 @@ def _network_status(self, nodes: list[BtcdRPC]) -> dict: for node in nodes: try: height = node.getblockcount() - peers = len(node.getpeerinfo()) + peers = len(node.getpeerinfo()) mempool = node.getmempoolinfo().get("size", 0) status[node._tank_name] = { - "height": height, - "peers": peers, + "height": height, + "peers": peers, "mempool": mempool, } except Exception as exc: @@ -70,11 +68,11 @@ def _network_status(self, nodes: list[BtcdRPC]) -> dict: return status def _log_status(self, round_num: int, status: dict): - self.log.info("┌────────────────┬──────────┬─────────┬─────────────┐") - self.log.info(f"│ Round {round_num:>3} Network Status │") - self.log.info("├────────────────┬──────────┬─────────┬─────────────┤") - self.log.info("│ Node │ Height │ Peers │ Mempool │") - self.log.info("├────────────────┼──────────┼─────────┼─────────────┤") + self.log.info("┌─────────────────────────────────────────────────────────────┐") + self.log.info(f"│ Round {round_num:>3} Network Status │") + self.log.info("├──────────────┬──────────┬─────────┬───────────────────────────┤") + self.log.info("│ Node │ Height │ Peers │ Mempool txs │") + self.log.info("├──────────────┼──────────┼─────────┼───────────────────────────┤") for name, data in status.items(): if "error" in data: self.log.info(f"│ {name:<14}│ ERROR │ │ {data['error'][:11]:<11} │") @@ -82,7 +80,7 @@ def _log_status(self, round_num: int, status: dict): self.log.info( f"│ {name:<14}│ {data['height']:>6} │ {data['peers']:>5} │ {data['mempool']:>5} txs │" ) - self.log.info("└────────────────┴──────────┴─────────┴─────────────┘") + self.log.info("└──────────────┴──────────┴─────────┴───────────────────────────┘") def _propagate(self, nodes: list[BtcdRPC], miner: BtcdRPC, target_height: int): time.sleep(3) @@ -97,10 +95,7 @@ def _propagate(self, nodes: list[BtcdRPC], miner: BtcdRPC, target_height: int): for elapsed in range(timeout): heights = {n._tank_name: n.getblockcount() for n in nodes} if all(h >= target_height for h in heights.values()): - self.log.info( - f"All nodes synced to height {target_height} " - f"in ~{elapsed}s" - ) + self.log.info(f"All nodes synced to height {target_height} in ~{elapsed}s") return if elapsed % 10 == 0 and elapsed > 0: behind = {k: v for k, v in heights.items() if v < target_height} @@ -110,10 +105,7 @@ def _propagate(self, nodes: list[BtcdRPC], miner: BtcdRPC, target_height: int): heights = {n._tank_name: n.getblockcount() for n in nodes} behind = {k: v for k, v in heights.items() if v < target_height} if behind: - self.log.warning( - f" Sync timeout after {timeout}s. Still behind: {behind}" - ) - + self.log.warning(f" Sync timeout after {timeout}s. Still behind: {behind}") def run_test(self): nodes = self._btcd_nodes() @@ -147,11 +139,8 @@ def run_test(self): try: before = miner.getblockcount() hashes = miner.generate(self.options.blocks) - after = miner.getblockcount() - self.log.info( - f" Mined {len(hashes)} block(s) — " - f"height {before} → {after}" - ) + after = miner.getblockcount() + self.log.info(f" Mined {len(hashes)} block(s) — height {before} → {after}") for h in hashes: self.log.info(f" {h}") except BtcdRPCError as exc: diff --git a/src/warnet/bitcoin.py b/src/warnet/bitcoin.py index 04ae84df2..e3fb14345 100644 --- a/src/warnet/bitcoin.py +++ b/src/warnet/bitcoin.py @@ -134,7 +134,9 @@ def grep_logs(pattern: str, show_k8s_timestamps: bool, no_sort: bool): pod_name = tank.metadata.name impl = (tank.metadata.labels or {}).get("implementation", BITCOINCORE_CONTAINER) - log_container = impl if impl in (BTCD_CONTAINER, BITCOINCORE_CONTAINER) else BITCOINCORE_CONTAINER + log_container = ( + impl if impl in (BTCD_CONTAINER, BITCOINCORE_CONTAINER) else BITCOINCORE_CONTAINER + ) logs = pod_log(pod_name, log_container) if logs is not False: