lnbits-legend/lnbits/wallets/cln.py
calle 2ee10e28c5
Backend: Unstuck outgoing payments (#857)
* first attempts

* lndrest works

* fix details

* optional fee update

* use base64.urlsafe_b64encode

* return paymentstatus

* CLN: return pending for pending invoices

* grpc wip

* lndgrpc works

* cln: return pending for pending invoices really this time

* retry wallet out of exception

* wip eclair

* take all routines into try except

* cliche: return error

* rename payment.check_pending() to payment.check_status()

* rename payment.check_pending() to payment.check_status()

* eclair: works

* eclair: better error check

* opennode: works

* check payment.checking_id istead of payment.ok

* payment.ok check as well

* cln: works?

* cln: works

* lntxbot: works

* lnbits/wallets/lnpay.py

* cln: error handling

* make format

* lndhub full detail update

* spark: wip

* error to False

* wallets: return clean PaymentResponse

* opennode: strict error

* cliche: works

* lnbits: works

* cln: dont throw error

* preimage not error

* fix cln

* do not add duplicate payments

* revert cln

* extra safety for cln

* undo crud changes until tests work

* tasks: better logging and 0.5s sleep for regular status check

* 0.1 s

* check if wallet exists

* lnbits unhashed description

* remove sleep

* revert app.py

* cleanup

* add check

* clean error

* readd app.py

* fix eclaid
2022-08-30 13:28:58 +02:00

202 lines
7.2 KiB
Python

try:
from pyln.client import LightningRpc, RpcError # type: ignore
except ImportError: # pragma: nocover
LightningRpc = None
import asyncio
import hashlib
import random
import time
from functools import partial, wraps
from os import getenv
from typing import AsyncGenerator, Optional
from loguru import logger
from lnbits import bolt11 as lnbits_bolt11
from .base import (
InvoiceResponse,
PaymentResponse,
PaymentStatus,
StatusResponse,
Unsupported,
Wallet,
)
def async_wrap(func):
@wraps(func)
async def run(*args, loop=None, executor=None, **kwargs):
if loop is None:
loop = asyncio.get_event_loop()
partial_func = partial(func, *args, **kwargs)
return await loop.run_in_executor(executor, partial_func)
return run
def _pay_invoice(ln, payload):
return ln.call("pay", payload)
def _paid_invoices_stream(ln, last_pay_index):
return ln.waitanyinvoice(last_pay_index)
class CoreLightningWallet(Wallet):
def __init__(self):
if LightningRpc is None: # pragma: nocover
raise ImportError(
"The `pyln-client` library must be installed to use `CoreLightningWallet`."
)
self.rpc = getenv("CORELIGHTNING_RPC") or getenv("CLIGHTNING_RPC")
self.ln = LightningRpc(self.rpc)
# check if description_hash is supported (from CLN>=v0.11.0)
self.supports_description_hash = (
"deschashonly" in self.ln.help("invoice")["help"][0]["command"]
)
# check last payindex so we can listen from that point on
self.last_pay_index = 0
invoices = self.ln.listinvoices()
for inv in invoices["invoices"][::-1]:
if "pay_index" in inv:
self.last_pay_index = inv["pay_index"]
break
async def status(self) -> StatusResponse:
try:
funds = self.ln.listfunds()
return StatusResponse(
None, sum([ch["channel_sat"] * 1000 for ch in funds["channels"]])
)
except RpcError as exc:
error_message = f"lightningd '{exc.method}' failed with '{exc.error}'."
return StatusResponse(error_message, 0)
async def create_invoice(
self,
amount: int,
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
) -> InvoiceResponse:
label = "lbl{}".format(random.random())
msat: int = int(amount * 1000)
try:
if description_hash and not unhashed_description:
raise Unsupported(
"'description_hash' unsupported by CLN, provide 'unhashed_description'"
)
if unhashed_description and not self.supports_description_hash:
raise Unsupported("unhashed_description")
r = self.ln.invoice(
msatoshi=msat,
label=label,
description=unhashed_description.decode("utf-8")
if unhashed_description
else memo,
exposeprivatechannels=True,
deschashonly=True
if unhashed_description
else False, # we can't pass None here
)
if r.get("code") and r.get("code") < 0:
raise Exception(r.get("message"))
return InvoiceResponse(True, r["payment_hash"], r["bolt11"], "")
except RpcError as exc:
error_message = f"CLN method '{exc.method}' failed with '{exc.error.get('message') or exc.error}'."
return InvoiceResponse(False, None, None, error_message)
except Exception as e:
return InvoiceResponse(False, None, None, str(e))
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
invoice = lnbits_bolt11.decode(bolt11)
previous_payment = await self.get_payment_status(invoice.payment_hash)
if previous_payment.paid:
return PaymentResponse(False, None, None, None, "invoice already paid")
fee_limit_percent = fee_limit_msat / invoice.amount_msat * 100
payload = {
"bolt11": bolt11,
"maxfeepercent": "{:.11}".format(fee_limit_percent),
"exemptfee": 0, # so fee_limit_percent is applied even on payments with fee < 5000 millisatoshi (which is default value of exemptfee)
}
try:
wrapped = async_wrap(_pay_invoice)
r = await wrapped(self.ln, payload)
except RpcError as exc:
try:
error_message = exc.error["attempts"][-1]["fail_reason"]
except:
error_message = f"CLN method '{exc.method}' failed with '{exc.error.get('message') or exc.error}'."
return PaymentResponse(False, None, None, None, error_message)
except Exception as exc:
return PaymentResponse(False, None, None, None, str(exc))
fee_msat = -int(r["msatoshi_sent"] - r["msatoshi"])
return PaymentResponse(
True, r["payment_hash"], fee_msat, r["payment_preimage"], None
)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
try:
r = self.ln.listinvoices(payment_hash=checking_id)
except:
return PaymentStatus(None)
if not r["invoices"]:
return PaymentStatus(None)
invoice_resp = r["invoices"][-1]
if invoice_resp["payment_hash"] == checking_id:
if invoice_resp["status"] == "paid":
return PaymentStatus(True)
elif invoice_resp["status"] == "unpaid":
return PaymentStatus(None)
logger.warning(f"supplied an invalid checking_id: {checking_id}")
return PaymentStatus(None)
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
try:
r = self.ln.call("listpays", {"payment_hash": checking_id})
except:
return PaymentStatus(None)
if not r["pays"]:
return PaymentStatus(None)
payment_resp = r["pays"][-1]
if payment_resp["payment_hash"] == checking_id:
status = payment_resp["status"]
if status == "complete":
fee_msat = -int(
payment_resp["amount_sent_msat"] - payment_resp["amount_msat"]
)
return PaymentStatus(True, fee_msat, payment_resp["preimage"])
elif status == "failed":
return PaymentStatus(False)
return PaymentStatus(None)
logger.warning(f"supplied an invalid checking_id: {checking_id}")
return PaymentStatus(None)
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
while True:
try:
wrapped = async_wrap(_paid_invoices_stream)
paid = await wrapped(self.ln, self.last_pay_index)
self.last_pay_index = paid["pay_index"]
yield paid["payment_hash"]
except Exception as exc:
logger.error(
f"lost connection to cln invoices stream: '{exc}', retrying in 5 seconds"
)
await asyncio.sleep(5)