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}'",
RuntimeWarning,
)
logger.info("Retrying connection to backend in 5 seconds...")
await asyncio.sleep(5)
except:
pass
logger.info("Retrying connection to backend in 5 seconds...")
await asyncio.sleep(5)
signal.signal(signal.SIGINT, original_sigint_handler)
logger.info(
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,
conn: Optional[Connection] = None,
) -> 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(
"""
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:
await (conn or db).execute(
"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.settings import WALLET
from lnbits.wallets.base import PaymentStatus
class Wallet(BaseModel):
@ -128,8 +129,16 @@ class Payment(BaseModel):
@property
def is_uncheckable(self) -> bool:
return self.checking_id.startswith("temp_") or self.checking_id.startswith(
"internal_"
return self.checking_id.startswith("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:
@ -137,9 +146,9 @@ class Payment(BaseModel):
await update_payment_status(self.checking_id, pending)
async def check_pending(self) -> None:
async def check_status(self) -> PaymentStatus:
if self.is_uncheckable:
return
return PaymentStatus(None)
logger.debug(
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}")
if self.is_out and status.failed:
logger.info(
logger.warning(
f"Deleting outgoing failed payment {self.checking_id}: {status}"
)
await self.delete()
@ -161,7 +170,8 @@ class Payment(BaseModel):
logger.info(
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:
from .crud import delete_payment

View file

@ -31,8 +31,10 @@ from .crud import (
delete_payment,
get_wallet,
get_wallet_payment,
update_payment_details,
update_payment_status,
)
from .models import Payment
try:
from typing import TypedDict # type: ignore
@ -101,11 +103,20 @@ async def pay_invoice(
description: str = "",
conn: Optional[Connection] = None,
) -> 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)
fee_reserve_msat = fee_reserve(invoice.amount_msat)
async with (db.reuse_conn(conn) if conn else db.connect()) as conn:
temp_id = f"temp_{urlsafe_short_hash()}"
internal_id = f"internal_{urlsafe_short_hash()}"
temp_id = invoice.payment_hash
internal_id = f"internal_{invoice.payment_hash}"
if invoice.amount_msat == 0:
raise ValueError("Amountless invoices not supported.")
@ -185,30 +196,41 @@ async def pay_invoice(
payment: PaymentResponse = await WALLET.pay_invoice(
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}")
if payment.ok and payment.checking_id:
logger.debug(f"creating final payment {payment.checking_id}")
if payment.checking_id and payment.ok != False:
# payment.ok can be True (paid) or None (pending)!
logger.debug(f"updating payment {temp_id}")
async with db.connect() as conn:
await create_payment(
checking_id=payment.checking_id,
await update_payment_details(
checking_id=temp_id,
pending=payment.ok != True,
fee=payment.fee_msat,
preimage=payment.preimage,
pending=payment.ok == None,
new_checking_id=payment.checking_id,
conn=conn,
**payment_kwargs,
)
logger.debug(f"deleting temporary payment {temp_id}")
await delete_payment(temp_id, conn=conn)
else:
logger.debug(f"backend payment failed")
logger.debug(f"payment successful {payment.checking_id}")
elif payment.checking_id is None and payment.ok == False:
# payment failed
logger.warning(f"backend sent payment failure")
async with db.connect() as conn:
logger.debug(f"deleting temporary payment {temp_id}")
await delete_payment(temp_id, conn=conn)
raise PaymentFailure(
payment.error_message
or "Payment failed, but backend didn't give us an error message."
f"payment failed: {payment.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
@ -344,23 +366,16 @@ async def perform_lnurlauth(
async def check_transaction_status(
wallet_id: str, payment_hash: str, conn: Optional[Connection] = None
) -> 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:
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:
return status
if payment.is_out and status.failed:
logger.info(f"deleting outgoing failed payment {payment.checking_id}: {status}")
await payment.delete()
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)
# note: before, we still checked the status of the payment again
return PaymentStatus(True)
status: PaymentStatus = await payment.check_status()
return status

View file

@ -402,6 +402,10 @@ async def subscribe(request: Request, wallet: Wallet):
async def api_payments_sse(
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(
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}
try:
await payment.check_pending()
await payment.check_status()
except Exception:
if wallet and wallet.id == payment.wallet_id:
return {"paid": False, "details": payment}

View file

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

View file

@ -86,6 +86,9 @@ async def check_pending_payments():
incoming = 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(
since=(int(time.time()) - 60 * 60 * 24 * 15), # 15 days ago
complete=False,
@ -94,11 +97,14 @@ async def check_pending_payments():
incoming=incoming,
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
if incoming:
logger.debug("Task: deleting all expired invoices")
await delete_expired_invoices()
logger.debug("Task: expired invoice deletion finished")
# after the first check we will only check outgoing, not incoming
# that will be handled by the global invoice listeners, hopefully
incoming = False

View file

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

View file

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

View file

@ -110,29 +110,38 @@ class CoreLightningWallet(Wallet):
return InvoiceResponse(True, r["payment_hash"], r["bolt11"], "")
except RpcError as exc:
error_message = f"lightningd '{exc.method}' failed with '{exc.error}'."
logger.error("RPC error:", error_message)
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:
logger.error("error:", 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 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:
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, 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(
True, r["payment_hash"], fee_msat, r["payment_preimage"], None
)
@ -144,9 +153,16 @@ class CoreLightningWallet(Wallet):
return PaymentStatus(None)
if not r["invoices"]:
return PaymentStatus(None)
if r["invoices"][0]["payment_hash"] == checking_id:
return PaymentStatus(r["invoices"][0]["status"] == "paid")
raise KeyError("supplied an invalid checking_id")
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:
@ -155,14 +171,21 @@ class CoreLightningWallet(Wallet):
return PaymentStatus(None)
if not r["pays"]:
return PaymentStatus(None)
if r["pays"][0]["payment_hash"] == checking_id:
status = r["pays"][0]["status"]
payment_resp = r["pays"][-1]
if payment_resp["payment_hash"] == checking_id:
status = payment_resp["status"]
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":
return PaymentStatus(False)
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]:
while True:

View file

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

View file

@ -62,9 +62,9 @@ class LNbitsWallet(Wallet):
data: Dict = {"out": False, "amount": amount}
if description_hash:
data["description_hash"] = description_hash.hex()
elif unhashed_description:
data["description_hash"] = hashlib.sha256(unhashed_description).hexdigest()
else:
if unhashed_description:
data["unhashed_description"] = unhashed_description.hex()
data["memo"] = memo or ""
async with httpx.AsyncClient() as client:
@ -94,15 +94,25 @@ class LNbitsWallet(Wallet):
json={"out": True, "bolt11": bolt11},
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:
error_message = r.json()["detail"]
return PaymentResponse(None, None, None, None, error_message)
else:
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:
try:
@ -125,8 +135,11 @@ class LNbitsWallet(Wallet):
if r.is_error:
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]:
url = f"{self.endpoint}/api/v1/payments/sse"

View file

@ -65,14 +65,32 @@ def get_ssl_context(cert_path: str):
return context
def parse_checking_id(checking_id: str) -> bytes:
def b64_to_bytes(checking_id: str) -> bytes:
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("/", "_")
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
# we need to use that cipher suite otherwise there will be a handhsake
# error when we communicate with the lnd rpc server.
@ -153,7 +171,7 @@ class LndWallet(Wallet):
error_message = str(exc)
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)
return InvoiceResponse(True, checking_id, payment_request, None)
@ -168,9 +186,9 @@ class LndWallet(Wallet):
try:
resp = await self.routerpc.SendPaymentV2(req).read()
except RpcError as exc:
return PaymentResponse(False, "", 0, None, exc._details)
return PaymentResponse(False, None, None, None, exc._details)
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
statuses = {
@ -180,29 +198,31 @@ class LndWallet(Wallet):
3: False, # FAILED
}
if resp.status in [0, 1, 3]:
fee_msat = 0
preimage = ""
checking_id = ""
elif resp.status == 2: # SUCCEEDED
fee_msat = resp.htlcs[-1].route.total_fees_msat
preimage = resp.payment_preimage
fee_msat = None
preimage = None
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(
statuses[resp.status], checking_id, fee_msat, preimage, None
)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
try:
r_hash = parse_checking_id(checking_id)
r_hash = hex_to_bytes(checking_id)
if len(r_hash) != 32:
raise binascii.Error
except binascii.Error:
# this may happen if we switch between backend wallets
# that use different checking_id formats
return PaymentStatus(None)
try:
resp = await self.rpc.LookupInvoice(ln.PaymentHash(r_hash=r_hash))
except RpcError as exc:
return PaymentStatus(None)
if resp.settled:
return PaymentStatus(True)
@ -213,7 +233,7 @@ class LndWallet(Wallet):
This routine checks the payment status using routerpc.TrackPaymentV2.
"""
try:
r_hash = parse_checking_id(checking_id)
r_hash = hex_to_bytes(checking_id)
if len(r_hash) != 32:
raise binascii.Error
except binascii.Error:
@ -221,11 +241,6 @@ class LndWallet(Wallet):
# that use different checking_id formats
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(
router.TrackPaymentRequest(payment_hash=r_hash)
)
@ -240,6 +255,12 @@ class LndWallet(Wallet):
try:
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])
except: # most likely the payment wasn't found
return PaymentStatus(None)
@ -248,13 +269,13 @@ class LndWallet(Wallet):
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
while True:
request = ln.InvoiceSubscription()
try:
request = ln.InvoiceSubscription()
async for i in self.rpc.SubscribeInvoices(request):
if not i.settled:
continue
checking_id = stringify_checking_id(i.r_hash)
checking_id = bytes_to_hex(i.r_hash)
yield checking_id
except Exception as exc:
logger.error(

View file

@ -123,18 +123,15 @@ class LndRestWallet(Wallet):
if r.is_error or r.json().get("payment_error"):
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()
payment_hash = data["payment_hash"]
checking_id = payment_hash
checking_id = base64.b64decode(data["payment_hash"]).hex()
fee_msat = int(data["payment_route"]["total_fees_msat"])
preimage = base64.b64decode(data["payment_preimage"]).hex()
return PaymentResponse(True, checking_id, fee_msat, preimage, None)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
checking_id = checking_id.replace("_", "/")
async with httpx.AsyncClient(verify=self.cert) as client:
r = await client.get(
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.
"""
# 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}"
# check payment.status:
# https://api.lightning.community/rest/index.html?python#peersynctype
# https://api.lightning.community/?python=#paymentpaymentstatus
statuses = {
"UNKNOWN": None,
"IN_FLIGHT": None,
@ -178,7 +183,11 @@ class LndRestWallet(Wallet):
return PaymentStatus(None)
payment = line.get("result")
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:
return PaymentStatus(None)
except:
@ -187,10 +196,9 @@ class LndRestWallet(Wallet):
return PaymentStatus(None)
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
url = self.endpoint + "/v1/invoices/subscribe"
while True:
try:
url = self.endpoint + "/v1/invoices/subscribe"
async with httpx.AsyncClient(
timeout=None, headers=self.auth, verify=self.cert
) as client:

View file

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

View file

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

View file

@ -47,7 +47,7 @@ class OpenNodeWallet(Wallet):
if r.is_error:
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(
self,
@ -92,11 +92,15 @@ class OpenNodeWallet(Wallet):
if r.is_error:
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"]
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)
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
@ -106,9 +110,9 @@ class OpenNodeWallet(Wallet):
)
if r.is_error:
return PaymentStatus(None)
statuses = {"processing": None, "paid": True, "unpaid": False}
return PaymentStatus(statuses[r.json()["data"]["status"]])
data = r.json()["data"]
statuses = {"processing": None, "paid": True, "unpaid": None}
return PaymentStatus(statuses[data.get("status")])
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
async with httpx.AsyncClient() as client:
@ -119,14 +123,16 @@ class OpenNodeWallet(Wallet):
if r.is_error:
return PaymentStatus(None)
data = r.json()["data"]
statuses = {
"initial": None,
"pending": None,
"confirmed": True,
"error": False,
"error": None,
"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]:
self.queue: asyncio.Queue = asyncio.Queue(0)

View file

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