refactor services

- add PaymentFiatAmount
- return Payment on api endpoints
This commit is contained in:
dni ⚡
2024-10-17 10:43:03 +02:00
parent 33bc380594
commit deee7d80be
11 changed files with 391 additions and 411 deletions
+21 -32
View File
@@ -36,6 +36,10 @@ from .models import (
) )
def update_payment_extra():
pass
async def create_account( async def create_account(
account: Optional[Account] = None, account: Optional[Account] = None,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
@@ -689,39 +693,24 @@ async def create_payment(
previous_payment = await get_standalone_payment(checking_id, conn=conn) previous_payment = await get_standalone_payment(checking_id, conn=conn)
assert previous_payment is None, "Payment already exists" assert previous_payment is None, "Payment already exists"
expiry_ph = db.timestamp_placeholder("expiry") payment = Payment(
await (conn or db).execute( checking_id=checking_id,
f""" status=status,
INSERT INTO apipayments wallet_id=data.wallet_id,
(wallet_id, checking_id, bolt11, payment_hash, preimage, payment_hash=data.payment_hash,
amount, status, memo, fee, extra, webhook, expiry) bolt11=data.bolt11,
VALUES (:wallet_id, :checking_id, :bolt11, :hash, :preimage, amount=data.amount_msat,
:amount, :status, :memo, :fee, :extra, :webhook, {expiry_ph}) memo=data.memo,
""", preimage=data.preimage,
{ expiry=data.expiry,
"wallet_id": data.wallet_id, webhook=data.webhook,
"checking_id": checking_id, fee=data.fee,
"bolt11": data.payment_request, extra=data.extra or {},
"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,
},
) )
new_payment = await get_wallet_payment(data.wallet_id, data.payment_hash, conn=conn) await (conn or db).insert("apipayments", payment)
assert new_payment, "Newly created payment couldn't be retrieved"
return new_payment return payment
async def update_payment( async def update_payment(
@@ -830,7 +819,7 @@ async def is_internal_status_success(
payment_hash: str, conn: Optional[Connection] = None payment_hash: str, conn: Optional[Connection] = None
) -> bool: ) -> 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( payment = await (conn or db).fetchone(
""" """
@@ -841,7 +830,7 @@ async def is_internal_status_success(
Payment, Payment,
) )
if not payment: if not payment:
return True return False
return payment.status == PaymentState.SUCCESS.value return payment.status == PaymentState.SUCCESS.value
+4
View File
@@ -616,3 +616,7 @@ async def m026_update_payment_table(db):
"checking_id": payment.get("checking_id"), "checking_id": payment.get("checking_id"),
}, },
) )
async def m027_update_payment_table(db):
await db.execute("ALTER TABLE apipayments ADD COLUMN fiat_amounts TEXT")
+37 -16
View File
@@ -257,34 +257,62 @@ class PaymentState(str, Enum):
return self.value 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): class CreatePayment(BaseModel):
wallet_id: str wallet_id: str
payment_request: str
payment_hash: str payment_hash: str
amount: int bolt11: str
amount_msat: int
memo: str memo: str
extra: Optional[dict] = {}
preimage: Optional[str] = None preimage: Optional[str] = None
expiry: Optional[datetime] = None expiry: Optional[datetime] = None
extra: Optional[dict] = None
webhook: Optional[str] = None webhook: Optional[str] = None
fee: int = 0 fee: int = 0
fiat_amounts: PaymentFiatAmounts = PaymentFiatAmounts()
class Payment(BaseModel): class Payment(BaseModel):
status: str
checking_id: str checking_id: str
payment_hash: str payment_hash: str
wallet_id: str wallet_id: str
amount: int amount: int
fee: int fee: int
memo: Optional[str]
time: datetime
bolt11: str bolt11: str
expiry: Optional[datetime] status: str = PaymentState.PENDING
extra: Optional[dict] memo: Optional[str] = None
webhook: Optional[str] expiry: Optional[datetime] = None
webhook: Optional[str] = None
webhook_status: Optional[int] = None webhook_status: Optional[int] = None
preimage: Optional[str] = "0" * 64 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 @property
def pending(self) -> bool: def pending(self) -> bool:
@@ -298,12 +326,6 @@ class Payment(BaseModel):
def failed(self) -> bool: def failed(self) -> bool:
return self.status == PaymentState.FAILED.value return self.status == PaymentState.FAILED.value
@property
def tag(self) -> Optional[str]:
if self.extra is None:
return ""
return self.extra.get("tag")
@property @property
def msat(self) -> int: def msat(self) -> int:
return self.amount return self.amount
@@ -428,7 +450,6 @@ class CreateInvoice(BaseModel):
def unit_is_from_allowed_currencies(cls, v): def unit_is_from_allowed_currencies(cls, v):
if v != "sat" and v not in allowed_currencies(): if v != "sat" and v not in allowed_currencies():
raise ValueError("The provided unit is not supported") raise ValueError("The provided unit is not supported")
return v return v
+260 -267
View File
@@ -8,7 +8,6 @@ from urllib.parse import parse_qs, urlparse
from uuid import UUID, uuid4 from uuid import UUID, uuid4
import httpx import httpx
from bolt11 import MilliSatoshi
from bolt11 import decode as bolt11_decode from bolt11 import decode as bolt11_decode
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
from fastapi import Depends, WebSocket from fastapi import Depends, WebSocket
@@ -54,7 +53,6 @@ from .crud import (
get_account_by_email, get_account_by_email,
get_account_by_pubkey, get_account_by_pubkey,
get_account_by_username, get_account_by_username,
get_payment,
get_payments, get_payments,
get_standalone_payment, get_standalone_payment,
get_super_settings, get_super_settings,
@@ -74,6 +72,7 @@ from .models import (
BalanceDelta, BalanceDelta,
CreatePayment, CreatePayment,
Payment, Payment,
PaymentFiatAmounts,
PaymentState, PaymentState,
User, User,
UserExtra, UserExtra,
@@ -83,22 +82,17 @@ from .models import (
async def calculate_fiat_amounts( async def calculate_fiat_amounts(
amount: float, amount: float,
wallet_id: str, wallet: Wallet,
currency: Optional[str] = None, currency: Optional[str] = None,
extra: Optional[dict] = None, ) -> tuple[int, PaymentFiatAmounts]:
conn: Optional[Connection] = None,
) -> tuple[int, Optional[dict]]:
wallet = await get_wallet(wallet_id, conn=conn)
assert wallet, "invalid wallet_id"
wallet_currency = wallet.currency or settings.lnbits_default_accounting_currency wallet_currency = wallet.currency or settings.lnbits_default_accounting_currency
fiat_amounts = PaymentFiatAmounts()
if currency and currency != "sat": if currency and currency != "sat":
amount_sat = await fiat_amount_as_satoshis(amount, currency) amount_sat = await fiat_amount_as_satoshis(amount, currency)
extra = extra or {}
if currency != wallet_currency: if currency != wallet_currency:
extra["fiat_currency"] = currency fiat_amounts.fiat_currency = currency
extra["fiat_amount"] = round(amount, ndigits=3) fiat_amounts.fiat_amount = round(amount, ndigits=3)
extra["fiat_rate"] = amount_sat / amount fiat_amounts.fiat_rate = amount_sat / amount
else: else:
amount_sat = int(amount) amount_sat = int(amount)
@@ -107,16 +101,15 @@ async def calculate_fiat_amounts(
fiat_amount = amount fiat_amount = amount
else: else:
fiat_amount = await satoshis_amount_as_fiat(amount_sat, wallet_currency) fiat_amount = await satoshis_amount_as_fiat(amount_sat, wallet_currency)
extra = extra or {} fiat_amounts.wallet_fiat_currency = wallet_currency
extra["wallet_fiat_currency"] = wallet_currency fiat_amounts.wallet_fiat_amount = round(fiat_amount, ndigits=3)
extra["wallet_fiat_amount"] = round(fiat_amount, ndigits=3) fiat_amounts.wallet_fiat_rate = amount_sat / fiat_amount
extra["wallet_fiat_rate"] = amount_sat / fiat_amount
logger.debug( 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( async def create_invoice(
@@ -132,7 +125,7 @@ async def create_invoice(
webhook: Optional[str] = None, webhook: Optional[str] = None,
internal: Optional[bool] = False, internal: Optional[bool] = False,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> tuple[str, str]: ) -> Payment:
if not amount > 0: if not amount > 0:
raise InvoiceError("Amountless invoices not supported.", status="failed") 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 # use the fake wallet if the invoice is for internal use only
funding_source = fake_wallet if internal else get_funding_source() funding_source = fake_wallet if internal else get_funding_source()
amount_sat, extra = await calculate_fiat_amounts( amount_sat, fiat_amounts = await calculate_fiat_amounts(
amount, wallet_id, currency=currency, extra=extra, conn=conn amount, user_wallet, currency
) )
if settings.is_wallet_max_balance_exceeded( if settings.is_wallet_max_balance_exceeded(
@@ -179,22 +172,202 @@ async def create_invoice(
create_payment_model = CreatePayment( create_payment_model = CreatePayment(
wallet_id=wallet_id, wallet_id=wallet_id,
payment_request=payment_request, bolt11=payment_request,
payment_hash=invoice.payment_hash, payment_hash=invoice.payment_hash,
amount=amount_sat * 1000, amount_msat=amount_sat * 1000,
expiry=invoice.expiry_date, expiry=invoice.expiry_date,
memo=memo, memo=memo,
extra=extra, extra=extra,
fiat_amounts=fiat_amounts,
webhook=webhook, webhook=webhook,
) )
await create_payment( payment = await create_payment(
checking_id=checking_id, checking_id=checking_id,
data=create_payment_model, data=create_payment_model,
conn=conn, 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( async def pay_invoice(
@@ -204,18 +377,9 @@ async def pay_invoice(
max_sat: Optional[int] = None, max_sat: Optional[int] = None,
extra: Optional[dict] = None, extra: Optional[dict] = None,
description: str = "", description: str = "",
tag: str = "",
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> str: ) -> Payment:
"""
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.
"""
try: try:
invoice = bolt11_decode(payment_request) invoice = bolt11_decode(payment_request)
except Exception as exc: except Exception as exc:
@@ -227,261 +391,96 @@ async def pay_invoice(
if max_sat and invoice.amount_msat > max_sat * 1000: if max_sat and invoice.amount_msat > max_sat * 1000:
raise PaymentError("Amount in invoice is too high.", status="failed") 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: 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( wallet = await get_wallet(wallet_id, conn=conn)
invoice.amount_msat / 1000, wallet_id, extra=extra, 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( create_payment_model = CreatePayment(
wallet_id=wallet_id, wallet_id=wallet_id,
payment_request=payment_request, bolt11=payment_request,
payment_hash=invoice.payment_hash, payment_hash=invoice.payment_hash,
amount=-invoice.amount_msat, amount_msat=invoice.amount_msat * -1,
expiry=invoice.expiry_date, expiry=invoice.expiry_date,
memo=description or invoice.description or "", memo=description or invoice.description or "",
extra=extra, extra=extra,
fiat_amounts=fiat_amounts,
) )
if await is_internal_status_success(invoice.payment_hash, conn=conn): payment = await _pay_internal_invoice(wallet, create_payment_model, conn)
raise PaymentError("Internal invoice already paid.", status="failed") 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 await _credit_service_fee_wallet(payment)
# (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")
logger.debug(f"creating temporary internal payment with id {internal_id}") return payment
# create a new payment from this wallet
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 async def _credit_service_fee_wallet(payment: Payment):
wallet = await get_wallet(wallet_id, conn=conn) service_fee_msat = service_fee(payment.amount, internal=payment.is_internal)
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
if settings.lnbits_service_fee_wallet and service_fee_msat: if settings.lnbits_service_fee_wallet and service_fee_msat:
create_payment_model = CreatePayment( create_payment_model = CreatePayment(
wallet_id=settings.lnbits_service_fee_wallet, wallet_id=settings.lnbits_service_fee_wallet,
payment_request=payment_request, bolt11=payment.bolt11,
payment_hash=invoice.payment_hash, payment_hash=payment.payment_hash,
amount=abs(service_fee_msat), amount_msat=abs(service_fee_msat),
memo="Service fee", memo="Service fee",
) )
new_payment = await create_payment( await create_payment(
checking_id=f"service_fee_{temp_id}", checking_id=f"service_fee_{payment.payment_hash}",
data=create_payment_model, data=create_payment_model,
status=PaymentState.SUCCESS, status=PaymentState.SUCCESS,
) )
return invoice.payment_hash
async def _create_external_payment( async def check_wallet_limits(
temp_id: str, wallet_id: str, amount_msat: int, conn: Optional[Connection] = None
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,
): ):
if wallet.balance_msat < 0: await check_time_limit_between_transactions(wallet_id, conn)
logger.debug("balance is too low, deleting temporary payment") await check_wallet_daily_withdraw_limit(wallet_id, amount_msat, conn)
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")
async def check_wallet_limits(wallet_id, conn, amount_msat): async def check_time_limit_between_transactions(
await check_time_limit_between_transactions(conn, wallet_id) wallet_id: str, conn: Optional[Connection] = None
await check_wallet_daily_withdraw_limit(conn, wallet_id, amount_msat) ):
async def check_time_limit_between_transactions(conn, wallet_id):
limit = settings.lnbits_wallet_limit_secs_between_trans limit = settings.lnbits_wallet_limit_secs_between_trans
if not limit or limit <= 0: if not limit or limit <= 0:
return return
payments = await get_payments( payments = await get_payments(
since=int(time.time()) - limit, since=int(time.time()) - limit,
wallet_id=wallet_id, wallet_id=wallet_id,
limit=1, limit=1,
conn=conn, conn=conn,
) )
if len(payments) == 0: if len(payments) == 0:
return return
raise PaymentError( raise PaymentError(
status="failed", status="failed",
message=f"The time limit of {limit} seconds between payments has been reached.", 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 limit = settings.lnbits_wallet_limit_daily_max_withdraw
if not limit: if not limit:
return 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): async def send_payment_notification(wallet: Wallet, payment: Payment):
await websocket_updater( await websocket_manager.send_data(payment.json(), wallet.inkey)
wallet.inkey, # json.dumps(
json.dumps( # {
{ # "wallet_balance": wallet.balance,
"wallet_balance": wallet.balance, # "payment": payment,
"payment": payment.dict(), # }
} # ),
), await websocket_manager.send_data(
) json.dumps({"pending": payment.pending}), payment.payment_hash
await websocket_updater(
payment.payment_hash, json.dumps({"pending": payment.pending})
) )
async def update_wallet_balance(wallet_id: str, amount: int): async def update_wallet_balance(wallet_id: str, amount: int):
async with db.connect() as conn: async with db.connect() as conn:
payment_hash, _ = await create_invoice( payment = await create_invoice(
wallet_id=wallet_id, wallet_id=wallet_id,
amount=amount, amount=amount,
memo="Admin top up", memo="Admin top up",
internal=True, internal=True,
conn=conn, conn=conn,
) )
internal_payment = await check_internal(payment_hash, conn=conn) payment.status = PaymentState.SUCCESS
assert internal_payment, "newly created checking_id cannot be retrieved" await update_payment(payment, conn=conn)
internal_payment.status = PaymentState.SUCCESS
await update_payment(internal_payment, conn=conn)
# notify receiver asynchronously # notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue 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(): async def check_admin_settings():
@@ -877,8 +870,8 @@ class WebsocketConnectionManager:
websocket_manager = WebsocketConnectionManager() websocket_manager = WebsocketConnectionManager()
async def websocket_updater(item_id, data): async def websocket_updater(item_id: str, data: str):
return await websocket_manager.send_data(f"{data}", item_id) return await websocket_manager.send_data(data, item_id)
async def switch_to_voidwallet() -> None: async def switch_to_voidwallet() -> None:
+7 -3
View File
@@ -1,3 +1,5 @@
import sys
import traceback
from http import HTTPStatus from http import HTTPStatus
from bolt11 import decode as bolt11_decode from bolt11 import decode as bolt11_decode
@@ -84,6 +86,8 @@ async def api_install_extension(data: CreateExtension):
except Exception as exc: except Exception as exc:
logger.warning(exc) logger.warning(exc)
etype, _, tb = sys.exc_info()
traceback.print_exception(etype, exc, tb)
ext_info.clean_extension_files() ext_info.clean_extension_files()
detail = ( detail = (
str(exc) 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, wallet_id=ext.meta.pay_to_enable.wallet,
amount=data.amount, amount=data.amount,
memo=f"Enable '{ext.name}' extension.", 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) user_ext = UserExtension(user=user.id, extension=ext_id, active=False)
await create_user_extension(user_ext) await create_user_extension(user_ext)
user_ext_info = user_ext.extra if user_ext.extra else UserExtensionInfo() 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 user_ext.extra = user_ext_info
await update_user_extension(user_ext) 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( @extension_router.get(
+33 -66
View File
@@ -3,13 +3,12 @@ import json
import uuid import uuid
from http import HTTPStatus from http import HTTPStatus
from math import ceil from math import ceil
from typing import List, Optional, Union from typing import List, Optional
from urllib.parse import urlparse from urllib.parse import urlparse
import httpx import httpx
from fastapi import ( from fastapi import (
APIRouter, APIRouter,
Body,
Depends, Depends,
Header, Header,
HTTPException, HTTPException,
@@ -21,7 +20,6 @@ from loguru import logger
from sse_starlette.sse import EventSourceResponse from sse_starlette.sse import EventSourceResponse
from lnbits import bolt11 from lnbits import bolt11
from lnbits.core.db import db
from lnbits.core.models import ( from lnbits.core.models import (
CreateInvoice, CreateInvoice,
CreateLnurl, CreateLnurl,
@@ -121,7 +119,7 @@ async def api_payments_paginated(
return page 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"" description_hash = b""
unhashed_description = b"" unhashed_description = b""
memo = data.memo or settings.lnbits_site_title 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 # do not save memo if description_hash or unhashed_description is set
memo = "" memo = ""
async with db.connect() as conn: payment = await create_invoice(
payment_hash, payment_request = await create_invoice( wallet_id=wallet.id,
wallet_id=wallet.id, amount=data.amount,
amount=data.amount, memo=memo,
memo=memo, currency=data.unit,
currency=data.unit, description_hash=description_hash,
description_hash=description_hash, unhashed_description=unhashed_description,
unhashed_description=unhashed_description, expiry=data.expiry,
expiry=data.expiry, extra=data.extra,
extra=data.extra, webhook=data.webhook,
webhook=data.webhook, internal=data.internal,
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
invoice = bolt11.decode(payment_request) # lnurl_response is not saved in the database
lnurl_response: Union[None, bool, str] = None
if data.lnurl_callback: if data.lnurl_callback:
headers = {"User-Agent": settings.user_agent} headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client: async with httpx.AsyncClient(headers=headers) as client:
try: try:
r = await client.get( r = await client.get(
data.lnurl_callback, data.lnurl_callback,
params={ params={"pr": payment.bolt11},
"pr": payment_request,
},
timeout=10, timeout=10,
) )
if r.is_error: if r.is_error:
lnurl_response = r.text payment.extra["lnurl_response"] = r.text
else: else:
resp = json.loads(r.text) resp = json.loads(r.text)
if resp["status"] != "OK": if resp["status"] != "OK":
lnurl_response = resp["reason"] payment.extra["lnurl_response"] = resp["reason"]
else: else:
lnurl_response = True payment.extra["lnurl_response"] = True
except (httpx.ConnectError, httpx.RequestError) as ex: except (httpx.ConnectError, httpx.RequestError) as ex:
logger.error(ex) logger.error(ex)
lnurl_response = False payment.extra["lnurl_response"] = False
return { return payment
"payment_hash": invoice.payment_hash,
"payment_request": payment_request,
"lnurl_response": lnurl_response,
# maintain backwards compatibility with API clients:
"checking_id": checking_id,
}
@payment_router.post( @payment_router.post(
@@ -220,30 +200,25 @@ async def api_payments_create_invoice(data: CreateInvoice, wallet: Wallet):
}, },
) )
async def api_payments_create( async def api_payments_create(
invoice_data: CreateInvoice,
wallet: WalletTypeInfo = Depends(require_invoice_key), wallet: WalletTypeInfo = Depends(require_invoice_key),
invoice_data: CreateInvoice = Body(...), ) -> Payment:
):
if invoice_data.out is True and wallet.key_type == KeyType.admin: if invoice_data.out is True and wallet.key_type == KeyType.admin:
if not invoice_data.bolt11: if not invoice_data.bolt11:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
detail="BOLT11 string is invalid or not given", detail="Missing BOLT11 invoice",
) )
payment = await pay_invoice(
payment_hash = await pay_invoice(
wallet_id=wallet.wallet.id, wallet_id=wallet.wallet.id,
payment_request=invoice_data.bolt11, payment_request=invoice_data.bolt11,
extra=invoice_data.extra, extra=invoice_data.extra,
) )
return { return payment
"payment_hash": payment_hash,
# maintain backwards compatibility with API clients:
"checking_id": payment_hash,
}
elif not invoice_data.out: elif not invoice_data.out:
# invoice key # invoice key
return await api_payments_create_invoice(invoice_data, wallet.wallet) return await _api_payments_create_invoice(invoice_data, wallet.wallet)
else: else:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.UNAUTHORIZED, status_code=HTTPStatus.UNAUTHORIZED,
@@ -269,7 +244,7 @@ async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONRespo
@payment_router.post("/lnurl") @payment_router.post("/lnurl")
async def api_payments_pay_lnurl( async def api_payments_pay_lnurl(
data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key) data: CreateLnurl, wallet: WalletTypeInfo = Depends(require_admin_key)
): ) -> Payment:
domain = urlparse(data.callback).netloc domain = urlparse(data.callback).netloc
headers = {"User-Agent": settings.user_agent} headers = {"User-Agent": settings.user_agent}
@@ -313,15 +288,12 @@ async def api_payments_pay_lnurl(
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.BAD_REQUEST,
detail=( detail=(
( f"{domain} returned an invalid invoice. Expected"
f"{domain} returned an invalid invoice. Expected" f" {amount_msat} msat, got {invoice.amount_msat}."
f" {amount_msat} msat, got {invoice.amount_msat}."
),
), ),
) )
extra = {} extra = {}
if params.get("successAction"): if params.get("successAction"):
extra["success_action"] = params["successAction"] extra["success_action"] = params["successAction"]
if data.comment: if data.comment:
@@ -330,19 +302,14 @@ async def api_payments_pay_lnurl(
extra["fiat_currency"] = data.unit extra["fiat_currency"] = data.unit
extra["fiat_amount"] = data.amount / 1000 extra["fiat_amount"] = data.amount / 1000
assert data.description is not None, "description is required" assert data.description is not None, "description is required"
payment_hash = await pay_invoice(
payment = await pay_invoice(
wallet_id=wallet.wallet.id, wallet_id=wallet.wallet.id,
payment_request=params["pr"], payment_request=params["pr"],
description=data.description, description=data.description,
extra=extra, extra=extra,
) )
return payment
return {
"success_action": params.get("successAction"),
"payment_hash": payment_hash,
# maintain backwards compatibility with API clients:
"checking_id": payment_hash,
}
async def subscribe_wallet_invoices(request: Request, wallet: Wallet): async def subscribe_wallet_invoices(request: Request, wallet: Wallet):
+1
View File
@@ -654,6 +654,7 @@ def dict_to_model(_row: dict, model: type[TModel]) -> TModel:
if issubclass(type_, BaseModel) and value: if issubclass(type_, BaseModel) and value:
_dict[key] = dict_to_submodel(type_, value) _dict[key] = dict_to_submodel(type_, value)
continue continue
# TODO: remove this when all sub models are migrated to Pydantic
if type_ is dict and value: if type_ is dict and value:
_dict[key] = json.loads(value) _dict[key] = json.loads(value)
continue continue
+11 -8
View File
@@ -144,9 +144,11 @@ window.app = Vue.createApp({
) )
.then(response => { .then(response => {
this.receive.status = 'success' this.receive.status = 'success'
this.receive.paymentReq = response.data.payment_request this.receive.paymentReq = response.data.bolt11
this.receive.paymentHash = response.data.payment_hash 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 !== null) {
if (response.data.lnurl_response === false) { if (response.data.lnurl_response === false) {
response.data.lnurl_response = `Unable to connect` response.data.lnurl_response = `Unable to connect`
@@ -393,12 +395,13 @@ window.app = Vue.createApp({
dismissPaymentMsg() dismissPaymentMsg()
clearInterval(this.parse.paymentChecker) clearInterval(this.parse.paymentChecker)
// show lnurlpay success action // show lnurlpay success action
if (response.data.success_action) { const extra = response.data.extra
switch (response.data.success_action.tag) { if (extra.success_action) {
switch (extra.success_action.tag) {
case 'url': case 'url':
Quasar.Notify.create({ Quasar.Notify.create({
message: `<a target="_blank" style="color: inherit" href="${response.data.success_action.url}">${response.data.success_action.url}</a>`, message: `<a target="_blank" style="color: inherit" href="${extra.success_action.url}">${extra.success_action.url}</a>`,
caption: response.data.success_action.description, caption: extra.success_action.description,
html: true, html: true,
type: 'positive', type: 'positive',
timeout: 0, timeout: 0,
@@ -407,7 +410,7 @@ window.app = Vue.createApp({
break break
case 'message': case 'message':
Quasar.Notify.create({ Quasar.Notify.create({
message: response.data.success_action.message, message: extra.success_action.message,
type: 'positive', type: 'positive',
timeout: 0, timeout: 0,
closeBtn: true closeBtn: true
@@ -418,14 +421,14 @@ window.app = Vue.createApp({
.getPayment(this.g.wallet, response.data.payment_hash) .getPayment(this.g.wallet, response.data.payment_hash)
.then(({data: payment}) => .then(({data: payment}) =>
decryptLnurlPayAES( decryptLnurlPayAES(
response.data.success_action, extra.success_action,
payment.preimage payment.preimage
) )
) )
.then(value => { .then(value => {
Quasar.Notify.create({ Quasar.Notify.create({
message: value, message: value,
caption: response.data.success_action.description, caption: extra.success_action.description,
html: true, html: true,
type: 'positive', type: 'positive',
timeout: 0, timeout: 0,
+8 -5
View File
@@ -28,7 +28,6 @@ from lnbits.core.crud import (
) )
from lnbits.core.models import Account, CreateInvoice, PaymentState, User from lnbits.core.models import Account, CreateInvoice, PaymentState, User
from lnbits.core.services import create_user_account, update_wallet_balance 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.db import DB_TYPE, SQLITE, Database
from lnbits.settings import AuthMethods, Settings from lnbits.settings import AuthMethods, Settings
from lnbits.settings import settings as lnbits_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") @pytest_asyncio.fixture(scope="session")
async def invoice(to_wallet): async def invoice(client, adminkey_headers_from):
data = await get_random_invoice_data() data = await get_random_invoice_data()
invoice_data = CreateInvoice(**data) invoice_data = CreateInvoice(**data)
invoice = await api_payments_create_invoice(invoice_data, to_wallet) response = await client.post(
yield invoice "/api/v1/payments", headers=adminkey_headers_from, json=invoice_data.dict()
del invoice )
assert response.is_success
data = response.json()
assert data["checking_id"]
yield data["bolt11"]
@pytest_asyncio.fixture(scope="function") @pytest_asyncio.fixture(scope="function")
@@ -12,35 +12,35 @@ description = "test create invoice"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_invoice(from_wallet): async def test_create_invoice(from_wallet):
payment_hash, pr = await create_invoice( payment = await create_invoice(
wallet_id=from_wallet.id, wallet_id=from_wallet.id,
amount=1000, amount=1000,
memo=description, memo=description,
) )
invoice = decode(pr) invoice = decode(payment.bolt11)
assert invoice.payment_hash == payment_hash assert invoice.payment_hash == payment.payment_hash
assert invoice.amount_msat == 1000000 assert invoice.amount_msat == 1000000
assert invoice.description == description assert invoice.description == description
funding_source = get_funding_source() 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 isinstance(status, PaymentStatus)
assert status.pending assert status.pending
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_create_internal_invoice(from_wallet): 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 wallet_id=from_wallet.id, amount=1000, memo=description, internal=True
) )
invoice = decode(pr) invoice = decode(payment.bolt11)
assert invoice.payment_hash == payment_hash assert invoice.payment_hash == payment.payment_hash
assert invoice.amount_msat == 1000000 assert invoice.amount_msat == 1000000
assert invoice.description == description assert invoice.description == description
# Internal invoices are not on fundingsource. so we should get some kind of error # 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 # that the invoice is not found, but we get status pending
funding_source = get_funding_source() 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 isinstance(status, PaymentStatus)
assert status.pending assert status.pending
+1 -6
View File
@@ -1,8 +1,5 @@
import pytest import pytest
from lnbits.core.crud import (
get_standalone_payment,
)
from lnbits.core.services import ( from lnbits.core.services import (
PaymentError, PaymentError,
PaymentState, PaymentState,
@@ -14,13 +11,11 @@ description = "test pay invoice"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_services_pay_invoice(to_wallet, real_invoice): async def test_services_pay_invoice(to_wallet, real_invoice):
payment_hash = await pay_invoice( payment = await pay_invoice(
wallet_id=to_wallet.id, wallet_id=to_wallet.id,
payment_request=real_invoice.get("bolt11"), payment_request=real_invoice.get("bolt11"),
description=description, description=description,
) )
assert payment_hash
payment = await get_standalone_payment(payment_hash)
assert payment assert payment
assert not payment.status == PaymentState.SUCCESS assert not payment.status == PaymentState.SUCCESS
assert payment.memo == description assert payment.memo == description