diff --git a/api/lightning/cln.py b/api/lightning/cln.py index b005d4ece..4e2e94335 100755 --- a/api/lightning/cln.py +++ b/api/lightning/cln.py @@ -1,6 +1,5 @@ import hashlib import os -import random import secrets import struct import time @@ -29,6 +28,13 @@ with open(os.path.join(CLN_DIR, "server.pem"), "rb") as f: server_cert = f.read() +HOLD_CERT_DIR = config( + "CLN_HOLD_CERT_DIR", cast=str, default=os.path.join(CLN_DIR, "hold") +) +HOLD_CA_PATH = os.path.join(HOLD_CERT_DIR, "ca.pem") +HOLD_CLIENT_CERT_PATH = os.path.join(HOLD_CERT_DIR, "client.pem") +HOLD_CLIENT_KEY_PATH = os.path.join(HOLD_CERT_DIR, "client-key.pem") + CLN_GRPC_HOST = config("CLN_GRPC_HOST", cast=str, default="localhost:9999") CLN_GRPC_HOLD_HOST = config("CLN_GRPC_HOLD_HOST", cast=str, default="localhost:9998") @@ -36,6 +42,12 @@ MAX_SWAP_AMOUNT = config("MAX_SWAP_AMOUNT", cast=int, default=500000) +HOLD_STATE_UNPAID = 0 +HOLD_STATE_ACCEPTED = 1 +HOLD_STATE_PAID = 2 +HOLD_STATE_CANCELLED = 3 + + class CLNNode: os.environ["GRPC_SSL_CIPHER_SUITES"] = "HIGH+ECDSA" @@ -45,8 +57,50 @@ class CLNNode: private_key=client_key, certificate_chain=client_cert, ) + + hold_channel = None + hold_channel_signature = None + + @staticmethod + def _hold_cert_signature(): + paths = (HOLD_CA_PATH, HOLD_CLIENT_CERT_PATH, HOLD_CLIENT_KEY_PATH) + if not all(os.path.exists(path) for path in paths): + return None + return tuple(os.path.getmtime(path) for path in paths) + + @classmethod + def get_hold_channel(cls): + signature = cls._hold_cert_signature() + if cls.hold_channel is not None and signature == cls.hold_channel_signature: + return cls.hold_channel + + if signature is None: + hold_creds = cls.creds + hold_channel_options = () + else: + # The Boltz hold plugin creates its own CA and client certs. + # Refresh lazily so startup order does not freeze the CLN gRPC creds. + with open(HOLD_CA_PATH, "rb") as f: + hold_ca = f.read() + with open(HOLD_CLIENT_CERT_PATH, "rb") as f: + hold_client_cert = f.read() + with open(HOLD_CLIENT_KEY_PATH, "rb") as f: + hold_client_key = f.read() + + hold_creds = grpc.ssl_channel_credentials( + root_certificates=hold_ca, + private_key=hold_client_key, + certificate_chain=hold_client_cert, + ) + hold_channel_options = (("grpc.ssl_target_name_override", "hold"),) + + cls.hold_channel = grpc.secure_channel( + CLN_GRPC_HOLD_HOST, hold_creds, options=hold_channel_options + ) + cls.hold_channel_signature = signature + return cls.hold_channel + # Create the gRPC channel using the SSL credentials - hold_channel = grpc.secure_channel(CLN_GRPC_HOLD_HOST, creds) node_channel = grpc.secure_channel(CLN_GRPC_HOST, creds) payment_failure_context = { @@ -231,24 +285,40 @@ def pay_onchain(cls, onchainpayment, queue_code=5, on_mempool_code=2): @classmethod def cancel_return_hold_invoice(cls, payment_hash): """Cancels or returns a hold invoice""" - request = hold_pb2.HoldInvoiceCancelRequest( - payment_hash=bytes.fromhex(payment_hash) - ) - holdstub = hold_pb2_grpc.HoldStub(cls.hold_channel) - response = holdstub.HoldInvoiceCancel(request) + request = hold_pb2.CancelRequest(payment_hash=bytes.fromhex(payment_hash)) + holdstub = hold_pb2_grpc.HoldStub(cls.get_hold_channel()) + holdstub.Cancel(request) - return response.state == hold_pb2.Holdstate.CANCELED + return True @classmethod def settle_hold_invoice(cls, preimage): """settles a hold invoice""" - request = hold_pb2.HoldInvoiceSettleRequest( - payment_hash=hashlib.sha256(bytes.fromhex(preimage)).digest() - ) - holdstub = hold_pb2_grpc.HoldStub(cls.hold_channel) - response = holdstub.HoldInvoiceSettle(request) + request = hold_pb2.SettleRequest(payment_preimage=bytes.fromhex(preimage)) + holdstub = hold_pb2_grpc.HoldStub(cls.get_hold_channel()) + holdstub.Settle(request) - return response.state == hold_pb2.Holdstate.SETTLED + return True + + @classmethod + def _lookup_hold_invoice(cls, payment_hash): + request = hold_pb2.ListRequest(payment_hash=bytes.fromhex(payment_hash)) + holdstub = hold_pb2_grpc.HoldStub(cls.get_hold_channel()) + response = holdstub.List(request) + if not response.invoices: + return None + return response.invoices[0] + + @staticmethod + def _hold_invoice_expiry_height(invoice): + expiries = [ + htlc.cltv_expiry + for htlc in invoice.htlcs + if getattr(htlc, "cltv_expiry", 0) + ] + if not expiries: + return 0 + return min(expiries) @classmethod def gen_hold_invoice( @@ -270,27 +340,27 @@ def gen_hold_invoice( hold_payment = {} # The preimage is a random hash of 256 bits entropy preimage = hashlib.sha256(secrets.token_bytes(nbytes=32)).digest() + payment_hash = hashlib.sha256(preimage).digest() - request = hold_pb2.HoldInvoiceRequest( - description=description, - amount_msat=hold_pb2.Amount(msat=num_satoshis * 1_000), - label=f"Order:{order_id}-{lnpayment_concept}-{time}--{random.randint(1, 100000)}", + request = hold_pb2.InvoiceRequest( + payment_hash=payment_hash, + amount_msat=num_satoshis * 1_000, + memo=description, expiry=invoice_expiry, - cltv=cltv_expiry_blocks, - preimage=preimage, # preimage is actually optional in cln, as cln would generate one by default + min_final_cltv_expiry=cltv_expiry_blocks, ) - holdstub = hold_pb2_grpc.HoldStub(cls.hold_channel) - response = holdstub.HoldInvoice(request) + holdstub = hold_pb2_grpc.HoldStub(cls.get_hold_channel()) + response = holdstub.Invoice(request) hold_payment["invoice"] = response.bolt11 payreq_decoded = cls.decode_payreq(hold_payment["invoice"]) hold_payment["preimage"] = preimage.hex() - hold_payment["payment_hash"] = response.payment_hash.hex() + hold_payment["payment_hash"] = payment_hash.hex() hold_payment["created_at"] = timezone.make_aware( datetime.fromtimestamp(payreq_decoded.created_at) ) hold_payment["expires_at"] = timezone.make_aware( - datetime.fromtimestamp(response.expires_at) + datetime.fromtimestamp(payreq_decoded.created_at + invoice_expiry) ) hold_payment["cltv_expiry"] = cltv_expiry_blocks @@ -301,23 +371,12 @@ def validate_hold_invoice_locked(cls, lnpayment): """Checks if hold invoice is locked""" from api.models import LNPayment - request = hold_pb2.HoldInvoiceLookupRequest( - payment_hash=bytes.fromhex(lnpayment.payment_hash) - ) - holdstub = hold_pb2_grpc.HoldStub(cls.hold_channel) - response = holdstub.HoldInvoiceLookup(request) - - # Will fail if 'empty result for listdatastore_state' or 'Invoice dropped from internal state unexpectedly'. Happens if invoice expiry - # time has passed (but these are 15% padded at the moment). Should catch it - # and report back that the invoice has expired (better robustness) - if response.state == hold_pb2.Holdstate.OPEN: - pass - if response.state == hold_pb2.Holdstate.SETTLED: - pass - if response.state == hold_pb2.Holdstate.CANCELED: - pass - if response.state == hold_pb2.Holdstate.ACCEPTED: - lnpayment.expiry_height = response.htlc_expiry + invoice = cls._lookup_hold_invoice(lnpayment.payment_hash) + if invoice is None: + return False + + if invoice.state == HOLD_STATE_ACCEPTED: + lnpayment.expiry_height = cls._hold_invoice_expiry_height(invoice) lnpayment.status = LNPayment.Status.LOCKED lnpayment.save(update_fields=["expiry_height", "status"]) return True @@ -334,36 +393,30 @@ def lookup_invoice_status(cls, lnpayment): expiry_height = 0 cln_response_state_to_lnpayment_status = { - 0: LNPayment.Status.INVGEN, # OPEN - 1: LNPayment.Status.SETLED, # SETTLED - 2: LNPayment.Status.CANCEL, # CANCELLED - 3: LNPayment.Status.LOCKED, # ACCEPTED + HOLD_STATE_UNPAID: LNPayment.Status.INVGEN, + HOLD_STATE_ACCEPTED: LNPayment.Status.LOCKED, + HOLD_STATE_PAID: LNPayment.Status.SETLED, + HOLD_STATE_CANCELLED: LNPayment.Status.CANCEL, } try: # this is similar to LNNnode.validate_hold_invoice_locked - request = hold_pb2.HoldInvoiceLookupRequest( - payment_hash=bytes.fromhex(lnpayment.payment_hash) - ) - holdstub = hold_pb2_grpc.HoldStub(cls.hold_channel) - response = holdstub.HoldInvoiceLookup(request) + response = cls._lookup_hold_invoice(lnpayment.payment_hash) - status = cln_response_state_to_lnpayment_status[response.state] + if response is None: + raise ValueError("hold invoice not found") - # try saving expiry height - if hasattr(response, "htlc_expiry"): - try: - expiry_height = response.htlc_expiry - except Exception: - pass + status = cln_response_state_to_lnpayment_status[response.state] + expiry_height = cls._hold_invoice_expiry_height(response) except Exception as e: - # If it fails at finding the invoice: it has been expired for more than an hour (and could be paid or just expired). - # In RoboSats DB we make a distinction between cancelled and returned - # (holdinvoice plugin has separate state for hodl-invoices, which it forgets after an invoice expired more than an hour ago) - if "empty result for listdatastore_state" in str( - e - ) or "Invoice dropped from internal state unexpectedly" in str(e): + # If the hold plugin cannot find the invoice, it may have expired or + # been cleaned. Fall back to CLN's invoice list before giving up. + if ( + "hold invoice not found" in str(e) + or "empty result for listdatastore_state" in str(e) + or "Invoice dropped from internal state unexpectedly" in str(e) + ): print(str(e)) request2 = node_pb2.ListinvoicesRequest( payment_hash=bytes.fromhex(lnpayment.payment_hash) @@ -862,16 +915,15 @@ def send_keysend( @classmethod def double_check_htlc_is_settled(cls, payment_hash): """Just as it sounds. Better safe than sorry!""" - request = hold_pb2.HoldInvoiceLookupRequest( - payment_hash=bytes.fromhex(payment_hash) - ) try: - holdstub = hold_pb2_grpc.HoldStub(cls.hold_channel) - response = holdstub.HoldInvoiceLookup(request) + response = cls._lookup_hold_invoice(payment_hash) except Exception as e: if "Timed out" in str(e): return False else: raise e - return response.state == hold_pb2.Holdstate.SETTLED + if response is None: + return False + + return response.state == HOLD_STATE_PAID diff --git a/docker-tests.yml b/docker-tests.yml index 457735d1d..a28cd95ae 100644 --- a/docker-tests.yml +++ b/docker-tests.yml @@ -89,9 +89,9 @@ services: LIGHTNINGD_NETWORK: 'regtest' volumes: - cln:/root/.lightning - - ./docker/cln/plugins/holdinvoice:/root/.lightning/plugins/holdinvoice + - ./docker/cln/plugins/hold:/root/.lightning/plugins/hold - bitcoin:/root/.bitcoin - command: --regtest --bitcoin-rpcuser=test --bitcoin-rpcpassword=test --developer --dev-bitcoind-poll=1 --dev-fast-gossip --log-level=debug --bind-addr=127.0.0.1:9737 --max-concurrent-htlcs=483 --grpc-port=9999 --grpc-hold-port=9998 --important-plugin=/root/.lightning/plugins/holdinvoice --database-upgrade=true + command: --regtest --bitcoin-rpcuser=test --bitcoin-rpcpassword=test --developer --dev-bitcoind-poll=1 --dev-fast-gossip --log-level=debug --bind-addr=127.0.0.1:9737 --max-concurrent-htlcs=483 --grpc-port=9999 --hold-grpc-port=9998 --important-plugin=/root/.lightning/plugins/hold --database-upgrade=true depends_on: - bitcoind network_mode: service:bitcoind @@ -211,4 +211,4 @@ volumes: bitcoin: lnd: cln: - lndrobot: \ No newline at end of file + lndrobot: diff --git a/docker/cln/Dockerfile b/docker/cln/Dockerfile index 2dba011bc..6689d3d1d 100644 --- a/docker/cln/Dockerfile +++ b/docker/cln/Dockerfile @@ -1,7 +1,7 @@ FROM debian:bullseye-slim as builder ARG DEBIAN_FRONTEND=noninteractive -ARG LIGHTNINGD_VERSION=v24.08 +ARG HOLD_VERSION=v0.3.3 RUN apt-get update -qq && \ apt-get install -qq -y --no-install-recommends \ autoconf \ @@ -10,6 +10,8 @@ RUN apt-get update -qq && \ ca-certificates \ curl \ git \ + libpq-dev \ + libsqlite3-dev \ protobuf-compiler ENV RUST_PROFILE=release @@ -18,16 +20,17 @@ RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y RUN rustup toolchain install stable --component rustfmt --allow-downgrade WORKDIR /opt/lightningd -RUN git clone https://github.com/daywalker90/holdinvoice.git /tmp/holdinvoice -RUN cd /tmp/holdinvoice \ +RUN git clone --branch ${HOLD_VERSION} --depth 1 https://github.com/BoltzExchange/hold.git /tmp/hold +RUN cd /tmp/hold \ && cargo build --release -FROM elementsproject/lightningd:v24.08 as final +ARG LIGHTNINGD_VERSION=v24.08 +FROM elementsproject/lightningd:${LIGHTNINGD_VERSION} as final -COPY --from=builder /tmp/holdinvoice/target/release/holdinvoice /tmp/holdinvoice +COPY --from=builder /tmp/hold/target/release/hold /tmp/hold COPY config /tmp/config COPY entrypoint.sh entrypoint.sh RUN chmod +x entrypoint.sh EXPOSE 9735 9835 -ENTRYPOINT [ "/usr/bin/tini", "-g", "--", "./entrypoint.sh" ] \ No newline at end of file +ENTRYPOINT [ "/usr/bin/tini", "-g", "--", "./entrypoint.sh" ] diff --git a/docker/cln/config b/docker/cln/config index 1210a9762..c83476758 100644 --- a/docker/cln/config +++ b/docker/cln/config @@ -3,8 +3,8 @@ proxy=127.0.0.1:9050 bind-addr=127.0.0.1:9736 addr=statictor:127.0.0.1:9051 grpc-port=9999 -grpc-hold-port=9998 +hold-grpc-port=9998 always-use-proxy=true -important-plugin=/root/.lightning/plugins/holdinvoice +important-plugin=/root/.lightning/plugins/hold # wallet=postgres://user:pass@localhost:5433/cln -# bookkeeper-db=postgres://user:pass@localhost:5433/cln \ No newline at end of file +# bookkeeper-db=postgres://user:pass@localhost:5433/cln diff --git a/docker/cln/entrypoint.sh b/docker/cln/entrypoint.sh index d9061091f..37956c8dd 100644 --- a/docker/cln/entrypoint.sh +++ b/docker/cln/entrypoint.sh @@ -17,11 +17,11 @@ if [ "$EXPOSE_TCP" == "true" ]; then socat "TCP4-listen:$LIGHTNINGD_RPC_PORT,fork,reuseaddr" "UNIX-CONNECT:${networkdatadir}/lightning-rpc" & fg %- else - # Always copy the holdinvoice plugin into the plugins directory on start up + # Always copy the hold plugin into the plugins directory on start up mkdir -p /root/.lightning/plugins - cp /tmp/holdinvoice /root/.lightning/plugins/holdinvoice + cp /tmp/hold /root/.lightning/plugins/hold if [ ! -f /root/.lightning/config ]; then cp /tmp/config /root/.lightning/config fi exec "$@" -fi \ No newline at end of file +fi diff --git a/docker/cln/plugins/holdinvoice b/docker/cln/plugins/hold similarity index 53% rename from docker/cln/plugins/holdinvoice rename to docker/cln/plugins/hold index 4818f580a..0f230b940 100755 Binary files a/docker/cln/plugins/holdinvoice and b/docker/cln/plugins/hold differ diff --git a/scripts/generate_grpc.sh b/scripts/generate_grpc.sh index 3101b6616..9dbe64992 100755 --- a/scripts/generate_grpc.sh +++ b/scripts/generate_grpc.sh @@ -10,7 +10,7 @@ curl --parallel -o lightning.proto https://raw.githubusercontent.com/lightningne -o router.proto https://raw.githubusercontent.com/lightningnetwork/lnd/master/lnrpc/routerrpc/router.proto \ -o signer.proto https://raw.githubusercontent.com/lightningnetwork/lnd/master/lnrpc/signrpc/signer.proto \ -o verrpc.proto https://raw.githubusercontent.com/lightningnetwork/lnd/master/lnrpc/verrpc/verrpc.proto \ - -o hold.proto https://raw.githubusercontent.com/daywalker90/holdinvoice/master/proto/hold.proto \ + -o hold.proto https://raw.githubusercontent.com/BoltzExchange/hold/v0.3.3/protos/hold.proto \ -o primitives.proto https://raw.githubusercontent.com/ElementsProject/lightning/v24.08/cln-grpc/proto/primitives.proto \ -o node.proto https://raw.githubusercontent.com/ElementsProject/lightning/v24.08/cln-grpc/proto/node.proto @@ -43,4 +43,4 @@ echo "Done" # On development environments the local volume will be mounted over these files. We copy pb2 and grpc files to /tmp/. # This way, we can find if these files are missing with our entrypoint.sh and copy them into the volume. cp -r *_pb2.py /tmp/ -cp -r *_grpc.py /tmp/ \ No newline at end of file +cp -r *_grpc.py /tmp/