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/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/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/.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..526a7af12 --- /dev/null +++ b/resources/charts/bitcoincore/charts/eclair/values.yaml @@ -0,0 +1,110 @@ +# Default values for eclair. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. +namespace: warnet + +restartPolicy: Never + +image: + repository: polarlightning/eclair + pullPolicy: IfNotPresent + tag: "0.12.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: 10 + periodSeconds: 5 + successThreshold: 1 + timeoutSeconds: 5 +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 + +config: "" + +defaultConfig: "" + +channels: [] 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/charts/bitcoincore/values.yaml b/resources/charts/bitcoincore/values.yaml index 67f1b9b10..41b47812c 100644 --- a/resources/charts/bitcoincore/values.yaml +++ b/resources/charts/bitcoincore/values.yaml @@ -123,11 +123,13 @@ 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 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: "" @@ -140,6 +142,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..57318307f 100644 --- a/resources/scenarios/ln_framework/ln.py +++ b/resources/scenarios/ln_framework/ln.py @@ -1,8 +1,12 @@ 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 @@ -80,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) @@ -89,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): @@ -101,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 @@ -121,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 @@ -131,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" @@ -217,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 @@ -235,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']}" @@ -245,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 @@ -257,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 @@ -269,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: @@ -294,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: @@ -307,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: @@ -330,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: @@ -343,7 +412,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...") @@ -355,9 +424,241 @@ def update(self, txid_hex: str, policy: dict, capacity: int, max_tries=2) -> dic return None +class ECLAIR(LNNode): + 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" + self.reset_connection() + + def reset_connection(self): + self.conn = http.client.HTTPConnection(host=self.name, port=8080, timeout=5) + + def get(self, uri: str): + if self.use_rpc: + cmd = uri.replace("/", "") + return self.rpc_call(cmd) + 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: 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) + 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 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): + 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 + 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("/channelbalances") + if not response: + sleep(2) + continue + res = json.loads(response) + return int(sum(o["canSend"] for o in res) / 1000) + return 0 + + def connect(self, target_uri, max_tries=5) -> dict: + attempt = 0 + while attempt < max_tries: + attempt += 1 + 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...") + sleep(2) + return None + + def channel(self, pk, capacity, push_amt, fee_rate, max_tries=10) -> dict: + NON_GROUPED_UTXO_BYTE_SIZE = 165 + data = { + "fundingSatoshis": capacity, + "pushMsat": push_amt, + "nodeId": pk, + "fundingFeerateSatByte": fee_rate, + "fundingFeeBudgetSatoshis": fee_rate * NON_GROUPED_UTXO_BYTE_SIZE, + } # https://acinq.github.io/eclair/#open-2 + attempt = 0 + while attempt < max_tries: + attempt += 1 + 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 + 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(5) + else: + self.log.debug(f"channel response: {response}, wait and retry...") + sleep(5) + return None + + def createinvoice(self, sats, label, description="new invoice") -> str: + 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["serialized"] + return None + + def payinvoice(self, payment_request) -> str: + 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 + return None + + def graph(self, max_tries=5) -> dict: + attempt = 0 + while attempt < max_tries: + attempt += 1 + 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: + 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) + 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): - 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 ) @@ -372,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: @@ -386,7 +690,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 +727,19 @@ 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 self.use_rpc: + response = self.rpc_call("newaddress", ["p2wkh"]) + else: + response = self.get("/v1/newaddress") + if not response: + sleep(5) + continue res = json.loads(response) if "address" in res: return True, res["address"] @@ -437,16 +747,27 @@ 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: - 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") @@ -457,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 @@ -503,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/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/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") 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/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/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/data/logging/network.yaml b/test/data/logging/network.yaml index 067161cae..d0c1d8a7e 100644 --- a/test/data/logging/network.yaml +++ b/test/data/logging/network.yaml @@ -11,9 +11,14 @@ nodes: - name: tank-0002 addnode: - tank-0000 - ln: - lnd: true lnd: + 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/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 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() diff --git a/test/test_base.py b/test/test_base.py index c0beeeda8..4ad555840 100644 --- a/test/test_base.py +++ b/test/test_base.py @@ -71,7 +71,7 @@ def warnet(self, cmd): proc = run(command, capture_output=True) 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():