diff --git a/lnbits/core/crud.py b/lnbits/core/crud.py
index c4412c2b7..6b3bd0e72 100644
--- a/lnbits/core/crud.py
+++ b/lnbits/core/crud.py
@@ -36,6 +36,10 @@ from .models import (
)
+def update_payment_extra():
+ pass
+
+
async def create_account(
account: Optional[Account] = None,
conn: Optional[Connection] = None,
@@ -689,39 +693,24 @@ async def create_payment(
previous_payment = await get_standalone_payment(checking_id, conn=conn)
assert previous_payment is None, "Payment already exists"
- expiry_ph = db.timestamp_placeholder("expiry")
- await (conn or db).execute(
- f"""
- INSERT INTO apipayments
- (wallet_id, checking_id, bolt11, payment_hash, preimage,
- amount, status, memo, fee, extra, webhook, expiry)
- VALUES (:wallet_id, :checking_id, :bolt11, :hash, :preimage,
- :amount, :status, :memo, :fee, :extra, :webhook, {expiry_ph})
- """,
- {
- "wallet_id": data.wallet_id,
- "checking_id": checking_id,
- "bolt11": data.payment_request,
- "hash": data.payment_hash,
- "preimage": data.preimage,
- "amount": data.amount,
- "status": status.value,
- "memo": data.memo,
- "fee": data.fee,
- "extra": (
- json.dumps(data.extra)
- if data.extra and data.extra != {} and isinstance(data.extra, dict)
- else None
- ),
- "webhook": data.webhook,
- "expiry": data.expiry if data.expiry else None,
- },
+ payment = Payment(
+ checking_id=checking_id,
+ status=status,
+ wallet_id=data.wallet_id,
+ payment_hash=data.payment_hash,
+ bolt11=data.bolt11,
+ amount=data.amount_msat,
+ memo=data.memo,
+ preimage=data.preimage,
+ expiry=data.expiry,
+ webhook=data.webhook,
+ fee=data.fee,
+ extra=data.extra or {},
)
- new_payment = await get_wallet_payment(data.wallet_id, data.payment_hash, conn=conn)
- assert new_payment, "Newly created payment couldn't be retrieved"
+ await (conn or db).insert("apipayments", payment)
- return new_payment
+ return payment
async def update_payment(
@@ -830,7 +819,7 @@ async def is_internal_status_success(
payment_hash: str, conn: Optional[Connection] = None
) -> bool:
"""
- Returns True if the internal payment was found and has the given status,
+ Returns True if the internal payment was found and is successful,
"""
payment = await (conn or db).fetchone(
"""
@@ -841,7 +830,7 @@ async def is_internal_status_success(
Payment,
)
if not payment:
- return True
+ return False
return payment.status == PaymentState.SUCCESS.value
diff --git a/lnbits/core/migrations.py b/lnbits/core/migrations.py
index 08d1bdbab..f598b861f 100644
--- a/lnbits/core/migrations.py
+++ b/lnbits/core/migrations.py
@@ -616,3 +616,7 @@ async def m026_update_payment_table(db):
"checking_id": payment.get("checking_id"),
},
)
+
+
+async def m027_update_payment_table(db):
+ await db.execute("ALTER TABLE apipayments ADD COLUMN fiat_amounts TEXT")
diff --git a/lnbits/core/models.py b/lnbits/core/models.py
index 81ab9f8b3..91655ff65 100644
--- a/lnbits/core/models.py
+++ b/lnbits/core/models.py
@@ -257,34 +257,62 @@ class PaymentState(str, Enum):
return self.value
+class PaymentExtra(BaseModel):
+ comment: Optional[str] = None
+ success_action: Optional[str] = None
+ lnurl_response: Optional[str] = None
+
+
+class PayInvoice(BaseModel):
+ payment_request: str
+ description: Optional[str] = None
+ max_sat: Optional[int] = None
+ extra: Optional[dict] = {}
+
+
+class PaymentFiatAmounts(BaseModel):
+ wallet_fiat_currency: Optional[str] = None
+ wallet_fiat_amount: Optional[float] = None
+ wallet_fiat_rate: Optional[float] = None
+ fiat_currency: Optional[str] = None
+ fiat_amount: Optional[float] = None
+ fiat_rate: Optional[float] = None
+
+
class CreatePayment(BaseModel):
wallet_id: str
- payment_request: str
payment_hash: str
- amount: int
+ bolt11: str
+ amount_msat: int
memo: str
+ extra: Optional[dict] = {}
preimage: Optional[str] = None
expiry: Optional[datetime] = None
- extra: Optional[dict] = None
webhook: Optional[str] = None
fee: int = 0
+ fiat_amounts: PaymentFiatAmounts = PaymentFiatAmounts()
class Payment(BaseModel):
- status: str
checking_id: str
payment_hash: str
wallet_id: str
amount: int
fee: int
- memo: Optional[str]
- time: datetime
bolt11: str
- expiry: Optional[datetime]
- extra: Optional[dict]
- webhook: Optional[str]
+ status: str = PaymentState.PENDING
+ memo: Optional[str] = None
+ expiry: Optional[datetime] = None
+ webhook: Optional[str] = None
webhook_status: Optional[int] = None
preimage: Optional[str] = "0" * 64
+ tag: Optional[str] = None
+ extension: Optional[str] = None
+ time: datetime = datetime.now(timezone.utc)
+ created_at: datetime = datetime.now(timezone.utc)
+ updated_at: datetime = datetime.now(timezone.utc)
+ fiat_amounts: PaymentFiatAmounts = PaymentFiatAmounts()
+ extra: dict = {}
@property
def pending(self) -> bool:
@@ -298,12 +326,6 @@ class Payment(BaseModel):
def failed(self) -> bool:
return self.status == PaymentState.FAILED.value
- @property
- def tag(self) -> Optional[str]:
- if self.extra is None:
- return ""
- return self.extra.get("tag")
-
@property
def msat(self) -> int:
return self.amount
@@ -428,7 +450,6 @@ class CreateInvoice(BaseModel):
def unit_is_from_allowed_currencies(cls, v):
if v != "sat" and v not in allowed_currencies():
raise ValueError("The provided unit is not supported")
-
return v
diff --git a/lnbits/core/services.py b/lnbits/core/services.py
index 7bb894a44..71f1212b2 100644
--- a/lnbits/core/services.py
+++ b/lnbits/core/services.py
@@ -8,7 +8,6 @@ from urllib.parse import parse_qs, urlparse
from uuid import UUID, uuid4
import httpx
-from bolt11 import MilliSatoshi
from bolt11 import decode as bolt11_decode
from cryptography.hazmat.primitives import serialization
from fastapi import Depends, WebSocket
@@ -54,7 +53,6 @@ from .crud import (
get_account_by_email,
get_account_by_pubkey,
get_account_by_username,
- get_payment,
get_payments,
get_standalone_payment,
get_super_settings,
@@ -74,6 +72,7 @@ from .models import (
BalanceDelta,
CreatePayment,
Payment,
+ PaymentFiatAmounts,
PaymentState,
User,
UserExtra,
@@ -83,22 +82,17 @@ from .models import (
async def calculate_fiat_amounts(
amount: float,
- wallet_id: str,
+ wallet: Wallet,
currency: Optional[str] = None,
- extra: Optional[dict] = None,
- conn: Optional[Connection] = None,
-) -> tuple[int, Optional[dict]]:
- wallet = await get_wallet(wallet_id, conn=conn)
- assert wallet, "invalid wallet_id"
+) -> tuple[int, PaymentFiatAmounts]:
wallet_currency = wallet.currency or settings.lnbits_default_accounting_currency
-
+ fiat_amounts = PaymentFiatAmounts()
if currency and currency != "sat":
amount_sat = await fiat_amount_as_satoshis(amount, currency)
- extra = extra or {}
if currency != wallet_currency:
- extra["fiat_currency"] = currency
- extra["fiat_amount"] = round(amount, ndigits=3)
- extra["fiat_rate"] = amount_sat / amount
+ fiat_amounts.fiat_currency = currency
+ fiat_amounts.fiat_amount = round(amount, ndigits=3)
+ fiat_amounts.fiat_rate = amount_sat / amount
else:
amount_sat = int(amount)
@@ -107,16 +101,15 @@ async def calculate_fiat_amounts(
fiat_amount = amount
else:
fiat_amount = await satoshis_amount_as_fiat(amount_sat, wallet_currency)
- extra = extra or {}
- extra["wallet_fiat_currency"] = wallet_currency
- extra["wallet_fiat_amount"] = round(fiat_amount, ndigits=3)
- extra["wallet_fiat_rate"] = amount_sat / fiat_amount
+ fiat_amounts.wallet_fiat_currency = wallet_currency
+ fiat_amounts.wallet_fiat_amount = round(fiat_amount, ndigits=3)
+ fiat_amounts.wallet_fiat_rate = amount_sat / fiat_amount
logger.debug(
- f"Calculated fiat amounts {wallet.id=} {amount=} {currency=}: {extra=}"
+ f"Calculated fiat amounts {wallet.id=} {amount=} {currency=}: {fiat_amounts=}"
)
- return amount_sat, extra
+ return amount_sat, fiat_amounts
async def create_invoice(
@@ -132,7 +125,7 @@ async def create_invoice(
webhook: Optional[str] = None,
internal: Optional[bool] = False,
conn: Optional[Connection] = None,
-) -> tuple[str, str]:
+) -> Payment:
if not amount > 0:
raise InvoiceError("Amountless invoices not supported.", status="failed")
@@ -145,8 +138,8 @@ async def create_invoice(
# use the fake wallet if the invoice is for internal use only
funding_source = fake_wallet if internal else get_funding_source()
- amount_sat, extra = await calculate_fiat_amounts(
- amount, wallet_id, currency=currency, extra=extra, conn=conn
+ amount_sat, fiat_amounts = await calculate_fiat_amounts(
+ amount, user_wallet, currency
)
if settings.is_wallet_max_balance_exceeded(
@@ -179,22 +172,202 @@ async def create_invoice(
create_payment_model = CreatePayment(
wallet_id=wallet_id,
- payment_request=payment_request,
+ bolt11=payment_request,
payment_hash=invoice.payment_hash,
- amount=amount_sat * 1000,
+ amount_msat=amount_sat * 1000,
expiry=invoice.expiry_date,
memo=memo,
extra=extra,
+ fiat_amounts=fiat_amounts,
webhook=webhook,
)
- await create_payment(
+ payment = await create_payment(
checking_id=checking_id,
data=create_payment_model,
conn=conn,
)
- return invoice.payment_hash, payment_request
+ return payment
+
+
+async def _pay_internal_invoice(
+ wallet: Wallet,
+ create_payment_model: CreatePayment,
+ conn: Optional[Connection] = None,
+) -> Optional[Payment]:
+ """
+ Pay an internal payment.
+ returns None if the payment is not internal.
+ """
+ # check_internal() returns the payment of the invoice we're waiting for
+ # (pending only)
+ internal_payment = await check_internal(
+ create_payment_model.payment_hash, conn=conn
+ )
+ if not internal_payment:
+ return None
+
+ # perform additional checks on the internal payment
+ # the payment hash is not enough to make sure that this is the same invoice
+ internal_invoice = await get_standalone_payment(
+ internal_payment.checking_id, incoming=True, conn=conn
+ )
+ if not internal_invoice:
+ raise PaymentError("Internal payment not found.", status="failed")
+
+ if (
+ internal_invoice.amount != abs(create_payment_model.amount_msat)
+ or internal_invoice.bolt11 != create_payment_model.bolt11.lower()
+ ):
+ raise PaymentError(
+ "Invalid invoice. Bolt11 or amount is not correct", status="failed"
+ )
+
+ fee_reserve_total_msat = fee_reserve_total(
+ abs(create_payment_model.amount_msat), internal=True
+ )
+ create_payment_model.fee = abs(fee_reserve_total_msat)
+
+ if (
+ wallet.balance_msat
+ < abs(create_payment_model.amount_msat) + fee_reserve_total_msat
+ ):
+ raise PaymentError("Insufficient balance.", status="failed")
+
+ internal_id = f"internal_{create_payment_model.payment_hash}"
+ logger.debug(f"creating temporary internal payment with id {internal_id}")
+ payment = await create_payment(
+ checking_id=internal_id,
+ data=create_payment_model,
+ status=PaymentState.SUCCESS,
+ conn=conn,
+ )
+
+ # mark the invoice from the other side as not pending anymore
+ # so the other side only has access to his new money when we are sure
+ # the payer has enough to deduct from
+ internal_payment.status = PaymentState.SUCCESS
+ await update_payment(internal_payment, conn=conn)
+
+ await send_payment_notification(wallet, payment)
+
+ # notify receiver asynchronously
+ from lnbits.tasks import internal_invoice_queue
+
+ logger.debug(f"enqueuing internal invoice {internal_payment.checking_id}")
+ await internal_invoice_queue.put(internal_payment.checking_id)
+
+ return payment
+
+
+async def _verify_external_payment(
+ payment: Payment, conn: Optional[Connection] = None
+) -> Payment:
+ # fail on pending payments
+ if payment.pending:
+ raise PaymentError("Payment is still pending.", status="pending")
+ if payment.success:
+ raise PaymentError("Payment already paid.", status="success")
+ if payment.failed:
+ status = await payment.check_status()
+ if status.success:
+ # payment was successful on the fundingsource
+ payment.status = PaymentState.SUCCESS
+ await update_payment(payment, conn=conn)
+ raise PaymentError(
+ "Failed payment was already paid on the fundingsource.",
+ status="success",
+ )
+ if status.failed:
+ raise PaymentError(
+ "Payment is failed node, retrying is not possible.", status="failed"
+ )
+ # status.pending fall through and try again
+ return payment
+
+
+async def _pay_external_invoice(
+ wallet: Wallet,
+ create_payment_model: CreatePayment,
+ conn: Optional[Connection] = None,
+) -> Payment:
+ fee_reserve_total_msat = fee_reserve_total(
+ create_payment_model.amount_msat, internal=False
+ )
+ create_payment_model.fee = -abs(fee_reserve_total_msat)
+
+ checking_id = create_payment_model.payment_hash
+
+ # check if there is already a payment with the same checking_id
+ old_payment = await get_standalone_payment(checking_id, conn=conn)
+ if old_payment:
+ return await _verify_external_payment(old_payment, conn)
+
+ if (
+ wallet.balance_msat
+ < abs(create_payment_model.amount_msat) + fee_reserve_total_msat
+ ):
+ raise PaymentError(
+ f"You must reserve at least ({round(fee_reserve_total_msat/1000)}"
+ " sat) to cover potential routing fees.",
+ status="failed",
+ )
+
+ payment = await create_payment(
+ checking_id=checking_id,
+ data=create_payment_model,
+ conn=conn,
+ )
+
+ fee_reserve_msat = fee_reserve(create_payment_model.amount_msat, internal=False)
+ service_fee_msat = service_fee(create_payment_model.amount_msat, internal=False)
+
+ funding_source = get_funding_source()
+
+ logger.debug(f"fundingsource: sending payment {checking_id}")
+ payment_response: PaymentResponse = await funding_source.pay_invoice(
+ create_payment_model.bolt11, fee_reserve_msat
+ )
+ logger.debug(f"backend: pay_invoice finished {checking_id}, {payment_response}")
+ if payment_response.checking_id and payment_response.checking_id != checking_id:
+ logger.warning(
+ f"backend sent unexpected checking_id (expected: {checking_id} got:"
+ f" {payment_response.checking_id})"
+ )
+ if payment_response.checking_id and payment_response.ok is not False:
+ # payment.ok can be True (paid) or None (pending)!
+ logger.debug(f"updating payment {checking_id}")
+ # TODO: why? we log a warning on an unexpected checking_id
+ # should that be handled?
+ # new checking id
+ payment.checking_id = payment_response.checking_id
+ payment.status = (
+ PaymentState.SUCCESS
+ if payment_response.ok is True
+ else PaymentState.PENDING
+ )
+ payment.fee = -(abs(payment_response.fee_msat or 0) + abs(service_fee_msat))
+ payment.preimage = payment_response.preimage
+ await update_payment(payment, conn=conn)
+ await send_payment_notification(wallet, payment)
+ logger.success(f"payment successful {payment_response.checking_id}")
+ elif payment_response.checking_id is None and payment_response.ok is False:
+ # payment failed
+ logger.debug(f"payment failed {checking_id}, {payment_response.error_message}")
+ payment.status = PaymentState.FAILED
+ await update_payment(payment, conn=conn)
+ raise PaymentError(
+ f"Payment failed: {payment_response.error_message}"
+ or "Payment failed, but backend didn't give us an error message.",
+ status="failed",
+ )
+ else:
+ logger.warning(
+ "didn't receive checking_id from backend, payment may be stuck in"
+ f" database: {checking_id}"
+ )
+ return payment
async def pay_invoice(
@@ -204,18 +377,9 @@ async def pay_invoice(
max_sat: Optional[int] = None,
extra: Optional[dict] = None,
description: str = "",
+ tag: 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.
- """
+) -> Payment:
try:
invoice = bolt11_decode(payment_request)
except Exception as exc:
@@ -227,261 +391,96 @@ async def pay_invoice(
if max_sat and invoice.amount_msat > max_sat * 1000:
raise PaymentError("Amount in invoice is too high.", status="failed")
- await check_wallet_limits(wallet_id, conn, invoice.amount_msat)
-
async with db.reuse_conn(conn) if conn else db.connect() as conn:
- temp_id = invoice.payment_hash
- internal_id = f"internal_{invoice.payment_hash}"
- _, extra = await calculate_fiat_amounts(
- invoice.amount_msat / 1000, wallet_id, extra=extra, conn=conn
+ wallet = await get_wallet(wallet_id, conn=conn)
+ if not wallet:
+ raise PaymentError(
+ f"Could not fetch wallet '{wallet_id}'.", status="failed"
+ )
+
+ # check if the payment is made for an extension that the user disabled
+ if tag:
+ status = await check_user_extension_access(wallet.user, tag)
+ if not status.success:
+ raise PaymentError(status.message)
+
+ await check_wallet_limits(wallet_id, invoice.amount_msat, conn)
+
+ if await is_internal_status_success(invoice.payment_hash, conn):
+ raise PaymentError("Internal invoice already paid.", status="failed")
+
+ _, fiat_amounts = await calculate_fiat_amounts(
+ invoice.amount_msat / 1000, wallet
)
create_payment_model = CreatePayment(
wallet_id=wallet_id,
- payment_request=payment_request,
+ bolt11=payment_request,
payment_hash=invoice.payment_hash,
- amount=-invoice.amount_msat,
+ amount_msat=invoice.amount_msat * -1,
expiry=invoice.expiry_date,
memo=description or invoice.description or "",
extra=extra,
+ fiat_amounts=fiat_amounts,
)
- if await is_internal_status_success(invoice.payment_hash, conn=conn):
- raise PaymentError("Internal invoice already paid.", status="failed")
+ payment = await _pay_internal_invoice(wallet, create_payment_model, conn)
+ if not payment:
+ payment = await _pay_external_invoice(wallet, create_payment_model, conn)
- # check_internal() returns the checking_id of the invoice we're waiting for
- # (pending only)
- internal_payment = await check_internal(invoice.payment_hash, conn=conn)
- if internal_payment:
- # perform additional checks on the internal payment
- # the payment hash is not enough to make sure that this is the same invoice
- internal_invoice = await get_standalone_payment(
- internal_payment.checking_id, incoming=True, conn=conn
- )
- assert internal_invoice is not None
- if (
- internal_invoice.amount != invoice.amount_msat
- or internal_invoice.bolt11 != payment_request.lower()
- ):
- raise PaymentError("Invalid invoice.", status="failed")
+ await _credit_service_fee_wallet(payment)
- logger.debug(f"creating temporary internal payment with id {internal_id}")
- # create a new payment from this wallet
+ return payment
- fee_reserve_total_msat = fee_reserve_total(
- invoice.amount_msat, internal=True
- )
- create_payment_model.fee = service_fee(invoice.amount_msat, True)
- new_payment = await create_payment(
- checking_id=internal_id,
- data=create_payment_model,
- status=PaymentState.SUCCESS,
- conn=conn,
- )
- else:
- new_payment = await _create_external_payment(
- temp_id=temp_id,
- amount_msat=invoice.amount_msat,
- data=create_payment_model,
- conn=conn,
- )
- # do the balance check
- wallet = await get_wallet(wallet_id, conn=conn)
- assert wallet, "Wallet for balancecheck could not be fetched"
- fee_reserve_total_msat = fee_reserve_total(invoice.amount_msat, internal=False)
- _check_wallet_balance(wallet, fee_reserve_total_msat, internal_payment)
-
- if extra and "tag" in extra:
- # check if the payment is made for an extension that the user disabled
- status = await check_user_extension_access(wallet.user, extra["tag"])
- if not status.success:
- raise PaymentError(status.message)
-
- if internal_payment:
- service_fee_msat = service_fee(invoice.amount_msat, internal=True)
- logger.debug(
- f"marking temporary payment as not pending {internal_payment.checking_id}"
- )
- # mark the invoice from the other side as not pending anymore
- # so the other side only has access to his new money when we are sure
- # the payer has enough to deduct from
- async with db.connect() as conn:
- internal_payment.status = PaymentState.SUCCESS
- await update_payment(internal_payment, conn=conn)
- await send_payment_notification(wallet, new_payment)
-
- # notify receiver asynchronously
- from lnbits.tasks import internal_invoice_queue
-
- logger.debug(f"enqueuing internal invoice {internal_payment.checking_id}")
- await internal_invoice_queue.put(internal_payment.checking_id)
- else:
- fee_reserve_msat = fee_reserve(invoice.amount_msat, internal=False)
- service_fee_msat = service_fee(invoice.amount_msat, internal=False)
- logger.debug(f"backend: sending payment {temp_id}")
- # actually pay the external invoice
- funding_source = get_funding_source()
- payment_response: PaymentResponse = await funding_source.pay_invoice(
- payment_request, fee_reserve_msat
- )
-
- if payment_response.checking_id and payment_response.checking_id != temp_id:
- logger.warning(
- f"backend sent unexpected checking_id (expected: {temp_id} got:"
- f" {payment_response.checking_id})"
- )
-
- logger.debug(f"backend: pay_invoice finished {temp_id}, {payment_response}")
- if payment_response.checking_id and payment_response.ok is not False:
- # payment.ok can be True (paid) or None (pending)!
- logger.debug(f"updating payment {temp_id}")
- async with db.connect() as conn:
- payment = await get_payment(temp_id, conn=conn)
- # new checking id
- payment.checking_id = payment_response.checking_id
- payment.status = (
- PaymentState.SUCCESS
- if payment_response.ok is True
- else PaymentState.PENDING
- )
- payment.fee = -(
- abs(payment_response.fee_msat or 0) + abs(service_fee_msat)
- )
- payment.preimage = payment_response.preimage
- await update_payment(payment, conn=conn)
- wallet = await get_wallet(wallet_id, conn=conn)
- if wallet and payment.success:
- await send_payment_notification(wallet, payment)
- logger.success(f"payment successful {payment_response.checking_id}")
- elif payment_response.checking_id is None and payment_response.ok is False:
- # payment failed
- logger.debug(f"payment failed {temp_id}, {payment_response.error_message}")
- async with db.connect() as conn:
- payment = await get_payment(temp_id, conn=conn)
- payment.status = PaymentState.FAILED
- await update_payment(payment, conn=conn)
- raise PaymentError(
- f"Payment failed: {payment_response.error_message}"
- or "Payment failed, but backend didn't give us an error message.",
- status="failed",
- )
- else:
- logger.warning(
- "didn't receive checking_id from backend, payment may be stuck in"
- f" database: {temp_id}"
- )
-
- # credit service fee wallet
+async def _credit_service_fee_wallet(payment: Payment):
+ service_fee_msat = service_fee(payment.amount, internal=payment.is_internal)
if settings.lnbits_service_fee_wallet and service_fee_msat:
create_payment_model = CreatePayment(
wallet_id=settings.lnbits_service_fee_wallet,
- payment_request=payment_request,
- payment_hash=invoice.payment_hash,
- amount=abs(service_fee_msat),
+ bolt11=payment.bolt11,
+ payment_hash=payment.payment_hash,
+ amount_msat=abs(service_fee_msat),
memo="Service fee",
)
- new_payment = await create_payment(
- checking_id=f"service_fee_{temp_id}",
+ await create_payment(
+ checking_id=f"service_fee_{payment.payment_hash}",
data=create_payment_model,
status=PaymentState.SUCCESS,
)
- return invoice.payment_hash
-async def _create_external_payment(
- temp_id: str,
- amount_msat: MilliSatoshi,
- data: CreatePayment,
- conn: Optional[Connection],
-) -> Payment:
- fee_reserve_total_msat = fee_reserve_total(amount_msat, internal=False)
- # check if there is already a payment with the same checking_id
- old_payment = await get_standalone_payment(temp_id, conn=conn)
- if old_payment:
- # fail on pending payments
- if old_payment.pending:
- raise PaymentError("Payment is still pending.", status="pending")
- if old_payment.success:
- raise PaymentError("Payment already paid.", status="success")
- if old_payment.failed:
- status = await old_payment.check_status()
- if status.success:
- # payment was successful on the fundingsource
- old_payment.status = PaymentState.SUCCESS
- await update_payment(old_payment, conn=conn)
- raise PaymentError(
- "Failed payment was already paid on the fundingsource.",
- status="success",
- )
- if status.failed:
- raise PaymentError(
- "Payment is failed node, retrying is not possible.", status="failed"
- )
- # status.pending fall through and try again
- return old_payment
-
- logger.debug(f"creating temporary payment with id {temp_id}")
- # create a temporary payment here so we can check if
- # the balance is enough in the next step
- try:
- data.fee = -abs(fee_reserve_total_msat)
- new_payment = await create_payment(
- checking_id=temp_id,
- data=data,
- conn=conn,
- )
- return new_payment
- except Exception as exc:
- logger.error(f"could not create temporary payment: {exc}")
- # happens if the same wallet tries to pay an invoice twice
- raise PaymentError("Could not make payment", status="failed") from exc
-
-
-def _check_wallet_balance(
- wallet: Wallet,
- fee_reserve_total_msat: int,
- internal_payment: Optional[Payment] = None,
+async def check_wallet_limits(
+ wallet_id: str, amount_msat: int, conn: Optional[Connection] = None
):
- if wallet.balance_msat < 0:
- logger.debug("balance is too low, deleting temporary payment")
- if not internal_payment and wallet.balance_msat > -fee_reserve_total_msat:
- raise PaymentError(
- f"You must reserve at least ({round(fee_reserve_total_msat/1000)}"
- " sat) to cover potential routing fees.",
- status="failed",
- )
- raise PaymentError("Insufficient balance.", status="failed")
+ await check_time_limit_between_transactions(wallet_id, conn)
+ await check_wallet_daily_withdraw_limit(wallet_id, amount_msat, conn)
-async def check_wallet_limits(wallet_id, conn, amount_msat):
- await check_time_limit_between_transactions(conn, wallet_id)
- await check_wallet_daily_withdraw_limit(conn, wallet_id, amount_msat)
-
-
-async def check_time_limit_between_transactions(conn, wallet_id):
+async def check_time_limit_between_transactions(
+ wallet_id: str, conn: Optional[Connection] = None
+):
limit = settings.lnbits_wallet_limit_secs_between_trans
if not limit or limit <= 0:
return
-
payments = await get_payments(
since=int(time.time()) - limit,
wallet_id=wallet_id,
limit=1,
conn=conn,
)
-
if len(payments) == 0:
return
-
raise PaymentError(
status="failed",
message=f"The time limit of {limit} seconds between payments has been reached.",
)
-async def check_wallet_daily_withdraw_limit(conn, wallet_id, amount_msat):
+async def check_wallet_daily_withdraw_limit(
+ wallet_id: str, amount_msat: int, conn: Optional[Connection] = None
+):
limit = settings.lnbits_wallet_limit_daily_max_withdraw
if not limit:
return
@@ -690,39 +689,33 @@ def fee_reserve_total(amount_msat: int, internal: bool = False) -> int:
async def send_payment_notification(wallet: Wallet, payment: Payment):
- await websocket_updater(
- wallet.inkey,
- json.dumps(
- {
- "wallet_balance": wallet.balance,
- "payment": payment.dict(),
- }
- ),
- )
-
- await websocket_updater(
- payment.payment_hash, json.dumps({"pending": payment.pending})
+ await websocket_manager.send_data(payment.json(), wallet.inkey)
+ # json.dumps(
+ # {
+ # "wallet_balance": wallet.balance,
+ # "payment": payment,
+ # }
+ # ),
+ await websocket_manager.send_data(
+ json.dumps({"pending": payment.pending}), payment.payment_hash
)
async def update_wallet_balance(wallet_id: str, amount: int):
async with db.connect() as conn:
- payment_hash, _ = await create_invoice(
+ payment = await create_invoice(
wallet_id=wallet_id,
amount=amount,
memo="Admin top up",
internal=True,
conn=conn,
)
- internal_payment = await check_internal(payment_hash, conn=conn)
- assert internal_payment, "newly created checking_id cannot be retrieved"
-
- internal_payment.status = PaymentState.SUCCESS
- await update_payment(internal_payment, conn=conn)
+ payment.status = PaymentState.SUCCESS
+ await update_payment(payment, conn=conn)
# notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue
- await internal_invoice_queue.put(internal_payment.checking_id)
+ await internal_invoice_queue.put(payment.checking_id)
async def check_admin_settings():
@@ -877,8 +870,8 @@ class WebsocketConnectionManager:
websocket_manager = WebsocketConnectionManager()
-async def websocket_updater(item_id, data):
- return await websocket_manager.send_data(f"{data}", item_id)
+async def websocket_updater(item_id: str, data: str):
+ return await websocket_manager.send_data(data, item_id)
async def switch_to_voidwallet() -> None:
diff --git a/lnbits/core/views/extension_api.py b/lnbits/core/views/extension_api.py
index 83afd20c7..1c93428aa 100644
--- a/lnbits/core/views/extension_api.py
+++ b/lnbits/core/views/extension_api.py
@@ -1,3 +1,5 @@
+import sys
+import traceback
from http import HTTPStatus
from bolt11 import decode as bolt11_decode
@@ -84,6 +86,8 @@ async def api_install_extension(data: CreateExtension):
except Exception as exc:
logger.warning(exc)
+ etype, _, tb = sys.exc_info()
+ traceback.print_exception(etype, exc, tb)
ext_info.clean_extension_files()
detail = (
str(exc)
@@ -430,7 +434,7 @@ async def get_pay_to_enable_invoice(
),
)
- payment_hash, payment_request = await create_invoice(
+ payment = await create_invoice(
wallet_id=ext.meta.pay_to_enable.wallet,
amount=data.amount,
memo=f"Enable '{ext.name}' extension.",
@@ -441,10 +445,10 @@ async def get_pay_to_enable_invoice(
user_ext = UserExtension(user=user.id, extension=ext_id, active=False)
await create_user_extension(user_ext)
user_ext_info = user_ext.extra if user_ext.extra else UserExtensionInfo()
- user_ext_info.payment_hash_to_enable = payment_hash
+ user_ext_info.payment_hash_to_enable = payment.payment_hash
user_ext.extra = user_ext_info
await update_user_extension(user_ext)
- return {"payment_hash": payment_hash, "payment_request": payment_request}
+ return {"payment_hash": payment.payment_hash, "payment_request": payment.bolt11}
@extension_router.get(
diff --git a/lnbits/core/views/payment_api.py b/lnbits/core/views/payment_api.py
index 2cb87c842..53d42f6e4 100644
--- a/lnbits/core/views/payment_api.py
+++ b/lnbits/core/views/payment_api.py
@@ -3,13 +3,12 @@ import json
import uuid
from http import HTTPStatus
from math import ceil
-from typing import List, Optional, Union
+from typing import List, Optional
from urllib.parse import urlparse
import httpx
from fastapi import (
APIRouter,
- Body,
Depends,
Header,
HTTPException,
@@ -21,7 +20,6 @@ from loguru import logger
from sse_starlette.sse import EventSourceResponse
from lnbits import bolt11
-from lnbits.core.db import db
from lnbits.core.models import (
CreateInvoice,
CreateLnurl,
@@ -121,7 +119,7 @@ async def api_payments_paginated(
return page
-async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
+async def _api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
description_hash = b""
unhashed_description = b""
memo = data.memo or settings.lnbits_site_title
@@ -145,60 +143,42 @@ async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
# do not save memo if description_hash or unhashed_description is set
memo = ""
- async with db.connect() as conn:
- payment_hash, payment_request = await create_invoice(
- wallet_id=wallet.id,
- amount=data.amount,
- memo=memo,
- currency=data.unit,
- description_hash=description_hash,
- unhashed_description=unhashed_description,
- expiry=data.expiry,
- extra=data.extra,
- webhook=data.webhook,
- internal=data.internal,
- conn=conn,
- )
- # NOTE: we get the checking_id with a seperate query because create_invoice
- # does not return it and it would be a big hustle to change its return type
- # (used across extensions)
- payment_db = await get_standalone_payment(payment_hash, conn=conn)
- assert payment_db is not None, "payment not found"
- checking_id = payment_db.checking_id
+ payment = await create_invoice(
+ wallet_id=wallet.id,
+ amount=data.amount,
+ memo=memo,
+ currency=data.unit,
+ description_hash=description_hash,
+ unhashed_description=unhashed_description,
+ expiry=data.expiry,
+ extra=data.extra,
+ webhook=data.webhook,
+ internal=data.internal,
+ )
- invoice = bolt11.decode(payment_request)
-
- lnurl_response: Union[None, bool, str] = None
+ # lnurl_response is not saved in the database
if data.lnurl_callback:
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
try:
r = await client.get(
data.lnurl_callback,
- params={
- "pr": payment_request,
- },
+ params={"pr": payment.bolt11},
timeout=10,
)
if r.is_error:
- lnurl_response = r.text
+ payment.extra["lnurl_response"] = r.text
else:
resp = json.loads(r.text)
if resp["status"] != "OK":
- lnurl_response = resp["reason"]
+ payment.extra["lnurl_response"] = resp["reason"]
else:
- lnurl_response = True
+ payment.extra["lnurl_response"] = True
except (httpx.ConnectError, httpx.RequestError) as ex:
logger.error(ex)
- lnurl_response = False
+ payment.extra["lnurl_response"] = False
- return {
- "payment_hash": invoice.payment_hash,
- "payment_request": payment_request,
- "lnurl_response": lnurl_response,
- # maintain backwards compatibility with API clients:
- "checking_id": checking_id,
- }
+ return payment
@payment_router.post(
@@ -220,30 +200,25 @@ async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
},
)
async def api_payments_create(
+ invoice_data: CreateInvoice,
wallet: WalletTypeInfo = Depends(require_invoice_key),
- invoice_data: CreateInvoice = Body(...),
-):
+) -> Payment:
if invoice_data.out is True and wallet.key_type == KeyType.admin:
if not invoice_data.bolt11:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
- detail="BOLT11 string is invalid or not given",
+ detail="Missing BOLT11 invoice",
)
-
- payment_hash = await pay_invoice(
+ payment = await pay_invoice(
wallet_id=wallet.wallet.id,
payment_request=invoice_data.bolt11,
extra=invoice_data.extra,
)
- return {
- "payment_hash": payment_hash,
- # maintain backwards compatibility with API clients:
- "checking_id": payment_hash,
- }
+ return payment
elif not invoice_data.out:
# invoice key
- return await api_payments_create_invoice(invoice_data, wallet.wallet)
+ return await _api_payments_create_invoice(invoice_data, wallet.wallet)
else:
raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED,
@@ -269,7 +244,7 @@ async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONRespo
@payment_router.post("/lnurl")
async def api_payments_pay_lnurl(
data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key)
-):
+) -> Payment:
domain = urlparse(data.callback).netloc
headers = {"User-Agent": settings.user_agent}
@@ -313,15 +288,12 @@ async def api_payments_pay_lnurl(
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=(
- (
- f"{domain} returned an invalid invoice. Expected"
- f" {amount_msat} msat, got {invoice.amount_msat}."
- ),
+ f"{domain} returned an invalid invoice. Expected"
+ f" {amount_msat} msat, got {invoice.amount_msat}."
),
)
extra = {}
-
if params.get("successAction"):
extra["success_action"] = params["successAction"]
if data.comment:
@@ -330,19 +302,14 @@ async def api_payments_pay_lnurl(
extra["fiat_currency"] = data.unit
extra["fiat_amount"] = data.amount / 1000
assert data.description is not None, "description is required"
- payment_hash = await pay_invoice(
+
+ payment = await pay_invoice(
wallet_id=wallet.wallet.id,
payment_request=params["pr"],
description=data.description,
extra=extra,
)
-
- return {
- "success_action": params.get("successAction"),
- "payment_hash": payment_hash,
- # maintain backwards compatibility with API clients:
- "checking_id": payment_hash,
- }
+ return payment
async def subscribe_wallet_invoices(request: Request, wallet: Wallet):
diff --git a/lnbits/db.py b/lnbits/db.py
index 0b56ffee9..cde523662 100644
--- a/lnbits/db.py
+++ b/lnbits/db.py
@@ -654,6 +654,7 @@ def dict_to_model(_row: dict, model: type[TModel]) -> TModel:
if issubclass(type_, BaseModel) and value:
_dict[key] = dict_to_submodel(type_, value)
continue
+ # TODO: remove this when all sub models are migrated to Pydantic
if type_ is dict and value:
_dict[key] = json.loads(value)
continue
diff --git a/lnbits/static/js/wallet.js b/lnbits/static/js/wallet.js
index f235d0d5f..75e059cda 100644
--- a/lnbits/static/js/wallet.js
+++ b/lnbits/static/js/wallet.js
@@ -144,9 +144,11 @@ window.app = Vue.createApp({
)
.then(response => {
this.receive.status = 'success'
- this.receive.paymentReq = response.data.payment_request
+ this.receive.paymentReq = response.data.bolt11
this.receive.paymentHash = response.data.payment_hash
+ // TODO: lnurl_callback and lnurl_response
+ // WITHDRAW
if (response.data.lnurl_response !== null) {
if (response.data.lnurl_response === false) {
response.data.lnurl_response = `Unable to connect`
@@ -393,12 +395,13 @@ window.app = Vue.createApp({
dismissPaymentMsg()
clearInterval(this.parse.paymentChecker)
// show lnurlpay success action
- if (response.data.success_action) {
- switch (response.data.success_action.tag) {
+ const extra = response.data.extra
+ if (extra.success_action) {
+ switch (extra.success_action.tag) {
case 'url':
Quasar.Notify.create({
- message: `${response.data.success_action.url}`,
- caption: response.data.success_action.description,
+ message: `${extra.success_action.url}`,
+ caption: extra.success_action.description,
html: true,
type: 'positive',
timeout: 0,
@@ -407,7 +410,7 @@ window.app = Vue.createApp({
break
case 'message':
Quasar.Notify.create({
- message: response.data.success_action.message,
+ message: extra.success_action.message,
type: 'positive',
timeout: 0,
closeBtn: true
@@ -418,14 +421,14 @@ window.app = Vue.createApp({
.getPayment(this.g.wallet, response.data.payment_hash)
.then(({data: payment}) =>
decryptLnurlPayAES(
- response.data.success_action,
+ extra.success_action,
payment.preimage
)
)
.then(value => {
Quasar.Notify.create({
message: value,
- caption: response.data.success_action.description,
+ caption: extra.success_action.description,
html: true,
type: 'positive',
timeout: 0,
diff --git a/tests/conftest.py b/tests/conftest.py
index 94959dd2d..2837f84bc 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -28,7 +28,6 @@ from lnbits.core.crud import (
)
from lnbits.core.models import Account, CreateInvoice, PaymentState, User
from lnbits.core.services import create_user_account, update_wallet_balance
-from lnbits.core.views.payment_api import api_payments_create_invoice
from lnbits.db import DB_TYPE, SQLITE, Database
from lnbits.settings import AuthMethods, Settings
from lnbits.settings import settings as lnbits_settings
@@ -232,12 +231,16 @@ async def adminkey_headers_to(to_wallet):
@pytest_asyncio.fixture(scope="session")
-async def invoice(to_wallet):
+async def invoice(client, adminkey_headers_from):
data = await get_random_invoice_data()
invoice_data = CreateInvoice(**data)
- invoice = await api_payments_create_invoice(invoice_data, to_wallet)
- yield invoice
- del invoice
+ response = await client.post(
+ "/api/v1/payments", headers=adminkey_headers_from, json=invoice_data.dict()
+ )
+ assert response.is_success
+ data = response.json()
+ assert data["checking_id"]
+ yield data["bolt11"]
@pytest_asyncio.fixture(scope="function")
diff --git a/tests/regtest/test_services_create_invoice.py b/tests/regtest/test_services_create_invoice.py
index 80b5c8f3f..1947c6989 100644
--- a/tests/regtest/test_services_create_invoice.py
+++ b/tests/regtest/test_services_create_invoice.py
@@ -12,35 +12,35 @@ description = "test create invoice"
@pytest.mark.asyncio
async def test_create_invoice(from_wallet):
- payment_hash, pr = await create_invoice(
+ payment = await create_invoice(
wallet_id=from_wallet.id,
amount=1000,
memo=description,
)
- invoice = decode(pr)
- assert invoice.payment_hash == payment_hash
+ invoice = decode(payment.bolt11)
+ assert invoice.payment_hash == payment.payment_hash
assert invoice.amount_msat == 1000000
assert invoice.description == description
funding_source = get_funding_source()
- status = await funding_source.get_invoice_status(payment_hash)
+ status = await funding_source.get_invoice_status(payment.payment_hash)
assert isinstance(status, PaymentStatus)
assert status.pending
@pytest.mark.asyncio
async def test_create_internal_invoice(from_wallet):
- payment_hash, pr = await create_invoice(
+ payment = await create_invoice(
wallet_id=from_wallet.id, amount=1000, memo=description, internal=True
)
- invoice = decode(pr)
- assert invoice.payment_hash == payment_hash
+ invoice = decode(payment.bolt11)
+ assert invoice.payment_hash == payment.payment_hash
assert invoice.amount_msat == 1000000
assert invoice.description == description
# Internal invoices are not on fundingsource. so we should get some kind of error
# that the invoice is not found, but we get status pending
funding_source = get_funding_source()
- status = await funding_source.get_invoice_status(payment_hash)
+ status = await funding_source.get_invoice_status(payment.payment_hash)
assert isinstance(status, PaymentStatus)
assert status.pending
diff --git a/tests/regtest/test_services_pay_invoice.py b/tests/regtest/test_services_pay_invoice.py
index 41bd76919..a91062ff7 100644
--- a/tests/regtest/test_services_pay_invoice.py
+++ b/tests/regtest/test_services_pay_invoice.py
@@ -1,8 +1,5 @@
import pytest
-from lnbits.core.crud import (
- get_standalone_payment,
-)
from lnbits.core.services import (
PaymentError,
PaymentState,
@@ -14,13 +11,11 @@ description = "test pay invoice"
@pytest.mark.asyncio
async def test_services_pay_invoice(to_wallet, real_invoice):
- payment_hash = await pay_invoice(
+ payment = await pay_invoice(
wallet_id=to_wallet.id,
payment_request=real_invoice.get("bolt11"),
description=description,
)
- assert payment_hash
- payment = await get_standalone_payment(payment_hash)
assert payment
assert not payment.status == PaymentState.SUCCESS
assert payment.memo == description