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 hold_pb2, hold_pb2_grpc, node_pb2, node_pb2_grpc from . import primitives_pb2 as primitives__pb2 ####### # Works with CLN ####### # Load the client's certificate and key CLN_DIR = config("CLN_DIR", cast=str, default="/cln/testnet/") with open(os.path.join(CLN_DIR, "client.pem"), "rb") as f: client_cert = f.read() with open(os.path.join(CLN_DIR, "client-key.pem"), "rb") as f: client_key = f.read() # Load the server's certificate with open(os.path.join(CLN_DIR, "server.pem"), "rb") as f: server_cert = f.read() CLN_GRPC_HOST = config("CLN_GRPC_HOST", cast=str, default="localhost:9999") CLN_GRPC_HOLD_HOST = config("CLN_GRPC_HOLD_HOST", cast=str, default="localhost:9998") 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 hold_channel = grpc.secure_channel(CLN_GRPC_HOLD_HOST, creds) node_channel = grpc.secure_channel(CLN_GRPC_HOST, creds) 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: nodestub = node_pb2_grpc.NodeStub(cls.node_channel) request = node_pb2.GetinfoRequest() response = nodestub.Getinfo(request) return response.version except Exception as e: print(f"Cannot get CLN version: {e}") return "Not installed" @classmethod def get_info(cls): try: nodestub = node_pb2_grpc.NodeStub(cls.node_channel) request = node_pb2.GetinfoRequest() response = nodestub.Getinfo(request) return response except Exception as e: print(f"Cannot get CLN node id: {e}") @classmethod def newaddress(cls): """Only used on tests to fund the regtest node""" nodestub = node_pb2_grpc.NodeStub(cls.node_channel) request = node_pb2.NewaddrRequest() response = nodestub.NewAddr(request) return response.bech32 @classmethod def decode_payreq(cls, invoice): """Decodes a lightning payment request (invoice)""" nodestub = node_pb2_grpc.NodeStub(cls.node_channel) request = node_pb2.DecodeRequest(string=invoice) response = nodestub.Decode(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 = node_pb2.FeeratesRequest(style="PERKB") nodestub = node_pb2_grpc.NodeStub(cls.node_channel) response = nodestub.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 = node_pb2.ListfundsRequest() nodestub = node_pb2_grpc.NodeStub(cls.node_channel) response = nodestub.ListFunds(request) unconfirmed_balance = 0 confirmed_balance = 0 total_balance = 0 for utxo in response.outputs: if not utxo.reserved: if ( utxo.status == node_pb2.ListfundsOutputs.ListfundsOutputsStatus.UNCONFIRMED ): unconfirmed_balance += utxo.amount_msat.msat // 1_000 total_balance += utxo.amount_msat.msat // 1_000 elif ( utxo.status == node_pb2.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 = node_pb2.ListpeerchannelsRequest() nodestub = node_pb2_grpc.NodeStub(cls.node_channel) response = nodestub.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 == node_pb2.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 == node_pb2.ListpeerchannelsChannelsHtlcs.ListpeerchannelsChannelsHtlcsDirection.IN ): unsettled_local_balance += htlc.amount_msat.msat // 1_000 elif ( htlc.direction == node_pb2.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 = node_pb2.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 if not config("TESTING", cast=bool, default=False): 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"]) nodestub = node_pb2_grpc.NodeStub(cls.node_channel) response = nodestub.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 = hold_pb2.HoldInvoiceCancelRequest( payment_hash=bytes.fromhex(payment_hash) ) holdstub = hold_pb2_grpc.HoldStub(cls.hold_channel) response = holdstub.HoldInvoiceCancel(request) return response.state == hold_pb2.Holdstate.CANCELED @classmethod def settle_hold_invoice(cls, preimage): """settles a hold invoice""" request = hold_pb2.HoldInvoiceSettleRequest( payment_hash=hashlib.sha256(bytes.fromhex(preimage)).digest() ) holdstub = hold_pb2_grpc.HoldStub(cls.hold_channel) response = holdstub.HoldInvoiceSettle(request) return response.state == hold_pb2.Holdstate.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 = hold_pb2.HoldInvoiceRequest( description=description, amount_msat=hold_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 ) holdstub = hold_pb2_grpc.HoldStub(cls.hold_channel) response = holdstub.HoldInvoice(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.created_at) ) 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 = hold_pb2.HoldInvoiceLookupRequest( payment_hash=bytes.fromhex(lnpayment.payment_hash) ) holdstub = hold_pb2_grpc.HoldStub(cls.hold_channel) response = holdstub.HoldInvoiceLookup(request) # Will fail if '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 == hold_pb2.Holdstate.OPEN: pass if response.state == hold_pb2.Holdstate.SETTLED: pass if response.state == hold_pb2.Holdstate.CANCELED: pass if response.state == hold_pb2.Holdstate.ACCEPTED: lnpayment.expiry_height = response.htlc_expiry 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 = hold_pb2.HoldInvoiceLookupRequest( payment_hash=bytes.fromhex(lnpayment.payment_hash) ) holdstub = hold_pb2_grpc.HoldStub(cls.hold_channel) response = holdstub.HoldInvoiceLookup(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 # (holdinvoice plugin has separate state for hodl-invoices, which it forgets after an invoice expired more than an hour ago) if "empty result for listdatastore_state" in str(e): print(str(e)) request2 = node_pb2.ListinvoicesRequest( payment_hash=bytes.fromhex(lnpayment.payment_hash) ) try: nodestub = node_pb2_grpc.NodeStub(cls.node_channel) response2 = nodestub.ListInvoices(request2).invoices except Exception as e: print(str(e)) if ( response2[0].status == node_pb2.ListinvoicesInvoices.ListinvoicesInvoicesStatus.PAID ): status = LNPayment.Status.SETLED elif ( response2[0].status == node_pb2.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.routes.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.fee_base_msat.msat / 1_000 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.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.created_at) ) 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 = node_pb2.PayRequest( bolt11=lnpayment.invoice, maxfee=primitives__pb2.Amount(msat=fee_limit_sat * 1_000), retry_for=timeout_seconds, ) try: nodestub = node_pb2_grpc.NodeStub(cls.node_channel) response = nodestub.Pay(request) if response.status == node_pb2.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 == node_pb2.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 == node_pb2.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 = node_pb2.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 = node_pb2.ListpaysRequest( payment_hash=bytes.fromhex(hash) ) while True: try: nodestub = node_pb2_grpc.NodeStub(cls.node_channel) response_listpays = nodestub.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 != node_pb2.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.update_status(Order.Status.PAY) nodestub = node_pb2_grpc.NodeStub(cls.node_channel) response = nodestub.Pay(request) if response.status == node_pb2.PayResponse.PayStatus.PENDING: print(f"Order: {order.id} IN_FLIGHT. Hash {hash}") watchpayment() handle_response() if response.status == node_pb2.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.update_status(Order.Status.FAI) order.expires_at = timezone.now() + timedelta( seconds=order.t_to_expire(Order.Status.FAI) ) order.save(update_fields=["expires_at"]) print( f"Order: {order.id} FAILED. Hash: {hash} Reason: {cls.payment_failure_context[-1]}" ) order.log( f"Payment LNPayment({lnpayment.payment_hash},{str(lnpayment)}) failed. Failure reason: {cls.payment_failure_context[-1]})" ) return { "succeded": False, "context": f"payment failure reason: {cls.payment_failure_context[-1]}", } if response.status == node_pb2.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.update_status(Order.Status.SUC) order.expires_at = timezone.now() + timedelta( seconds=order.t_to_expire(Order.Status.SUC) ) order.save(update_fields=["expires_at"]) order.log( f"Payment LNPayment({lnpayment.payment_hash},{str(lnpayment)}) succeeded" ) 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.update_status(Order.Status.FAI) order.expires_at = timezone.now() + timedelta( seconds=order.t_to_expire(Order.Status.FAI) ) order.save(update_fields=["expires_at"]) print( f"Order: {order.id} FAILED. Hash: {hash} Reason: {cls.payment_failure_context[status_code]}" ) order.log( f"Payment LNPayment({lnpayment.payment_hash},{str(lnpayment)}) failed. Failure 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 == node_pb2.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.update_status(Order.Status.FAI) order.expires_at = timezone.now() + timedelta( seconds=order.t_to_expire(Order.Status.FAI) ) order.save(update_fields=["expires_at"]) order.log( f"Payment LNPayment({lnpayment.payment_hash},{str(lnpayment)}) had expired" ) 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: nodestub = node_pb2_grpc.NodeStub(cls.node_channel) self_pubkey = nodestub.Getinfo(node_pb2.GetinfoRequest()).id timestamp = struct.pack(">i", int(time.time())) nodestub = node_pb2_grpc.NodeStub(cls.node_channel) signature = nodestub.SignMessage( node_pb2.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 = node_pb2.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), ) nodestub = node_pb2_grpc.NodeStub(cls.node_channel) response = nodestub.KeySend(request) keysend_payment["preimage"] = response.payment_preimage.hex() keysend_payment["payment_hash"] = response.payment_hash.hex() waitreq = node_pb2.WaitsendpayRequest( payment_hash=response.payment_hash, timeout=timeout ) try: nodestub = node_pb2_grpc.NodeStub(cls.node_channel) waitresp = nodestub.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 = hold_pb2.HoldInvoiceLookupRequest( payment_hash=bytes.fromhex(payment_hash) ) try: holdstub = hold_pb2_grpc.HoldStub(cls.hold_channel) response = holdstub.HoldInvoiceLookup(request) except Exception as e: if "Timed out" in str(e): return False else: raise e return response.state == hold_pb2.Holdstate.SETTLED