Skip to content
31 changes: 21 additions & 10 deletions src/backend/kubernetes_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,12 @@ def ln_cli(self, tank: Tank, command: list[str]):
self.log.debug(f"Running lncli {cmd=:} on {tank.index=:}")
return self.exec_run(tank.index, ServiceType.LIGHTNING, cmd)

def ln_pub_key(self, tank) -> str:
if tank.lnnode is None:
raise Exception("No LN node configured for tank")
self.log.debug(f"Getting pub key for tank {tank.index}")
return tank.lnnode.get_pub_key()

def get_bitcoin_cli(self, tank: Tank, method: str, params=None):
if params:
cmd = f"bitcoin-cli -regtest -rpcuser={tank.rpc_user} -rpcport={tank.rpc_port} -rpcpassword={tank.rpc_password} {method} {' '.join(map(str, params))}"
Expand Down Expand Up @@ -468,14 +474,21 @@ def remove_prometheus_service_monitors(self, tanks):
def get_lnnode_hostname(self, index: int) -> str:
return f"{self.get_service_name(index, ServiceType.LIGHTNING)}.{self.namespace}"

def create_lnd_container(
self, tank, bitcoind_service_name, volume_mounts
) -> client.V1Container:
def create_ln_container(self, tank, bitcoind_service_name, volume_mounts) -> client.V1Container:
# These args are appended to the Dockerfile `ENTRYPOINT ["lnd"]`
bitcoind_rpc_host = f"{bitcoind_service_name}.{self.namespace}"
lightning_dns = self.get_lnnode_hostname(tank.index)
args = tank.lnnode.get_conf(lightning_dns, bitcoind_rpc_host)
self.log.debug(f"Creating lightning container for tank {tank.index} using {args=:}")
lightning_ready_probe = ""
if tank.lnnode.impl == "lnd":
lightning_ready_probe = "lncli --network=regtest getinfo"
elif tank.lnnode.impl == "cln":
lightning_ready_probe = "lightning-cli --network=regtest getinfo"
else:
raise Exception(
f"Lightning node implementation {tank.lnnode.impl} for tank {tank.index} not supported"
)
lightning_container = client.V1Container(
name=LN_CONTAINER_NAME,
image=tank.lnnode.image,
Expand All @@ -486,12 +499,10 @@ def create_lnd_container(
readiness_probe=client.V1Probe(
failure_threshold=1,
success_threshold=3,
initial_delay_seconds=1,
initial_delay_seconds=10,
period_seconds=2,
timeout_seconds=2,
_exec=client.V1ExecAction(
command=["/bin/sh", "-c", "lncli --network=regtest getinfo"]
),
_exec=client.V1ExecAction(command=["/bin/sh", "-c", lightning_ready_probe]),
),
security_context=client.V1SecurityContext(
privileged=True,
Expand Down Expand Up @@ -681,7 +692,7 @@ def deploy_pods(self, warnet):
raise e
self.client.create_namespaced_service(namespace=self.namespace, body=bitcoind_service)

# Create and deploy LND pod
# Create and deploy a lightning pod
if tank.lnnode:
conts = []
vols = []
Expand All @@ -700,9 +711,9 @@ def deploy_pods(self, warnet):
)
# Add circuit breaker container
conts.append(self.create_circuitbreaker_container(tank, volume_mounts))
# Add lnd container
# Add lightning container
conts.append(
self.create_lnd_container(tank, bitcoind_service.metadata.name, volume_mounts)
self.create_ln_container(tank, bitcoind_service.metadata.name, volume_mounts)
)
# Put it all together in a pod
lnd_pod = self.create_pod_object(
Expand Down
15 changes: 15 additions & 0 deletions src/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,21 @@ def lncli(node: int, command: tuple, network: str):
)


@cli.command(context_settings={"ignore_unknown_options": True})
@click.argument("node", type=int)
@click.option("--network", default="warnet", show_default=True, type=str)
def ln_pub_key(node: int, network: str):
"""
Get lightning node pub key on <node> in [network]
"""
print(
rpc_call(
"tank_ln_pub_key",
{"network": network, "node": node},
)
)


@cli.command()
@click.argument("node", type=int, required=True)
@click.option("--network", default="warnet", show_default=True)
Expand Down
40 changes: 28 additions & 12 deletions src/scenarios/ln_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

from scenarios.utils import ensure_miner
from warnet.test_framework_bridge import WarnetTestFramework
from warnet.utils import channel_match


def cli_help():
Expand Down Expand Up @@ -51,7 +50,7 @@ def funded_lnnodes():
for tank in self.warnet.tanks:
if tank.lnnode is None:
continue
if int(tank.lnnode.get_wallet_balance()["confirmed_balance"]) < (split * 100000000):
if int(tank.lnnode.get_wallet_balance()) < (split * 100000000):
return False
return True

Expand Down Expand Up @@ -117,13 +116,13 @@ def funded_lnnodes():
self.log.info(f"Waiting for graph gossip sync, LN nodes remaining: {ln_nodes_gossip}")
for index in ln_nodes_gossip:
lnnode = self.warnet.tanks[index].lnnode
my_edges = len(lnnode.lncli("describegraph")["edges"])
my_nodes = len(lnnode.lncli("describegraph")["nodes"])
if my_edges == len(chan_opens) and my_nodes == len(ln_nodes):
count_channels = len(lnnode.get_graph_channels())
count_graph_nodes = len(lnnode.get_graph_nodes())
if count_channels == len(chan_opens) and count_graph_nodes == len(ln_nodes):
ln_nodes_gossip.remove(index)
else:
self.log.info(
f" node {index} not synced (channels: {my_edges}/{len(chan_opens)}, nodes: {my_nodes}/{len(ln_nodes)})"
f" node {index} not synced (channels: {count_channels}/{len(chan_opens)}, nodes: {count_graph_nodes}/{len(ln_nodes)})"
)
sleep(5)

Expand All @@ -142,14 +141,31 @@ def funded_lnnodes():
score = 0
for tank_index, me in enumerate(ln_nodes):
you = (tank_index + 1) % len(ln_nodes)
my_channels = self.warnet.tanks[me].lnnode.lncli("describegraph")["edges"]
your_channels = self.warnet.tanks[you].lnnode.lncli("describegraph")["edges"]
my_channels = self.warnet.tanks[me].lnnode.get_graph_channels()
your_channels = self.warnet.tanks[you].lnnode.get_graph_channels()
match = True
for chan_index, my_chan in enumerate(my_channels):
your_chan = your_channels[chan_index]
if not channel_match(my_chan, your_chan, allow_flip=False):
for _chan_index, my_chan in enumerate(my_channels):
your_chan = [
chan
for chan in your_channels
if chan.short_chan_id == my_chan.short_chan_id
][0]
if not your_chan:
print(f"Channel policy missing for channel: {my_chan.short_chan_id}")
match = False
break

try:
if not my_chan.channel_match(your_chan):
print(
f"Channel policy doesn't match between tanks {me} & {you}: {my_chan.short_chan_id}"
)
match = False
break
except Exception as e:
print(f"Error comparing channel policies: {e}")
print(
f"Channel policy doesn't match between tanks {me} & {you}: {my_chan['channel_id']}"
f"Channel policy doesn't match between tanks {me} & {you}: {my_chan.short_chan_id}"
)
match = False
break
Expand Down
2 changes: 1 addition & 1 deletion src/schema/graph_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
"comment": "A string of configure options used when building Bitcoin Core from source code, e.g. '--without-gui --disable-tests'"},
"ln": {
"type": "string",
"comment": "Attach a lightning network node of this implementation (currently only supports 'lnd')"},
"comment": "Attach a lightning network node of this implementation (currently only supports 'lnd' or 'cln')"},
"ln_image": {
"type": "string",
"comment": "Specify a lightning network node image from Dockerhub with the format repository/image:tag"},
Expand Down
181 changes: 181 additions & 0 deletions src/warnet/cln.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import io
import tarfile

from backend.kubernetes_backend import KubernetesBackend
from warnet.services import ServiceType
from warnet.utils import exponential_backoff, generate_ipv4_addr, handle_json

from .lnchannel import LNChannel
from .lnnode import LNNode
from .status import RunningStatus

CLN_CONFIG_BASE = " ".join(
[
"--network=regtest",
"--database-upgrade=true",
"--bitcoin-retry-timeout=600",
"--bind-addr=0.0.0.0:9735",
"--developer",
"--dev-fast-gossip",
"--log-level=debug",
]
)


class CLNNode(LNNode):
def __init__(self, warnet, tank, backend: KubernetesBackend, options):
self.warnet = warnet
self.tank = tank
self.backend = backend
self.image = options["ln_image"]
self.cb = options["cb_image"]
self.ln_config = options["ln_config"]
self.ipv4 = generate_ipv4_addr(self.warnet.subnet)
self.rpc_port = 10009
self.impl = "cln"

@property
def status(self) -> RunningStatus:
return super().status

@property
def cb_status(self) -> RunningStatus:
return super().cb_status

def get_conf(self, ln_container_name, tank_container_name) -> str:
conf = CLN_CONFIG_BASE
conf += f" --alias={self.tank.index}"
conf += f" --grpc-port={self.rpc_port}"
conf += f" --bitcoin-rpcuser={self.tank.rpc_user}"
conf += f" --bitcoin-rpcpassword={self.tank.rpc_password}"
conf += f" --bitcoin-rpcconnect={tank_container_name}"
conf += f" --bitcoin-rpcport={self.tank.rpc_port}"
conf += f" --announce-addr=dns:{ln_container_name}:9735"
return conf

@exponential_backoff(max_retries=20, max_delay=300)
@handle_json
def lncli(self, cmd) -> dict:
cli = "lightning-cli"
cmd = f"{cli} --network=regtest {cmd}"
return self.backend.exec_run(self.tank.index, ServiceType.LIGHTNING, cmd)

def getnewaddress(self):
return self.lncli("newaddr")["bech32"]

def get_pub_key(self):
res = self.lncli("getinfo")
return res["id"]

def getURI(self):
res = self.lncli("getinfo")
if len(res["address"]) < 1:
return None
return f'{res["id"]}@{res["address"][0]["address"]}:{res["address"][0]["port"]}'

def get_wallet_balance(self) -> int:
res = self.lncli("listfunds")
return int(sum(o["amount_msat"] for o in res["outputs"]) / 1000)

# returns the channel point in the form txid:output_index
def open_channel_to_tank(self, index: int, channel_open_data: str) -> str:
tank = self.warnet.tanks[index]
[pubkey, host] = tank.lnnode.getURI().split("@")
res = self.lncli(f"fundchannel id={pubkey} {channel_open_data}")
return f"{res['txid']}:{res['outnum']}"

def update_channel_policy(self, chan_point: str, policy: str) -> str:
return self.lncli(f"setchannel {chan_point} {policy}")

def get_graph_nodes(self) -> list[str]:
return list(n["nodeid"] for n in self.lncli("listnodes")["nodes"])

def get_graph_channels(self) -> list[LNChannel]:
cln_channels = self.lncli("listchannels")["channels"]
# CLN lists channels twice, once for each direction. This finds the unique channel ids.
short_channel_ids = {chan["short_channel_id"]: chan for chan in cln_channels}.keys()
channels = []
for short_channel_id in short_channel_ids:
node1, node2 = (
chans for chans in cln_channels if chans["short_channel_id"] == short_channel_id
)
channels.append(self.lnchannel_from_json(node1, node2))
return channels

@staticmethod
def lnchannel_from_json(node1: object, node2: object) -> LNChannel:
assert node1["short_channel_id"] == node2["short_channel_id"]
assert node1["direction"] != node2["direction"]
return LNChannel(
node1_pub=node1["source"],
node2_pub=node2["source"],
capacity_msat=node1["amount_msat"],
short_chan_id=node1["short_channel_id"],
node1_min_htlc=node1["htlc_minimum_msat"],
node2_min_htlc=node2["htlc_minimum_msat"],
node1_max_htlc=node1["htlc_maximum_msat"],
node2_max_htlc=node2["htlc_maximum_msat"],
node1_base_fee_msat=node1["base_fee_millisatoshi"],
node2_base_fee_msat=node2["base_fee_millisatoshi"],
node1_fee_rate_milli_msat=node1["fee_per_millionth"],
node2_fee_rate_milli_msat=node2["fee_per_millionth"],
)

def get_peers(self) -> list[str]:
return list(p["id"] for p in self.lncli("listpeers")["peers"])

def connect_to_tank(self, index):
return super().connect_to_tank(index)

def generate_cli_command(self, command: list[str]):
network = f"--network={self.tank.warnet.bitcoin_network}"
cmd = f"{network} {' '.join(command)}"
cmd = f"lightning-cli {cmd}"
return cmd

def export(self, config: object, tar_file):
# Retrieve the credentials
ca_cert = self.backend.get_file(
self.tank.index,
ServiceType.LIGHTNING,
"/root/.lightning/regtest/ca.pem",
)
client_cert = self.backend.get_file(
self.tank.index,
ServiceType.LIGHTNING,
"/root/.lightning/regtest/client.pem",
)
client_key = self.backend.get_file(
self.tank.index,
ServiceType.LIGHTNING,
"/root/.lightning/regtest/client-key.pem",
)
name = f"ln-{self.tank.index}"
ca_cert_filename = f"{name}_ca_cert.pem"
client_cert_filename = f"{name}_client_cert.pem"
client_key_filename = f"{name}_client_key.pem"
host = self.backend.get_lnnode_hostname(self.tank.index)

# Add the files to the in-memory tar archive
tarinfo1 = tarfile.TarInfo(name=ca_cert_filename)
tarinfo1.size = len(ca_cert)
fileobj1 = io.BytesIO(ca_cert)
tar_file.addfile(tarinfo=tarinfo1, fileobj=fileobj1)
tarinfo2 = tarfile.TarInfo(name=client_cert_filename)
tarinfo2.size = len(client_cert)
fileobj2 = io.BytesIO(client_cert)
tar_file.addfile(tarinfo=tarinfo2, fileobj=fileobj2)
tarinfo3 = tarfile.TarInfo(name=client_key_filename)
tarinfo3.size = len(client_key)
fileobj3 = io.BytesIO(client_key)
tar_file.addfile(tarinfo=tarinfo3, fileobj=fileobj3)

config["nodes"].append(
{
"id": name,
"address": f"https://{host}:{self.rpc_port}",
"ca_cert": f"/simln/{ca_cert_filename}",
"client_cert": f"/simln/{client_cert_filename}",
"client_key": f"/simln/{client_key_filename}",
}
)
Loading