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
This commit is contained in:
calle 2022-08-30 13:28:58 +02:00 committed by GitHub
parent 78a98ca97d
commit 2ee10e28c5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 384 additions and 194 deletions

View file

@ -122,10 +122,10 @@ def check_funding_source(app: FastAPI) -> None:
f"The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'", f"The backend for {WALLET.__class__.__name__} isn't working properly: '{error_message}'",
RuntimeWarning, RuntimeWarning,
) )
logger.info("Retrying connection to backend in 5 seconds...")
await asyncio.sleep(5)
except: except:
pass pass
logger.info("Retrying connection to backend in 5 seconds...")
await asyncio.sleep(5)
signal.signal(signal.SIGINT, original_sigint_handler) signal.signal(signal.SIGINT, original_sigint_handler)
logger.info( logger.info(
f"✔️ Backend {WALLET.__class__.__name__} connected and with a balance of {balance} msat." f"✔️ Backend {WALLET.__class__.__name__} connected and with a balance of {balance} msat."

View file

@ -365,6 +365,11 @@ async def create_payment(
webhook: Optional[str] = None, webhook: Optional[str] = None,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> Payment: ) -> Payment:
# todo: add this when tests are fixed
# previous_payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn)
# assert previous_payment is None, "Payment already exists"
await (conn or db).execute( await (conn or db).execute(
""" """
INSERT INTO apipayments INSERT INTO apipayments
@ -404,6 +409,40 @@ async def update_payment_status(
) )
async def update_payment_details(
checking_id: str,
pending: Optional[bool] = None,
fee: Optional[int] = None,
preimage: Optional[str] = None,
new_checking_id: Optional[str] = None,
conn: Optional[Connection] = None,
) -> None:
set_clause: List[str] = []
set_variables: List[Any] = []
if new_checking_id is not None:
set_clause.append("checking_id = ?")
set_variables.append(new_checking_id)
if pending is not None:
set_clause.append("pending = ?")
set_variables.append(pending)
if fee is not None:
set_clause.append("fee = ?")
set_variables.append(fee)
if preimage is not None:
set_clause.append("preimage = ?")
set_variables.append(preimage)
set_variables.append(checking_id)
await (conn or db).execute(
f"UPDATE apipayments SET {', '.join(set_clause)} WHERE checking_id = ?",
tuple(set_variables),
)
return
async def delete_payment(checking_id: str, conn: Optional[Connection] = None) -> None: async def delete_payment(checking_id: str, conn: Optional[Connection] = None) -> None:
await (conn or db).execute( await (conn or db).execute(
"DELETE FROM apipayments WHERE checking_id = ?", (checking_id,) "DELETE FROM apipayments WHERE checking_id = ?", (checking_id,)

View file

@ -11,6 +11,7 @@ from pydantic import BaseModel
from lnbits.helpers import url_for from lnbits.helpers import url_for
from lnbits.settings import WALLET from lnbits.settings import WALLET
from lnbits.wallets.base import PaymentStatus
class Wallet(BaseModel): class Wallet(BaseModel):
@ -128,8 +129,16 @@ class Payment(BaseModel):
@property @property
def is_uncheckable(self) -> bool: def is_uncheckable(self) -> bool:
return self.checking_id.startswith("temp_") or self.checking_id.startswith( return self.checking_id.startswith("internal_")
"internal_"
async def update_status(self, status: PaymentStatus) -> None:
from .crud import update_payment_details
await update_payment_details(
checking_id=self.checking_id,
pending=status.pending,
fee=status.fee_msat,
preimage=status.preimage,
) )
async def set_pending(self, pending: bool) -> None: async def set_pending(self, pending: bool) -> None:
@ -137,9 +146,9 @@ class Payment(BaseModel):
await update_payment_status(self.checking_id, pending) await update_payment_status(self.checking_id, pending)
async def check_pending(self) -> None: async def check_status(self) -> PaymentStatus:
if self.is_uncheckable: if self.is_uncheckable:
return return PaymentStatus(None)
logger.debug( logger.debug(
f"Checking {'outgoing' if self.is_out else 'incoming'} pending payment {self.checking_id}" f"Checking {'outgoing' if self.is_out else 'incoming'} pending payment {self.checking_id}"
@ -153,7 +162,7 @@ class Payment(BaseModel):
logger.debug(f"Status: {status}") logger.debug(f"Status: {status}")
if self.is_out and status.failed: if self.is_out and status.failed:
logger.info( logger.warning(
f"Deleting outgoing failed payment {self.checking_id}: {status}" f"Deleting outgoing failed payment {self.checking_id}: {status}"
) )
await self.delete() await self.delete()
@ -161,7 +170,8 @@ class Payment(BaseModel):
logger.info( logger.info(
f"Marking '{'in' if self.is_in else 'out'}' {self.checking_id} as not pending anymore: {status}" f"Marking '{'in' if self.is_in else 'out'}' {self.checking_id} as not pending anymore: {status}"
) )
await self.set_pending(status.pending) await self.update_status(status)
return status
async def delete(self) -> None: async def delete(self) -> None:
from .crud import delete_payment from .crud import delete_payment

View file

@ -31,8 +31,10 @@ from .crud import (
delete_payment, delete_payment,
get_wallet, get_wallet,
get_wallet_payment, get_wallet_payment,
update_payment_details,
update_payment_status, update_payment_status,
) )
from .models import Payment
try: try:
from typing import TypedDict # type: ignore from typing import TypedDict # type: ignore
@ -101,11 +103,20 @@ async def pay_invoice(
description: str = "", description: str = "",
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> str: ) -> str:
"""
Pay a Lightning invoice.
First, we create a temporary payment in the database with fees set to the reserve fee.
We then check whether the balance of the payer would go negative.
We then attempt to pay the invoice through the backend.
If the payment is successful, we update the payment in the database with the payment details.
If the payment is unsuccessful, we delete the temporary payment.
If the payment is still in flight, we hope that some other process will regularly check for the payment.
"""
invoice = bolt11.decode(payment_request) invoice = bolt11.decode(payment_request)
fee_reserve_msat = fee_reserve(invoice.amount_msat) fee_reserve_msat = fee_reserve(invoice.amount_msat)
async with (db.reuse_conn(conn) if conn else db.connect()) as conn: async with (db.reuse_conn(conn) if conn else db.connect()) as conn:
temp_id = f"temp_{urlsafe_short_hash()}" temp_id = invoice.payment_hash
internal_id = f"internal_{urlsafe_short_hash()}" internal_id = f"internal_{invoice.payment_hash}"
if invoice.amount_msat == 0: if invoice.amount_msat == 0:
raise ValueError("Amountless invoices not supported.") raise ValueError("Amountless invoices not supported.")
@ -185,30 +196,41 @@ async def pay_invoice(
payment: PaymentResponse = await WALLET.pay_invoice( payment: PaymentResponse = await WALLET.pay_invoice(
payment_request, fee_reserve_msat payment_request, fee_reserve_msat
) )
if payment.checking_id and payment.checking_id != temp_id:
logger.warning(
f"backend sent unexpected checking_id (expected: {temp_id} got: {payment.checking_id})"
)
logger.debug(f"backend: pay_invoice finished {temp_id}") logger.debug(f"backend: pay_invoice finished {temp_id}")
if payment.ok and payment.checking_id: if payment.checking_id and payment.ok != False:
logger.debug(f"creating final payment {payment.checking_id}") # payment.ok can be True (paid) or None (pending)!
logger.debug(f"updating payment {temp_id}")
async with db.connect() as conn: async with db.connect() as conn:
await create_payment( await update_payment_details(
checking_id=payment.checking_id, checking_id=temp_id,
pending=payment.ok != True,
fee=payment.fee_msat, fee=payment.fee_msat,
preimage=payment.preimage, preimage=payment.preimage,
pending=payment.ok == None, new_checking_id=payment.checking_id,
conn=conn, conn=conn,
**payment_kwargs,
) )
logger.debug(f"deleting temporary payment {temp_id}") logger.debug(f"payment successful {payment.checking_id}")
await delete_payment(temp_id, conn=conn) elif payment.checking_id is None and payment.ok == False:
else: # payment failed
logger.debug(f"backend payment failed") logger.warning(f"backend sent payment failure")
async with db.connect() as conn: async with db.connect() as conn:
logger.debug(f"deleting temporary payment {temp_id}") logger.debug(f"deleting temporary payment {temp_id}")
await delete_payment(temp_id, conn=conn) await delete_payment(temp_id, conn=conn)
raise PaymentFailure( raise PaymentFailure(
payment.error_message f"payment failed: {payment.error_message}"
or "Payment failed, but backend didn't give us an error message." or "payment failed, but backend didn't give us an error message"
) )
logger.debug(f"payment successful {payment.checking_id}") else:
logger.warning(
f"didn't receive checking_id from backend, payment may be stuck in database: {temp_id}"
)
return invoice.payment_hash return invoice.payment_hash
@ -344,23 +366,16 @@ async def perform_lnurlauth(
async def check_transaction_status( async def check_transaction_status(
wallet_id: str, payment_hash: str, conn: Optional[Connection] = None wallet_id: str, payment_hash: str, conn: Optional[Connection] = None
) -> PaymentStatus: ) -> PaymentStatus:
payment = await get_wallet_payment(wallet_id, payment_hash, conn=conn) payment: Optional[Payment] = await get_wallet_payment(
wallet_id, payment_hash, conn=conn
)
if not payment: if not payment:
return PaymentStatus(None) return PaymentStatus(None)
if payment.is_out:
status = await WALLET.get_payment_status(payment.checking_id)
else:
status = await WALLET.get_invoice_status(payment.checking_id)
if not payment.pending: if not payment.pending:
return status # note: before, we still checked the status of the payment again
if payment.is_out and status.failed: return PaymentStatus(True)
logger.info(f"deleting outgoing failed payment {payment.checking_id}: {status}")
await payment.delete() status: PaymentStatus = await payment.check_status()
elif not status.pending:
logger.info(
f"marking '{'in' if payment.is_in else 'out'}' {payment.checking_id} as not pending anymore: {status}"
)
await payment.set_pending(status.pending)
return status return status

View file

@ -402,6 +402,10 @@ async def subscribe(request: Request, wallet: Wallet):
async def api_payments_sse( async def api_payments_sse(
request: Request, wallet: WalletTypeInfo = Depends(get_key_type) request: Request, wallet: WalletTypeInfo = Depends(get_key_type)
): ):
if wallet is None or wallet.wallet is None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist."
)
return EventSourceResponse( return EventSourceResponse(
subscribe(request, wallet.wallet), ping=20, media_type="text/event-stream" subscribe(request, wallet.wallet), ping=20, media_type="text/event-stream"
) )
@ -436,7 +440,7 @@ async def api_payment(payment_hash, X_Api_Key: Optional[str] = Header(None)):
return {"paid": True, "preimage": payment.preimage} return {"paid": True, "preimage": payment.preimage}
try: try:
await payment.check_pending() await payment.check_status()
except Exception: except Exception:
if wallet and wallet.id == payment.wallet_id: if wallet and wallet.id == payment.wallet_id:
return {"paid": False, "details": payment} return {"paid": False, "details": payment}

View file

@ -130,9 +130,8 @@ async def lndhub_gettxs(
offset=offset, offset=offset,
exclude_uncheckable=True, exclude_uncheckable=True,
): ):
await payment.set_pending( await payment.check_status()
(await WALLET.get_payment_status(payment.checking_id)).pending
)
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
return [ return [

View file

@ -86,6 +86,9 @@ async def check_pending_payments():
incoming = True incoming = True
while True: while True:
logger.debug(
f"Task: checking all pending payments (incoming={incoming}, outgoing={outgoing}) of last 15 days"
)
for payment in await get_payments( for payment in await get_payments(
since=(int(time.time()) - 60 * 60 * 24 * 15), # 15 days ago since=(int(time.time()) - 60 * 60 * 24 * 15), # 15 days ago
complete=False, complete=False,
@ -94,11 +97,14 @@ async def check_pending_payments():
incoming=incoming, incoming=incoming,
exclude_uncheckable=True, exclude_uncheckable=True,
): ):
await payment.check_pending() await payment.check_status()
logger.debug("Task: pending payments check finished")
# we delete expired invoices once upon the first pending check # we delete expired invoices once upon the first pending check
if incoming: if incoming:
logger.debug("Task: deleting all expired invoices")
await delete_expired_invoices() await delete_expired_invoices()
logger.debug("Task: expired invoice deletion finished")
# after the first check we will only check outgoing, not incoming # after the first check we will only check outgoing, not incoming
# that will be handled by the global invoice listeners, hopefully # that will be handled by the global invoice listeners, hopefully
incoming = False incoming = False

View file

@ -18,13 +18,15 @@ class PaymentResponse(NamedTuple):
# when ok is None it means we don't know if this succeeded # when ok is None it means we don't know if this succeeded
ok: Optional[bool] = None ok: Optional[bool] = None
checking_id: Optional[str] = None # payment_hash, rcp_id checking_id: Optional[str] = None # payment_hash, rcp_id
fee_msat: int = 0 fee_msat: Optional[int] = None
preimage: Optional[str] = None preimage: Optional[str] = None
error_message: Optional[str] = None error_message: Optional[str] = None
class PaymentStatus(NamedTuple): class PaymentStatus(NamedTuple):
paid: Optional[bool] = None paid: Optional[bool] = None
fee_msat: Optional[int] = None
preimage: Optional[str] = None
@property @property
def pending(self) -> bool: def pending(self) -> bool:

View file

@ -81,31 +81,41 @@ class ClicheWallet(Wallet):
data["result"]["invoice"], data["result"]["invoice"],
) )
else: else:
return InvoiceResponse( return InvoiceResponse(False, None, None, "Could not get payment hash")
False, checking_id, payment_request, "Could not get payment hash"
)
return InvoiceResponse(True, checking_id, payment_request, error_message) return InvoiceResponse(True, checking_id, payment_request, error_message)
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
ws = create_connection(self.endpoint) ws = create_connection(self.endpoint)
ws.send(f"pay-invoice --invoice {bolt11}") ws.send(f"pay-invoice --invoice {bolt11}")
for _ in range(2):
r = ws.recv() r = ws.recv()
data = json.loads(r) data = json.loads(r)
checking_id = None checking_id, fee_msat, preimage, error_message, payment_ok = (
error_message = None None,
None,
None,
None,
None,
)
if data.get("error") is not None and data["error"].get("message"): if data.get("error") is not None:
logger.error(data["error"]["message"]) error_message = data["error"].get("message")
error_message = data["error"]["message"] return PaymentResponse(False, None, None, None, error_message)
return PaymentResponse(False, None, 0, error_message)
if data.get("result") is not None and data["result"].get("payment_hash"): if data.get("method") == "payment_succeeded":
checking_id = data["result"]["payment_hash"] payment_ok = True
else: checking_id = data["params"]["payment_hash"]
return PaymentResponse(False, checking_id, 0, "Could not get payment hash") fee_msat = data["params"]["fee_msatoshi"]
preimage = data["params"]["preimage"]
continue
return PaymentResponse(True, checking_id, 0, error_message) if data.get("result") is None:
return PaymentResponse(None)
return PaymentResponse(
payment_ok, checking_id, fee_msat, preimage, error_message
)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus: async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
ws = create_connection(self.endpoint) ws = create_connection(self.endpoint)
@ -129,22 +139,30 @@ class ClicheWallet(Wallet):
if data.get("error") is not None and data["error"].get("message"): if data.get("error") is not None and data["error"].get("message"):
logger.error(data["error"]["message"]) logger.error(data["error"]["message"])
return PaymentStatus(None) return PaymentStatus(None)
payment = data["result"]
statuses = {"pending": None, "complete": True, "failed": False} statuses = {"pending": None, "complete": True, "failed": False}
return PaymentStatus(statuses[data["result"]["status"]]) return PaymentStatus(
statuses[payment["status"]],
payment.get("fee_msatoshi"),
payment.get("preimage"),
)
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
while True:
try: try:
ws = await create_connection(self.endpoint) ws = await create_connection(self.endpoint)
while True: while True:
r = await ws.recv() r = await ws.recv()
data = json.loads(r) data = json.loads(r)
print(data)
try: try:
if data["result"]["status"]: if data["result"]["status"]:
yield data["result"]["payment_hash"] yield data["result"]["payment_hash"]
except: except:
continue continue
except: except Exception as exc:
pass logger.error(
logger.error("lost connection to cliche's websocket, retrying in 5 seconds") f"lost connection to cliche's invoices stream: '{exc}', retrying in 5 seconds"
)
await asyncio.sleep(5) await asyncio.sleep(5)
continue

View file

@ -110,29 +110,38 @@ class CoreLightningWallet(Wallet):
return InvoiceResponse(True, r["payment_hash"], r["bolt11"], "") return InvoiceResponse(True, r["payment_hash"], r["bolt11"], "")
except RpcError as exc: except RpcError as exc:
error_message = f"lightningd '{exc.method}' failed with '{exc.error}'." error_message = f"CLN method '{exc.method}' failed with '{exc.error.get('message') or exc.error}'."
logger.error("RPC error:", error_message)
return InvoiceResponse(False, None, None, error_message) return InvoiceResponse(False, None, None, error_message)
except Exception as e: except Exception as e:
logger.error("error:", e)
return InvoiceResponse(False, None, None, str(e)) return InvoiceResponse(False, None, None, str(e))
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse: async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
invoice = lnbits_bolt11.decode(bolt11) 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 fee_limit_percent = fee_limit_msat / invoice.amount_msat * 100
payload = { payload = {
"bolt11": bolt11, "bolt11": bolt11,
"maxfeepercent": "{:.11}".format(fee_limit_percent), "maxfeepercent": "{:.11}".format(fee_limit_percent),
"exemptfee": 0, # so fee_limit_percent is applied even on payments with fee under 5000 millisatoshi (which is default value of exemptfee) "exemptfee": 0, # so fee_limit_percent is applied even on payments with fee < 5000 millisatoshi (which is default value of exemptfee)
} }
try: try:
wrapped = async_wrap(_pay_invoice) wrapped = async_wrap(_pay_invoice)
r = await wrapped(self.ln, payload) 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: except Exception as exc:
return PaymentResponse(False, None, 0, None, str(exc)) return PaymentResponse(False, None, None, None, str(exc))
fee_msat = r["msatoshi_sent"] - r["msatoshi"] fee_msat = -int(r["msatoshi_sent"] - r["msatoshi"])
return PaymentResponse( return PaymentResponse(
True, r["payment_hash"], fee_msat, r["payment_preimage"], None True, r["payment_hash"], fee_msat, r["payment_preimage"], None
) )
@ -144,9 +153,16 @@ class CoreLightningWallet(Wallet):
return PaymentStatus(None) return PaymentStatus(None)
if not r["invoices"]: if not r["invoices"]:
return PaymentStatus(None) return PaymentStatus(None)
if r["invoices"][0]["payment_hash"] == checking_id:
return PaymentStatus(r["invoices"][0]["status"] == "paid") invoice_resp = r["invoices"][-1]
raise KeyError("supplied an invalid checking_id")
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: async def get_payment_status(self, checking_id: str) -> PaymentStatus:
try: try:
@ -155,14 +171,21 @@ class CoreLightningWallet(Wallet):
return PaymentStatus(None) return PaymentStatus(None)
if not r["pays"]: if not r["pays"]:
return PaymentStatus(None) return PaymentStatus(None)
if r["pays"][0]["payment_hash"] == checking_id: payment_resp = r["pays"][-1]
status = r["pays"][0]["status"]
if payment_resp["payment_hash"] == checking_id:
status = payment_resp["status"]
if status == "complete": if status == "complete":
return PaymentStatus(True) fee_msat = -int(
payment_resp["amount_sent_msat"] - payment_resp["amount_msat"]
)
return PaymentStatus(True, fee_msat, payment_resp["preimage"])
elif status == "failed": elif status == "failed":
return PaymentStatus(False) return PaymentStatus(False)
return PaymentStatus(None) return PaymentStatus(None)
raise KeyError("supplied an invalid checking_id") logger.warning(f"supplied an invalid checking_id: {checking_id}")
return PaymentStatus(None)
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
while True: while True:

View file

@ -50,7 +50,7 @@ class EclairWallet(Wallet):
async def status(self) -> StatusResponse: async def status(self) -> StatusResponse:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
r = await client.post( r = await client.post(
f"{self.url}/usablebalances", headers=self.auth, timeout=40 f"{self.url}/globalbalance", headers=self.auth, timeout=5
) )
try: try:
data = r.json() data = r.json()
@ -60,9 +60,11 @@ class EclairWallet(Wallet):
) )
if r.is_error: if r.is_error:
return StatusResponse(data["error"], 0) return StatusResponse(data.get("error") or "undefined error", 0)
if len(data) == 0:
return StatusResponse("no data", 0)
return StatusResponse(None, data[0]["canSend"] * 1000) return StatusResponse(None, int(data.get("total") * 100_000_000_000))
async def create_invoice( async def create_invoice(
self, self,
@ -114,13 +116,18 @@ class EclairWallet(Wallet):
except: except:
error_message = r.text error_message = r.text
pass pass
return PaymentResponse(False, None, 0, None, error_message) return PaymentResponse(False, None, None, None, error_message)
data = r.json() data = r.json()
if data["type"] == "payment-failed":
return PaymentResponse(False, None, None, None, "payment failed")
checking_id = data["paymentHash"] checking_id = data["paymentHash"]
preimage = data["paymentPreimage"] preimage = data["paymentPreimage"]
# We do all this again to get the fee:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
r = await client.post( r = await client.post(
f"{self.url}/getsentinfo", f"{self.url}/getsentinfo",
@ -136,15 +143,22 @@ class EclairWallet(Wallet):
except: except:
error_message = r.text error_message = r.text
pass pass
return PaymentResponse(None, checking_id, None, preimage, error_message)
statuses = {
"sent": True,
"failed": False,
"pending": None,
}
data = r.json()[-1]
if data["status"]["type"] == "sent":
fee_msat = -data["status"]["feesPaid"]
preimage = data["status"]["paymentPreimage"]
return PaymentResponse( return PaymentResponse(
True, checking_id, 0, preimage, error_message statuses[data["status"]["type"]], checking_id, fee_msat, preimage, None
) ## ?? is this ok ?? )
data = r.json()
fees = [i["status"] for i in data]
fee_msat = sum([i["feesPaid"] for i in fees])
return PaymentResponse(True, checking_id, fee_msat, preimage, None)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus: async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
@ -155,34 +169,47 @@ class EclairWallet(Wallet):
) )
data = r.json() data = r.json()
if r.is_error or "error" in data: if r.is_error or "error" in data or data.get("status") is None:
return PaymentStatus(None) return PaymentStatus(None)
if data["status"]["type"] != "received": statuses = {
return PaymentStatus(False) "received": True,
"expired": False,
return PaymentStatus(True) "pending": None,
}
return PaymentStatus(statuses.get(data["status"]["type"]))
async def get_payment_status(self, checking_id: str) -> PaymentStatus: async def get_payment_status(self, checking_id: str) -> PaymentStatus:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
r = await client.post( r = await client.post(
url=f"{self.url}/getsentinfo", f"{self.url}/getsentinfo",
headers=self.auth, headers=self.auth,
data={"paymentHash": checking_id}, data={"paymentHash": checking_id},
timeout=40,
) )
data = r.json()[0]
if r.is_error: if r.is_error:
return PaymentStatus(None) return PaymentStatus(None)
if data["status"]["type"] != "sent": data = r.json()[-1]
return PaymentStatus(False)
return PaymentStatus(True) if r.is_error or "error" in data or data.get("status") is None:
return PaymentStatus(None)
fee_msat, preimage = None, None
if data["status"]["type"] == "sent":
fee_msat = -data["status"]["feesPaid"]
preimage = data["status"]["paymentPreimage"]
statuses = {
"sent": True,
"failed": False,
"pending": None,
}
return PaymentStatus(statuses.get(data["status"]["type"]), fee_msat, preimage)
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
while True:
try: try:
async with connect( async with connect(
self.ws_url, self.ws_url,
@ -195,14 +222,8 @@ class EclairWallet(Wallet):
if message and message["type"] == "payment-received": if message and message["type"] == "payment-received":
yield message["paymentHash"] yield message["paymentHash"]
except ( except Exception as exc:
OSError, logger.error(
ConnectionClosedOK, f"lost connection to eclair invoices stream: '{exc}', retrying in 5 seconds"
ConnectionClosedError, )
ConnectionClosed,
) as ose:
logger.error("OSE", ose)
pass
logger.error("lost connection to eclair's websocket, retrying in 5 seconds")
await asyncio.sleep(5) await asyncio.sleep(5)

View file

@ -62,9 +62,9 @@ class LNbitsWallet(Wallet):
data: Dict = {"out": False, "amount": amount} data: Dict = {"out": False, "amount": amount}
if description_hash: if description_hash:
data["description_hash"] = description_hash.hex() data["description_hash"] = description_hash.hex()
elif unhashed_description: if unhashed_description:
data["description_hash"] = hashlib.sha256(unhashed_description).hexdigest() data["unhashed_description"] = unhashed_description.hex()
else:
data["memo"] = memo or "" data["memo"] = memo or ""
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
@ -94,15 +94,25 @@ class LNbitsWallet(Wallet):
json={"out": True, "bolt11": bolt11}, json={"out": True, "bolt11": bolt11},
timeout=None, timeout=None,
) )
ok, checking_id, fee_msat, error_message = not r.is_error, None, 0, None ok, checking_id, fee_msat, preimage, error_message = (
not r.is_error,
None,
None,
None,
None,
)
if r.is_error: if r.is_error:
error_message = r.json()["detail"] error_message = r.json()["detail"]
return PaymentResponse(None, None, None, None, error_message)
else: else:
data = r.json() data = r.json()
checking_id = data["checking_id"] checking_id = data["payment_hash"]
return PaymentResponse(ok, checking_id, fee_msat, error_message) # we do this to get the fee and preimage
payment: PaymentStatus = await self.get_payment_status(checking_id)
return PaymentResponse(ok, checking_id, payment.fee_msat, payment.preimage)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus: async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
try: try:
@ -125,8 +135,11 @@ class LNbitsWallet(Wallet):
if r.is_error: if r.is_error:
return PaymentStatus(None) return PaymentStatus(None)
data = r.json()
if "paid" not in data and "details" not in data:
return PaymentStatus(None)
return PaymentStatus(r.json()["paid"]) return PaymentStatus(data["paid"], data["details"]["fee"], data["preimage"])
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
url = f"{self.endpoint}/api/v1/payments/sse" url = f"{self.endpoint}/api/v1/payments/sse"

View file

@ -65,14 +65,32 @@ def get_ssl_context(cert_path: str):
return context return context
def parse_checking_id(checking_id: str) -> bytes: def b64_to_bytes(checking_id: str) -> bytes:
return base64.b64decode(checking_id.replace("_", "/")) return base64.b64decode(checking_id.replace("_", "/"))
def stringify_checking_id(r_hash: bytes) -> str: def bytes_to_b64(r_hash: bytes) -> str:
return base64.b64encode(r_hash).decode("utf-8").replace("/", "_") return base64.b64encode(r_hash).decode("utf-8").replace("/", "_")
def hex_to_b64(hex_str: str) -> str:
try:
return base64.b64encode(bytes.fromhex(hex_str)).decode()
except ValueError:
return ""
def hex_to_bytes(hex_str: str) -> bytes:
try:
return bytes.fromhex(hex_str)
except:
return b""
def bytes_to_hex(b: bytes) -> str:
return b.hex()
# Due to updated ECDSA generated tls.cert we need to let gprc know that # Due to updated ECDSA generated tls.cert we need to let gprc know that
# we need to use that cipher suite otherwise there will be a handhsake # we need to use that cipher suite otherwise there will be a handhsake
# error when we communicate with the lnd rpc server. # error when we communicate with the lnd rpc server.
@ -153,7 +171,7 @@ class LndWallet(Wallet):
error_message = str(exc) error_message = str(exc)
return InvoiceResponse(False, None, None, error_message) return InvoiceResponse(False, None, None, error_message)
checking_id = stringify_checking_id(resp.r_hash) checking_id = bytes_to_hex(resp.r_hash)
payment_request = str(resp.payment_request) payment_request = str(resp.payment_request)
return InvoiceResponse(True, checking_id, payment_request, None) return InvoiceResponse(True, checking_id, payment_request, None)
@ -168,9 +186,9 @@ class LndWallet(Wallet):
try: try:
resp = await self.routerpc.SendPaymentV2(req).read() resp = await self.routerpc.SendPaymentV2(req).read()
except RpcError as exc: except RpcError as exc:
return PaymentResponse(False, "", 0, None, exc._details) return PaymentResponse(False, None, None, None, exc._details)
except Exception as exc: except Exception as exc:
return PaymentResponse(False, "", 0, None, str(exc)) return PaymentResponse(False, None, None, None, str(exc))
# PaymentStatus from https://github.com/lightningnetwork/lnd/blob/master/channeldb/payments.go#L178 # PaymentStatus from https://github.com/lightningnetwork/lnd/blob/master/channeldb/payments.go#L178
statuses = { statuses = {
@ -180,29 +198,31 @@ class LndWallet(Wallet):
3: False, # FAILED 3: False, # FAILED
} }
if resp.status in [0, 1, 3]: fee_msat = None
fee_msat = 0 preimage = None
preimage = ""
checking_id = ""
elif resp.status == 2: # SUCCEEDED
fee_msat = resp.htlcs[-1].route.total_fees_msat
preimage = resp.payment_preimage
checking_id = resp.payment_hash checking_id = resp.payment_hash
if resp.status: # SUCCEEDED
fee_msat = -resp.htlcs[-1].route.total_fees_msat
preimage = bytes_to_hex(resp.payment_preimage)
return PaymentResponse( return PaymentResponse(
statuses[resp.status], checking_id, fee_msat, preimage, None statuses[resp.status], checking_id, fee_msat, preimage, None
) )
async def get_invoice_status(self, checking_id: str) -> PaymentStatus: async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
try: try:
r_hash = parse_checking_id(checking_id) r_hash = hex_to_bytes(checking_id)
if len(r_hash) != 32: if len(r_hash) != 32:
raise binascii.Error raise binascii.Error
except binascii.Error: except binascii.Error:
# this may happen if we switch between backend wallets # this may happen if we switch between backend wallets
# that use different checking_id formats # that use different checking_id formats
return PaymentStatus(None) return PaymentStatus(None)
try:
resp = await self.rpc.LookupInvoice(ln.PaymentHash(r_hash=r_hash)) resp = await self.rpc.LookupInvoice(ln.PaymentHash(r_hash=r_hash))
except RpcError as exc:
return PaymentStatus(None)
if resp.settled: if resp.settled:
return PaymentStatus(True) return PaymentStatus(True)
@ -213,7 +233,7 @@ class LndWallet(Wallet):
This routine checks the payment status using routerpc.TrackPaymentV2. This routine checks the payment status using routerpc.TrackPaymentV2.
""" """
try: try:
r_hash = parse_checking_id(checking_id) r_hash = hex_to_bytes(checking_id)
if len(r_hash) != 32: if len(r_hash) != 32:
raise binascii.Error raise binascii.Error
except binascii.Error: except binascii.Error:
@ -221,11 +241,6 @@ class LndWallet(Wallet):
# that use different checking_id formats # that use different checking_id formats
return PaymentStatus(None) return PaymentStatus(None)
# for some reason our checking_ids are in base64 but the payment hashes
# returned here are in hex, lnd is weird
checking_id = checking_id.replace("_", "/")
checking_id = base64.b64decode(checking_id).hex()
resp = self.routerpc.TrackPaymentV2( resp = self.routerpc.TrackPaymentV2(
router.TrackPaymentRequest(payment_hash=r_hash) router.TrackPaymentRequest(payment_hash=r_hash)
) )
@ -240,6 +255,12 @@ class LndWallet(Wallet):
try: try:
async for payment in resp: async for payment in resp:
if statuses[payment.htlcs[-1].status]:
return PaymentStatus(
True,
-payment.htlcs[-1].route.total_fees_msat,
bytes_to_hex(payment.htlcs[-1].preimage),
)
return PaymentStatus(statuses[payment.htlcs[-1].status]) return PaymentStatus(statuses[payment.htlcs[-1].status])
except: # most likely the payment wasn't found except: # most likely the payment wasn't found
return PaymentStatus(None) return PaymentStatus(None)
@ -248,13 +269,13 @@ class LndWallet(Wallet):
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
while True: while True:
request = ln.InvoiceSubscription()
try: try:
request = ln.InvoiceSubscription()
async for i in self.rpc.SubscribeInvoices(request): async for i in self.rpc.SubscribeInvoices(request):
if not i.settled: if not i.settled:
continue continue
checking_id = stringify_checking_id(i.r_hash) checking_id = bytes_to_hex(i.r_hash)
yield checking_id yield checking_id
except Exception as exc: except Exception as exc:
logger.error( logger.error(

View file

@ -123,18 +123,15 @@ class LndRestWallet(Wallet):
if r.is_error or r.json().get("payment_error"): if r.is_error or r.json().get("payment_error"):
error_message = r.json().get("payment_error") or r.text error_message = r.json().get("payment_error") or r.text
return PaymentResponse(False, None, 0, None, error_message) return PaymentResponse(False, None, None, None, error_message)
data = r.json() data = r.json()
payment_hash = data["payment_hash"] checking_id = base64.b64decode(data["payment_hash"]).hex()
checking_id = payment_hash
fee_msat = int(data["payment_route"]["total_fees_msat"]) fee_msat = int(data["payment_route"]["total_fees_msat"])
preimage = base64.b64decode(data["payment_preimage"]).hex() preimage = base64.b64decode(data["payment_preimage"]).hex()
return PaymentResponse(True, checking_id, fee_msat, preimage, None) return PaymentResponse(True, checking_id, fee_msat, preimage, None)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus: async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
checking_id = checking_id.replace("_", "/")
async with httpx.AsyncClient(verify=self.cert) as client: async with httpx.AsyncClient(verify=self.cert) as client:
r = await client.get( r = await client.get(
url=f"{self.endpoint}/v1/invoice/{checking_id}", headers=self.auth url=f"{self.endpoint}/v1/invoice/{checking_id}", headers=self.auth
@ -151,10 +148,18 @@ class LndRestWallet(Wallet):
""" """
This routine checks the payment status using routerpc.TrackPaymentV2. This routine checks the payment status using routerpc.TrackPaymentV2.
""" """
# convert checking_id from hex to base64 and some LND magic
try:
checking_id = base64.urlsafe_b64encode(bytes.fromhex(checking_id)).decode(
"ascii"
)
except ValueError:
return PaymentStatus(None)
url = f"{self.endpoint}/v2/router/track/{checking_id}" url = f"{self.endpoint}/v2/router/track/{checking_id}"
# check payment.status: # check payment.status:
# https://api.lightning.community/rest/index.html?python#peersynctype # https://api.lightning.community/?python=#paymentpaymentstatus
statuses = { statuses = {
"UNKNOWN": None, "UNKNOWN": None,
"IN_FLIGHT": None, "IN_FLIGHT": None,
@ -178,7 +183,11 @@ class LndRestWallet(Wallet):
return PaymentStatus(None) return PaymentStatus(None)
payment = line.get("result") payment = line.get("result")
if payment is not None and payment.get("status"): if payment is not None and payment.get("status"):
return PaymentStatus(statuses[payment["status"]]) return PaymentStatus(
paid=statuses[payment["status"]],
fee_msat=payment.get("fee_msat"),
preimage=payment.get("payment_preimage"),
)
else: else:
return PaymentStatus(None) return PaymentStatus(None)
except: except:
@ -187,10 +196,9 @@ class LndRestWallet(Wallet):
return PaymentStatus(None) return PaymentStatus(None)
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
url = self.endpoint + "/v1/invoices/subscribe"
while True: while True:
try: try:
url = self.endpoint + "/v1/invoices/subscribe"
async with httpx.AsyncClient( async with httpx.AsyncClient(
timeout=None, headers=self.auth, verify=self.cert timeout=None, headers=self.auth, verify=self.cert
) as client: ) as client:

View file

@ -100,7 +100,7 @@ class LNPayWallet(Wallet):
) )
if r.is_error: if r.is_error:
return PaymentResponse(False, None, 0, None, data["message"]) return PaymentResponse(False, None, None, None, data["message"])
checking_id = data["lnTx"]["id"] checking_id = data["lnTx"]["id"]
fee_msat = 0 fee_msat = 0
@ -113,15 +113,18 @@ class LNPayWallet(Wallet):
async def get_payment_status(self, checking_id: str) -> PaymentStatus: async def get_payment_status(self, checking_id: str) -> PaymentStatus:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
r = await client.get( r = await client.get(
url=f"{self.endpoint}/lntx/{checking_id}?fields=settled", url=f"{self.endpoint}/lntx/{checking_id}",
headers=self.auth, headers=self.auth,
) )
if r.is_error: if r.is_error:
return PaymentStatus(None) return PaymentStatus(None)
data = r.json()
preimage = data["payment_preimage"]
fee_msat = data["fee_msat"]
statuses = {0: None, 1: True, -1: False} statuses = {0: None, 1: True, -1: False}
return PaymentStatus(statuses[r.json()["settled"]]) return PaymentStatus(statuses[data["settled"]], fee_msat, preimage)
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
self.queue: asyncio.Queue = asyncio.Queue(0) self.queue: asyncio.Queue = asyncio.Queue(0)

View file

@ -97,10 +97,11 @@ class LntxbotWallet(Wallet):
except: except:
error_message = r.text error_message = r.text
pass pass
return PaymentResponse(False, None, None, None, error_message)
return PaymentResponse(False, None, 0, None, error_message)
data = r.json() data = r.json()
if data.get("type") != "paid_invoice":
return PaymentResponse(None)
checking_id = data["payment_hash"] checking_id = data["payment_hash"]
fee_msat = -data["fee_msat"] fee_msat = -data["fee_msat"]
preimage = data["payment_preimage"] preimage = data["payment_preimage"]

View file

@ -47,7 +47,7 @@ class OpenNodeWallet(Wallet):
if r.is_error: if r.is_error:
return StatusResponse(data["message"], 0) return StatusResponse(data["message"], 0)
return StatusResponse(None, data["balance"]["BTC"] / 100_000_000_000) return StatusResponse(None, data["balance"]["BTC"] * 1000)
async def create_invoice( async def create_invoice(
self, self,
@ -92,11 +92,15 @@ class OpenNodeWallet(Wallet):
if r.is_error: if r.is_error:
error_message = r.json()["message"] error_message = r.json()["message"]
return PaymentResponse(False, None, 0, None, error_message) return PaymentResponse(False, None, None, None, error_message)
data = r.json()["data"] data = r.json()["data"]
checking_id = data["id"] checking_id = data["id"]
fee_msat = data["fee"] * 1000 fee_msat = -data["fee"] * 1000
if data["status"] != "paid":
return PaymentResponse(None, checking_id, fee_msat, None, "payment failed")
return PaymentResponse(True, checking_id, fee_msat, None, None) return PaymentResponse(True, checking_id, fee_msat, None, None)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus: async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
@ -106,9 +110,9 @@ class OpenNodeWallet(Wallet):
) )
if r.is_error: if r.is_error:
return PaymentStatus(None) return PaymentStatus(None)
data = r.json()["data"]
statuses = {"processing": None, "paid": True, "unpaid": False} statuses = {"processing": None, "paid": True, "unpaid": None}
return PaymentStatus(statuses[r.json()["data"]["status"]]) return PaymentStatus(statuses[data.get("status")])
async def get_payment_status(self, checking_id: str) -> PaymentStatus: async def get_payment_status(self, checking_id: str) -> PaymentStatus:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
@ -119,14 +123,16 @@ class OpenNodeWallet(Wallet):
if r.is_error: if r.is_error:
return PaymentStatus(None) return PaymentStatus(None)
data = r.json()["data"]
statuses = { statuses = {
"initial": None, "initial": None,
"pending": None, "pending": None,
"confirmed": True, "confirmed": True,
"error": False, "error": None,
"failed": False, "failed": False,
} }
return PaymentStatus(statuses[r.json()["data"]["status"]]) fee_msat = -data.get("fee") * 1000
return PaymentStatus(statuses[data.get("status")], fee_msat)
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]: async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
self.queue: asyncio.Queue = asyncio.Queue(0) self.queue: asyncio.Queue = asyncio.Queue(0)

View file

@ -137,7 +137,7 @@ class SparkWallet(Wallet):
pays = listpays["pays"] pays = listpays["pays"]
if len(pays) == 0: if len(pays) == 0:
return PaymentResponse(False, None, 0, None, str(exc)) return PaymentResponse(False, None, None, None, str(exc))
pay = pays[0] pay = pays[0]
payment_hash = pay["payment_hash"] payment_hash = pay["payment_hash"]
@ -148,11 +148,9 @@ class SparkWallet(Wallet):
) )
if pay["status"] == "failed": if pay["status"] == "failed":
return PaymentResponse(False, None, 0, None, str(exc)) return PaymentResponse(False, None, None, None, str(exc))
elif pay["status"] == "pending": elif pay["status"] == "pending":
return PaymentResponse( return PaymentResponse(None, payment_hash, None, None, None)
None, payment_hash, fee_limit_msat, None, None
)
elif pay["status"] == "complete": elif pay["status"] == "complete":
r = pay r = pay
r["payment_preimage"] = pay["preimage"] r["payment_preimage"] = pay["preimage"]
@ -163,7 +161,7 @@ class SparkWallet(Wallet):
# this is good # this is good
pass pass
fee_msat = r["msatoshi_sent"] - r["msatoshi"] fee_msat = -int(r["msatoshi_sent"] - r["msatoshi"])
preimage = r["payment_preimage"] preimage = r["payment_preimage"]
return PaymentResponse(True, r["payment_hash"], fee_msat, preimage, None) return PaymentResponse(True, r["payment_hash"], fee_msat, preimage, None)
@ -201,7 +199,10 @@ class SparkWallet(Wallet):
if r["pays"][0]["payment_hash"] == checking_id: if r["pays"][0]["payment_hash"] == checking_id:
status = r["pays"][0]["status"] status = r["pays"][0]["status"]
if status == "complete": if status == "complete":
return PaymentStatus(True) fee_msat = -int(
r["pays"][0]["amount_sent_msat"] - r["pays"][0]["amount_msat"]
)
return PaymentStatus(True, fee_msat, r["pays"][0]["preimage"])
elif status == "failed": elif status == "failed":
return PaymentStatus(False) return PaymentStatus(False)
return PaymentStatus(None) return PaymentStatus(None)