From 234137eee4d29bac41dacbcb22854a0b6082f1d0 Mon Sep 17 00:00:00 2001 From: macgyver13 <4712150+macgyver13@users.noreply.github.com> Date: Wed, 18 Jun 2025 20:36:24 -0400 Subject: [PATCH 01/12] Add eclair support to simln plugin - upgrade to 0.2.4 override network graph check for eclair nodes pin eclair docker image to 0.11.0 version enable keysend feature for eclair handle new enabled scheme for ln nodes conditional enable wallets for eclair nodes update documentation to use new ln node enable scheme --- docs/circuit-breaker.md | 25 +-- resources/charts/bitcoincore/Chart.yaml | 7 +- .../bitcoincore/charts/eclair/.helmignore | 23 ++ .../bitcoincore/charts/eclair/Chart.yaml | 24 +++ .../charts/eclair/templates/_helpers.tpl | 78 +++++++ .../charts/eclair/templates/configmap.yaml | 32 +++ .../charts/eclair/templates/pod.yaml | 82 +++++++ .../charts/eclair/templates/service.yaml | 20 ++ .../bitcoincore/charts/eclair/values.yaml | 111 ++++++++++ resources/charts/bitcoincore/values.yaml | 11 +- resources/plugins/simln/README.md | 22 +- .../plugins/simln/charts/simln/values.yaml | 2 +- resources/plugins/simln/plugin.py | 10 +- resources/scenarios/commander.py | 4 +- resources/scenarios/ln_framework/ln.py | 201 +++++++++++++++++- resources/scenarios/ln_init.py | 9 + src/warnet/deploy.py | 7 +- test/data/ln/node-defaults.yaml | 3 +- test/data/logging/network.yaml | 3 +- test/data/network_with_plugins/network.yaml | 22 +- test/data/plugins/hello/README.md | 22 +- 21 files changed, 644 insertions(+), 74 deletions(-) create mode 100644 resources/charts/bitcoincore/charts/eclair/.helmignore create mode 100644 resources/charts/bitcoincore/charts/eclair/Chart.yaml create mode 100644 resources/charts/bitcoincore/charts/eclair/templates/_helpers.tpl create mode 100644 resources/charts/bitcoincore/charts/eclair/templates/configmap.yaml create mode 100644 resources/charts/bitcoincore/charts/eclair/templates/pod.yaml create mode 100644 resources/charts/bitcoincore/charts/eclair/templates/service.yaml create mode 100644 resources/charts/bitcoincore/charts/eclair/values.yaml diff --git a/docs/circuit-breaker.md b/docs/circuit-breaker.md index d6124c07d..cd9e3d516 100644 --- a/docs/circuit-breaker.md +++ b/docs/circuit-breaker.md @@ -20,9 +20,8 @@ nodes: - name: tank-0003 addnode: - tank-0000 - ln: - lnd: true lnd: + enabled: true config: | bitcoin.timelockdelta=33 channels: @@ -51,27 +50,26 @@ nodes: - name: tank-0000 addnode: - tank-0001 - ln: - lnd: true + lnd: + enabled: true - name: tank-0001 addnode: - tank-0002 - ln: - lnd: true + lnd: + enabled: true - name: tank-0002 addnode: - tank-0000 - ln: - lnd: true + lnd: + enabled: true - name: tank-0003 addnode: - tank-0000 - ln: - lnd: true lnd: + enabled: true config: | bitcoin.timelockdelta=33 channels: @@ -88,9 +86,8 @@ nodes: - name: tank-0004 addnode: - tank-0000 - ln: - lnd: true lnd: + enabled: true channels: - id: block: 300 @@ -102,8 +99,8 @@ nodes: - name: tank-0005 addnode: - tank-0000 - ln: - lnd: true + lnd: + enabled: true ``` ## Accessing Circuit Breaker diff --git a/resources/charts/bitcoincore/Chart.yaml b/resources/charts/bitcoincore/Chart.yaml index 36f7498af..aa90de0e7 100644 --- a/resources/charts/bitcoincore/Chart.yaml +++ b/resources/charts/bitcoincore/Chart.yaml @@ -5,10 +5,13 @@ description: A Helm chart for Bitcoin Core dependencies: - name: lnd version: 0.1.0 - condition: ln.lnd + condition: lnd.enabled - name: cln version: 0.1.0 - condition: ln.cln + condition: cln.enabled + - name: eclair + version: 0.1.0 + condition: eclair.enabled # A chart can be either an 'application' or a 'library' chart. # diff --git a/resources/charts/bitcoincore/charts/eclair/.helmignore b/resources/charts/bitcoincore/charts/eclair/.helmignore new file mode 100644 index 000000000..0e8a0eb36 --- /dev/null +++ b/resources/charts/bitcoincore/charts/eclair/.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/bitcoincore/charts/eclair/Chart.yaml b/resources/charts/bitcoincore/charts/eclair/Chart.yaml new file mode 100644 index 000000000..c496a596b --- /dev/null +++ b/resources/charts/bitcoincore/charts/eclair/Chart.yaml @@ -0,0 +1,24 @@ +apiVersion: v2 +name: eclair +description: A Helm chart for Eclair + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 0.1.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +# It is recommended to use it with quotes. +appVersion: "0.1.0" diff --git a/resources/charts/bitcoincore/charts/eclair/templates/_helpers.tpl b/resources/charts/bitcoincore/charts/eclair/templates/_helpers.tpl new file mode 100644 index 000000000..674b4ea97 --- /dev/null +++ b/resources/charts/bitcoincore/charts/eclair/templates/_helpers.tpl @@ -0,0 +1,78 @@ +{{/* +Expand the name of the PARENT chart. +*/}} +{{- define "bitcoincore.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified PARENT app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "bitcoincore.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s" .Release.Name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} + + +{{/* +Expand the name of the chart. +*/}} +{{- define "eclair.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}-ln +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "eclair.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s" .Release.Name | trunc 63 | trimSuffix "-" }}-ln +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "eclair.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "eclair.labels" -}} +helm.sh/chart: {{ include "eclair.chart" . }} +{{ include "eclair.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "eclair.selectorLabels" -}} +app.kubernetes.io/name: {{ include "eclair.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "eclair.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "eclair.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/resources/charts/bitcoincore/charts/eclair/templates/configmap.yaml b/resources/charts/bitcoincore/charts/eclair/templates/configmap.yaml new file mode 100644 index 000000000..7d74e5476 --- /dev/null +++ b/resources/charts/bitcoincore/charts/eclair/templates/configmap.yaml @@ -0,0 +1,32 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "eclair.fullname" . }} + labels: + {{- include "eclair.labels" . | nindent 4 }} +data: + eclair.conf: | + {{- .Values.baseConfig | nindent 4 }} + {{- .Values.defaultConfig | nindent 4 }} + {{- .Values.config | nindent 4 }} + eclair.chain = {{ .Values.global.chain }} + eclair.bitcoind.host = {{ include "bitcoincore.fullname" . }} + eclair.bitcoind.rpcport = {{ index .Values.global .Values.global.chain "RPCPort" }} + eclair.bitcoind.rpcuser = user + eclair.bitcoind.rpcpassword = {{ .Values.global.rpcpassword }} + eclair.node-alias = {{ include "eclair.fullname" . }} + eclair.bitcoind.zmqblock = "tcp://{{ include "bitcoincore.fullname" . }}:{{ .Values.global.ZMQBlockPort }}" + eclair.bitcoind.zmqtx = "tcp://{{ include "bitcoincore.fullname" . }}:{{ .Values.global.ZMQTxPort }}" + eclair.bitcoind.startup-locked-utxos-behavior = "unlock" +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "eclair.fullname" . }}-channels + labels: + channels: "true" + {{- include "eclair.labels" . | nindent 4 }} +data: + source: {{ include "eclair.fullname" . }} + channels: | + {{ .Values.channels | toJson }} diff --git a/resources/charts/bitcoincore/charts/eclair/templates/pod.yaml b/resources/charts/bitcoincore/charts/eclair/templates/pod.yaml new file mode 100644 index 000000000..ee6f16acc --- /dev/null +++ b/resources/charts/bitcoincore/charts/eclair/templates/pod.yaml @@ -0,0 +1,82 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {{ include "eclair.fullname" . }} + labels: + {{- include "eclair.labels" . | nindent 4 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 4 }} + {{- end }} + app: {{ include "eclair.fullname" . }} + {{- if .Values.collectLogs }} + collect_logs: "true" + {{- end }} + chain: {{ .Values.global.chain }} + annotations: + kubectl.kubernetes.io/default-container: "eclair" +spec: + {{- with .Values.imagePullSecrets }} + restartPolicy: "{{ .Values.restartPolicy }}" + imagePullSecrets: + {{- toYaml . | nindent 4 }} + {{- end }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 4 }} + containers: + - name: {{ .Chart.Name }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + command: + - "sh" + - "-c" + args: + - > + /app/eclair-node/bin/eclair-node.sh -v & + while [ ! -f /root/.eclair/eclair.log ]; do + echo "Waiting for log file" + sleep 2 + done && + tail -f /root/.eclair/eclair.log + ports: + - name: server + containerPort: {{ .Values.ServerPort }} + protocol: TCP + - name: rest + containerPort: {{ .Values.RestPort }} + protocol: TCP + livenessProbe: + {{- toYaml .Values.livenessProbe | nindent 8 }} + readinessProbe: + {{- toYaml .Values.readinessProbe | nindent 8 }} + startupProbe: + {{- toYaml .Values.startupProbe | nindent 8 }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumeMounts: + {{- with .Values.volumeMounts }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.extraContainers }} + {{- toYaml . | nindent 6 }} + {{- end }} + volumes: + {{- with .Values.volumes }} + {{- toYaml . | nindent 4 }} + {{- end }} + - configMap: + name: {{ include "eclair.fullname" . }} + name: config + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/resources/charts/bitcoincore/charts/eclair/templates/service.yaml b/resources/charts/bitcoincore/charts/eclair/templates/service.yaml new file mode 100644 index 000000000..f1458557a --- /dev/null +++ b/resources/charts/bitcoincore/charts/eclair/templates/service.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "eclair.fullname" . }} + labels: + {{- include "eclair.labels" . | nindent 4 }} + app: {{ include "eclair.fullname" . }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.ServerPort }} + targetPort: server + protocol: TCP + name: server + - port: {{ .Values.RestPort }} + targetPort: rest + protocol: TCP + name: rest + selector: + {{- include "eclair.selectorLabels" . | nindent 4 }} diff --git a/resources/charts/bitcoincore/charts/eclair/values.yaml b/resources/charts/bitcoincore/charts/eclair/values.yaml new file mode 100644 index 000000000..79b2228ad --- /dev/null +++ b/resources/charts/bitcoincore/charts/eclair/values.yaml @@ -0,0 +1,111 @@ +# Default values for eclair. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. +namespace: warnet + +restartPolicy: Never + +image: + repository: bitdonkey/eclair + pullPolicy: IfNotPresent + tag: "0.11.0" + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +podLabels: + app: "warnet" + mission: "lightning" + +podSecurityContext: {} + +securityContext: {} + +service: + type: ClusterIP + +ServerPort: 9735 +RestPort: 8080 + +ingress: + enabled: false + className: "" + annotations: {} + hosts: + - host: chart-example.local + paths: + - path: / + pathType: ImplementationSpecific + tls: [] + +resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +# This is to setup the liveness and readiness probes more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/ +livenessProbe: + exec: + command: + - eclair-cli + - -p + - 21satoshi + - getinfo + failureThreshold: 3 + initialDelaySeconds: 5 + periodSeconds: 5 + successThreshold: 1 + timeoutSeconds: 1 +readinessProbe: + failureThreshold: 3 + periodSeconds: 5 + successThreshold: 1 + timeoutSeconds: 10 + exec: + command: + - eclair-cli + - -p + - 21satoshi + - getinfo + +# Additional volumes on the output Deployment definition. +volumes: + - name: temp-storage + emptyDir: {} + +# Additional volumeMounts on the output Deployment definition. +volumeMounts: + - mountPath: /root/.eclair/eclair.conf + name: config + subPath: eclair.conf + - mountPath: /root/.eclair + name: temp-storage + +nodeSelector: {} + +tolerations: [] + +affinity: {} + +baseConfig: | + eclair.server.port = 9735 + eclair.api.enabled = true + eclair.api.binding-ip = 0.0.0.0 + eclair.api.password = 21satoshi + eclair.api.port = 8080 + eclair.features.keysend = optional + eclair.bitcoind.startup-locked-utxos-behavior = "unlock" + +config: "" + +defaultConfig: "" + +channels: [] diff --git a/resources/charts/bitcoincore/values.yaml b/resources/charts/bitcoincore/values.yaml index 67f1b9b10..46b72819e 100644 --- a/resources/charts/bitcoincore/values.yaml +++ b/resources/charts/bitcoincore/values.yaml @@ -123,6 +123,7 @@ baseConfig: | capturemessages=1 fallbackfee=0.00001000 listen=1 + txindex=1 rpcuser=user # rpcpassword MUST be set as a chart value rpcallowip=0.0.0.0/0 @@ -140,6 +141,10 @@ loadSnapshot: enabled: false url: "" -ln: - lnd: false - cln: false +cln: + enabled: false +eclair: + enabled: false +lnd: + enabled: false + diff --git a/resources/plugins/simln/README.md b/resources/plugins/simln/README.md index a627813af..58bf4ced6 100644 --- a/resources/plugins/simln/README.md +++ b/resources/plugins/simln/README.md @@ -48,27 +48,26 @@ nodes: - name: tank-0000 addnode: - tank-0001 - ln: - lnd: true + lnd: + enabled: true - name: tank-0001 addnode: - tank-0002 - ln: - lnd: true + lnd: + enabled: true - name: tank-0002 addnode: - tank-0000 - ln: - lnd: true + eclair: + enabled: true - name: tank-0003 addnode: - tank-0000 - ln: - lnd: true lnd: + enabled: true config: | bitcoin.timelockdelta=33 channels: @@ -82,9 +81,8 @@ nodes: - name: tank-0004 addnode: - tank-0000 - ln: - cln: true cln: + enabled: true channels: - id: block: 300 @@ -96,8 +94,8 @@ nodes: - name: tank-0005 addnode: - tank-0000 - ln: - lnd: true + lnd: + enabled: true plugins: postDeploy: diff --git a/resources/plugins/simln/charts/simln/values.yaml b/resources/plugins/simln/charts/simln/values.yaml index a1647a963..dc1e47783 100644 --- a/resources/plugins/simln/charts/simln/values.yaml +++ b/resources/plugins/simln/charts/simln/values.yaml @@ -1,7 +1,7 @@ name: "simln" image: repository: "bitcoindevproject/simln" - tag: "0.2.3" + tag: "0.2.4" pullPolicy: IfNotPresent workingVolume: diff --git a/resources/plugins/simln/plugin.py b/resources/plugins/simln/plugin.py index 78df1a917..bff8b93d2 100755 --- a/resources/plugins/simln/plugin.py +++ b/resources/plugins/simln/plugin.py @@ -163,20 +163,22 @@ def _launch_activity(activity: Optional[list[dict]], plugin_dir: str) -> str: def _generate_activity_json(activity: Optional[list[dict]]) -> str: nodes = [] - for i in get_mission(LIGHTNING_MISSION): ln_name = i.metadata.name - port = 10009 node = {"id": ln_name} if "cln" in i.metadata.labels["app.kubernetes.io/name"]: - port = 9736 + node["address"] = f"https://{ln_name}:9736" node["ca_cert"] = f"/working/{ln_name}-ca.pem" node["client_cert"] = f"/working/{ln_name}-client.pem" node["client_key"] = f"/working/{ln_name}-client-key.pem" + elif "eclair" in i.metadata.labels["app.kubernetes.io/name"]: + node["base_url"] = f"http://{ln_name}:8080" + node["api_username"] = "" + node["api_password"] = "21satoshi" else: + node["address"] = f"https://{ln_name}:10009" node["macaroon"] = "/working/admin.macaroon" node["cert"] = "/working/tls.cert" - node["address"] = f"https://{ln_name}:{port}" nodes.append(node) if activity: diff --git a/resources/scenarios/commander.py b/resources/scenarios/commander.py index 4a44ab4a7..ba457fd18 100644 --- a/resources/scenarios/commander.py +++ b/resources/scenarios/commander.py @@ -13,7 +13,7 @@ from time import sleep from kubernetes import client, config -from ln_framework.ln import CLN, LND, LNNode +from ln_framework.ln import CLN, ECLAIR, LND, LNNode from test_framework.authproxy import AuthServiceProxy from test_framework.p2p import NetworkThread from test_framework.test_framework import ( @@ -73,6 +73,8 @@ lnnode = LND(pod.metadata.name, pod.status.pod_ip) if "cln" in pod.metadata.labels["app.kubernetes.io/name"]: lnnode = CLN(pod.metadata.name, pod.status.pod_ip) + elif "eclair" in pod.metadata.labels["app.kubernetes.io/name"]: + lnnode = ECLAIR(pod.metadata.name, pod.status.pod_ip) WARNET["lightning"].append(lnnode) for cm in cmaps.items: diff --git a/resources/scenarios/ln_framework/ln.py b/resources/scenarios/ln_framework/ln.py index f2ec4cbc2..0aba3a1cb 100644 --- a/resources/scenarios/ln_framework/ln.py +++ b/resources/scenarios/ln_framework/ln.py @@ -2,7 +2,9 @@ import http.client import json import logging +import re import ssl +import urllib.parse from abc import ABC, abstractmethod from time import sleep @@ -343,7 +345,7 @@ def graph(self, max_tries=2) -> dict: channel["capacity"] = channel["amount_msat"] // 1000 return {"edges": sorted_channels} else: - self.log.warning(f"unable to open channel: {res}, wait and retry...") + self.log.warning(f"unable to list channels: {res}, wait and retry...") sleep(1) else: self.log.debug(f"channel response: {response}, wait and retry...") @@ -354,6 +356,192 @@ def update(self, txid_hex: str, policy: dict, capacity: int, max_tries=2) -> dic self.log.warning("Channel Policy Updates not supported by CLN yet!") return None +class ECLAIR(LNNode): + def __init__(self, pod_name, ip_address): + super().__init__(pod_name, ip_address) + self.conn = None + self.headers = {"Authorization": "Basic OjIxc2F0b3NoaQ=="} + self.impl = "eclair" + self.reset_connection() + + def reset_connection(self): + self.conn = http.client.HTTPConnection( + host=self.name, port=8080, timeout=5 + ) + + def get(self, uri): + attempt = 0 + while True: + try: + self.log.warning(f"headers: {self.headers}") + self.conn.request( + method="GET", + url=uri, + headers=self.headers, + ) + return self.conn.getresponse().read().decode("utf8") + except Exception as e: + self.reset_connection() + attempt += 1 + if attempt > 5: + self.log.error(f"Error ECLAIR GET, Abort: {e}") + return None + sleep(1) + + def post(self, uri, data=None): + if not data: + data = {} + body = urllib.parse.urlencode(data) + post_header = self.headers + post_header["Content-Length"] = str(len(body)) + post_header["Content-Type"] = "application/x-www-form-urlencoded" + attempt = 0 + while True: + try: + self.conn.request( + method="POST", + url=uri, + body=body, + headers=post_header, + ) + # Stream output, otherwise we get a timeout error + res = self.conn.getresponse() + stream = "" + while True: + try: + data = res.read(1) + if len(data) == 0: + break + else: + stream += data.decode("utf8") + except Exception: + break + return stream + except Exception as e: + self.reset_connection() + attempt += 1 + if attempt > 5: + self.log.error(f"Error ECLAIR POST, Abort: {e}") + return None + sleep(1) + + def newaddress(self, max_tries=10): + attempt = 0 + while attempt < max_tries: + attempt += 1 + response = self.post("/getnewaddress") + if not response: + sleep(5) + continue + return True, response.strip("\"") + return False, "" + + def uri(self): + res = json.loads(self.post("/getinfo")) + return f"{res['nodeId']}@{res['alias']}:9735" + + def walletbalance(self, max_tries=2) -> int: + attempt = 0 + while attempt < max_tries: + attempt += 1 + response = self.post("/globalbalance") + if not response: + sleep(2) + continue + res = json.loads(response) + return int(res["total"] * 100000000) # convert to sats + return 0 + + def channelbalance(self, max_tries=2) -> int: + attempt = 0 + while attempt < max_tries: + attempt += 1 + response = self.post("/usablebalances") + if not response: + sleep(2) + continue + res = json.loads(response) + return int(sum(o["canSend"] + o["canReceive"] for o in res)) + return 0 + + def connect(self, target_uri, max_tries=5) -> dict: + attempt = 0 + while attempt < max_tries: + attempt += 1 + response = self.post("/connect", {"uri": target_uri}) + if "connected" in response: + return {} + else: + self.log.debug(f"connect response: {response}, wait and retry...") + sleep(2) + return None + + def channel(self, pk, capacity, push_amt, fee_rate, max_tries=10) -> dict: + import math + fee_rate_factor = math.ceil(fee_rate/170) #FIXME: reduce fee rate by factor to get close to original value + data = { + "fundingSatoshis": capacity, + "pushMsat": push_amt, + "nodeId": pk, + "fundingFeerateSatByte": fee_rate_factor, + "fundingFeeBudgetSatoshis": fee_rate + } #FIXME: https://acinq.github.io/eclair/#open-2 what parameters should be sent? + attempt = 0 + while attempt < max_tries: + attempt += 1 + response = self.post("/open", data) + print("channel open", response) + if response: + if "created channel" in response: + # created channel e872f515dc5d8a3d61ccbd2127f33141eaa115807271dcc5c5c727f3eca914d3 with fundingTxId=bc2b8db55b9588d3a18bd06bd0e284f63ee8cc149c63138d51ac8ef81a72fc6f and fees=720 sat + channel_id = re.search(r'channel ([0-9a-f]+)', response).group(1) + funding_tx_id = re.search(r'fundingTxId=([0-9a-f]+)', response).group(1) + return {"txid": funding_tx_id, "outpoint": f"{funding_tx_id}:N/A", "channel": channel_id} + else: + self.log.warning(f"unable to open channel: {response}, wait and retry...") + sleep(1) + else: + self.log.debug(f"channel response: {response}, wait and retry...") + sleep(5) + return None + + def createinvoice(self, sats, label, description="new invoice") -> str: + b64_desc = base64.b64encode(description.encode("utf-8")) + response = self.post( + "/createinvoice", {"amountMsat": sats * 1000, "description": label, "description": b64_desc} + ) # https://acinq.github.io/eclair/#createinvoice + if response: + res = json.loads(response) + return res + return None + + def payinvoice(self, payment_request) -> str: + response = self.post("/payinvoice", {"invoice": payment_request}) + # https://acinq.github.io/eclair/#payinvoice + if response: + return response + return None + + def graph(self, max_tries=5) -> dict: + attempt = 0 + while attempt < max_tries: + attempt += 1 + response = self.post("/allupdates") # https://acinq.github.io/eclair/#allupdates + if response: + res = json.loads(response) + if len(res) > 0: + return {"edges": res} + else: + self.log.warning(f"unable to list channels: {res}, wait and retry...") + sleep(10) + else: + self.log.debug(f"channel response: {response}, wait and retry...") + sleep(2) + return None + + def update(self, txid_hex: str, policy: dict, capacity: int, max_tries=2) -> dict: + self.log.warning("Channel Policy Updates not supported by ECLAIR yet!") + return None class LND(LNNode): def __init__(self, pod_name, ip_address): @@ -386,7 +574,7 @@ def get(self, uri): self.reset_connection() attempt += 1 if attempt > 5: - self.log.error(f"Error LND POST, Abort: {e}") + self.log.error(f"Error LND GET, Abort: {e}") return None sleep(1) @@ -423,13 +611,16 @@ def post(self, uri, data): if attempt > 5: self.log.error(f"Error LND POST, Abort: {e}") return None - sleep(1) + sleep(5) - def newaddress(self, max_tries=10): + def newaddress(self, max_tries=5): attempt = 0 while attempt < max_tries: attempt += 1 response = self.get("/v1/newaddress") + if not response: + sleep(5) + continue res = json.loads(response) if "address" in res: return True, res["address"] @@ -437,7 +628,7 @@ def newaddress(self, max_tries=10): self.log.warning( f"Couldn't get wallet address from {self.name}:\n {res}\n wait and retry..." ) - sleep(1) + sleep(5) return False, "" def walletbalance(self) -> int: diff --git a/resources/scenarios/ln_init.py b/resources/scenarios/ln_init.py index 416763ed7..386affbc4 100644 --- a/resources/scenarios/ln_init.py +++ b/resources/scenarios/ln_init.py @@ -28,6 +28,12 @@ def run_test(self): self.log.info("Setting up miner...") miner = self.ensure_miner(self.nodes[0]) miner_addr = miner.getnewaddress() + # create wallet for any eclair node + for node in self.nodes[1:]: + for ln in self.lns.values(): + if node.tank in ln.name and ln.impl == "eclair": + self.log.info(f"creating wallet for {node.tank}") + node.createwallet("eclair", descriptors=True) def gen(n): return self.generatetoaddress(self.nodes[0], n, miner_addr, sync_fun=self.no_op) @@ -361,6 +367,9 @@ def matching_graph(self, expected, ln): f"Expected edges {len(expected)}, actual edges {len(actual)}\n{actual}" ) for i, actual_ch in enumerate(actual): + if ln.impl == "eclair": + self.log.debug("eclair nodes do not support network capacity checks") + continue expected_ch = expected[i] capacity = expected_ch["capacity"] # We assert this because it isn't updated as part of policy. diff --git a/src/warnet/deploy.py b/src/warnet/deploy.py index 1fac94d4c..58193727e 100644 --- a/src/warnet/deploy.py +++ b/src/warnet/deploy.py @@ -366,11 +366,10 @@ def deploy_network(directory: Path, debug: bool = False, namespace: Optional[str network_file = yaml.safe_load(f) needs_ln_init = False - supported_ln_projects = ["lnd", "cln"] + supported_ln_projects = ["lnd", "cln", "eclair"] for node in network_file["nodes"]: - ln_config = node.get("ln", {}) for key in supported_ln_projects: - if ln_config.get(key, False) and key in node and "channels" in node[key]: + if key in node and node[key].get("enabled", False): needs_ln_init = True break if needs_ln_init: @@ -379,7 +378,7 @@ def deploy_network(directory: Path, debug: bool = False, namespace: Optional[str default_file_path = directory / DEFAULTS_FILE with default_file_path.open() as f: default_file = yaml.safe_load(f) - if any(default_file.get("ln", {}).get(key, False) for key in supported_ln_projects): + if any(default_file.get(key, {}).get("enabled", False) for key in supported_ln_projects): needs_ln_init = True processes = [] diff --git a/test/data/ln/node-defaults.yaml b/test/data/ln/node-defaults.yaml index 62e05199f..249bd8d7c 100644 --- a/test/data/ln/node-defaults.yaml +++ b/test/data/ln/node-defaults.yaml @@ -9,9 +9,8 @@ collectLogs: false metricsExport: false #LN configs -ln: - lnd: true lnd: + enabled: true defaultConfig: | color=#000000 config: | diff --git a/test/data/logging/network.yaml b/test/data/logging/network.yaml index 067161cae..6096d35c1 100644 --- a/test/data/logging/network.yaml +++ b/test/data/logging/network.yaml @@ -11,9 +11,8 @@ nodes: - name: tank-0002 addnode: - tank-0000 - ln: - lnd: true lnd: + enabled: true metricsExport: true prometheusMetricsPort: 9332 extraContainers: diff --git a/test/data/network_with_plugins/network.yaml b/test/data/network_with_plugins/network.yaml index 6e4d64a30..4df445702 100644 --- a/test/data/network_with_plugins/network.yaml +++ b/test/data/network_with_plugins/network.yaml @@ -2,27 +2,26 @@ nodes: - name: tank-0000 addnode: - tank-0001 - ln: - lnd: true + lnd: + enabled: true - name: tank-0001 addnode: - tank-0002 - ln: - lnd: true + lnd: + enabled: true - name: tank-0002 addnode: - tank-0000 - ln: - lnd: true + lnd: + enabled: true - name: tank-0003 addnode: - tank-0000 - ln: - lnd: true lnd: + enabled: true config: | bitcoin.timelockdelta=33 channels: @@ -39,9 +38,8 @@ nodes: - name: tank-0004 addnode: - tank-0000 - ln: - lnd: true lnd: + enabled: true channels: - id: block: 300 @@ -53,8 +51,8 @@ nodes: - name: tank-0005 addnode: - tank-0000 - ln: - lnd: true + lnd: + enabled: true plugins: # Each plugin section has a number of hooks available (preDeploy, postDeploy, etc) preDeploy: # For example, the preDeploy hook means it's plugin will run before all other deploy code diff --git a/test/data/plugins/hello/README.md b/test/data/plugins/hello/README.md index 77bb5040f..9cf6d8803 100644 --- a/test/data/plugins/hello/README.md +++ b/test/data/plugins/hello/README.md @@ -35,27 +35,26 @@ nodes: - name: tank-0000 addnode: - tank-0001 - ln: - lnd: true + lnd: + enabled: true - name: tank-0001 addnode: - tank-0002 - ln: - lnd: true + lnd: + enabled: true - name: tank-0002 addnode: - tank-0000 - ln: - lnd: true + lnd: + enabled: true - name: tank-0003 addnode: - tank-0000 - ln: - lnd: true lnd: + enabled: true config: | bitcoin.timelockdelta=33 channels: @@ -69,9 +68,8 @@ nodes: - name: tank-0004 addnode: - tank-0000 - ln: - lnd: true lnd: + enabled: true channels: - id: block: 300 @@ -83,8 +81,8 @@ nodes: - name: tank-0005 addnode: - tank-0000 - ln: - lnd: true + lnd: + enabled: true plugins: # Each plugin section has a number of hooks available (preDeploy, postDeploy, etc) preDeploy: # For example, the preDeploy hook means it's plugin will run before all other deploy code From 06e43666f893514a885766c48274d070363aa419 Mon Sep 17 00:00:00 2001 From: macgyver13 <4712150+macgyver13@users.noreply.github.com> Date: Wed, 18 Jun 2025 20:39:45 -0400 Subject: [PATCH 02/12] fix format --- resources/scenarios/ln_framework/ln.py | 36 ++++++++++++++++---------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/resources/scenarios/ln_framework/ln.py b/resources/scenarios/ln_framework/ln.py index 0aba3a1cb..6c946d805 100644 --- a/resources/scenarios/ln_framework/ln.py +++ b/resources/scenarios/ln_framework/ln.py @@ -356,6 +356,7 @@ def update(self, txid_hex: str, policy: dict, capacity: int, max_tries=2) -> dic self.log.warning("Channel Policy Updates not supported by CLN yet!") return None + class ECLAIR(LNNode): def __init__(self, pod_name, ip_address): super().__init__(pod_name, ip_address) @@ -365,9 +366,7 @@ def __init__(self, pod_name, ip_address): self.reset_connection() def reset_connection(self): - self.conn = http.client.HTTPConnection( - host=self.name, port=8080, timeout=5 - ) + self.conn = http.client.HTTPConnection(host=self.name, port=8080, timeout=5) def get(self, uri): attempt = 0 @@ -433,7 +432,7 @@ def newaddress(self, max_tries=10): if not response: sleep(5) continue - return True, response.strip("\"") + return True, response.strip('"') return False, "" def uri(self): @@ -449,7 +448,7 @@ def walletbalance(self, max_tries=2) -> int: sleep(2) continue res = json.loads(response) - return int(res["total"] * 100000000) # convert to sats + return int(res["total"] * 100000000) # convert to sats return 0 def channelbalance(self, max_tries=2) -> int: @@ -478,14 +477,17 @@ def connect(self, target_uri, max_tries=5) -> dict: def channel(self, pk, capacity, push_amt, fee_rate, max_tries=10) -> dict: import math - fee_rate_factor = math.ceil(fee_rate/170) #FIXME: reduce fee rate by factor to get close to original value + + fee_rate_factor = math.ceil( + fee_rate / 170 + ) # FIXME: reduce fee rate by factor to get close to original value data = { "fundingSatoshis": capacity, "pushMsat": push_amt, "nodeId": pk, "fundingFeerateSatByte": fee_rate_factor, - "fundingFeeBudgetSatoshis": fee_rate - } #FIXME: https://acinq.github.io/eclair/#open-2 what parameters should be sent? + "fundingFeeBudgetSatoshis": fee_rate, + } # FIXME: https://acinq.github.io/eclair/#open-2 what parameters should be sent? attempt = 0 while attempt < max_tries: attempt += 1 @@ -494,9 +496,13 @@ def channel(self, pk, capacity, push_amt, fee_rate, max_tries=10) -> dict: if response: if "created channel" in response: # created channel e872f515dc5d8a3d61ccbd2127f33141eaa115807271dcc5c5c727f3eca914d3 with fundingTxId=bc2b8db55b9588d3a18bd06bd0e284f63ee8cc149c63138d51ac8ef81a72fc6f and fees=720 sat - channel_id = re.search(r'channel ([0-9a-f]+)', response).group(1) - funding_tx_id = re.search(r'fundingTxId=([0-9a-f]+)', response).group(1) - return {"txid": funding_tx_id, "outpoint": f"{funding_tx_id}:N/A", "channel": channel_id} + channel_id = re.search(r"channel ([0-9a-f]+)", response).group(1) + funding_tx_id = re.search(r"fundingTxId=([0-9a-f]+)", response).group(1) + return { + "txid": funding_tx_id, + "outpoint": f"{funding_tx_id}:N/A", + "channel": channel_id, + } else: self.log.warning(f"unable to open channel: {response}, wait and retry...") sleep(1) @@ -508,8 +514,9 @@ def channel(self, pk, capacity, push_amt, fee_rate, max_tries=10) -> dict: def createinvoice(self, sats, label, description="new invoice") -> str: b64_desc = base64.b64encode(description.encode("utf-8")) response = self.post( - "/createinvoice", {"amountMsat": sats * 1000, "description": label, "description": b64_desc} - ) # https://acinq.github.io/eclair/#createinvoice + "/createinvoice", + {"amountMsat": sats * 1000, "description": label, "descriptionHash": b64_desc}, + ) # https://acinq.github.io/eclair/#createinvoice if response: res = json.loads(response) return res @@ -526,7 +533,7 @@ def graph(self, max_tries=5) -> dict: attempt = 0 while attempt < max_tries: attempt += 1 - response = self.post("/allupdates") # https://acinq.github.io/eclair/#allupdates + response = self.post("/allupdates") # https://acinq.github.io/eclair/#allupdates if response: res = json.loads(response) if len(res) > 0: @@ -543,6 +550,7 @@ def update(self, txid_hex: str, policy: dict, capacity: int, max_tries=2) -> dic self.log.warning("Channel Policy Updates not supported by ECLAIR yet!") return None + class LND(LNNode): def __init__(self, pod_name, ip_address): super().__init__(pod_name, ip_address) From 3238e64f4049756cf3d64f0eb3b8409c10453edb Mon Sep 17 00:00:00 2001 From: macgyver13 <4712150+macgyver13@users.noreply.github.com> Date: Thu, 19 Jun 2025 20:27:28 -0400 Subject: [PATCH 03/12] fix fee rate calculation assumptions for eclair channels --- .../charts/bitcoincore/charts/eclair/values.yaml | 2 +- resources/scenarios/ln_framework/ln.py | 13 +++++-------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/resources/charts/bitcoincore/charts/eclair/values.yaml b/resources/charts/bitcoincore/charts/eclair/values.yaml index 79b2228ad..6adb3ba3e 100644 --- a/resources/charts/bitcoincore/charts/eclair/values.yaml +++ b/resources/charts/bitcoincore/charts/eclair/values.yaml @@ -103,7 +103,7 @@ baseConfig: | eclair.api.port = 8080 eclair.features.keysend = optional eclair.bitcoind.startup-locked-utxos-behavior = "unlock" - + config: "" defaultConfig: "" diff --git a/resources/scenarios/ln_framework/ln.py b/resources/scenarios/ln_framework/ln.py index 6c946d805..68972272c 100644 --- a/resources/scenarios/ln_framework/ln.py +++ b/resources/scenarios/ln_framework/ln.py @@ -476,17 +476,13 @@ def connect(self, target_uri, max_tries=5) -> dict: return None def channel(self, pk, capacity, push_amt, fee_rate, max_tries=10) -> dict: - import math - - fee_rate_factor = math.ceil( - fee_rate / 170 - ) # FIXME: reduce fee rate by factor to get close to original value + NON_GROUPED_UTXO_BYTE_SIZE = 165 data = { "fundingSatoshis": capacity, "pushMsat": push_amt, "nodeId": pk, - "fundingFeerateSatByte": fee_rate_factor, - "fundingFeeBudgetSatoshis": fee_rate, + "fundingFeerateSatByte": fee_rate, + "fundingFeeBudgetSatoshis": fee_rate * NON_GROUPED_UTXO_BYTE_SIZE, } # FIXME: https://acinq.github.io/eclair/#open-2 what parameters should be sent? attempt = 0 while attempt < max_tries: @@ -537,7 +533,8 @@ def graph(self, max_tries=5) -> dict: if response: res = json.loads(response) if len(res) > 0: - return {"edges": res} + filtered_channels = [ch for ch in res if ch["channelFlags"]["isNode1"]] + return {"edges": filtered_channels} else: self.log.warning(f"unable to list channels: {res}, wait and retry...") sleep(10) From efb2deb377c36441ac09c39a8638c8ad0b6cb950 Mon Sep 17 00:00:00 2001 From: macgyver13 <4712150+macgyver13@users.noreply.github.com> Date: Sat, 5 Jul 2025 12:55:34 -0400 Subject: [PATCH 04/12] correct lnd node syntax graph::import-network --- src/warnet/graph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/warnet/graph.py b/src/warnet/graph.py index 9a6d54ad9..a6acd3a1e 100644 --- a/src/warnet/graph.py +++ b/src/warnet/graph.py @@ -333,7 +333,7 @@ def _import_network(graph_file_path, output_path): tank = f"tank-{index:04d}" pk_to_tank[node["pub_key"]] = tank tank_to_pk[tank] = node["pub_key"] - tanks[tank] = {"name": tank, "ln": {"lnd": True}, "lnd": {"channels": []}} + tanks[tank] = {"name": tank, "lnd": {"enabled": True, "channels": []}} index += 1 print(f"Imported {index} nodes") From d509de0ae50d174caaa9f5b83a7449e47571aafa Mon Sep 17 00:00:00 2001 From: macgyver13 <4712150+macgyver13@users.noreply.github.com> Date: Sat, 5 Jul 2025 14:43:15 -0400 Subject: [PATCH 05/12] remove fixme --- resources/scenarios/ln_framework/ln.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/scenarios/ln_framework/ln.py b/resources/scenarios/ln_framework/ln.py index 68972272c..7a26ff869 100644 --- a/resources/scenarios/ln_framework/ln.py +++ b/resources/scenarios/ln_framework/ln.py @@ -483,7 +483,7 @@ def channel(self, pk, capacity, push_amt, fee_rate, max_tries=10) -> dict: "nodeId": pk, "fundingFeerateSatByte": fee_rate, "fundingFeeBudgetSatoshis": fee_rate * NON_GROUPED_UTXO_BYTE_SIZE, - } # FIXME: https://acinq.github.io/eclair/#open-2 what parameters should be sent? + } # https://acinq.github.io/eclair/#open-2 attempt = 0 while attempt < max_tries: attempt += 1 From 4aadc525c14d0fd780ec168a7446b9097958fb7c Mon Sep 17 00:00:00 2001 From: macgyver13 <4712150+macgyver13@users.noreply.github.com> Date: Sun, 6 Jul 2025 17:16:45 -0400 Subject: [PATCH 06/12] fix logging test - enable lnd to satisfy metric fix proc exception, add --debug command support --- test/data/logging/network.yaml | 8 +++++++- test/data/logging/node-defaults.yaml | 3 +++ test/test_base.py | 5 +++-- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/test/data/logging/network.yaml b/test/data/logging/network.yaml index 6096d35c1..d0c1d8a7e 100644 --- a/test/data/logging/network.yaml +++ b/test/data/logging/network.yaml @@ -12,7 +12,13 @@ nodes: addnode: - tank-0000 lnd: - enabled: true + channels: + - id: + block: 300 + index: 1 + target: tank-0001-ln + capacity: 100000 + push_amt: 50000 metricsExport: true prometheusMetricsPort: 9332 extraContainers: diff --git a/test/data/logging/node-defaults.yaml b/test/data/logging/node-defaults.yaml index b914c8bba..9cc68d3b7 100644 --- a/test/data/logging/node-defaults.yaml +++ b/test/data/logging/node-defaults.yaml @@ -3,3 +3,6 @@ image: repository: bitcoindevproject/bitcoin pullPolicy: IfNotPresent tag: "27.0" + +lnd: + enabled: true \ No newline at end of file diff --git a/test/test_base.py b/test/test_base.py index c0beeeda8..09bffc109 100644 --- a/test/test_base.py +++ b/test/test_base.py @@ -68,10 +68,11 @@ def assert_log_msgs(self): def warnet(self, cmd): self.log.debug(f"Executing warnet command: {cmd}") command = ["warnet"] + cmd.split() - proc = run(command, capture_output=True) + capture_output = "--debug" not in cmd + proc = run(command, capture_output=capture_output) if proc.stderr: raise Exception(proc.stderr.decode().strip()) - return proc.stdout.decode().strip() + return proc.stdout.decode().strip() if proc.stdout else "" def output_reader(self, pipe, func): while not self.stop_threads.is_set(): From 8291e7ec7c8d39d9fbbe267b197dca8f978449ec Mon Sep 17 00:00:00 2001 From: macgyver13 <4712150+macgyver13@users.noreply.github.com> Date: Sun, 6 Jul 2025 18:08:37 -0400 Subject: [PATCH 07/12] reverse capture_output change --- test/test_base.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/test_base.py b/test/test_base.py index 09bffc109..4ad555840 100644 --- a/test/test_base.py +++ b/test/test_base.py @@ -68,8 +68,7 @@ def assert_log_msgs(self): def warnet(self, cmd): self.log.debug(f"Executing warnet command: {cmd}") command = ["warnet"] + cmd.split() - capture_output = "--debug" not in cmd - proc = run(command, capture_output=capture_output) + proc = run(command, capture_output=True) if proc.stderr: raise Exception(proc.stderr.decode().strip()) return proc.stdout.decode().strip() if proc.stdout else "" From 4a109cf44e1f411fd5df700edae8f0be17acb3bd Mon Sep 17 00:00:00 2001 From: macgyver13 <4712150+macgyver13@users.noreply.github.com> Date: Tue, 8 Jul 2025 22:45:28 -0400 Subject: [PATCH 08/12] add support for testing LNNode through rpc add new ln_mixed_test add support for switching zmqblock format on bitcoin core node --- .github/workflows/test.yml | 1 + .../bitcoincore/charts/eclair/values.yaml | 1 - .../charts/bitcoincore/templates/_helpers.tpl | 12 +- .../bitcoincore/templates/configmap.yaml | 2 +- resources/scenarios/ln_framework/ln.py | 329 +++++++++++++----- src/warnet/ln.py | 13 +- test/data/ln_mixed/network.yaml | 61 ++++ test/data/ln_mixed/node-defaults.yaml | 15 + test/ln_mixed_test.py | 102 ++++++ 9 files changed, 448 insertions(+), 88 deletions(-) create mode 100644 test/data/ln_mixed/network.yaml create mode 100644 test/data/ln_mixed/node-defaults.yaml create mode 100755 test/ln_mixed_test.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index aba823228..8cfd49c6e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,6 +41,7 @@ jobs: - graph_test.py - logging_test.py - ln_basic_test.py + - ln_mixed_test.py - ln_test.py - onion_test.py - plugin_test.py diff --git a/resources/charts/bitcoincore/charts/eclair/values.yaml b/resources/charts/bitcoincore/charts/eclair/values.yaml index 6adb3ba3e..eef5301e0 100644 --- a/resources/charts/bitcoincore/charts/eclair/values.yaml +++ b/resources/charts/bitcoincore/charts/eclair/values.yaml @@ -102,7 +102,6 @@ baseConfig: | eclair.api.password = 21satoshi eclair.api.port = 8080 eclair.features.keysend = optional - eclair.bitcoind.startup-locked-utxos-behavior = "unlock" config: "" diff --git a/resources/charts/bitcoincore/templates/_helpers.tpl b/resources/charts/bitcoincore/templates/_helpers.tpl index 81ab85a37..c66813bbe 100644 --- a/resources/charts/bitcoincore/templates/_helpers.tpl +++ b/resources/charts/bitcoincore/templates/_helpers.tpl @@ -56,7 +56,6 @@ Create the name of the service account to use {{- end }} {{- end }} - {{/* Add network section heading in bitcoin.conf Always add for custom semver, check version for valid semver @@ -68,3 +67,14 @@ Always add for custom semver, check version for valid semver [{{ .Values.global.chain }}] {{- end -}} {{- end -}} + +{{/* +eclair requires zmqpubhashblock https://github.com/ACINQ/eclair/blob/master/README.md#installation +*/}} +{{- define "bitcoincore.set_zmqblocktype" -}} +{{- if .Values.eclair.enabled -}} +zmqpubhashblock +{{- else }} +zmqpubrawblock +{{- end -}} +{{- end -}} \ No newline at end of file diff --git a/resources/charts/bitcoincore/templates/configmap.yaml b/resources/charts/bitcoincore/templates/configmap.yaml index cc1e580f2..56f8fb1a6 100644 --- a/resources/charts/bitcoincore/templates/configmap.yaml +++ b/resources/charts/bitcoincore/templates/configmap.yaml @@ -12,7 +12,7 @@ data: {{- .Values.baseConfig | nindent 4 }} rpcport={{ index .Values.global .Values.global.chain "RPCPort" }} rpcpassword={{ .Values.global.rpcpassword }} - zmqpubrawblock=tcp://0.0.0.0:{{ .Values.global.ZMQBlockPort }} + {{- include "bitcoincore.set_zmqblocktype" . | nindent 4 }}=tcp://0.0.0.0:{{ .Values.global.ZMQBlockPort }} zmqpubrawtx=tcp://0.0.0.0:{{ .Values.global.ZMQTxPort }} {{- .Values.defaultConfig | nindent 4 }} {{- .Values.config | nindent 4 }} diff --git a/resources/scenarios/ln_framework/ln.py b/resources/scenarios/ln_framework/ln.py index 7a26ff869..18664c775 100644 --- a/resources/scenarios/ln_framework/ln.py +++ b/resources/scenarios/ln_framework/ln.py @@ -1,9 +1,11 @@ import base64 +import hashlib import http.client import json import logging import re import ssl +import subprocess import urllib.parse from abc import ABC, abstractmethod from time import sleep @@ -82,7 +84,7 @@ def to_lnd_chanpolicy(self, capacity): class LNNode(ABC): @abstractmethod - def __init__(self, pod_name, ip_address): + def __init__(self, pod_name, ip_address=None, use_rpc=False): self.name = pod_name self.ip_address = ip_address self.log = logging.getLogger(pod_name) @@ -91,6 +93,7 @@ def __init__(self, pod_name, ip_address): handler.setFormatter(formatter) self.log.addHandler(handler) self.log.setLevel(logging.INFO) + self.use_rpc = use_rpc @staticmethod def hex_to_b64(hex): @@ -103,14 +106,44 @@ def b64_to_hex(b64, reverse=False): else: return base64.b64decode(b64).hex() + def rpc_call(self, command: str, data: list[str] = None) -> str: + """Call warnet ln rpc via tank-name command and return stdout as string""" + try: + command_list = ["warnet", "ln", "rpc", self.name, command] + if data: + command_list.extend(data) + self.log.debug(f"rpc command: {command_list}") + result = subprocess.run( + command_list, + capture_output=True, + check=True, + text=True, + ) + return result.stdout + except subprocess.CalledProcessError as e: + # Only log and return the actual error output + self.log.error(e.stderr.strip()) + return None + except Exception as e: + self.log.error(f"RPC call failed: {e}") + return None + @abstractmethod def newaddress(self, max_tries=10) -> tuple[bool, str]: pass + @abstractmethod + def nodeid(self) -> str: + pass + @abstractmethod def uri(self) -> str: pass + @abstractmethod + def channelbalance(self) -> int: + pass + @abstractmethod def walletbalance(self) -> int: pass @@ -123,6 +156,14 @@ def connect(self, target_uri) -> dict: def channel(self, pk, capacity, push_amt, fee_rate) -> dict: pass + @abstractmethod + def createinvoice(self, sats, label, description) -> str: + pass + + @abstractmethod + def payinvoice(self, payment_request) -> str: + pass + @abstractmethod def graph(self) -> dict: pass @@ -133,8 +174,8 @@ def update(self, txid_hex: str, policy: dict, capacity: int) -> dict: class CLN(LNNode): - def __init__(self, pod_name, ip_address): - super().__init__(pod_name, ip_address) + def __init__(self, pod_name, ip_address=None, use_rpc=False): + super().__init__(pod_name, ip_address, use_rpc=use_rpc) self.conn = None self.headers = {} self.impl = "cln" @@ -219,11 +260,12 @@ def createrune(self, max_tries=2): raise Exception(f"Unable to fetch rune from {self.name}") def newaddress(self, max_tries=2): - self.createrune() + if not self.use_rpc: + self.createrune() attempt = 0 while attempt < max_tries: attempt += 1 - response = self.post("/v1/newaddr") + response = self.rpc_call("newaddr") if self.use_rpc else self.post("/v1/newaddr") if not response: sleep(2) continue @@ -237,8 +279,18 @@ def newaddress(self, max_tries=2): sleep(2) return False, "" + def nodeid(self): + if self.use_rpc: + res = json.loads(self.rpc_call("getinfo")) + else: + res = json.loads(self.post("/v1/getinfo")) + return res["id"] + def uri(self): - res = json.loads(self.post("/v1/getinfo")) + if self.use_rpc: + res = json.loads(self.rpc_call("getinfo")) + else: + res = json.loads(self.post("/v1/getinfo")) if len(res["address"]) < 1: return None return f"{res['id']}@{res['address'][0]['address']}:{res['address'][0]['port']}" @@ -247,7 +299,7 @@ def walletbalance(self, max_tries=2) -> int: attempt = 0 while attempt < max_tries: attempt += 1 - response = self.post("/v1/listfunds") + response = self.rpc_call("listfunds") if self.use_rpc else self.post("/v1/listfunds") if not response: sleep(2) continue @@ -259,7 +311,7 @@ def channelbalance(self, max_tries=2) -> int: attempt = 0 while attempt < max_tries: attempt += 1 - response = self.post("/v1/listfunds") + response = self.rpc_call("listfunds") if self.use_rpc else self.post("/v1/listfunds") if not response: sleep(2) continue @@ -271,7 +323,10 @@ def connect(self, target_uri, max_tries=5) -> dict: attempt = 0 while attempt < max_tries: attempt += 1 - response = self.post("/v1/connect", {"id": target_uri}) + if self.use_rpc: + response = self.rpc_call("connect", [target_uri]) + else: + response = self.post("/v1/connect", {"id": target_uri}) if response: res = json.loads(response) if "id" in res: @@ -296,7 +351,10 @@ def channel(self, pk, capacity, push_amt, fee_rate, max_tries=5) -> dict: attempt = 0 while attempt < max_tries: attempt += 1 - response = self.post("/v1/fundchannel", data) + if self.use_rpc: + response = self.rpc_call("fundchannel", [pk, str(capacity)]) + else: + response = self.post("/v1/fundchannel", data) if response: res = json.loads(response) if "txid" in res: @@ -309,17 +367,23 @@ def channel(self, pk, capacity, push_amt, fee_rate, max_tries=5) -> dict: sleep(2) return None - def createinvoice(self, sats, label, description="new invoice") -> str: - response = self.post( - "invoice", {"amount_msat": sats * 1000, "label": label, "description": description} - ) + def createinvoice(self, sats, label, description="new_invoice") -> str: + if self.use_rpc: + response = self.rpc_call("invoice", [str(sats * 1000), label, description]) + else: + response = self.post( + "invoice", {"amount_msat": sats * 1000, "label": label, "description": description} + ) if response: res = json.loads(response) return res["bolt11"] return None def payinvoice(self, payment_request) -> str: - response = self.post("/v1/pay", {"bolt11": payment_request}) + if self.use_rpc: + response = self.rpc_call("pay", [payment_request]) + else: + response = self.post("/v1/pay", {"bolt11": payment_request}) if response: res = json.loads(response) if "code" in res: @@ -332,7 +396,10 @@ def graph(self, max_tries=2) -> dict: attempt = 0 while attempt < max_tries: attempt += 1 - response = self.post("/v1/listchannels") + if self.use_rpc: + response = self.rpc_call("listchannels") + else: + response = self.post("/v1/listchannels") if response: res = json.loads(response) if "channels" in res: @@ -358,8 +425,8 @@ def update(self, txid_hex: str, policy: dict, capacity: int, max_tries=2) -> dic class ECLAIR(LNNode): - def __init__(self, pod_name, ip_address): - super().__init__(pod_name, ip_address) + def __init__(self, pod_name, ip_address=None, use_rpc=False): + super().__init__(pod_name, ip_address, use_rpc=use_rpc) self.conn = None self.headers = {"Authorization": "Basic OjIxc2F0b3NoaQ=="} self.impl = "eclair" @@ -368,7 +435,10 @@ def __init__(self, pod_name, ip_address): def reset_connection(self): self.conn = http.client.HTTPConnection(host=self.name, port=8080, timeout=5) - def get(self, uri): + def get(self, uri: str): + if self.use_rpc: + cmd = uri.replace("/", "") + return self.rpc_call(cmd) attempt = 0 while True: try: @@ -387,7 +457,10 @@ def get(self, uri): return None sleep(1) - def post(self, uri, data=None): + def post(self, uri: str, data=None): + if self.use_rpc: + cmd = uri.replace("/", "") + return self.rpc_call(cmd, data) if not data: data = {} body = urllib.parse.urlencode(data) @@ -435,9 +508,20 @@ def newaddress(self, max_tries=10): return True, response.strip('"') return False, "" + def nodeid(self): + if self.use_rpc: + res = json.loads(self.rpc_call("getinfo")) + else: + res = json.loads(self.post("/v1/getinfo")) + return res["nodeId"] + def uri(self): - res = json.loads(self.post("/getinfo")) - return f"{res['nodeId']}@{res['alias']}:9735" + response = self.post("/getinfo") + if response: + res = json.loads(response) + if "nodeId" in res: + return f"{res['nodeId']}@{res['alias']}:9735" + return None def walletbalance(self, max_tries=2) -> int: attempt = 0 @@ -467,8 +551,11 @@ def connect(self, target_uri, max_tries=5) -> dict: attempt = 0 while attempt < max_tries: attempt += 1 - response = self.post("/connect", {"uri": target_uri}) - if "connected" in response: + if self.use_rpc: + response = self.rpc_call("connect", [f"--nodeId={target_uri}"]) + else: + response = self.post("/connect", {"uri": target_uri}) + if response and "connected" in response: return {} else: self.log.debug(f"connect response: {response}, wait and retry...") @@ -487,8 +574,19 @@ def channel(self, pk, capacity, push_amt, fee_rate, max_tries=10) -> dict: attempt = 0 while attempt < max_tries: attempt += 1 - response = self.post("/open", data) - print("channel open", response) + if self.use_rpc: + response = self.rpc_call( + "open", + [ + f"--nodeId={pk}", + f"--fundingSatoshis={capacity}", + f"--pushMsat={push_amt}", + f"--fundingFeerateSatByte={fee_rate}", + f"--fundingFeeBudgetSatoshis={fee_rate * NON_GROUPED_UTXO_BYTE_SIZE}", + ], + ) + else: + response = self.post("/open", data) if response: if "created channel" in response: # created channel e872f515dc5d8a3d61ccbd2127f33141eaa115807271dcc5c5c727f3eca914d3 with fundingTxId=bc2b8db55b9588d3a18bd06bd0e284f63ee8cc149c63138d51ac8ef81a72fc6f and fees=720 sat @@ -508,18 +606,26 @@ def channel(self, pk, capacity, push_amt, fee_rate, max_tries=10) -> dict: return None def createinvoice(self, sats, label, description="new invoice") -> str: - b64_desc = base64.b64encode(description.encode("utf-8")) - response = self.post( - "/createinvoice", - {"amountMsat": sats * 1000, "description": label, "descriptionHash": b64_desc}, - ) # https://acinq.github.io/eclair/#createinvoice + if self.use_rpc: + response = self.rpc_call( + "createinvoice", [f"--amountMsat={sats * 1000}", f'--description="{label}"'] + ) + else: + b64_desc = base64.b64encode(description.encode("utf-8")) + response = self.post( + "/createinvoice", + {"amountMsat": sats * 1000, "description": label, "descriptionHash": b64_desc}, + ) # https://acinq.github.io/eclair/#createinvoice if response: res = json.loads(response) - return res + return res["serialized"] return None def payinvoice(self, payment_request) -> str: - response = self.post("/payinvoice", {"invoice": payment_request}) + if self.use_rpc: + response = self.rpc_call("payinvoice", [f"--invoice={payment_request}"]) + else: + response = self.post("/payinvoice", {"invoice": payment_request}) # https://acinq.github.io/eclair/#payinvoice if response: return response @@ -529,7 +635,9 @@ def graph(self, max_tries=5) -> dict: attempt = 0 while attempt < max_tries: attempt += 1 - response = self.post("/allupdates") # https://acinq.github.io/eclair/#allupdates + response = ( + self.rpc_call("allupdates") if self.use_rpc else self.post("/allupdates") + ) # https://acinq.github.io/eclair/#allupdates if response: res = json.loads(response) if len(res) > 0: @@ -549,8 +657,8 @@ def update(self, txid_hex: str, policy: dict, capacity: int, max_tries=2) -> dic class LND(LNNode): - def __init__(self, pod_name, ip_address): - super().__init__(pod_name, ip_address) + def __init__(self, pod_name, ip_address=None, use_rpc=False): + super().__init__(pod_name, ip_address, use_rpc=use_rpc) self.conn = http.client.HTTPSConnection( host=pod_name, port=8080, timeout=5, context=INSECURE_CONTEXT ) @@ -565,7 +673,10 @@ def reset_connection(self): host=self.name, port=8080, timeout=5, context=INSECURE_CONTEXT ) - def get(self, uri): + def get(self, uri: str): + if self.use_rpc: + cmd = uri.replace("/v1/", "") + return self.rpc_call(cmd) attempt = 0 while True: try: @@ -622,7 +733,10 @@ def newaddress(self, max_tries=5): attempt = 0 while attempt < max_tries: attempt += 1 - response = self.get("/v1/newaddress") + if self.use_rpc: + response = self.rpc_call("newaddress", ["p2wkh"]) + else: + response = self.get("/v1/newaddress") if not response: sleep(5) continue @@ -637,12 +751,23 @@ def newaddress(self, max_tries=5): return False, "" def walletbalance(self) -> int: - res = self.get("/v1/balance/blockchain") - return int(json.loads(res)["confirmed_balance"]) + res = self.rpc_call("walletbalance") if self.use_rpc else self.get("/v1/balance/blockchain") + if res: + return int(json.loads(res)["confirmed_balance"]) + else: + return 0 def channelbalance(self) -> int: - res = self.get("/v1/balance/channels") - return int(json.loads(res)["balance"]) + res = self.rpc_call("channelbalance") if self.use_rpc else self.get("/v1/balance/channels") + if res: + return int(json.loads(res)["balance"]) + else: + return 0 + + def nodeid(self): + res = self.get("/v1/getinfo") + info = json.loads(res) + return info["identity_pubkey"] def uri(self): res = self.get("/v1/getinfo") @@ -653,36 +778,59 @@ def uri(self): def connect(self, target_uri): pk, host = target_uri.split("@") - res = self.post("/v1/peers", data={"addr": {"pubkey": pk, "host": host}}) - return json.loads(res) + if self.use_rpc: + res = self.rpc_call("connect", [target_uri]) + else: + res = self.post("/v1/peers", data={"addr": {"pubkey": pk, "host": host}}) + if res: + return json.loads(res) + else: + return res - def channel(self, pk, capacity, push_amt, fee_rate, max_tries=2): - b64_pk = self.hex_to_b64(pk) + def channel(self, pk, capacity, push_amt, fee_rate, max_tries=5): attempt = 0 while attempt < max_tries: attempt += 1 - response = self.post( - "/v1/channels/stream", - data={ - "local_funding_amount": capacity, - "push_sat": push_amt, - "node_pubkey": b64_pk, - "sat_per_vbyte": fee_rate, - }, - ) - try: - res = json.loads(response) - if "result" in res: - res["txid"] = self.b64_to_hex( - res["result"]["chan_pending"]["txid"], reverse=True - ) - res["outpoint"] = ( - f"{res['txid']}:{res['result']['chan_pending']['output_index']}" - ) - return res - self.log.warning(f"Open LND channel error: {res}") - except Exception as e: - self.log.error(f"Error opening LND channel: {e}") + if self.use_rpc: + response = self.rpc_call( + "openchannel", + [ + "--node_key", + pk, + "--local_amt", + capacity, + "--push_amt", + push_amt, + "--sat_per_vbyte", + fee_rate, + ], + ) + if response: + res = json.loads(response) + return {"txid": res["funding_txid"]} + else: + response = self.post( + "/v1/channels/stream", + data={ + "local_funding_amount": capacity, + "push_sat": push_amt, + "node_pubkey": self.hex_to_b64(pk), + "sat_per_vbyte": fee_rate, + }, + ) + try: + res = json.loads(response) + if "result" in res: + res["txid"] = self.b64_to_hex( + res["result"]["chan_pending"]["txid"], reverse=True + ) + res["outpoint"] = ( + f"{res['txid']}:{res['result']['chan_pending']['output_index']}" + ) + return res + self.log.warning(f"Open LND channel error: {res}") + except Exception as e: + self.log.error(f"Error opening LND channel: {e}") sleep(2) return None @@ -699,27 +847,44 @@ def update(self, txid_hex: str, policy: dict, capacity: int): return json.loads(res) def createinvoice(self, sats, label, description="new invoice") -> str: - b64_desc = base64.b64encode(description.encode("utf-8")) - response = self.post( - "/v1/invoices", data={"value": sats, "memo": label, "description_hash": b64_desc} - ) + if self.use_rpc: + response = self.rpc_call( + "addinvoice", + [ + "--amt", + str(sats), + "--memo", + label, + "--description_hash", + hashlib.sha256(description.encode("utf-8")).digest().hex(), + ], + ) + else: + b64_desc = base64.b64encode(description.encode("utf-8")) + response = self.post( + "/v1/invoices", data={"value": sats, "memo": label, "description_hash": b64_desc} + ) if response: res = json.loads(response) return res["payment_request"] return None def payinvoice(self, payment_request) -> str: - response = self.post( - "/v1/channels/transaction-stream", data={"payment_request": payment_request} - ) - if response: - res = json.loads(response) - if "payment_error" in res: - return res["payment_error"] - else: - return res["payment_hash"] + if self.use_rpc: + response = self.rpc_call("payinvoice", ["-f", payment_request]) + return response + else: + response = self.post( + "/v1/channels/transaction-stream", data={"payment_request": payment_request} + ) + if response: + res = json.loads(response) + if "payment_error" in res: + return res["payment_error"] + else: + return res["payment_hash"] return None def graph(self): - res = self.get("/v1/graph") + res = self.get("describegraph") if self.use_rpc else self.get("/v1/graph") return json.loads(res) diff --git a/src/warnet/ln.py b/src/warnet/ln.py index d3691bd4d..fd00e3abc 100644 --- a/src/warnet/ln.py +++ b/src/warnet/ln.py @@ -31,10 +31,12 @@ def _rpc(pod_name: str, method: str, params: str = "", namespace: Optional[str] pod = get_pod(pod_name) namespace = get_default_namespace_or(namespace) chain = pod.metadata.labels["chain"] - ln_client = "lncli" + ln_client = f"lncli --network {chain}" if "cln" in pod.metadata.labels["app.kubernetes.io/name"]: ln_client = "lightning-cli" - cmd = f"kubectl -n {namespace} exec {pod_name} -- {ln_client} --network {chain} {method} {' '.join(map(str, params))}" + elif "eclair" in pod.metadata.labels["app.kubernetes.io/name"]: + ln_client = "eclair-cli -p 21satoshi" + cmd = f"kubectl -n {namespace} exec {pod_name} -- {ln_client} {method} {' '.join(map(str, params))}" return run_command(cmd) @@ -55,6 +57,8 @@ def _pubkey(pod_name: str): pubkey_key = "identity_pubkey" if "cln" in pod.metadata.labels["app.kubernetes.io/name"]: pubkey_key = "id" + elif "eclair" in pod.metadata.labels["app.kubernetes.io/name"]: + pubkey_key = "nodeId" return json.loads(info)[pubkey_key] @@ -72,7 +76,10 @@ def host( def _host(pod_name: str): info = _rpc(pod_name, "getinfo") pod = get_pod(pod_name) - if "cln" in pod.metadata.labels["app.kubernetes.io/name"]: + if ( + "cln" in pod.metadata.labels["app.kubernetes.io/name"] + or "eclair" in pod.metadata.labels["app.kubernetes.io/name"] + ): return json.loads(info)["alias"] else: uris = json.loads(info)["uris"] diff --git a/test/data/ln_mixed/network.yaml b/test/data/ln_mixed/network.yaml new file mode 100644 index 000000000..3cfff92fd --- /dev/null +++ b/test/data/ln_mixed/network.yaml @@ -0,0 +1,61 @@ +nodes: + - name: tank-0001 + addnode: + - tank-0003 + eclair: + enabled: true + channels: + - id: + block: 300 + index: 1 + target: tank-0003-ln + capacity: 50001 + push_amt: 25003 + - name: tank-0002 + addnode: + - tank-0001 + cln: + enabled: true + channels: + - id: + block: 301 + index: 1 + target: tank-0004-ln + capacity: 50002 + push_amt: 25001 + - name: tank-0003 + addnode: + - tank-0004 + lnd: + enabled: true + channels: + - id: + block: 302 + index: 3 + target: tank-0002-ln + capacity: 200003 + push_amt: 100004 + - name: tank-0004 + addnode: + - tank-0003 + lnd: + enabled: true + channels: + - id: + block: 302 + index: 2 + target: tank-0001-ln + capacity: 150004 + push_amt: 25005 + - name: tank-0005 + addnode: + - tank-0003 + lnd: + enabled: true + channels: + - id: + block: 302 + index: 1 + target: tank-0004-ln + capacity: 100005 + push_amt: 51002 \ No newline at end of file diff --git a/test/data/ln_mixed/node-defaults.yaml b/test/data/ln_mixed/node-defaults.yaml new file mode 100644 index 000000000..539e0e66f --- /dev/null +++ b/test/data/ln_mixed/node-defaults.yaml @@ -0,0 +1,15 @@ +image: + repository: bitcoindevproject/bitcoin + pullPolicy: IfNotPresent + tag: "29.0" + +lnd: + defaultConfig: | + color=#000000 + config: | + bitcoin.timelockdelta=33 + minchansize=200 + +cln: + defaultConfig: | + rgb=ff3155 diff --git a/test/ln_mixed_test.py b/test/ln_mixed_test.py new file mode 100755 index 000000000..784730ed2 --- /dev/null +++ b/test/ln_mixed_test.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 + +import json +import os +from pathlib import Path +from time import sleep + +from test_base import TestBase + +from resources.scenarios.ln_framework.ln import CLN, ECLAIR, LND, LNNode +from warnet.process import stream_command + + +class LNMultiTest(TestBase): + def __init__(self): + super().__init__() + self.network_dir = Path(os.path.dirname(__file__)) / "data" / "ln_mixed" + self.scen_dir = Path(os.path.dirname(__file__)).parent / "resources" / "scenarios" + self.lns = [ + ECLAIR("tank-0001-ln", use_rpc=True), + CLN("tank-0002-ln", use_rpc=True), + LND("tank-0003-ln", use_rpc=True), + LND("tank-0004-ln", use_rpc=True), + LND("tank-0005-ln", use_rpc=True), + ] + + def node(self, name: str) -> LNNode: + matching_nodes = [n for n in self.lns if n.name == name] + if not matching_nodes: + raise ValueError(f"No node found with name: {name}") + return matching_nodes[0] + + def run_test(self): + try: + # Wait for all nodes to wake up. ln_init will start automatically + self.setup_network() + + # open channel and pay invoice + self.manual_open_channels() + self.pay_invoice(sender="tank-0003-ln", recipient="tank-0004-ln") + + # pay cln to lnd - channel opened by ln_init - test routing + self.pay_invoice(sender="tank-0002-ln", recipient="tank-0003-ln") + + # pay lnd to eclair - channel opened by ln_init - test routing 3 -> 1 + self.pay_invoice(sender="tank-0003-ln", recipient="tank-0001-ln") + + finally: + self.cleanup() + + def setup_network(self): + self.log.info("Setting up network") + stream_command(f"warnet deploy {self.network_dir}") + + def wait_for_txs(self, count): + self.wait_for_predicate( + lambda: json.loads(self.warnet("bitcoin rpc tank-0001 getmempoolinfo"))["size"] == count + ) + + def manual_open_channels(self): + # 3 -> 4 + pk4 = self.node("tank-0004-ln").nodeid() + channel = self.node("tank-0003-ln").channel(pk4, "444444", "200000", "5000", 1) + assert "txid" in channel, "Failed to create channel between nodes" + self.log.info(f"Channel txid {channel['txid']}") + + self.wait_for_txs(1) + + self.warnet("bitcoin rpc tank-0001 -generate 10") + + def wait_for_gossip_sync(self, nodes, expected): + while len(nodes) > 0: + for node in nodes: + chs = node.graph()["edges"] + if len(chs) >= expected: + self.log.info(f"Too many edges for {node}") + sleep(1) + + def pay_invoice(self, sender: str, recipient: str): + self.log.info(f"pay invoice using LNNode {sender} -> {recipient}") + init_balance = self.node(recipient).channelbalance() + assert init_balance > 0, f"{recipient} is zero, abort" + + self.log.info(f"{recipient} initial balance {init_balance}") + + # create invoice + inv = self.node(recipient).createinvoice(10000, f"{sender}-{recipient}") + self.log.info(f"invoice {inv}") + # pay recipient invoice + self.log.info(self.node(sender).payinvoice(inv)) + + def wait_for_success(): + current_balance = self.node(recipient).channelbalance() + self.log.info(f"{recipient} current balance {current_balance}") + return current_balance == init_balance + 10000 + + self.wait_for_predicate(wait_for_success) + + +if __name__ == "__main__": + test = LNMultiTest() + test.run_test() From f23276836d351a9c3488739307d9a75e7ff16a5d Mon Sep 17 00:00:00 2001 From: macgyver13 <4712150+macgyver13@users.noreply.github.com> Date: Tue, 8 Jul 2025 23:18:20 -0400 Subject: [PATCH 09/12] fix eclair channelbalance calculation issue --- resources/scenarios/ln_framework/ln.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/scenarios/ln_framework/ln.py b/resources/scenarios/ln_framework/ln.py index 18664c775..6bf0f9999 100644 --- a/resources/scenarios/ln_framework/ln.py +++ b/resources/scenarios/ln_framework/ln.py @@ -539,12 +539,12 @@ def channelbalance(self, max_tries=2) -> int: attempt = 0 while attempt < max_tries: attempt += 1 - response = self.post("/usablebalances") + response = self.post("/channelbalances") if not response: sleep(2) continue res = json.loads(response) - return int(sum(o["canSend"] + o["canReceive"] for o in res)) + return int(sum(o["canSend"] for o in res) / 1000) return 0 def connect(self, target_uri, max_tries=5) -> dict: From fdbaf71f62e494b35d276e6b8d097b26b0bd36d7 Mon Sep 17 00:00:00 2001 From: macgyver13 <4712150+macgyver13@users.noreply.github.com> Date: Wed, 9 Jul 2025 19:20:13 -0400 Subject: [PATCH 10/12] make cln rune creation more robust with startupProbe --- .../bitcoincore/charts/cln/templates/pod.yaml | 7 +------ .../charts/bitcoincore/charts/cln/values.yaml | 18 ++++++++++++++++-- .../bitcoincore/charts/eclair/values.yaml | 4 ++-- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/resources/charts/bitcoincore/charts/cln/templates/pod.yaml b/resources/charts/bitcoincore/charts/cln/templates/pod.yaml index b2d651173..0831961cf 100644 --- a/resources/charts/bitcoincore/charts/cln/templates/pod.yaml +++ b/resources/charts/bitcoincore/charts/cln/templates/pod.yaml @@ -41,12 +41,7 @@ spec: - /bin/sh - -c - | - lightningd --conf=/root/.lightning/config & - sleep 1 - lightning-cli createrune > /working/rune.json - echo "Here is the rune file contents" - cat /working/rune.json - wait + lightningd --conf=/root/.lightning/config livenessProbe: {{- toYaml .Values.livenessProbe | nindent 8 }} readinessProbe: diff --git a/resources/charts/bitcoincore/charts/cln/values.yaml b/resources/charts/bitcoincore/charts/cln/values.yaml index d34144100..aac263223 100644 --- a/resources/charts/bitcoincore/charts/cln/values.yaml +++ b/resources/charts/bitcoincore/charts/cln/values.yaml @@ -57,10 +57,10 @@ livenessProbe: - "-c" - "lightning-cli getinfo >/dev/null 2>&1" failureThreshold: 3 - initialDelaySeconds: 5 + initialDelaySeconds: 10 periodSeconds: 5 successThreshold: 1 - timeoutSeconds: 1 + timeoutSeconds: 5 readinessProbe: failureThreshold: 10 periodSeconds: 30 @@ -71,6 +71,20 @@ readinessProbe: - "/bin/sh" - "-c" - "lightning-cli getinfo 2>/dev/null | grep -q 'id' || exit 1" +startupProbe: + failureThreshold: 10 + periodSeconds: 30 + successThreshold: 1 + timeoutSeconds: 60 + exec: + command: + - /bin/sh + - -c + - | + while [ ! -s /working/rune.json ]; do + lightning-cli createrune > /working/rune.json 2>/dev/null + sleep 2 + done # Additional volumes on the output Deployment definition. volumes: diff --git a/resources/charts/bitcoincore/charts/eclair/values.yaml b/resources/charts/bitcoincore/charts/eclair/values.yaml index eef5301e0..13d9e0316 100644 --- a/resources/charts/bitcoincore/charts/eclair/values.yaml +++ b/resources/charts/bitcoincore/charts/eclair/values.yaml @@ -60,10 +60,10 @@ livenessProbe: - 21satoshi - getinfo failureThreshold: 3 - initialDelaySeconds: 5 + initialDelaySeconds: 10 periodSeconds: 5 successThreshold: 1 - timeoutSeconds: 1 + timeoutSeconds: 5 readinessProbe: failureThreshold: 3 periodSeconds: 5 From c99f8fdaaea87551e597d251db5642f0e06c4f82 Mon Sep 17 00:00:00 2001 From: macgyver13 <4712150+macgyver13@users.noreply.github.com> Date: Thu, 10 Jul 2025 08:45:44 -0400 Subject: [PATCH 11/12] increase delay on eclair channel open retries --- resources/scenarios/ln_framework/ln.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/scenarios/ln_framework/ln.py b/resources/scenarios/ln_framework/ln.py index 6bf0f9999..57318307f 100644 --- a/resources/scenarios/ln_framework/ln.py +++ b/resources/scenarios/ln_framework/ln.py @@ -599,7 +599,7 @@ def channel(self, pk, capacity, push_amt, fee_rate, max_tries=10) -> dict: } else: self.log.warning(f"unable to open channel: {response}, wait and retry...") - sleep(1) + sleep(5) else: self.log.debug(f"channel response: {response}, wait and retry...") sleep(5) From 4d12d87b73e635968619ae407657ae7a29f4aef1 Mon Sep 17 00:00:00 2001 From: macgyver13 <4712150+macgyver13@users.noreply.github.com> Date: Thu, 10 Jul 2025 21:24:37 -0400 Subject: [PATCH 12/12] address eclair stability with dnsseed bitcoin core config update to polarlightning/eclair 0.12.0 --- resources/charts/bitcoincore/charts/eclair/values.yaml | 4 ++-- resources/charts/bitcoincore/values.yaml | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/resources/charts/bitcoincore/charts/eclair/values.yaml b/resources/charts/bitcoincore/charts/eclair/values.yaml index 13d9e0316..526a7af12 100644 --- a/resources/charts/bitcoincore/charts/eclair/values.yaml +++ b/resources/charts/bitcoincore/charts/eclair/values.yaml @@ -6,9 +6,9 @@ namespace: warnet restartPolicy: Never image: - repository: bitdonkey/eclair + repository: polarlightning/eclair pullPolicy: IfNotPresent - tag: "0.11.0" + tag: "0.12.0" imagePullSecrets: [] nameOverride: "" diff --git a/resources/charts/bitcoincore/values.yaml b/resources/charts/bitcoincore/values.yaml index 46b72819e..41b47812c 100644 --- a/resources/charts/bitcoincore/values.yaml +++ b/resources/charts/bitcoincore/values.yaml @@ -129,6 +129,7 @@ baseConfig: | rpcallowip=0.0.0.0/0 rpcbind=0.0.0.0 rest=1 + dnsseed=0 # needed for eclair stability # rpcport and zmq endpoints are configured by chain in configmap.yaml config: ""