diff --git a/.env-sample b/.env-sample index 71901bb6..a1dc628c 100644 --- a/.env-sample +++ b/.env-sample @@ -1,16 +1,21 @@ # Coordinator Alias (Same as longAlias) COORDINATOR_ALIAS="Local Dev" +# Lightning node vendor: CLN | LND +LNVENDOR='CLN' # LND directory to read TLS cert and macaroon LND_DIR='/lnd/' MACAROON_PATH='data/chain/bitcoin/testnet/admin.macaroon' - # LND directory can not be specified, instead cert and macaroon can be provided as base64 strings # base64 ~/.lnd/tls.cert | tr -d '\n' LND_CERT_BASE64='LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNLVENDQWRDZ0F3SUJBZ0lRQ0VoeGpPZXY1bGQyVFNPTXhKalFvekFLQmdncWhrak9QUVFEQWpBNE1SOHcKSFFZRFZRUUtFeFpzYm1RZ1lYVjBiMmRsYm1WeVlYUmxaQ0JqWlhKME1SVXdFd1lEVlFRREV3d3dNakJtTVRnMQpZelkwTnpVd0hoY05Nakl3TWpBNE1UWXhOalV3V2hjTk1qTXdOREExTVRZeE5qVXdXakE0TVI4d0hRWURWUVFLCkV4WnNibVFnWVhWMGIyZGxibVZ5WVhSbFpDQmpaWEowTVJVd0V3WURWUVFERXd3d01qQm1NVGcxWXpZME56VXcKV1RBVEJnY3Foa2pPUFFJQkJnZ3Foa2pPUFFNQkJ3TkNBQVNJVWdkcVMrWFZKL3EzY0JZeWd6ZDc2endaanlmdQpLK3BzcWNYVkFyeGZjU2NXQ25jbXliNGRaMy9Lc3lLWlRaamlySDE3aEY0OGtIMlp5clRZSW9hZG80RzdNSUc0Ck1BNEdBMVVkRHdFQi93UUVBd0lDcERBVEJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREFUQVBCZ05WSFJNQkFmOEUKQlRBREFRSC9NQjBHQTFVZERnUVdCQlEwWUJjZXdsd1BqYTJPRXFyTGxzZnJscEswUFRCaEJnTlZIUkVFV2pCWQpnZ3d3TWpCbU1UZzFZelkwTnpXQ0NXeHZZMkZzYUc5emRJSUVkVzVwZUlJS2RXNXBlSEJoWTJ0bGRJSUhZblZtClkyOXVib2NFZndBQUFZY1FBQUFBQUFBQUFBQUFBQUFBQUFBQUFZY0V3S2dRQW9jRUFBQUFBREFLQmdncWhrak8KUFFRREFnTkhBREJFQWlBd0dMY05qNXVZSkVwanhYR05OUnNFSzAwWmlSUUh2Qm50NHp6M0htWHBiZ0lnSWtvUQo3cHFvNGdWNGhiczdrSmt1bnk2bkxlNVg0ZzgxYjJQOW52ZnZ2bkk9Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K' # base64 ~/.lnd/data/chain/bitcoin/testnet/admin.macaroon | tr -d '\n' LND_MACAROON_BASE64='AgEDbG5kAvgBAwoQsyI+PK+fyb7F2UyTeZ4seRIBMBoWCgdhZGRyZXNzEgRyZWFkEgV3cml0ZRoTCgRpbmZvEgRyZWFkEgV3cml0ZRoXCghpbnZvaWNlcxIEcmVhZBIFd3JpdGUaIQoIbWFjYXJvb24SCGdlbmVyYXRlEgRyZWFkEgV3cml0ZRoWCgdtZXNzYWdlEgRyZWFkEgV3cml0ZRoXCghvZmZjaGFpbhIEcmVhZBIFd3JpdGUaFgoHb25jaGFpbhIEcmVhZBIFd3JpdGUaFAoFcGVlcnMSBHJlYWQSBXdyaXRlGhgKBnNpZ25lchIIZ2VuZXJhdGUSBHJlYWQAAAYgMt90uD6v4truTadWCjlppoeJ4hZrL1SBb09Y+4WOiI0=' +# CLN directory +CLN_DIR='/cln/testnet/' +CLN_GRPC_HOST='localhost:9999' + # Bitcoin Core Daemon RPC, used to validate addresses BITCOIND_RPCURL = 'http://127.0.0.1:18332' BITCOIND_RPCUSER = 'robodev' diff --git a/api/lightning/cln.py b/api/lightning/cln.py new file mode 100755 index 00000000..70d398c5 --- /dev/null +++ b/api/lightning/cln.py @@ -0,0 +1,826 @@ +import hashlib +import os +import secrets +import struct +import time +from datetime import datetime, timedelta + +import grpc +import ring +from decouple import config +from django.utils import timezone + +from . import node_pb2 as noderpc +from . import node_pb2_grpc as nodestub +from . import primitives_pb2 as primitives__pb2 + +####### +# Works with CLN +####### + +# Load the client's certificate and key +with open(os.path.join(config("CLN_DIR"), "client.pem"), "rb") as f: + client_cert = f.read() +with open(os.path.join(config("CLN_DIR"), "client-key.pem"), "rb") as f: + client_key = f.read() + +# Load the server's certificate +with open(os.path.join(config("CLN_DIR"), "server.pem"), "rb") as f: + server_cert = f.read() + + +CLN_GRPC_HOST = config("CLN_GRPC_HOST") +DISABLE_ONCHAIN = config("DISABLE_ONCHAIN", cast=bool, default=True) +MAX_SWAP_AMOUNT = config("MAX_SWAP_AMOUNT", cast=int, default=500000) + + +class CLNNode: + + os.environ["GRPC_SSL_CIPHER_SUITES"] = "HIGH+ECDSA" + + # Create the SSL credentials object + creds = grpc.ssl_channel_credentials( + root_certificates=server_cert, + private_key=client_key, + certificate_chain=client_cert, + ) + # Create the gRPC channel using the SSL credentials + channel = grpc.secure_channel(CLN_GRPC_HOST, creds) + + # Create the gRPC stub + stub = nodestub.NodeStub(channel) + + noderpc = noderpc + + payment_failure_context = { + -1: "Catchall nonspecific error.", + 201: "Already paid with this hash using different amount or destination.", + 203: "Permanent failure at destination.", + 205: "Unable to find a route.", + 206: "Route too expensive.", + 207: "Invoice expired.", + 210: "Payment timed out without a payment in progress.", + } + + @classmethod + def get_version(cls): + try: + request = noderpc.GetinfoRequest() + print(request) + response = cls.stub.Getinfo(request) + print(response) + return response.version + except Exception as e: + print(e) + return None + + @classmethod + def decode_payreq(cls, invoice): + """Decodes a lightning payment request (invoice)""" + request = noderpc.DecodeBolt11Request(bolt11=invoice) + + response = cls.stub.DecodeBolt11(request) + return response + + @classmethod + def estimate_fee(cls, amount_sats, target_conf=2, min_confs=1): + """Returns estimated fee for onchain payouts""" + # feerate estimaes work a bit differently in cln see https://lightning.readthedocs.io/lightning-feerates.7.html + request = noderpc.FeeratesRequest(style="PERKB") + + response = cls.stub.Feerates(request) + + # "opening" -> ~12 block target + return { + "mining_fee_sats": response.onchain_fee_estimates.opening_channel_satoshis, + "mining_fee_rate": response.perkb.opening / 1000, + } + + wallet_balance_cache = {} + + @ring.dict(wallet_balance_cache, expire=10) # keeps in cache for 10 seconds + @classmethod + def wallet_balance(cls): + """Returns onchain balance""" + request = noderpc.ListfundsRequest() + + response = cls.stub.ListFunds(request) + + unconfirmed_balance = 0 + confirmed_balance = 0 + total_balance = 0 + for utxo in response.outputs: + if not utxo.reserved: + if ( + utxo.status + == noderpc.ListfundsOutputs.ListfundsOutputsStatus.UNCONFIRMED + ): + unconfirmed_balance += utxo.amount_msat.msat // 1_000 + total_balance += utxo.amount_msat.msat // 1_000 + elif ( + utxo.status + == noderpc.ListfundsOutputs.ListfundsOutputsStatus.CONFIRMED + ): + confirmed_balance += utxo.amount_msat.msat // 1_000 + total_balance += utxo.amount_msat.msat // 1_000 + + return { + "total_balance": total_balance, + "confirmed_balance": confirmed_balance, + "unconfirmed_balance": unconfirmed_balance, + } + + channel_balance_cache = {} + + @ring.dict(channel_balance_cache, expire=10) # keeps in cache for 10 seconds + @classmethod + def channel_balance(cls): + """Returns channels balance""" + request = noderpc.ListpeerchannelsRequest() + + response = cls.stub.ListPeerChannels(request) + + local_balance_sat = 0 + remote_balance_sat = 0 + unsettled_local_balance = 0 + unsettled_remote_balance = 0 + for channel in response.channels: + if ( + channel.state + == noderpc.ListpeerchannelsChannels.ListpeerchannelsChannelsState.CHANNELD_NORMAL + ): + local_balance_sat += channel.to_us_msat.msat // 1_000 + remote_balance_sat += ( + channel.total_msat.msat - channel.to_us_msat.msat + ) // 1_000 + for htlc in channel.htlcs: + if ( + htlc.direction + == noderpc.ListpeerchannelsChannelsHtlcs.ListpeerchannelsChannelsHtlcsDirection.IN + ): + unsettled_local_balance += htlc.amount_msat.msat // 1_000 + elif ( + htlc.direction + == noderpc.ListpeerchannelsChannelsHtlcs.ListpeerchannelsChannelsHtlcsDirection.OUT + ): + unsettled_remote_balance += htlc.amount_msat.msat // 1_000 + + return { + "local_balance": local_balance_sat, + "remote_balance": remote_balance_sat, + "unsettled_local_balance": unsettled_local_balance, + "unsettled_remote_balance": unsettled_remote_balance, + } + + @classmethod + def pay_onchain(cls, onchainpayment, queue_code=5, on_mempool_code=2): + """Send onchain transaction for buyer payouts""" + + if DISABLE_ONCHAIN or onchainpayment.sent_satoshis > MAX_SWAP_AMOUNT: + return False + + request = noderpc.WithdrawRequest( + destination=onchainpayment.address, + satoshi=primitives__pb2.AmountOrAll( + amount=primitives__pb2.Amount(msat=onchainpayment.sent_satoshis * 1_000) + ), + feerate=primitives__pb2.Feerate( + perkb=int(onchainpayment.mining_fee_rate) * 1_000 + ), + minconf=int(not config("SPEND_UNCONFIRMED", default=False, cast=bool)), + ) + + # Cheap security measure to ensure there has been some non-deterministic time between request and DB check + delay = ( + secrets.randbelow(2**256) / (2**256) * 10 + ) # Random uniform 0 to 5 secs with good entropy + time.sleep(3 + delay) + + if onchainpayment.status == queue_code: + # Changing the state to "MEMPO" should be atomic with SendCoins. + onchainpayment.status = on_mempool_code + onchainpayment.save(update_fields=["status"]) + response = cls.stub.Withdraw(request) + + if response.txid: + onchainpayment.txid = response.txid.hex() + onchainpayment.broadcasted = True + onchainpayment.save(update_fields=["txid", "broadcasted"]) + return True + + elif onchainpayment.status == on_mempool_code: + # Bug, double payment attempted + return True + + @classmethod + def cancel_return_hold_invoice(cls, payment_hash): + """Cancels or returns a hold invoice""" + request = noderpc.HodlInvoiceCancelRequest( + payment_hash=bytes.fromhex(payment_hash) + ) + response = cls.stub.HodlInvoiceCancel(request) + + return response.state == noderpc.HodlInvoiceCancelResponse.Hodlstate.CANCELED + + @classmethod + def settle_hold_invoice(cls, preimage): + """settles a hold invoice""" + request = noderpc.HodlInvoiceSettleRequest( + payment_hash=hashlib.sha256(bytes.fromhex(preimage)).digest() + ) + response = cls.stub.HodlInvoiceSettle(request) + + return response.state == noderpc.HodlInvoiceSettleResponse.Hodlstate.SETTLED + + @classmethod + def gen_hold_invoice( + cls, + num_satoshis, + description, + invoice_expiry, + cltv_expiry_blocks, + order_id, + lnpayment_concept, + time, + ): + """Generates hold invoice""" + + # constant 100h invoice expiry because cln has to cancel htlcs if invoice expires + # or it can't associate them anymore + invoice_expiry = cltv_expiry_blocks * 10 * 60 + + hold_payment = {} + # The preimage is a random hash of 256 bits entropy + preimage = hashlib.sha256(secrets.token_bytes(nbytes=32)).digest() + + request = noderpc.InvoiceRequest( + description=description, + amount_msat=primitives__pb2.AmountOrAny( + amount=primitives__pb2.Amount(msat=num_satoshis * 1_000) + ), + label=f"Order:{order_id}-{lnpayment_concept}-{time}", + expiry=invoice_expiry, + cltv=cltv_expiry_blocks, + preimage=preimage, # preimage is actually optional in cln, as cln would generate one by default + ) + response = cls.stub.HodlInvoice(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["created_at"] = timezone.make_aware( + datetime.fromtimestamp(payreq_decoded.timestamp) + ) + hold_payment["expires_at"] = timezone.make_aware( + datetime.fromtimestamp(response.expires_at) + ) + hold_payment["cltv_expiry"] = cltv_expiry_blocks + + return hold_payment + + @classmethod + def validate_hold_invoice_locked(cls, lnpayment): + """Checks if hold invoice is locked""" + from api.models import LNPayment + + request = noderpc.HodlInvoiceLookupRequest( + payment_hash=bytes.fromhex(lnpayment.payment_hash) + ) + response = cls.stub.HodlInvoiceLookup(request) + + # Will fail if 'unable to locate invoice'. 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 == noderpc.HodlInvoiceLookupResponse.Hodlstate.OPEN: + pass + if response.state == noderpc.HodlInvoiceLookupResponse.Hodlstate.SETTLED: + pass + if response.state == noderpc.HodlInvoiceLookupResponse.Hodlstate.CANCELED: + pass + if response.state == noderpc.HodlInvoiceLookupResponse.Hodlstate.ACCEPTED: + lnpayment.expiry_height = response.htlc_expiry + lnpayment.status = LNPayment.Status.LOCKED + lnpayment.save(update_fields=["expiry_height", "status"]) + return True + + @classmethod + def lookup_invoice_status(cls, lnpayment): + """ + Returns the status (as LNpayment.Status) of the given payment_hash + If unchanged, returns the previous status + """ + from api.models import LNPayment + + status = lnpayment.status + 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 + } + + try: + # this is similar to LNNnode.validate_hold_invoice_locked + request = noderpc.HodlInvoiceLookupRequest( + payment_hash=bytes.fromhex(lnpayment.payment_hash) + ) + response = cls.stub.HodlInvoiceLookup(request) + + status = cln_response_state_to_lnpayment_status[response.state] + + # try saving expiry height + if hasattr(response, "htlc_expiry"): + try: + expiry_height = response.htlc_expiry + except Exception: + pass + + 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 + # (cln-grpc-hodl 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): + print(str(e)) + request2 = noderpc.ListinvoicesRequest( + payment_hash=bytes.fromhex(lnpayment.payment_hash) + ) + try: + response2 = cls.stub.ListInvoices(request2).invoices + except Exception as e: + print(str(e)) + + if ( + response2[0].status + == noderpc.ListinvoicesInvoices.ListinvoicesInvoicesStatus.PAID + ): + status = LNPayment.Status.SETLED + elif ( + response2[0].status + == noderpc.ListinvoicesInvoices.ListinvoicesInvoicesStatus.EXPIRED + ): + status = LNPayment.Status.CANCEL + else: + print(str(e)) + + # Other write to logs + else: + print(str(e)) + + return status, expiry_height + + @classmethod + def resetmc(cls): + # don't think an equivalent exists for cln, maybe deleting gossip_store file? + return False + + @classmethod + def validate_ln_invoice(cls, invoice, num_satoshis, routing_budget_ppm): + """Checks if the submited LN invoice comforms to expectations""" + + payout = { + "valid": False, + "context": None, + "description": None, + "payment_hash": None, + "created_at": None, + "expires_at": None, + } + + try: + payreq_decoded = cls.decode_payreq(invoice) + except Exception: + payout["context"] = { + "bad_invoice": "Does not look like a valid lightning invoice" + } + return payout + + # Some wallet providers (e.g. Muun) force routing through a private channel with high fees >1500ppm + # These payments will fail. So it is best to let the user know in advance this invoice is not valid. + route_hints = payreq_decoded.route_hints.hints + + # Max amount RoboSats will pay for routing + if routing_budget_ppm == 0: + max_routing_fee_sats = max( + num_satoshis * float(config("PROPORTIONAL_ROUTING_FEE_LIMIT")), + float(config("MIN_FLAT_ROUTING_FEE_LIMIT_REWARD")), + ) + else: + max_routing_fee_sats = int( + float(num_satoshis) * float(routing_budget_ppm) / 1000000 + ) + + if route_hints: + routes_cost = [] + # For every hinted route... + for hinted_route in route_hints: + route_cost = 0 + # ...add up the cost of every hinted hop... + for hop_hint in hinted_route.hops: + route_cost += hop_hint.feebase.msat / 1_000 + route_cost += hop_hint.feeprop * num_satoshis / 1_000_000 + + # ...and store the cost of the route to the array + routes_cost.append(route_cost) + + # If the cheapest possible private route is more expensive than what RoboSats is willing to pay + if min(routes_cost) >= max_routing_fee_sats: + payout["context"] = { + "bad_invoice": "The invoice hinted private routes are not payable within the submitted routing budget." + } + return payout + + if payreq_decoded.amount_msat.msat == 0: + payout["context"] = { + "bad_invoice": "The invoice provided has no explicit amount" + } + return payout + + if not payreq_decoded.amount_msat.msat // 1_000 == num_satoshis: + payout["context"] = { + "bad_invoice": "The invoice provided is not for " + + "{:,}".format(num_satoshis) + + " Sats" + } + return payout + + payout["created_at"] = timezone.make_aware( + datetime.fromtimestamp(payreq_decoded.timestamp) + ) + payout["expires_at"] = payout["created_at"] + timedelta( + seconds=payreq_decoded.expiry + ) + + if payout["expires_at"] < timezone.now(): + payout["context"] = { + "bad_invoice": "The invoice provided has already expired" + } + return payout + + payout["valid"] = True + payout["description"] = payreq_decoded.description + payout["payment_hash"] = payreq_decoded.payment_hash.hex() + + return payout + + @classmethod + def pay_invoice(cls, lnpayment): + """Sends sats. Used for rewards payouts""" + from api.models import LNPayment + + fee_limit_sat = int( + max( + lnpayment.num_satoshis + * float(config("PROPORTIONAL_ROUTING_FEE_LIMIT")), + float(config("MIN_FLAT_ROUTING_FEE_LIMIT_REWARD")), + ) + ) # 200 ppm or 10 sats + timeout_seconds = int(config("REWARDS_TIMEOUT_SECONDS")) + request = noderpc.PayRequest( + bolt11=lnpayment.invoice, + maxfee=primitives__pb2.Amount(msat=fee_limit_sat * 1_000), + retry_for=timeout_seconds, + ) + + try: + response = cls.stub.Pay(request) + + if response.status == noderpc.PayResponse.PayStatus.COMPLETE: + lnpayment.status = LNPayment.Status.SUCCED + lnpayment.fee = ( + float(response.amount_sent_msat.msat - response.amount_msat.msat) + / 1000 + ) + lnpayment.preimage = response.payment_preimage.hex() + lnpayment.save(update_fields=["fee", "status", "preimage"]) + return True, None + elif response.status == noderpc.PayResponse.PayStatus.PENDING: + failure_reason = "Payment isn't failed (yet)" + lnpayment.failure_reason = LNPayment.FailureReason.NOTYETF + lnpayment.status = LNPayment.Status.FLIGHT + lnpayment.save(update_fields=["failure_reason", "status"]) + return False, failure_reason + else: # response.status == noderpc.PayResponse.PayStatus.FAILED + failure_reason = "All possible routes were tried and failed permanently. Or were no routes to the destination at all." + lnpayment.failure_reason = LNPayment.FailureReason.NOROUTE + lnpayment.status = LNPayment.Status.FAILRO + lnpayment.save(update_fields=["failure_reason", "status"]) + return False, failure_reason + except grpc._channel._InactiveRpcError as e: + status_code = int(e.details().split("code: Some(")[1].split(")")[0]) + failure_reason = cls.payment_failure_context[status_code] + lnpayment.failure_reason = LNPayment.FailureReason.NOROUTE + lnpayment.status = LNPayment.Status.FAILRO + lnpayment.save(update_fields=["failure_reason", "status"]) + return False, failure_reason + + @classmethod + def follow_send_payment(cls, lnpayment, fee_limit_sat, timeout_seconds): + """Sends sats to buyer, continuous update""" + + from api.models import LNPayment, Order + + hash = lnpayment.payment_hash + + # retry_for is not quite the same as a timeout. Pay can still take SIGNIFICANTLY longer to return if htlcs are stuck! + # allow_self_payment=True, No such thing in pay command and self_payments do not work with pay! + request = noderpc.PayRequest( + bolt11=lnpayment.invoice, + maxfee=primitives__pb2.Amount(msat=fee_limit_sat * 1_000), + retry_for=timeout_seconds, + ) + + order = lnpayment.order_paid_LN + if order.trade_escrow.num_satoshis < lnpayment.num_satoshis: + print(f"Order: {order.id} Payout is larger than collateral !?") + return + + def watchpayment(): + request_listpays = noderpc.ListpaysRequest(payment_hash=bytes.fromhex(hash)) + while True: + try: + response_listpays = cls.stub.ListPays(request_listpays) + except Exception as e: + print(str(e)) + time.sleep(2) + continue + + if ( + len(response_listpays.pays) == 0 + or response_listpays.pays[0].status + != noderpc.ListpaysPays.ListpaysPaysStatus.PENDING + ): + return response_listpays + else: + time.sleep(2) + + def handle_response(): + try: + lnpayment.status = LNPayment.Status.FLIGHT + lnpayment.in_flight = True + lnpayment.save(update_fields=["in_flight", "status"]) + + order.status = Order.Status.PAY + order.save(update_fields=["status"]) + + response = cls.stub.Pay(request) + + if response.status == noderpc.PayResponse.PayStatus.PENDING: + print(f"Order: {order.id} IN_FLIGHT. Hash {hash}") + + watchpayment() + + handle_response() + + if response.status == noderpc.PayResponse.PayStatus.FAILED: + lnpayment.status = LNPayment.Status.FAILRO + lnpayment.last_routing_time = timezone.now() + lnpayment.routing_attempts += 1 + lnpayment.failure_reason = LNPayment.FailureReason.NOROUTE + lnpayment.in_flight = False + if lnpayment.routing_attempts > 2: + lnpayment.status = LNPayment.Status.EXPIRE + lnpayment.routing_attempts = 0 + lnpayment.save( + update_fields=[ + "status", + "last_routing_time", + "routing_attempts", + "failure_reason", + "in_flight", + ] + ) + + order.status = Order.Status.FAI + order.expires_at = timezone.now() + timedelta( + seconds=order.t_to_expire(Order.Status.FAI) + ) + order.save(update_fields=["status", "expires_at"]) + print( + f"Order: {order.id} FAILED. Hash: {hash} Reason: {cls.payment_failure_context[-1]}" + ) + return { + "succeded": False, + "context": f"payment failure reason: {cls.payment_failure_context[-1]}", + } + + if response.status == noderpc.PayResponse.PayStatus.COMPLETE: + print(f"Order: {order.id} SUCCEEDED. Hash: {hash}") + lnpayment.status = LNPayment.Status.SUCCED + lnpayment.fee = ( + float( + response.amount_sent_msat.msat - response.amount_msat.msat + ) + / 1000 + ) + lnpayment.preimage = response.payment_preimage.hex() + lnpayment.save(update_fields=["status", "fee", "preimage"]) + order.status = Order.Status.SUC + order.expires_at = timezone.now() + timedelta( + seconds=order.t_to_expire(Order.Status.SUC) + ) + order.save(update_fields=["status", "expires_at"]) + + results = {"succeded": True} + return results + + except grpc._channel._InactiveRpcError as e: + if "code: Some" in str(e): + status_code = int(e.details().split("code: Some(")[1].split(")")[0]) + if ( + status_code == 201 + ): # Already paid with this hash using different amount or destination + # i don't think this can happen really, since we don't use the amount_msat in request + # and if you just try 'pay' 2x where the first time it succeeds you get the same + # non-error result the 2nd time. + print( + f"Order: {order.id} ALREADY PAID using different amount or destination THIS SHOULD NEVER HAPPEN! Hash: {hash}." + ) + + # Permanent failure at destination. or Unable to find a route. or Route too expensive. + elif ( + status_code == 203 + or status_code == 205 + or status_code == 206 + or status_code == 210 + ): + lnpayment.status = LNPayment.Status.FAILRO + lnpayment.last_routing_time = timezone.now() + lnpayment.routing_attempts += 1 + lnpayment.failure_reason = LNPayment.FailureReason.NOROUTE + lnpayment.in_flight = False + if lnpayment.routing_attempts > 2: + lnpayment.status = LNPayment.Status.EXPIRE + lnpayment.routing_attempts = 0 + lnpayment.save( + update_fields=[ + "status", + "last_routing_time", + "routing_attempts", + "in_flight", + "failure_reason", + ] + ) + + order.status = Order.Status.FAI + order.expires_at = timezone.now() + timedelta( + seconds=order.t_to_expire(Order.Status.FAI) + ) + order.save(update_fields=["status", "expires_at"]) + print( + f"Order: {order.id} FAILED. Hash: {hash} Reason: {cls.payment_failure_context[status_code]}" + ) + return { + "succeded": False, + "context": f"payment failure reason: {cls.payment_failure_context[status_code]}", + } + elif status_code == 207: # invoice expired + print(f"Order: {order.id}. INVOICE EXPIRED. Hash: {hash}") + + last_payresponse = watchpayment() + + # check if succeeded while pending and expired + if ( + len(last_payresponse.pays) > 0 + and last_payresponse.pays[0].status + == noderpc.ListpaysPays.ListpaysPaysStatus.COMPLETE + ): + handle_response() + else: + lnpayment.status = LNPayment.Status.EXPIRE + lnpayment.last_routing_time = timezone.now() + lnpayment.in_flight = False + lnpayment.save( + update_fields=[ + "status", + "last_routing_time", + "in_flight", + ] + ) + order.status = Order.Status.FAI + order.expires_at = timezone.now() + timedelta( + seconds=order.t_to_expire(Order.Status.FAI) + ) + order.save(update_fields=["status", "expires_at"]) + results = { + "succeded": False, + "context": "The payout invoice has expired", + } + return results + else: # -1 (general error) + print(str(e)) + else: + print(str(e)) + + handle_response() + + @classmethod + def send_keysend( + cls, target_pubkey, message, num_satoshis, routing_budget_sats, timeout, sign + ): + # keysends for dev donations + from api.models import LNPayment + + # Cannot perform selfpayments + # config("ALLOW_SELF_KEYSEND", cast=bool, default=False) + + keysend_payment = {} + keysend_payment["created_at"] = timezone.now() + keysend_payment["expires_at"] = timezone.now() + try: + custom_records = [] + + msg = str(message) + + if len(msg) > 0: + custom_records.append( + primitives__pb2.TlvEntry( + type=34349334, value=bytes.fromhex(msg.encode("utf-8").hex()) + ) + ) + if sign: + self_pubkey = cls.stub.GetInfo(noderpc.GetinfoRequest()).id + timestamp = struct.pack(">i", int(time.time())) + signature = cls.stub.SignMessage( + noderpc.SignmessageRequest( + message=( + bytes.fromhex(self_pubkey) + + bytes.fromhex(target_pubkey) + + timestamp + + bytes.fromhex(msg.encode("utf-8").hex()) + ), + ) + ).zbase + custom_records.append( + primitives__pb2.TlvEntry(type=34349337, value=signature) + ) + custom_records.append( + primitives__pb2.TlvEntry( + type=34349339, value=bytes.fromhex(self_pubkey) + ) + ) + custom_records.append( + primitives__pb2.TlvEntry(type=34349343, value=timestamp) + ) + + # no maxfee for Keysend + maxfeepercent = (routing_budget_sats / num_satoshis) * 100 + request = noderpc.KeysendRequest( + destination=bytes.fromhex(target_pubkey), + extratlvs=primitives__pb2.TlvStream(entries=custom_records), + maxfeepercent=maxfeepercent, + retry_for=timeout, + amount_msat=primitives__pb2.Amount(msat=num_satoshis * 1000), + ) + response = cls.stub.KeySend(request) + + keysend_payment["preimage"] = response.payment_preimage.hex() + keysend_payment["payment_hash"] = response.payment_hash.hex() + + waitreq = noderpc.WaitsendpayRequest( + payment_hash=response.payment_hash, timeout=timeout + ) + try: + waitresp = cls.stub.WaitSendPay(waitreq) + keysend_payment["fee"] = ( + float(waitresp.amount_sent_msat.msat - waitresp.amount_msat.msat) + / 1000 + ) + keysend_payment["status"] = LNPayment.Status.SUCCED + except grpc._channel._InactiveRpcError as e: + if "code: Some" in str(e): + status_code = int(e.details().split("code: Some(")[1].split(")")[0]) + if status_code == 200: # Timed out before the payment could complete. + keysend_payment["status"] = LNPayment.Status.FLIGHT + elif status_code == 208: + print( + f"A payment for {response.payment_hash.hex()} was never made and there is nothing to wait for" + ) + else: + keysend_payment["status"] = LNPayment.Status.FAILRO + keysend_payment["failure_reason"] = response.failure_reason + except Exception as e: + print("Error while sending keysend payment! Error: " + str(e)) + + except Exception as e: + print("Error while sending keysend payment! Error: " + str(e)) + + return True, keysend_payment + + @classmethod + def double_check_htlc_is_settled(cls, payment_hash): + """Just as it sounds. Better safe than sorry!""" + request = noderpc.HodlInvoiceLookupRequest( + payment_hash=bytes.fromhex(payment_hash) + ) + try: + response = cls.stub.HodlInvoiceLookup(request) + except Exception as e: + if "Timed out" in str(e): + return False + else: + raise e + + return response.state == noderpc.HodlInvoiceLookupResponse.Hodlstate.SETTLED diff --git a/api/lightning/lnd.py b/api/lightning/lnd.py new file mode 100644 index 00000000..8ad7015c --- /dev/null +++ b/api/lightning/lnd.py @@ -0,0 +1,716 @@ +import hashlib +import os +import secrets +import struct +import time +from base64 import b64decode +from datetime import datetime, timedelta + +import grpc +import ring +from decouple import config +from django.utils import timezone + +from . import invoices_pb2 as invoicesrpc +from . import invoices_pb2_grpc as invoicesstub +from . import lightning_pb2 as lnrpc +from . import lightning_pb2_grpc as lightningstub +from . import router_pb2 as routerrpc +from . import router_pb2_grpc as routerstub +from . import signer_pb2 as signerrpc +from . import signer_pb2_grpc as signerstub +from . import verrpc_pb2 as verrpc +from . import verrpc_pb2_grpc as verstub + +####### +# Works with LND (c-lightning in the future for multi-vendor resilience) +####### + +# Read tls.cert from file or .env variable string encoded as base64 +try: + with open(os.path.join(config("LND_DIR"), "tls.cert"), "rb") as f: + CERT = f.read() +except Exception: + CERT = b64decode(config("LND_CERT_BASE64")) + +# Read macaroon from file or .env variable string encoded as base64 +try: + with open(os.path.join(config("LND_DIR"), config("MACAROON_path")), "rb") as f: + MACAROON = f.read() +except Exception: + MACAROON = b64decode(config("LND_MACAROON_BASE64")) + +LND_GRPC_HOST = config("LND_GRPC_HOST") +DISABLE_ONCHAIN = config("DISABLE_ONCHAIN", cast=bool, default=True) +MAX_SWAP_AMOUNT = config("MAX_SWAP_AMOUNT", cast=int, default=500_000) + + +class LNDNode: + + os.environ["GRPC_SSL_CIPHER_SUITES"] = "HIGH+ECDSA" + + def metadata_callback(context, callback): + callback([("macaroon", MACAROON.hex())], None) + + ssl_creds = grpc.ssl_channel_credentials(CERT) + auth_creds = grpc.metadata_call_credentials(metadata_callback) + combined_creds = grpc.composite_channel_credentials(ssl_creds, auth_creds) + channel = grpc.secure_channel(LND_GRPC_HOST, combined_creds) + + lightningstub = lightningstub.LightningStub(channel) + invoicesstub = invoicesstub.InvoicesStub(channel) + routerstub = routerstub.RouterStub(channel) + signerstub = signerstub.SignerStub(channel) + verstub = verstub.VersionerStub(channel) + + payment_failure_context = { + 0: "Payment isn't failed (yet)", + 1: "There are more routes to try, but the payment timeout was exceeded.", + 2: "All possible routes were tried and failed permanently. Or were no routes to the destination at all.", + 3: "A non-recoverable error has occured.", + 4: "Payment details incorrect (unknown hash, invalid amt or invalid final cltv delta)", + 5: "Insufficient local balance.", + } + + @classmethod + def get_version(cls): + try: + request = verrpc.VersionRequest() + response = cls.verstub.GetVersion(request) + return "v" + response.version + except Exception as e: + print(e) + return None + + @classmethod + def decode_payreq(cls, invoice): + """Decodes a lightning payment request (invoice)""" + request = lnrpc.PayReqString(pay_req=invoice) + response = cls.lightningstub.DecodePayReq(request) + return response + + @classmethod + def estimate_fee(cls, amount_sats, target_conf=2, min_confs=1): + """Returns estimated fee for onchain payouts""" + + # We assume segwit. Use hardcoded address as shortcut so there is no need of user inputs yet. + request = lnrpc.EstimateFeeRequest( + AddrToAmount={"bc1qgxwaqe4m9mypd7ltww53yv3lyxhcfnhzzvy5j3": amount_sats}, + target_conf=target_conf, + min_confs=min_confs, + spend_unconfirmed=False, + ) + + response = cls.lightningstub.EstimateFee(request) + + return { + "mining_fee_sats": response.fee_sat, + "mining_fee_rate": response.sat_per_vbyte, + } + + wallet_balance_cache = {} + + @ring.dict(wallet_balance_cache, expire=10) # keeps in cache for 10 seconds + @classmethod + def wallet_balance(cls): + """Returns onchain balance""" + request = lnrpc.WalletBalanceRequest() + response = cls.lightningstub.WalletBalance(request) + + return { + "total_balance": response.total_balance, + "confirmed_balance": response.confirmed_balance, + "unconfirmed_balance": response.unconfirmed_balance, + } + + channel_balance_cache = {} + + @ring.dict(channel_balance_cache, expire=10) # keeps in cache for 10 seconds + @classmethod + def channel_balance(cls): + """Returns channels balance""" + request = lnrpc.ChannelBalanceRequest() + response = cls.lightningstub.ChannelBalance(request) + + return { + "local_balance": response.local_balance.sat, + "remote_balance": response.remote_balance.sat, + "unsettled_local_balance": response.unsettled_local_balance.sat, + "unsettled_remote_balance": response.unsettled_remote_balance.sat, + } + + @classmethod + def pay_onchain(cls, onchainpayment, queue_code=5, on_mempool_code=2): + """Send onchain transaction for buyer payouts""" + + if DISABLE_ONCHAIN or onchainpayment.sent_satoshis > MAX_SWAP_AMOUNT: + return False + + request = lnrpc.SendCoinsRequest( + addr=onchainpayment.address, + amount=int(onchainpayment.sent_satoshis), + sat_per_vbyte=int(onchainpayment.mining_fee_rate), + label=str("Payout order #" + str(onchainpayment.order_paid_TX.id)), + spend_unconfirmed=config("SPEND_UNCONFIRMED", default=False, cast=bool), + ) + + # Cheap security measure to ensure there has been some non-deterministic time between request and DB check + delay = ( + secrets.randbelow(2**256) / (2**256) * 10 + ) # Random uniform 0 to 5 secs with good entropy + time.sleep(3 + delay) + + if onchainpayment.status == queue_code: + # Changing the state to "MEMPO" should be atomic with SendCoins. + onchainpayment.status = on_mempool_code + onchainpayment.save(update_fields=["status"]) + response = cls.lightningstub.SendCoins(request) + + if response.txid: + onchainpayment.txid = response.txid + onchainpayment.broadcasted = True + onchainpayment.save(update_fields=["txid", "broadcasted"]) + return True + + elif onchainpayment.status == on_mempool_code: + # Bug, double payment attempted + return True + + @classmethod + def cancel_return_hold_invoice(cls, payment_hash): + """Cancels or returns a hold invoice""" + request = invoicesrpc.CancelInvoiceMsg(payment_hash=bytes.fromhex(payment_hash)) + response = cls.invoicesstub.CancelInvoice(request) + # Fix this: tricky because canceling sucessfully an invoice has no response. TODO + return str(response) == "" # True if no response, false otherwise. + + @classmethod + def settle_hold_invoice(cls, preimage): + """settles a hold invoice""" + request = invoicesrpc.SettleInvoiceMsg(preimage=bytes.fromhex(preimage)) + response = cls.invoicesstub.SettleInvoice(request) + # Fix this: tricky because settling sucessfully an invoice has None response. TODO + return str(response) == "" # True if no response, false otherwise. + + @classmethod + def gen_hold_invoice(cls, num_satoshis, description, invoice_expiry, cltv_expiry_blocks, order_id, lnpayment_concept, time): + """Generates hold invoice""" + + hold_payment = {} + # The preimage is a random hash of 256 bits entropy + preimage = hashlib.sha256(secrets.token_bytes(nbytes=32)).digest() + + # Its hash is used to generate the hold invoice + r_hash = hashlib.sha256(preimage).digest() + + request = invoicesrpc.AddHoldInvoiceRequest( + memo=description, + value=num_satoshis, + hash=r_hash, + expiry=int( + invoice_expiry * 1.5 + ), # actual expiry is padded by 50%, if tight, wrong client system clock will say invoice is expired. + cltv_expiry=cltv_expiry_blocks, + ) + response = cls.invoicesstub.AddHoldInvoice(request) + + hold_payment["invoice"] = response.payment_request + payreq_decoded = cls.decode_payreq(hold_payment["invoice"]) + hold_payment["preimage"] = preimage.hex() + hold_payment["payment_hash"] = payreq_decoded.payment_hash + hold_payment["created_at"] = timezone.make_aware( + datetime.fromtimestamp(payreq_decoded.timestamp) + ) + hold_payment["expires_at"] = hold_payment["created_at"] + timedelta( + seconds=payreq_decoded.expiry + ) + hold_payment["cltv_expiry"] = cltv_expiry_blocks + + return hold_payment + + @classmethod + def validate_hold_invoice_locked(cls, lnpayment): + """Checks if hold invoice is locked""" + from api.models import LNPayment + + request = invoicesrpc.LookupInvoiceMsg( + payment_hash=bytes.fromhex(lnpayment.payment_hash) + ) + response = cls.invoicesstub.LookupInvoiceV2(request) + + # Will fail if 'unable to locate invoice'. 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 == lnrpc.Invoice.InvoiceState.OPEN: # OPEN + pass + if response.state == lnrpc.Invoice.InvoiceState.SETTLED: # SETTLED + pass + if response.state == lnrpc.Invoice.InvoiceState.CANCELED: # CANCELED + pass + if response.state == lnrpc.Invoice.InvoiceState.ACCEPTED: # ACCEPTED (LOCKED) + lnpayment.expiry_height = response.htlcs[0].expiry_height + lnpayment.status = LNPayment.Status.LOCKED + lnpayment.save(update_fields=["expiry_height", "status"]) + return True + + @classmethod + def lookup_invoice_status(cls, lnpayment): + """ + Returns the status (as LNpayment.Status) of the given payment_hash + If unchanged, returns the previous status + """ + from api.models import LNPayment + + status = lnpayment.status + expiry_height = 0 + + lnd_response_state_to_lnpayment_status = { + 0: LNPayment.Status.INVGEN, # OPEN + 1: LNPayment.Status.SETLED, # SETTLED + 2: LNPayment.Status.CANCEL, # CANCELED + 3: LNPayment.Status.LOCKED, # ACCEPTED + } + + try: + # this is similar to LNNnode.validate_hold_invoice_locked + request = invoicesrpc.LookupInvoiceMsg( + payment_hash=bytes.fromhex(lnpayment.payment_hash) + ) + response = cls.invoicesstub.LookupInvoiceV2(request) + + status = lnd_response_state_to_lnpayment_status[response.state] + + # get expiry height + if hasattr(response, "htlcs"): + try: + for htlc in response.htlcs: + expiry_height = max(expiry_height, htlc.expiry_height) + except Exception: + pass + + except Exception as e: + # If it fails at finding the invoice: it has been canceled. + # In RoboSats DB we make a distinction between CANCELED and returned (LND does not) + if "unable to locate invoice" in str(e): + print(str(e)) + status = LNPayment.Status.CANCEL + + # LND restarted. + if "wallet locked, unlock it" in str(e): + print(str(timezone.now()) + " :: Wallet Locked") + + # Other write to logs + else: + print(str(e)) + + return status, expiry_height + + @classmethod + def resetmc(cls): + request = routerrpc.ResetMissionControlRequest() + _ = cls.routerstub.ResetMissionControl(request) + return True + + @classmethod + def validate_ln_invoice(cls, invoice, num_satoshis, routing_budget_ppm): + """Checks if the submited LN invoice comforms to expectations""" + + payout = { + "valid": False, + "context": None, + "description": None, + "payment_hash": None, + "created_at": None, + "expires_at": None, + } + + try: + payreq_decoded = cls.decode_payreq(invoice) + except Exception: + payout["context"] = { + "bad_invoice": "Does not look like a valid lightning invoice" + } + return payout + + # Some wallet providers (e.g. Muun) force routing through a private channel with high fees >1500ppm + # These payments will fail. So it is best to let the user know in advance this invoice is not valid. + route_hints = payreq_decoded.route_hints + + # Max amount RoboSats will pay for routing + if routing_budget_ppm == 0: + max_routing_fee_sats = max( + num_satoshis * float(config("PROPORTIONAL_ROUTING_FEE_LIMIT")), + float(config("MIN_FLAT_ROUTING_FEE_LIMIT_REWARD")), + ) + else: + max_routing_fee_sats = int( + float(num_satoshis) * float(routing_budget_ppm) / 1_000_000 + ) + + if route_hints: + routes_cost = [] + # For every hinted route... + for hinted_route in route_hints: + route_cost = 0 + # ...add up the cost of every hinted hop... + for hop_hint in hinted_route.hop_hints: + route_cost += hop_hint.fee_base_msat / 1000 + route_cost += ( + hop_hint.fee_proportional_millionths * num_satoshis / 1_000_000 + ) + + # ...and store the cost of the route to the array + routes_cost.append(route_cost) + + # If the cheapest possible private route is more expensive than what RoboSats is willing to pay + if min(routes_cost) >= max_routing_fee_sats: + payout["context"] = { + "bad_invoice": "The invoice hinted private routes are not payable within the submitted routing budget." + } + return payout + + if payreq_decoded.num_satoshis == 0: + payout["context"] = { + "bad_invoice": "The invoice provided has no explicit amount" + } + return payout + + if not payreq_decoded.num_satoshis == num_satoshis: + payout["context"] = { + "bad_invoice": "The invoice provided is not for " + + "{:,}".format(num_satoshis) + + " Sats" + } + return payout + + payout["created_at"] = timezone.make_aware( + datetime.fromtimestamp(payreq_decoded.timestamp) + ) + payout["expires_at"] = payout["created_at"] + timedelta( + seconds=payreq_decoded.expiry + ) + + if payout["expires_at"] < timezone.now(): + payout["context"] = { + "bad_invoice": "The invoice provided has already expired" + } + return payout + + payout["valid"] = True + payout["description"] = payreq_decoded.description + payout["payment_hash"] = payreq_decoded.payment_hash + + return payout + + @classmethod + def pay_invoice(cls, lnpayment): + """Sends sats. Used for rewards payouts""" + from api.models import LNPayment + + fee_limit_sat = int( + max( + lnpayment.num_satoshis + * float(config("PROPORTIONAL_ROUTING_FEE_LIMIT")), + float(config("MIN_FLAT_ROUTING_FEE_LIMIT_REWARD")), + ) + ) # 200 ppm or 10 sats + timeout_seconds = int(config("REWARDS_TIMEOUT_SECONDS")) + request = routerrpc.SendPaymentRequest( + payment_request=lnpayment.invoice, + fee_limit_sat=fee_limit_sat, + timeout_seconds=timeout_seconds, + ) + + for response in cls.routerstub.SendPaymentV2(request): + + if ( + response.status == lnrpc.Payment.PaymentStatus.UNKNOWN + ): # Status 0 'UNKNOWN' + # Not sure when this status happens + pass + + if ( + response.status == lnrpc.Payment.PaymentStatus.IN_FLIGHT + ): # Status 1 'IN_FLIGHT' + pass + + if ( + response.status == lnrpc.Payment.PaymentStatus.FAILED + ): # Status 3 'FAILED' + """0 Payment isn't failed (yet). + 1 There are more routes to try, but the payment timeout was exceeded. + 2 All possible routes were tried and failed permanently. Or were no routes to the destination at all. + 3 A non-recoverable error has occured. + 4 Payment details incorrect (unknown hash, invalid amt or invalid final cltv delta) + 5 Insufficient local balance. + """ + failure_reason = cls.payment_failure_context[response.failure_reason] + lnpayment.failure_reason = response.failure_reason + lnpayment.status = LNPayment.Status.FAILRO + lnpayment.save(update_fields=["failure_reason", "status"]) + return False, failure_reason + + if ( + response.status == lnrpc.Payment.PaymentStatus.SUCCEEDED + ): # STATUS 'SUCCEEDED' + lnpayment.status = LNPayment.Status.SUCCED + lnpayment.fee = float(response.fee_msat) / 1000 + lnpayment.preimage = response.payment_preimage + lnpayment.save(update_fields=["fee", "status", "preimage"]) + return True, None + + return False + + @classmethod + def follow_send_payment(cls, lnpayment, fee_limit_sat, timeout_seconds): + """ + Sends sats to buyer, continuous update. + Has a lot of boilerplate to correctly handle every possible condition and failure case. + """ + from api.models import LNPayment, Order + + hash = lnpayment.payment_hash + + request = routerrpc.SendPaymentRequest( + payment_request=lnpayment.invoice, + fee_limit_sat=fee_limit_sat, + timeout_seconds=timeout_seconds, + allow_self_payment=True, + ) + + order = lnpayment.order_paid_LN + if order.trade_escrow.num_satoshis < lnpayment.num_satoshis: + print(f"Order: {order.id} Payout is larger than collateral !?") + return + + def handle_response(response, was_in_transit=False): + lnpayment.status = LNPayment.Status.FLIGHT + lnpayment.in_flight = True + lnpayment.save(update_fields=["in_flight", "status"]) + order.status = Order.Status.PAY + order.save(update_fields=["status"]) + + if ( + response.status == lnrpc.Payment.PaymentStatus.UNKNOWN + ): # Status 0 'UNKNOWN' + # Not sure when this status happens + print(f"Order: {order.id} UNKNOWN. Hash {hash}") + lnpayment.in_flight = False + lnpayment.save(update_fields=["in_flight"]) + + if ( + response.status == lnrpc.Payment.PaymentStatus.IN_FLIGHT + ): # Status 1 'IN_FLIGHT' + print(f"Order: {order.id} IN_FLIGHT. Hash {hash}") + + # If payment was already "payment is in transition" we do not + # want to spawn a new thread every 3 minutes to check on it. + # in case this thread dies, let's move the last_routing_time + # 20 minutes in the future so another thread spawns. + if was_in_transit: + lnpayment.last_routing_time = timezone.now() + timedelta(minutes=20) + lnpayment.save(update_fields=["last_routing_time"]) + + if ( + response.status == lnrpc.Payment.PaymentStatus.FAILED + ): # Status 3 'FAILED' + lnpayment.status = LNPayment.Status.FAILRO + lnpayment.last_routing_time = timezone.now() + lnpayment.routing_attempts += 1 + lnpayment.failure_reason = response.failure_reason + lnpayment.in_flight = False + if lnpayment.routing_attempts > 2: + lnpayment.status = LNPayment.Status.EXPIRE + lnpayment.routing_attempts = 0 + lnpayment.save( + update_fields=[ + "status", + "last_routing_time", + "routing_attempts", + "failure_reason", + "in_flight", + ] + ) + + order.status = Order.Status.FAI + order.expires_at = timezone.now() + timedelta( + seconds=order.t_to_expire(Order.Status.FAI) + ) + order.save(update_fields=["status", "expires_at"]) + print( + f"Order: {order.id} FAILED. Hash: {hash} Reason: {cls.payment_failure_context[response.failure_reason]}" + ) + return { + "succeded": False, + "context": f"payment failure reason: {cls.payment_failure_context[response.failure_reason]}", + } + + if ( + response.status == lnrpc.Payment.PaymentStatus.SUCCEEDED + ): # Status 2 'SUCCEEDED' + print(f"Order: {order.id} SUCCEEDED. Hash: {hash}") + lnpayment.status = LNPayment.Status.SUCCED + lnpayment.fee = float(response.fee_msat) / 1000 + lnpayment.preimage = response.payment_preimage + lnpayment.save(update_fields=["status", "fee", "preimage"]) + + order.status = Order.Status.SUC + order.expires_at = timezone.now() + timedelta( + seconds=order.t_to_expire(Order.Status.SUC) + ) + order.save(update_fields=["status", "expires_at"]) + + results = {"succeded": True} + return results + + try: + for response in cls.routerstub.SendPaymentV2(request): + + handle_response(response) + + except Exception as e: + + if "invoice expired" in str(e): + print(f"Order: {order.id}. INVOICE EXPIRED. Hash: {hash}") + # An expired invoice can already be in-flight. Check. + try: + request = routerrpc.TrackPaymentRequest( + payment_hash=bytes.fromhex(hash) + ) + + for response in cls.routerstub.TrackPaymentV2(request): + handle_response(response, was_in_transit=True) + + except Exception as e: + if "payment isn't initiated" in str(e): + print( + f"Order: {order.id}. The expired invoice had not been initiated. Hash: {hash}" + ) + + lnpayment.status = LNPayment.Status.EXPIRE + lnpayment.last_routing_time = timezone.now() + lnpayment.in_flight = False + lnpayment.save( + update_fields=["status", "last_routing_time", "in_flight"] + ) + + order.status = Order.Status.FAI + order.expires_at = timezone.now() + timedelta( + seconds=order.t_to_expire(Order.Status.FAI) + ) + order.save(update_fields=["status", "expires_at"]) + + results = { + "succeded": False, + "context": "The payout invoice has expired", + } + return results + + elif "payment is in transition" in str(e): + print(f"Order: {order.id} ALREADY IN TRANSITION. Hash: {hash}.") + + request = routerrpc.TrackPaymentRequest( + payment_hash=bytes.fromhex(hash) + ) + + for response in cls.routerstub.TrackPaymentV2(request): + handle_response(response, was_in_transit=True) + + elif "invoice is already paid" in str(e): + print(f"Order: {order.id} ALREADY PAID. Hash: {hash}.") + + request = routerrpc.TrackPaymentRequest( + payment_hash=bytes.fromhex(hash) + ) + + for response in cls.routerstub.TrackPaymentV2(request): + handle_response(response) + + else: + print(str(e)) + + @classmethod + def send_keysend( + cls, target_pubkey, message, num_satoshis, routing_budget_sats, timeout, sign + ): + # Thank you @cryptosharks131 / lndg for the inspiration + # Source https://github.com/cryptosharks131/lndg/blob/master/keysend.py + + from api.models import LNPayment + + ALLOW_SELF_KEYSEND = config("ALLOW_SELF_KEYSEND", cast=bool, default=False) + keysend_payment = {} + keysend_payment["created_at"] = timezone.now() + keysend_payment["expires_at"] = timezone.now() + try: + secret = secrets.token_bytes(32) + hashed_secret = hashlib.sha256(secret).hexdigest() + custom_records = [ + (5482373484, secret), + ] + keysend_payment["preimage"] = secret.hex() + keysend_payment["payment_hash"] = hashed_secret + + msg = str(message) + + if len(msg) > 0: + custom_records.append( + (34349334, bytes.fromhex(msg.encode("utf-8").hex())) + ) + if sign: + self_pubkey = cls.lightningstub.GetInfo( + lnrpc.GetInfoRequest() + ).identity_pubkey + timestamp = struct.pack(">i", int(time.time())) + signature = cls.signerstub.SignMessage( + signerrpc.SignMessageReq( + msg=( + bytes.fromhex(self_pubkey) + + bytes.fromhex(target_pubkey) + + timestamp + + bytes.fromhex(msg.encode("utf-8").hex()) + ), + key_loc=signerrpc.KeyLocator(key_family=6, key_index=0), + ) + ).signature + custom_records.append((34349337, signature)) + custom_records.append((34349339, bytes.fromhex(self_pubkey))) + custom_records.append((34349343, timestamp)) + + request = routerrpc.SendPaymentRequest( + dest=bytes.fromhex(target_pubkey), + dest_custom_records=custom_records, + fee_limit_sat=routing_budget_sats, + timeout_seconds=timeout, + amt=num_satoshis, + payment_hash=bytes.fromhex(hashed_secret), + allow_self_payment=ALLOW_SELF_KEYSEND, + ) + for response in cls.routerstub.SendPaymentV2(request): + if response.status == lnrpc.Payment.PaymentStatus.IN_FLIGHT: + keysend_payment["status"] = LNPayment.Status.FLIGHT + if response.status == lnrpc.Payment.PaymentStatus.SUCCEEDED: + keysend_payment["fee"] = float(response.fee_msat) / 1000 + keysend_payment["status"] = LNPayment.Status.SUCCED + if response.status == lnrpc.Payment.PaymentStatus.FAILED: + keysend_payment["status"] = LNPayment.Status.FAILRO + keysend_payment["failure_reason"] = response.failure_reason + if response.status == lnrpc.Payment.PaymentStatus.UNKNOWN: + print("Unknown Error") + except Exception as e: + if "self-payments not allowed" in str(e): + print("Self keysend is not allowed") + else: + print("Error while sending keysend payment! Error: " + str(e)) + + return True, keysend_payment + + @classmethod + def double_check_htlc_is_settled(cls, payment_hash): + """Just as it sounds. Better safe than sorry!""" + request = invoicesrpc.LookupInvoiceMsg(payment_hash=bytes.fromhex(payment_hash)) + response = cls.invoicesstub.LookupInvoiceV2(request) + + return ( + response.state == lnrpc.Invoice.InvoiceState.SETTLED + ) # LND states: 0 OPEN, 1 SETTLED, 3 ACCEPTED, GRPC_ERROR status 5 when CANCELED/returned diff --git a/api/lightning/node.py b/api/lightning/node.py index 2ff31081..c8af6010 100644 --- a/api/lightning/node.py +++ b/api/lightning/node.py @@ -1,718 +1,16 @@ -import hashlib -import os -import secrets -import struct -import time -from base64 import b64decode -from datetime import datetime, timedelta - -import grpc -import ring from decouple import config -from django.utils import timezone -from . import invoices_pb2 as invoicesrpc -from . import invoices_pb2_grpc as invoicesstub -from . import lightning_pb2 as lnrpc -from . import lightning_pb2_grpc as lightningstub -from . import router_pb2 as routerrpc -from . import router_pb2_grpc as routerstub -from . import signer_pb2 as signerrpc -from . import signer_pb2_grpc as signerstub -from . import verrpc_pb2 as verrpc -from . import verrpc_pb2_grpc as verstub +LN_vendor = config("LNVENDOR", cast=str, default="LND") -####### -# Works with LND (c-lightning in the future for multi-vendor resilience) -####### +if LN_vendor == "LND": + from api.lightning.lnd import LNDNode -# Read tls.cert from file or .env variable string encoded as base64 -try: - with open(os.path.join(config("LND_DIR"), "tls.cert"), "rb") as f: - CERT = f.read() -except Exception: - CERT = b64decode(config("LND_CERT_BASE64")) + LNNode = LNDNode +elif LN_vendor == "CLN": + from api.lightning.cln import CLNNode -# Read macaroon from file or .env variable string encoded as base64 -try: - with open(os.path.join(config("LND_DIR"), config("MACAROON_path")), "rb") as f: - MACAROON = f.read() -except Exception: - MACAROON = b64decode(config("LND_MACAROON_BASE64")) - -LND_GRPC_HOST = config("LND_GRPC_HOST") -DISABLE_ONCHAIN = config("DISABLE_ONCHAIN", cast=bool, default=True) -MAX_SWAP_AMOUNT = config("MAX_SWAP_AMOUNT", cast=int, default=500_000) - - -class LNNode: - - os.environ["GRPC_SSL_CIPHER_SUITES"] = "HIGH+ECDSA" - - def metadata_callback(context, callback): - callback([("macaroon", MACAROON.hex())], None) - - ssl_creds = grpc.ssl_channel_credentials(CERT) - auth_creds = grpc.metadata_call_credentials(metadata_callback) - combined_creds = grpc.composite_channel_credentials(ssl_creds, auth_creds) - channel = grpc.secure_channel(LND_GRPC_HOST, combined_creds) - - lightningstub = lightningstub.LightningStub(channel) - invoicesstub = invoicesstub.InvoicesStub(channel) - routerstub = routerstub.RouterStub(channel) - signerstub = signerstub.SignerStub(channel) - verstub = verstub.VersionerStub(channel) - - payment_failure_context = { - 0: "Payment isn't failed (yet)", - 1: "There are more routes to try, but the payment timeout was exceeded.", - 2: "All possible routes were tried and failed permanently. Or were no routes to the destination at all.", - 3: "A non-recoverable error has occured.", - 4: "Payment details incorrect (unknown hash, invalid amt or invalid final cltv delta)", - 5: "Insufficient local balance.", - } - - @classmethod - def get_version(cls): - try: - request = verrpc.VersionRequest() - response = cls.verstub.GetVersion(request) - return "v" + response.version - except Exception as e: - print(e) - return None - - @classmethod - def decode_payreq(cls, invoice): - """Decodes a lightning payment request (invoice)""" - request = lnrpc.PayReqString(pay_req=invoice) - response = cls.lightningstub.DecodePayReq(request) - return response - - @classmethod - def estimate_fee(cls, amount_sats, target_conf=2, min_confs=1): - """Returns estimated fee for onchain payouts""" - - # We assume segwit. Use hardcoded address as shortcut so there is no need of user inputs yet. - request = lnrpc.EstimateFeeRequest( - AddrToAmount={"bc1qgxwaqe4m9mypd7ltww53yv3lyxhcfnhzzvy5j3": amount_sats}, - target_conf=target_conf, - min_confs=min_confs, - spend_unconfirmed=False, - ) - - response = cls.lightningstub.EstimateFee(request) - - return { - "mining_fee_sats": response.fee_sat, - "mining_fee_rate": response.sat_per_vbyte, - } - - wallet_balance_cache = {} - - @ring.dict(wallet_balance_cache, expire=10) # keeps in cache for 10 seconds - @classmethod - def wallet_balance(cls): - """Returns onchain balance""" - request = lnrpc.WalletBalanceRequest() - response = cls.lightningstub.WalletBalance(request) - - return { - "total_balance": response.total_balance, - "confirmed_balance": response.confirmed_balance, - "unconfirmed_balance": response.unconfirmed_balance, - } - - channel_balance_cache = {} - - @ring.dict(channel_balance_cache, expire=10) # keeps in cache for 10 seconds - @classmethod - def channel_balance(cls): - """Returns channels balance""" - request = lnrpc.ChannelBalanceRequest() - response = cls.lightningstub.ChannelBalance(request) - - return { - "local_balance": response.local_balance.sat, - "remote_balance": response.remote_balance.sat, - "unsettled_local_balance": response.unsettled_local_balance.sat, - "unsettled_remote_balance": response.unsettled_remote_balance.sat, - } - - @classmethod - def pay_onchain(cls, onchainpayment, queue_code=5, on_mempool_code=2): - """Send onchain transaction for buyer payouts""" - - if DISABLE_ONCHAIN or onchainpayment.sent_satoshis > MAX_SWAP_AMOUNT: - return False - - request = lnrpc.SendCoinsRequest( - addr=onchainpayment.address, - amount=int(onchainpayment.sent_satoshis), - sat_per_vbyte=int(onchainpayment.mining_fee_rate), - label=str("Payout order #" + str(onchainpayment.order_paid_TX.id)), - spend_unconfirmed=config("SPEND_UNCONFIRMED", default=False, cast=bool), - ) - - # Cheap security measure to ensure there has been some non-deterministic time between request and DB check - delay = ( - secrets.randbelow(2**256) / (2**256) * 10 - ) # Random uniform 0 to 5 secs with good entropy - time.sleep(3 + delay) - - if onchainpayment.status == queue_code: - # Changing the state to "MEMPO" should be atomic with SendCoins. - onchainpayment.status = on_mempool_code - onchainpayment.save(update_fields=["status"]) - response = cls.lightningstub.SendCoins(request) - - if response.txid: - onchainpayment.txid = response.txid - onchainpayment.broadcasted = True - onchainpayment.save(update_fields=["txid", "broadcasted"]) - return True - - elif onchainpayment.status == on_mempool_code: - # Bug, double payment attempted - return True - - @classmethod - def cancel_return_hold_invoice(cls, payment_hash): - """Cancels or returns a hold invoice""" - request = invoicesrpc.CancelInvoiceMsg(payment_hash=bytes.fromhex(payment_hash)) - response = cls.invoicesstub.CancelInvoice(request) - # Fix this: tricky because canceling sucessfully an invoice has no response. TODO - return str(response) == "" # True if no response, false otherwise. - - @classmethod - def settle_hold_invoice(cls, preimage): - """settles a hold invoice""" - request = invoicesrpc.SettleInvoiceMsg(preimage=bytes.fromhex(preimage)) - response = cls.invoicesstub.SettleInvoice(request) - # Fix this: tricky because settling sucessfully an invoice has None response. TODO - return str(response) == "" # True if no response, false otherwise. - - @classmethod - def gen_hold_invoice( - cls, num_satoshis, description, invoice_expiry, cltv_expiry_blocks - ): - """Generates hold invoice""" - - hold_payment = {} - # The preimage is a random hash of 256 bits entropy - preimage = hashlib.sha256(secrets.token_bytes(nbytes=32)).digest() - - # Its hash is used to generate the hold invoice - r_hash = hashlib.sha256(preimage).digest() - - request = invoicesrpc.AddHoldInvoiceRequest( - memo=description, - value=num_satoshis, - hash=r_hash, - expiry=int( - invoice_expiry * 1.5 - ), # actual expiry is padded by 50%, if tight, wrong client system clock will say invoice is expired. - cltv_expiry=cltv_expiry_blocks, - ) - response = cls.invoicesstub.AddHoldInvoice(request) - - hold_payment["invoice"] = response.payment_request - payreq_decoded = cls.decode_payreq(hold_payment["invoice"]) - hold_payment["preimage"] = preimage.hex() - hold_payment["payment_hash"] = payreq_decoded.payment_hash - hold_payment["created_at"] = timezone.make_aware( - datetime.fromtimestamp(payreq_decoded.timestamp) - ) - hold_payment["expires_at"] = hold_payment["created_at"] + timedelta( - seconds=payreq_decoded.expiry - ) - hold_payment["cltv_expiry"] = cltv_expiry_blocks - - return hold_payment - - @classmethod - def validate_hold_invoice_locked(cls, lnpayment): - """Checks if hold invoice is locked""" - from api.models import LNPayment - - request = invoicesrpc.LookupInvoiceMsg( - payment_hash=bytes.fromhex(lnpayment.payment_hash) - ) - response = cls.invoicesstub.LookupInvoiceV2(request) - - # Will fail if 'unable to locate invoice'. 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 == lnrpc.Invoice.InvoiceState.OPEN: # OPEN - pass - if response.state == lnrpc.Invoice.InvoiceState.SETTLED: # SETTLED - pass - if response.state == lnrpc.Invoice.InvoiceState.CANCELED: # CANCELED - pass - if response.state == lnrpc.Invoice.InvoiceState.ACCEPTED: # ACCEPTED (LOCKED) - lnpayment.expiry_height = response.htlcs[0].expiry_height - lnpayment.status = LNPayment.Status.LOCKED - lnpayment.save(update_fields=["expiry_height", "status"]) - return True - - @classmethod - def lookup_invoice_status(cls, lnpayment): - """ - Returns the status (as LNpayment.Status) of the given payment_hash - If unchanged, returns the previous status - """ - from api.models import LNPayment - - status = lnpayment.status - expiry_height = 0 - - lnd_response_state_to_lnpayment_status = { - 0: LNPayment.Status.INVGEN, # OPEN - 1: LNPayment.Status.SETLED, # SETTLED - 2: LNPayment.Status.CANCEL, # CANCELED - 3: LNPayment.Status.LOCKED, # ACCEPTED - } - - try: - # this is similar to LNNnode.validate_hold_invoice_locked - request = invoicesrpc.LookupInvoiceMsg( - payment_hash=bytes.fromhex(lnpayment.payment_hash) - ) - response = cls.invoicesstub.LookupInvoiceV2(request) - - status = lnd_response_state_to_lnpayment_status[response.state] - - # get expiry height - if hasattr(response, "htlcs"): - try: - for htlc in response.htlcs: - expiry_height = max(expiry_height, htlc.expiry_height) - except Exception: - pass - - except Exception as e: - # If it fails at finding the invoice: it has been canceled. - # In RoboSats DB we make a distinction between CANCELED and returned (LND does not) - if "unable to locate invoice" in str(e): - print(str(e)) - status = LNPayment.Status.CANCEL - - # LND restarted. - if "wallet locked, unlock it" in str(e): - print(str(timezone.now()) + " :: Wallet Locked") - - # Other write to logs - else: - print(str(e)) - - return status, expiry_height - - @classmethod - def resetmc(cls): - request = routerrpc.ResetMissionControlRequest() - _ = cls.routerstub.ResetMissionControl(request) - return True - - @classmethod - def validate_ln_invoice(cls, invoice, num_satoshis, routing_budget_ppm): - """Checks if the submited LN invoice comforms to expectations""" - - payout = { - "valid": False, - "context": None, - "description": None, - "payment_hash": None, - "created_at": None, - "expires_at": None, - } - - try: - payreq_decoded = cls.decode_payreq(invoice) - except Exception: - payout["context"] = { - "bad_invoice": "Does not look like a valid lightning invoice" - } - return payout - - # Some wallet providers (e.g. Muun) force routing through a private channel with high fees >1500ppm - # These payments will fail. So it is best to let the user know in advance this invoice is not valid. - route_hints = payreq_decoded.route_hints - - # Max amount RoboSats will pay for routing - if routing_budget_ppm == 0: - max_routing_fee_sats = max( - num_satoshis * float(config("PROPORTIONAL_ROUTING_FEE_LIMIT")), - float(config("MIN_FLAT_ROUTING_FEE_LIMIT_REWARD")), - ) - else: - max_routing_fee_sats = int( - float(num_satoshis) * float(routing_budget_ppm) / 1_000_000 - ) - - if route_hints: - routes_cost = [] - # For every hinted route... - for hinted_route in route_hints: - route_cost = 0 - # ...add up the cost of every hinted hop... - for hop_hint in hinted_route.hop_hints: - route_cost += hop_hint.fee_base_msat / 1000 - route_cost += ( - hop_hint.fee_proportional_millionths * num_satoshis / 1_000_000 - ) - - # ...and store the cost of the route to the array - routes_cost.append(route_cost) - - # If the cheapest possible private route is more expensive than what RoboSats is willing to pay - if min(routes_cost) >= max_routing_fee_sats: - payout["context"] = { - "bad_invoice": "The invoice hinted private routes are not payable within the submitted routing budget." - } - return payout - - if payreq_decoded.num_satoshis == 0: - payout["context"] = { - "bad_invoice": "The invoice provided has no explicit amount" - } - return payout - - if not payreq_decoded.num_satoshis == num_satoshis: - payout["context"] = { - "bad_invoice": "The invoice provided is not for " - + "{:,}".format(num_satoshis) - + " Sats" - } - return payout - - payout["created_at"] = timezone.make_aware( - datetime.fromtimestamp(payreq_decoded.timestamp) - ) - payout["expires_at"] = payout["created_at"] + timedelta( - seconds=payreq_decoded.expiry - ) - - if payout["expires_at"] < timezone.now(): - payout["context"] = { - "bad_invoice": "The invoice provided has already expired" - } - return payout - - payout["valid"] = True - payout["description"] = payreq_decoded.description - payout["payment_hash"] = payreq_decoded.payment_hash - - return payout - - @classmethod - def pay_invoice(cls, lnpayment): - """Sends sats. Used for rewards payouts""" - from api.models import LNPayment - - fee_limit_sat = int( - max( - lnpayment.num_satoshis - * float(config("PROPORTIONAL_ROUTING_FEE_LIMIT")), - float(config("MIN_FLAT_ROUTING_FEE_LIMIT_REWARD")), - ) - ) # 200 ppm or 10 sats - timeout_seconds = int(config("REWARDS_TIMEOUT_SECONDS")) - request = routerrpc.SendPaymentRequest( - payment_request=lnpayment.invoice, - fee_limit_sat=fee_limit_sat, - timeout_seconds=timeout_seconds, - ) - - for response in cls.routerstub.SendPaymentV2(request): - - if ( - response.status == lnrpc.Payment.PaymentStatus.UNKNOWN - ): # Status 0 'UNKNOWN' - # Not sure when this status happens - pass - - if ( - response.status == lnrpc.Payment.PaymentStatus.IN_FLIGHT - ): # Status 1 'IN_FLIGHT' - pass - - if ( - response.status == lnrpc.Payment.PaymentStatus.FAILED - ): # Status 3 'FAILED' - """0 Payment isn't failed (yet). - 1 There are more routes to try, but the payment timeout was exceeded. - 2 All possible routes were tried and failed permanently. Or were no routes to the destination at all. - 3 A non-recoverable error has occured. - 4 Payment details incorrect (unknown hash, invalid amt or invalid final cltv delta) - 5 Insufficient local balance. - """ - failure_reason = cls.payment_failure_context[response.failure_reason] - lnpayment.failure_reason = response.failure_reason - lnpayment.status = LNPayment.Status.FAILRO - lnpayment.save(update_fields=["failure_reason", "status"]) - return False, failure_reason - - if ( - response.status == lnrpc.Payment.PaymentStatus.SUCCEEDED - ): # STATUS 'SUCCEEDED' - lnpayment.status = LNPayment.Status.SUCCED - lnpayment.fee = float(response.fee_msat) / 1000 - lnpayment.preimage = response.payment_preimage - lnpayment.save(update_fields=["fee", "status", "preimage"]) - return True, None - - return False - - @classmethod - def follow_send_payment(cls, lnpayment, fee_limit_sat, timeout_seconds): - """ - Sends sats to buyer, continuous update. - Has a lot of boilerplate to correctly handle every possible condition and failure case. - """ - from api.models import LNPayment, Order - - hash = lnpayment.payment_hash - - request = routerrpc.SendPaymentRequest( - payment_request=lnpayment.invoice, - fee_limit_sat=fee_limit_sat, - timeout_seconds=timeout_seconds, - allow_self_payment=True, - ) - - order = lnpayment.order_paid_LN - if order.trade_escrow.num_satoshis < lnpayment.num_satoshis: - print(f"Order: {order.id} Payout is larger than collateral !?") - return - - def handle_response(response, was_in_transit=False): - lnpayment.status = LNPayment.Status.FLIGHT - lnpayment.in_flight = True - lnpayment.save(update_fields=["in_flight", "status"]) - order.status = Order.Status.PAY - order.save(update_fields=["status"]) - - if ( - response.status == lnrpc.Payment.PaymentStatus.UNKNOWN - ): # Status 0 'UNKNOWN' - # Not sure when this status happens - print(f"Order: {order.id} UNKNOWN. Hash {hash}") - lnpayment.in_flight = False - lnpayment.save(update_fields=["in_flight"]) - - if ( - response.status == lnrpc.Payment.PaymentStatus.IN_FLIGHT - ): # Status 1 'IN_FLIGHT' - print(f"Order: {order.id} IN_FLIGHT. Hash {hash}") - - # If payment was already "payment is in transition" we do not - # want to spawn a new thread every 3 minutes to check on it. - # in case this thread dies, let's move the last_routing_time - # 20 minutes in the future so another thread spawns. - if was_in_transit: - lnpayment.last_routing_time = timezone.now() + timedelta(minutes=20) - lnpayment.save(update_fields=["last_routing_time"]) - - if ( - response.status == lnrpc.Payment.PaymentStatus.FAILED - ): # Status 3 'FAILED' - lnpayment.status = LNPayment.Status.FAILRO - lnpayment.last_routing_time = timezone.now() - lnpayment.routing_attempts += 1 - lnpayment.failure_reason = response.failure_reason - lnpayment.in_flight = False - if lnpayment.routing_attempts > 2: - lnpayment.status = LNPayment.Status.EXPIRE - lnpayment.routing_attempts = 0 - lnpayment.save( - update_fields=[ - "status", - "last_routing_time", - "routing_attempts", - "failure_reason", - "in_flight", - ] - ) - - order.status = Order.Status.FAI - order.expires_at = timezone.now() + timedelta( - seconds=order.t_to_expire(Order.Status.FAI) - ) - order.save(update_fields=["status", "expires_at"]) - print( - f"Order: {order.id} FAILED. Hash: {hash} Reason: {cls.payment_failure_context[response.failure_reason]}" - ) - return { - "succeded": False, - "context": f"payment failure reason: {cls.payment_failure_context[response.failure_reason]}", - } - - if ( - response.status == lnrpc.Payment.PaymentStatus.SUCCEEDED - ): # Status 2 'SUCCEEDED' - print(f"Order: {order.id} SUCCEEDED. Hash: {hash}") - lnpayment.status = LNPayment.Status.SUCCED - lnpayment.fee = float(response.fee_msat) / 1000 - lnpayment.preimage = response.payment_preimage - lnpayment.save(update_fields=["status", "fee", "preimage"]) - - order.status = Order.Status.SUC - order.expires_at = timezone.now() + timedelta( - seconds=order.t_to_expire(Order.Status.SUC) - ) - order.save(update_fields=["status", "expires_at"]) - - results = {"succeded": True} - return results - - try: - for response in cls.routerstub.SendPaymentV2(request): - - handle_response(response) - - except Exception as e: - - if "invoice expired" in str(e): - print(f"Order: {order.id}. INVOICE EXPIRED. Hash: {hash}") - # An expired invoice can already be in-flight. Check. - try: - request = routerrpc.TrackPaymentRequest( - payment_hash=bytes.fromhex(hash) - ) - - for response in cls.routerstub.TrackPaymentV2(request): - handle_response(response, was_in_transit=True) - - except Exception as e: - if "payment isn't initiated" in str(e): - print( - f"Order: {order.id}. The expired invoice had not been initiated. Hash: {hash}" - ) - - lnpayment.status = LNPayment.Status.EXPIRE - lnpayment.last_routing_time = timezone.now() - lnpayment.in_flight = False - lnpayment.save( - update_fields=["status", "last_routing_time", "in_flight"] - ) - - order.status = Order.Status.FAI - order.expires_at = timezone.now() + timedelta( - seconds=order.t_to_expire(Order.Status.FAI) - ) - order.save(update_fields=["status", "expires_at"]) - - results = { - "succeded": False, - "context": "The payout invoice has expired", - } - return results - - elif "payment is in transition" in str(e): - print(f"Order: {order.id} ALREADY IN TRANSITION. Hash: {hash}.") - - request = routerrpc.TrackPaymentRequest( - payment_hash=bytes.fromhex(hash) - ) - - for response in cls.routerstub.TrackPaymentV2(request): - handle_response(response, was_in_transit=True) - - elif "invoice is already paid" in str(e): - print(f"Order: {order.id} ALREADY PAID. Hash: {hash}.") - - request = routerrpc.TrackPaymentRequest( - payment_hash=bytes.fromhex(hash) - ) - - for response in cls.routerstub.TrackPaymentV2(request): - handle_response(response) - - else: - print(str(e)) - - @classmethod - def send_keysend( - cls, target_pubkey, message, num_satoshis, routing_budget_sats, timeout, sign - ): - # Thank you @cryptosharks131 / lndg for the inspiration - # Source https://github.com/cryptosharks131/lndg/blob/master/keysend.py - - from api.models import LNPayment - - ALLOW_SELF_KEYSEND = config("ALLOW_SELF_KEYSEND", cast=bool, default=False) - keysend_payment = {} - keysend_payment["created_at"] = timezone.now() - keysend_payment["expires_at"] = timezone.now() - try: - secret = secrets.token_bytes(32) - hashed_secret = hashlib.sha256(secret).hexdigest() - custom_records = [ - (5482373484, secret), - ] - keysend_payment["preimage"] = secret.hex() - keysend_payment["payment_hash"] = hashed_secret - - msg = str(message) - - if len(msg) > 0: - custom_records.append( - (34349334, bytes.fromhex(msg.encode("utf-8").hex())) - ) - if sign: - self_pubkey = cls.lightningstub.GetInfo( - lnrpc.GetInfoRequest() - ).identity_pubkey - timestamp = struct.pack(">i", int(time.time())) - signature = cls.signerstub.SignMessage( - signerrpc.SignMessageReq( - msg=( - bytes.fromhex(self_pubkey) - + bytes.fromhex(target_pubkey) - + timestamp - + bytes.fromhex(msg.encode("utf-8").hex()) - ), - key_loc=signerrpc.KeyLocator(key_family=6, key_index=0), - ) - ).signature - custom_records.append((34349337, signature)) - custom_records.append((34349339, bytes.fromhex(self_pubkey))) - custom_records.append((34349343, timestamp)) - - request = routerrpc.SendPaymentRequest( - dest=bytes.fromhex(target_pubkey), - dest_custom_records=custom_records, - fee_limit_sat=routing_budget_sats, - timeout_seconds=timeout, - amt=num_satoshis, - payment_hash=bytes.fromhex(hashed_secret), - allow_self_payment=ALLOW_SELF_KEYSEND, - ) - for response in cls.routerstub.SendPaymentV2(request): - if response.status == lnrpc.Payment.PaymentStatus.IN_FLIGHT: - keysend_payment["status"] = LNPayment.Status.FLIGHT - if response.status == lnrpc.Payment.PaymentStatus.SUCCEEDED: - keysend_payment["fee"] = float(response.fee_msat) / 1000 - keysend_payment["status"] = LNPayment.Status.SUCCED - if response.status == lnrpc.Payment.PaymentStatus.FAILED: - keysend_payment["status"] = LNPayment.Status.FAILRO - keysend_payment["failure_reason"] = response.failure_reason - if response.status == lnrpc.Payment.PaymentStatus.UNKNOWN: - print("Unknown Error") - except Exception as e: - if "self-payments not allowed" in str(e): - print("Self keysend is not allowed") - else: - print("Error while sending keysend payment! Error: " + str(e)) - - return True, keysend_payment - - @classmethod - def double_check_htlc_is_settled(cls, payment_hash): - """Just as it sounds. Better safe than sorry!""" - request = invoicesrpc.LookupInvoiceMsg(payment_hash=bytes.fromhex(payment_hash)) - response = cls.invoicesstub.LookupInvoiceV2(request) - - return ( - response.state == lnrpc.Invoice.InvoiceState.SETTLED - ) # LND states: 0 OPEN, 1 SETTLED, 3 ACCEPTED, GRPC_ERROR status 5 when CANCELED/returned + LNNode = CLNNode +else: + raise ValueError( + f'Invalid Lightning Node vendor: {LN_vendor}. Must be either "LND" or "CLN"' + ) diff --git a/api/logics.py b/api/logics.py index 0ecafe0f..80b142bf 100644 --- a/api/logics.py +++ b/api/logics.py @@ -559,7 +559,8 @@ class Logics: # Compute a safer available onchain liquidity: (confirmed_utxos - reserve - pending_outgoing_txs)) # Accounts for already committed outgoing TX for previous users. confirmed = onchain_payment.balance.onchain_confirmed - reserve = 300_000 # We assume a reserve of 300K Sats (3 times higher than LND's default anchor reserve) + # We assume a reserve of 300K Sats (3 times higher than LND's default anchor reserve) + reserve = 300_000 pending_txs = OnchainPayment.objects.filter( status__in=[OnchainPayment.Status.VALID, OnchainPayment.Status.QUEUE] ).aggregate(Sum("num_satoshis"))["num_satoshis__sum"] @@ -790,7 +791,8 @@ class Logics: concept=LNPayment.Concepts.PAYBUYER, type=LNPayment.Types.NORM, sender=User.objects.get(username=ESCROW_USERNAME), - order_paid_LN=order, # In case this user has other payouts, update the one related to this order. + # In case this user has other payouts, update the one related to this order. + order_paid_LN=order, receiver=user, routing_budget_ppm=routing_budget_ppm, routing_budget_sats=routing_budget_sats, @@ -1097,6 +1099,9 @@ class Logics: description, invoice_expiry=order.t_to_expire(Order.Status.WFB), cltv_expiry_blocks=cls.compute_cltv_expiry_blocks(order, "maker_bond"), + order_id=order.id, + lnpayment_concept=LNPayment.Concepts.MAKEBOND.label, + time=int(timezone.now().timestamp()), ) except Exception as e: print(str(e)) @@ -1208,6 +1213,9 @@ class Logics: description, invoice_expiry=order.t_to_expire(Order.Status.TAK), cltv_expiry_blocks=cls.compute_cltv_expiry_blocks(order, "taker_bond"), + order_id=order.id, + lnpayment_concept=LNPayment.Concepts.TAKEBOND.label, + time=int(timezone.now().timestamp()), ) except Exception as e: @@ -1298,6 +1306,9 @@ class Logics: cltv_expiry_blocks=cls.compute_cltv_expiry_blocks( order, "trade_escrow" ), + order_id=order.id, + lnpayment_concept=LNPayment.Concepts.TRESCROW.label, + time=int(timezone.now().timestamp()), ) except Exception as e: diff --git a/api/utils.py b/api/utils.py index 5a9c670f..dc6455f8 100644 --- a/api/utils.py +++ b/api/utils.py @@ -145,10 +145,20 @@ lnd_version_cache = {} @ring.dict(lnd_version_cache, expire=3600) def get_lnd_version(): - from api.lightning.node import LNNode + from api.lightning.lnd import LNDNode - print(LNNode.get_version()) - return LNNode.get_version() + return LNDNode.get_version() + + +cln_version_cache = {} + + +@ring.dict(cln_version_cache, expire=3600) +def get_cln_version(): + + from api.lightning.cln import CLNNode + + return CLNNode.get_version() robosats_commit_cache = {} diff --git a/api/views.py b/api/views.py index aeae5c90..67f3affe 100644 --- a/api/views.py +++ b/api/views.py @@ -54,6 +54,7 @@ from api.serializers import ( from api.utils import ( compute_avg_premium, compute_premium_percentile, + get_cln_version, get_lnd_version, get_robosats_commit, validate_pgp_keys, @@ -991,6 +992,7 @@ class InfoView(ListAPIView): context["last_day_volume"] = round(total_volume, 8) context["lifetime_volume"] = round(lifetime_volume, 8) context["lnd_version"] = get_lnd_version() + context["cln_version"] = get_cln_version() context["robosats_running_commit_hash"] = get_robosats_commit() context["version"] = settings.VERSION context["alternative_site"] = config("ALTERNATIVE_SITE") diff --git a/docker-compose.yml b/docker-compose.yml index 3933239d..7b9ade4b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,6 +34,7 @@ services: volumes: - .:/usr/src/robosats - ./node/lnd:/lnd + - ./node/cln:/cln network_mode: service:tor command: python3 -u manage.py runserver 0.0.0.0:8000 @@ -69,6 +70,7 @@ services: volumes: - .:/usr/src/robosats - ./node/lnd:/lnd + - ./node/cln:/cln network_mode: service:tor follow-invoices: @@ -84,6 +86,7 @@ services: volumes: - .:/usr/src/robosats - ./node/lnd:/lnd + - ./node/cln:/cln network_mode: service:tor telegram-watcher: @@ -96,6 +99,7 @@ services: volumes: - .:/usr/src/robosats - ./node/lnd:/lnd + - ./node/cln:/cln network_mode: service:tor celery-worker: @@ -108,6 +112,7 @@ services: volumes: - .:/usr/src/robosats - ./node/lnd:/lnd + - ./node/cln:/cln command: celery -A robosats worker --loglevel=INFO --concurrency 4 --max-tasks-per-child=4 --max-memory-per-child=200000 depends_on: - redis @@ -123,6 +128,8 @@ services: command: celery -A robosats beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler volumes: - .:/usr/src/robosats + - ./node/lnd:/lnd + - ./node/cln:/cln depends_on: - redis network_mode: service:tor @@ -169,6 +176,22 @@ services: LND_REST_PORT: 8080 AUTO_UNLOCK_PWD: ${AUTO_UNLOCK_PWD} + cln: + build: ./docker/cln + restart: always + network_mode: service:tor + container_name: cln-dev + depends_on: + - tor + - bitcoind + # - postgres-cln + volumes: + - ./node/tor/data:/var/lib/tor + - ./node/tor/config:/etc/tor + - ./node/cln:/root/.lightning + - ./node/bitcoin:/root/.bitcoin + command: lightningd + bitcoind: build: ./docker/bitcoind container_name: btc-dev @@ -194,5 +217,19 @@ services: volumes: - ./node/db:/var/lib/postgresql/data + # # Postgresql for CLN + # postgres-cln: + # image: postgres:14.2-alpine + # container_name: cln-sql-dev + # restart: always + # environment: + # PGUSER: user + # PGDATABASE: cln + # POSTGRES_PASSWORD: pass + # PGPORT: 5433 + # network_mode: service:tor + # volumes: + # - ./node/cln-db:/var/lib/postgresql/data + volumes: redisdata: diff --git a/docker/cln/Dockerfile b/docker/cln/Dockerfile new file mode 100644 index 00000000..0a0b95d1 --- /dev/null +++ b/docker/cln/Dockerfile @@ -0,0 +1,151 @@ +# Forked of https://github.com/ElementsProject/lightning/blob/2c9b043be97ee4aeca1334d29c2f0ad99da69d34/Dockerfile +# Changes over base core-lightning Dockerfile: +# Adds hodlvoice grpc plugin +# ARG DEVELOPER=0 + +# This dockerfile is meant to compile a core-lightning x64 image +# It is using multi stage build: +# * downloader: Download bitcoin and qemu binaries needed for core-lightning +# * builder: Compile core-lightning dependencies, then core-lightning itself with static linking +# * final: Copy the binaries required at runtime +# The resulting image uploaded to dockerhub will only contain what is needed for runtime. +# From the root of the repository, run "docker build -t yourimage:yourtag ." +FROM debian:bullseye-slim as downloader +ARG DEBIAN_FRONTEND=noninteractive +RUN set -ex \ + && apt-get update \ + && apt-get install -qq --no-install-recommends ca-certificates dirmngr wget + +WORKDIR /opt + +RUN wget -qO /opt/tini "https://github.com/krallin/tini/releases/download/v0.18.0/tini" \ + && echo "12d20136605531b09a2c2dac02ccee85e1b874eb322ef6baf7561cd93f93c855 /opt/tini" | sha256sum -c - \ + && chmod +x /opt/tini + +ARG BITCOIN_VERSION=24.0.1 +ENV BITCOIN_TARBALL bitcoin-${BITCOIN_VERSION}-x86_64-linux-gnu.tar.gz +ENV BITCOIN_URL https://bitcoincore.org/bin/bitcoin-core-$BITCOIN_VERSION/$BITCOIN_TARBALL +ENV BITCOIN_ASC_URL https://bitcoincore.org/bin/bitcoin-core-$BITCOIN_VERSION/SHA256SUMS + +RUN mkdir /opt/bitcoin && cd /opt/bitcoin \ + && wget -qO $BITCOIN_TARBALL "$BITCOIN_URL" \ + && wget -qO bitcoin "$BITCOIN_ASC_URL" \ + && grep $BITCOIN_TARBALL bitcoin | tee SHA256SUMS \ + && sha256sum -c SHA256SUMS \ + && BD=bitcoin-$BITCOIN_VERSION/bin \ + && tar -xzvf $BITCOIN_TARBALL $BD/bitcoin-cli --strip-components=1 \ + && rm $BITCOIN_TARBALL + +FROM debian:bullseye-slim as builder +ARG DEBIAN_FRONTEND=noninteractive + +ARG LIGHTNINGD_VERSION=v23.05 + +RUN apt-get update -qq && \ + apt-get install -qq -y --no-install-recommends \ + autoconf \ + automake \ + build-essential \ + ca-certificates \ + curl \ + dirmngr \ + gettext \ + git \ + gnupg \ + libpq-dev \ + libtool \ + libffi-dev \ + protobuf-compiler \ + python3 \ + python3-dev \ + python3-mako \ + python3-pip \ + python3-venv \ + python3-setuptools \ + wget + +# RUN apt-get install -y --no-install-recommends \ +# postgresql-common \ +# postgresql-14 \ +# libpq-dev=14.* \ +# && rm -rf /var/lib/apt/lists/* + +RUN wget -q https://zlib.net/fossils/zlib-1.2.13.tar.gz \ + && tar xvf zlib-1.2.13.tar.gz \ + && cd zlib-1.2.13 \ + && ./configure \ + && make \ + && make install && cd .. && \ + rm zlib-1.2.13.tar.gz && \ + rm -rf zlib-1.2.13 + +RUN apt-get install -y --no-install-recommends unzip tclsh \ + && wget -q https://www.sqlite.org/2019/sqlite-src-3290000.zip \ + && unzip sqlite-src-3290000.zip \ + && cd sqlite-src-3290000 \ + && ./configure --enable-static --disable-readline --disable-threadsafe --disable-load-extension \ + && make \ + && make install && cd .. && rm sqlite-src-3290000.zip && rm -rf sqlite-src-3290000 + +RUN wget -q https://gmplib.org/download/gmp/gmp-6.1.2.tar.xz \ + && tar xvf gmp-6.1.2.tar.xz \ + && cd gmp-6.1.2 \ + && ./configure --disable-assembly \ + && make \ + && make install && cd .. && rm gmp-6.1.2.tar.xz && rm -rf gmp-6.1.2 + +ENV RUST_PROFILE=release +ENV PATH=$PATH:/root/.cargo/bin/ +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 +# Clone git repo into /tmp/lightning +RUN git clone --recursive --branch $LIGHTNINGD_VERSION https://github.com/ElementsProject/lightning.git /tmp/lightning +RUN git clone --recursive /tmp/lightning . && \ + git checkout $(git --work-tree=/tmp/lightning --git-dir=/tmp/lightning/.git rev-parse HEAD) + +RUN git clone --recursive --branch hodlvoice https://github.com/daywalker90/lightning.git /tmp/hodlvoice +RUN cd /tmp/hodlvoice/plugins/grpc-plugin \ + && cargo build --release + +ENV PYTHON_VERSION=3 +RUN curl -sSL https://install.python-poetry.org | python3 - \ + && pip3 install -U pip \ + && pip3 install -U wheel \ + && /root/.local/bin/poetry install + +RUN ./configure --prefix=/tmp/lightning_install --enable-static && \ + make DEVELOPER=${DEVELOPER} && \ + /root/.local/bin/poetry run make install + +FROM debian:bullseye-slim as final + +COPY --from=downloader /opt/tini /usr/bin/tini + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + socat \ + inotify-tools \ + python3 \ + python3-pip \ + libpq5 && \ + rm -rf /var/lib/apt/lists/* + +ENV LIGHTNINGD_DATA=/root/.lightning +ENV LIGHTNINGD_RPC_PORT=9835 +ENV LIGHTNINGD_PORT=9735 +ENV LIGHTNINGD_NETWORK=bitcoin + +RUN mkdir $LIGHTNINGD_DATA && \ + touch $LIGHTNINGD_DATA/config +VOLUME [ "/root/.lightning" ] +COPY --from=builder /tmp/lightning_install/ /usr/local/ +COPY --from=builder /tmp/hodlvoice/target/release/cln-grpc-hodl /tmp/cln-grpc-hodl +COPY --from=downloader /opt/bitcoin/bin /usr/bin +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 diff --git a/docker/cln/config b/docker/cln/config new file mode 100644 index 00000000..133d9e8b --- /dev/null +++ b/docker/cln/config @@ -0,0 +1,9 @@ +network=testnet +proxy=127.0.0.1:9050 +bind-addr=127.0.0.1:9736 +addr=statictor:127.0.0.1:9051 +grpc-port=9999 +always-use-proxy=true +important-plugin=/root/.lightning/plugins/cln-grpc-hodl +# wallet=postgres://user:pass@localhost:5433/cln +# bookkeeper-db=postgres://user:pass@localhost:5433/cln \ No newline at end of file diff --git a/docker/cln/entrypoint.sh b/docker/cln/entrypoint.sh new file mode 100644 index 00000000..2f51791c --- /dev/null +++ b/docker/cln/entrypoint.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash + +: "${EXPOSE_TCP:=false}" + +networkdatadir="${LIGHTNINGD_DATA}/${LIGHTNINGD_NETWORK}" + +if [ "$EXPOSE_TCP" == "true" ]; then + set -m + lightningd "$@" & + + echo "Core-Lightning starting" + while read -r i; do if [ "$i" = "lightning-rpc" ]; then break; fi; done \ + < <(inotifywait -e create,open --format '%f' --quiet "${networkdatadir}" --monitor) + echo "Core-Lightning started" + echo "Core-Lightning started, RPC available on port $LIGHTNINGD_RPC_PORT" + + socat "TCP4-listen:$LIGHTNINGD_RPC_PORT,fork,reuseaddr" "UNIX-CONNECT:${networkdatadir}/lightning-rpc" & + fg %- +else + # Always copy the cln-grpc-hodl plugin into the plugins directory on start up + mkdir -p /root/.lightning/plugins + cp /tmp/cln-grpc-hodl /root/.lightning/plugins/cln-grpc-hodl + if [ ! -f /root/.lightning/config ]; then + cp /tmp/config /root/.lightning/config + fi + exec "$@" +fi \ No newline at end of file diff --git a/frontend/src/components/Dialogs/Stats.tsx b/frontend/src/components/Dialogs/Stats.tsx index 14e7ea3d..fc3ee847 100644 --- a/frontend/src/components/Dialogs/Stats.tsx +++ b/frontend/src/components/Dialogs/Stats.tsx @@ -66,12 +66,23 @@ const StatsDialog = ({ open = false, onClose, info }: Props): JSX.Element => { - - - - - - + {info.lnd_version ? ( + + + + + + + ) : null} + + {info.lnd_version ? ( + + + + + + + ) : null} diff --git a/frontend/src/models/Info.model.ts b/frontend/src/models/Info.model.ts index 60e19077..0b2a18d4 100644 --- a/frontend/src/models/Info.model.ts +++ b/frontend/src/models/Info.model.ts @@ -8,7 +8,8 @@ export interface Info { last_day_nonkyc_btc_premium: number; last_day_volume: number; lifetime_volume: number; - lnd_version: string; + lnd_version?: string; + cln_version?: string; robosats_running_commit_hash: string; alternative_site: string; alternative_name: string; @@ -35,7 +36,8 @@ export const defaultInfo: Info = { last_day_nonkyc_btc_premium: 0, last_day_volume: 0, lifetime_volume: 0, - lnd_version: 'v0.0.0-beta', + lnd_version: '0.0.0-beta', + cln_version: '0.0.0', robosats_running_commit_hash: '000000000000000', alternative_site: 'RoboSats6tkf3eva7x2voqso3a5wcorsnw34jveyxfqi2fu7oyheasid.onion', alternative_name: 'RoboSats Mainnet', diff --git a/scripts/generate_grpc.sh b/scripts/generate_grpc.sh index ea0cac2f..95ce3034 100755 --- a/scripts/generate_grpc.sh +++ b/scripts/generate_grpc.sh @@ -1,6 +1,6 @@ #!/bin/sh -# generate grpc definitions +# generate LND grpc definitions cd api/lightning [ -d googleapis ] || git clone https://github.com/googleapis/googleapis.git googleapis @@ -24,10 +24,16 @@ python3 -m grpc_tools.protoc --proto_path=googleapis:. --python_out=. --grpc_pyt curl -o verrpc.proto -s https://raw.githubusercontent.com/lightningnetwork/lnd/master/lnrpc/verrpc/verrpc.proto python3 -m grpc_tools.protoc --proto_path=googleapis:. --python_out=. --grpc_python_out=. verrpc.proto +# generate CLN grpc definitions +curl -o node.proto -s https://raw.githubusercontent.com/daywalker90/lightning/hodlvoice/cln-grpc/proto/node.proto +curl -o primitives.proto -s https://raw.githubusercontent.com/daywalker90/lightning/hodlvoice/cln-grpc/proto/primitives.proto +python3 -m grpc_tools.protoc --proto_path=. --python_out=. --grpc_python_out=. node.proto primitives.proto + # delete googleapis rm -r googleapis # patch generated files relative imports +# LND sed -i 's/^import .*_pb2 as/from . \0/' router_pb2.py sed -i 's/^import .*_pb2 as/from . \0/' signer_pb2.py sed -i 's/^import .*_pb2 as/from . \0/' invoices_pb2.py @@ -38,8 +44,12 @@ sed -i 's/^import .*_pb2 as/from . \0/' lightning_pb2_grpc.py sed -i 's/^import .*_pb2 as/from . \0/' invoices_pb2_grpc.py sed -i 's/^import .*_pb2 as/from . \0/' verrpc_pb2_grpc.py +# CLN +sed -i 's/^import .*_pb2 as/from . \0/' node_pb2.py +sed -i 's/^import .*_pb2 as/from . \0/' node_pb2_grpc.py + # 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/