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..117bae886 --- /dev/null +++ b/examples/networks/6_node_btcd/node-defaults.yaml @@ -0,0 +1,18 @@ +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 + +resources: {} + +image: + repository: lucasdbr05/btcd + pullPolicy: IfNotPresent + tag: "latest" 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: "" 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..1c2724c87 --- /dev/null +++ b/resources/scenarios/btcd_framework/btcd.py @@ -0,0 +1,251 @@ +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"] + + """ + Execute a JSON-RPC call and return the result field. + """ + def _call(self, method: str, *params): + 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) + + + + # standard Bitcoin methods + + def getblockcount(self) -> int: + return self._call("getblockcount") + + def getbestblockhash(self) -> str: + return self._call("getbestblockhash") + + def getblockhash(self, height: int) -> str: + return self._call("getblockhash", height) + + def getblock(self, block_hash: str, verbosity: int = 1): + return self._call("getblock", block_hash, verbosity) + + def getblockheader(self, block_hash: str, verbose: bool = True): + return self._call("getblockheader", block_hash, verbose) + + def getpeerinfo(self) -> list: + return self._call("getpeerinfo") + + def getconnectioncount(self) -> int: + return self._call("getconnectioncount") + + def addnode(self, peer: str, command: str) -> None: + """ + peer : str + IP address and port of the peer, e.g. ``"10.0.0.2:18444"``. + command : str + ``"add"`` to add a persistent peer, ``"remove"`` to remove one, + or ``"onetry"`` to attempt a single connection. + """ + return self._call("addnode", peer, command) + + def getinfo(self) -> dict: + return self._call("getinfo") + + def getmininginfo(self) -> dict: + return self._call("getmininginfo") + + def getmempoolinfo(self) -> dict: + return self._call("getmempoolinfo") + + def getrawmempool(self, verbose: bool = False): + return self._call("getrawmempool", verbose) + + def getrawtransaction(self, txid: str, verbose: int = 0): + return self._call("getrawtransaction", txid, verbose) + + def sendrawtransaction(self, signed_hex: str) -> str: + return self._call("sendrawtransaction", signed_hex) + + def decoderawtransaction(self, hex_tx: str) -> dict: + return self._call("decoderawtransaction", hex_tx) + + def createrawtransaction(self, inputs: list, outputs: dict) -> str: + return self._call("createrawtransaction", inputs, outputs) + + def validateaddress(self, address: str) -> dict: + return self._call("validateaddress", address) + + def verifychain(self, check_level: int = 3, num_blocks: int = 300) -> bool: + return self._call("verifychain", check_level, num_blocks) + + def submitblock(self, hex_block: str): + return self._call("submitblock", hex_block) + + def ping(self) -> None: + return self._call("ping") + + def stop(self) -> str: + return self._call("stop") + + + # btcd extension methods + + def generate(self, num_blocks: int) -> list: + return self._call("generate", num_blocks) + + def getbestblock(self) -> dict: + return self._call("getbestblock") + + def getcurrentnet(self) -> int: + return self._call("getcurrentnet") + + def version(self) -> dict: + return self._call("version") + + 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 debuglevel(self, level_spec: str) -> str: + return self._call("debuglevel", level_spec) + + 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/btcd_miner.py b/resources/scenarios/btcd_miner.py new file mode 100644 index 000000000..72b258db7 --- /dev/null +++ b/resources/scenarios/btcd_miner.py @@ -0,0 +1,188 @@ +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( + f"┌─────────────────────────────────────────────────────────────┐" + ) + self.log.info( + f"│ Round {round_num:>3} Network Status │" + ) + self.log.info( + f"├──────────────┬──────────┬─────────┬───────────────────────────┤" + ) + self.log.info( + f"│ Node │ Height │ Peers │ Mempool txs │" + ) + self.log.info( + f"├──────────────┼──────────┼─────────┼───────────────────────────┤" + ) + for name, data in status.items(): + if "error" in data: + self.log.info(f"│ {name:<12}│ ERROR │ │ {data['error'][:26]:<26} │") + else: + self.log.info( + f"│ {name:<12}│ {data['height']:>6} │ {data['peers']:>5} │ {data['mempool']:>5} txs │" + ) + self.log.info( + f"└──────────────┴──────────┴─────────┴───────────────────────────┘" + ) + + 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/commander.py b/resources/scenarios/commander.py index a5129dbd9..4c81a35e0 100644 --- a/resources/scenarios/commander.py +++ b/resources/scenarios/commander.py @@ -96,6 +96,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.status.pod_ip, "rpc_port": int(pod.metadata.labels["RPCPort"]), 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..aba5e9676 --- /dev/null +++ b/resources/scenarios/test_scenarios/btcd_rpc_test.py @@ -0,0 +1,228 @@ +import time + +from btcd_framework import BtcdRPC +from commander import WARNET, Commander + + +class BtcdRpcTest(Commander): + def set_test_params(self): + self.num_nodes = 1 + self.mining_addr = "Sh6VJ4TabWtfBm9kvLWPzj8WGNsMmtyaGF" + + 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 _ 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', []))}, 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() diff --git a/src/warnet/bitcoin.py b/src/warnet/bitcoin.py index 5b392e22e..29177c47e 100644 --- a/src/warnet/bitcoin.py +++ b/src/warnet/bitcoin.py @@ -12,11 +12,24 @@ 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 .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 +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) + container_names = [c.name for c in pod.spec.containers] + if BTCD_CONTAINER in container_names: + return BTCD_CONTAINER + return BITCOINCORE_CONTAINER + except Exception: + return BITCOINCORE_CONTAINER + + @click.group(name="bitcoin") def bitcoin(): """Control running bitcoin nodes""" @@ -42,6 +55,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 +81,18 @@ 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: + 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 - cmd = f"kubectl -n {namespace} exec {tank} --container {BITCOINCORE_CONTAINER} -- bitcoin-cli {method}" + if is_btcd: + 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}" return run_command(cmd) @@ -111,7 +135,12 @@ 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) + container_names = [c.name for c in tank.spec.containers] + if BTCD_CONTAINER in container_names: + log_container = BTCD_CONTAINER + else: + log_container = BITCOINCORE_CONTAINER + logs = pod_log(pod_name, log_container) if logs is not False: try: diff --git a/src/warnet/btcd.py b/src/warnet/btcd.py new file mode 100644 index 000000000..5235347aa --- /dev/null +++ b/src/warnet/btcd.py @@ -0,0 +1,27 @@ +from .k8s import get_pod + +_BTCD_CHAIN_FLAGS = { + "regtest": "--simnet", + "signet": "--signet", + "testnet": "--testnet", + "mainnet": "", +} + + +def get_btcd_rpc_info(tank: str, namespace: str) -> tuple[str, str]: + 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) + chain_flag = _BTCD_CHAIN_FLAGS.get(chain, default_flag) + return chain_flag, rpc_port + except Exception: + return default_flag, default_port + + +def get_btcctl_flags(tank: str, namespace: str) -> str: + chain_flag, rpc_port = get_btcd_rpc_info(tank, namespace) + return f"--rpcuser=user --rpcpass=gn0cchi --rpccert=/root/.btcd/rpc.cert --rpcserver=127.0.0.1:{rpc_port} {chain_flag}".strip() diff --git a/src/warnet/constants.py b/src/warnet/constants.py index 19c75599c..f6c9a1ac4 100644 --- a/src/warnet/constants.py +++ b/src/warnet/constants.py @@ -24,8 +24,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" @@ -49,6 +56,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" @@ -72,6 +80,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 2c3bff45c..bcacedd3d 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, @@ -364,6 +365,7 @@ def filter(path): "commander.py", "test_framework", "ln_framework", + "btcd_framework", scenario_path.name, ] ): @@ -496,7 +498,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"