import grpc, os, hashlib, secrets from . import lightning_pb2 as lnrpc, lightning_pb2_grpc as lightningstub from . import invoices_pb2 as invoicesrpc, invoices_pb2_grpc as invoicesstub from . import router_pb2 as routerrpc, router_pb2_grpc as routerstub from decouple import config from base64 import b64decode from datetime import timedelta, datetime from django.utils import timezone from api.models import LNPayment ####### # Should work with LND (c-lightning in the future if there are features that deserve the work) ####### # Read tls.cert from file or .env variable string encoded as base64 try: CERT = open(os.path.join(config("LND_DIR"), "tls.cert"), "rb").read() except: CERT = b64decode(config("LND_CERT_BASE64")) # Read macaroon from file or .env variable string encoded as base64 try: MACAROON = open(os.path.join(config("LND_DIR"), config("MACAROON_path")), "rb").read() except: MACAROON = b64decode(config("LND_MACAROON_BASE64")) LND_GRPC_HOST = config("LND_GRPC_HOST") class LNNode: os.environ["GRPC_SSL_CIPHER_SUITES"] = "HIGH+ECDSA" creds = grpc.ssl_channel_credentials(CERT) channel = grpc.secure_channel(LND_GRPC_HOST, creds) lightningstub = lightningstub.LightningStub(channel) invoicesstub = invoicesstub.InvoicesStub(channel) routerstub = routerstub.RouterStub(channel) lnrpc = lnrpc invoicesrpc = invoicesrpc routerrpc = routerrpc 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 decode_payreq(cls, invoice): """Decodes a lightning payment request (invoice)""" request = lnrpc.PayReqString(pay_req=invoice) response = cls.lightningstub.DecodePayReq(request, metadata=[("macaroon", MACAROON.hex())]) return response @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, metadata=[("macaroon", MACAROON.hex())]) # 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, metadata=[("macaroon", MACAROON.hex())]) # 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_secs): """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() # timelock expiry for the last hop, computed based on a 10 minutes block with 30% padding (~7 min block) cltv_expiry_blocks = int(cltv_expiry_secs / (7 * 60)) 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, metadata=[("macaroon", MACAROON.hex())]) 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""" request = invoicesrpc.LookupInvoiceMsg( payment_hash=bytes.fromhex(lnpayment.payment_hash)) response = cls.invoicesstub.LookupInvoiceV2(request, metadata=[("macaroon", MACAROON.hex()) ]) print("status here") print(response.state) # TODO ERROR HANDLING # 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 == 0: # OPEN print("STATUS: OPEN") pass if response.state == 1: # SETTLED pass if response.state == 2: # CANCELLED pass if response.state == 3: # ACCEPTED (LOCKED) print("STATUS: ACCEPTED") lnpayment.expiry_height = response.htlcs[0].expiry_height lnpayment.status = LNPayment.Status.LOCKED lnpayment.save() return True @classmethod def resetmc(cls): request = routerrpc.ResetMissionControlRequest() response = cls.routerstub.ResetMissionControl(request, metadata=[ ("macaroon", MACAROON.hex()) ]) return True @classmethod def validate_ln_invoice(cls, invoice, num_satoshis): """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) print(payreq_decoded) except: payout["context"] = { "bad_invoice": "Does not look like a valid lightning invoice" } 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": f"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, invoice, num_satoshis): """Sends sats to buyer""" fee_limit_sat = int( max( num_satoshis * float(config("PROPORTIONAL_ROUTING_FEE_LIMIT")), float(config("MIN_FLAT_ROUTING_FEE_LIMIT")), )) # 200 ppm or 10 sats request = routerrpc.SendPaymentRequest(payment_request=invoice, fee_limit_sat=fee_limit_sat, timeout_seconds=60) for response in cls.routerstub.SendPaymentV2(request, metadata=[("macaroon", MACAROON.hex()) ]): print(response) print(response.status) # TODO ERROR HANDLING if response.status == 0: # Status 0 'UNKNOWN' pass if response.status == 1: # Status 1 'IN_FLIGHT' return True, "In flight" if response.status == 3: # 4 '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. """ context = cls.payment_failure_context[response.failure_reason] return False, context if response.status == 2: # STATUS 'SUCCEEDED' return True, None # How to catch the errors like:"grpc_message":"invoice is already paid","grpc_status":6} # These are not in the response only printed to commandline return False @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, metadata=[("macaroon", MACAROON.hex()) ]) return ( response.state == 1 ) # LND states: 0 OPEN, 1 SETTLED, 3 ACCEPTED, GRPC_ERROR status 5 when cancelled/returned