diff --git a/lnbits/core/__init__.py b/lnbits/core/__init__.py index 749e8b8a1..b97373874 100644 --- a/lnbits/core/__init__.py +++ b/lnbits/core/__init__.py @@ -11,6 +11,7 @@ from .views.fiat_api import fiat_router # this compat is needed for usermanager extension from .views.generic import generic_router +from .views.hold_invoice_api import hold_invoice_router from .views.node_api import node_router, public_node_router, super_node_router from .views.payment_api import payment_router from .views.tinyurl_api import tinyurl_router @@ -33,6 +34,7 @@ def init_core_routers(app: FastAPI): app.include_router(super_node_router) app.include_router(public_node_router) app.include_router(payment_router) + app.include_router(hold_invoice_router) app.include_router(wallet_router) app.include_router(api_router) app.include_router(websocket_router) diff --git a/lnbits/core/models.py b/lnbits/core/models.py new file mode 100644 index 000000000..a10b9dc11 --- /dev/null +++ b/lnbits/core/models.py @@ -0,0 +1,457 @@ +from __future__ import annotations + +import datetime +import hashlib +import hmac +import json +import time +from dataclasses import dataclass +from enum import Enum +from sqlite3 import Row +from typing import Callable, Optional + +from ecdsa import SECP256k1, SigningKey +from fastapi import Query +from pydantic import BaseModel, validator + +from lnbits.db import FilterModel, FromRowModel +from lnbits.helpers import url_for +from lnbits.lnurl import encode as lnurl_encode +from lnbits.settings import settings +from lnbits.utils.exchange_rates import allowed_currencies +from lnbits.wallets import get_funding_source +from lnbits.wallets.base import ( + PaymentPendingStatus, + PaymentStatus, +) + + +class BaseWallet(BaseModel): + id: str + name: str + adminkey: str + inkey: str + balance_msat: int + + +class Wallet(BaseWallet): + user: str + currency: Optional[str] + deleted: bool + created_at: Optional[int] = None + updated_at: Optional[int] = None + + @property + def balance(self) -> int: + return self.balance_msat // 1000 + + @property + def withdrawable_balance(self) -> int: + from .services import fee_reserve + + return self.balance_msat - fee_reserve(self.balance_msat) + + @property + def lnurlwithdraw_full(self) -> str: + url = url_for("/withdraw", external=True, usr=self.user, wal=self.id) + try: + return lnurl_encode(url) + except Exception: + return "" + + def lnurlauth_key(self, domain: str) -> SigningKey: + hashing_key = hashlib.sha256(self.id.encode()).digest() + linking_key = hmac.digest(hashing_key, domain.encode(), "sha256") + + return SigningKey.from_string( + linking_key, curve=SECP256k1, hashfunc=hashlib.sha256 + ) + + async def get_payment(self, payment_hash: str) -> Optional[Payment]: + from .crud import get_standalone_payment + + return await get_standalone_payment(payment_hash) + + +class KeyType(Enum): + admin = 0 + invoice = 1 + invalid = 2 + + # backwards compatibility + def __eq__(self, other): + return self.value == other + + +@dataclass +class WalletTypeInfo: + key_type: KeyType + wallet: Wallet + + +class UserConfig(BaseModel): + email_verified: Optional[bool] = False + first_name: Optional[str] = None + last_name: Optional[str] = None + display_name: Optional[str] = None + picture: Optional[str] = None + # Auth provider, possible values: + # - "env": the user was created automatically by the system + # - "lnbits": the user was created via register form (username/pass or user_id only) + # - "google | github | ...": the user was created using an SSO provider + provider: Optional[str] = "lnbits" # auth provider + + +class Account(FromRowModel): + id: str + is_super_user: Optional[bool] = False + is_admin: Optional[bool] = False + username: Optional[str] = None + email: Optional[str] = None + balance_msat: Optional[int] = 0 + transaction_count: Optional[int] = 0 + wallet_count: Optional[int] = 0 + last_payment: Optional[datetime.datetime] = None + + +class AccountFilters(FilterModel): + __search_fields__ = ["id", "email", "username"] + __sort_fields__ = [ + "balance_msat", + "email", + "username", + "transaction_count", + "wallet_count", + "last_payment", + ] + + id: str + last_payment: Optional[datetime.datetime] = None + transaction_count: Optional[int] = None + wallet_count: Optional[int] = None + username: Optional[str] = None + email: Optional[str] = None + + +class User(BaseModel): + id: str + email: Optional[str] = None + username: Optional[str] = None + extensions: list[str] = [] + wallets: list[Wallet] = [] + admin: bool = False + super_user: bool = False + has_password: bool = False + config: Optional[UserConfig] = None + created_at: Optional[int] = None + updated_at: Optional[int] = None + + @property + def wallet_ids(self) -> list[str]: + return [wallet.id for wallet in self.wallets] + + def get_wallet(self, wallet_id: str) -> Optional[Wallet]: + w = [wallet for wallet in self.wallets if wallet.id == wallet_id] + return w[0] if w else None + + @classmethod + def is_extension_for_user(cls, ext: str, user: str) -> bool: + if ext not in settings.lnbits_admin_extensions: + return True + if user == settings.super_user: + return True + if user in settings.lnbits_admin_users: + return True + return False + + +class CreateUser(BaseModel): + email: Optional[str] = Query(default=None) + username: str = Query(default=..., min_length=2, max_length=20) + password: str = Query(default=..., min_length=8, max_length=50) + password_repeat: str = Query(default=..., min_length=8, max_length=50) + + +class UpdateUser(BaseModel): + user_id: str + email: Optional[str] = Query(default=None) + username: Optional[str] = Query(default=..., min_length=2, max_length=20) + config: Optional[UserConfig] = None + + +class UpdateUserPassword(BaseModel): + user_id: str + password: str = Query(default=..., min_length=8, max_length=50) + password_repeat: str = Query(default=..., min_length=8, max_length=50) + password_old: Optional[str] = Query(default=None, min_length=8, max_length=50) + username: Optional[str] = Query(default=..., min_length=2, max_length=20) + + +class UpdateSuperuserPassword(BaseModel): + username: str = Query(default=..., min_length=2, max_length=20) + password: str = Query(default=..., min_length=8, max_length=50) + password_repeat: str = Query(default=..., min_length=8, max_length=50) + + +class LoginUsr(BaseModel): + usr: str + + +class LoginUsernamePassword(BaseModel): + username: str + password: str + + +class PaymentState(str, Enum): + PENDING = "pending" + SUCCESS = "success" + FAILED = "failed" + + def __str__(self) -> str: + return self.value + + +class Payment(FromRowModel): + status: str + # TODO should be removed in the future, backward compatibility + pending: bool + checking_id: str + amount: int + fee: int + memo: Optional[str] + time: int + bolt11: str + preimage: str + payment_hash: str + expiry: Optional[float] + extra: dict = {} + wallet_id: str + webhook: Optional[str] + webhook_status: Optional[int] + + @property + def success(self) -> bool: + return self.status == PaymentState.SUCCESS.value + + @property + def failed(self) -> bool: + return self.status == PaymentState.FAILED.value + + @classmethod + def from_row(cls, row: Row): + return cls( + checking_id=row["checking_id"], + payment_hash=row["hash"] or "0" * 64, + bolt11=row["bolt11"] or "", + preimage=row["preimage"] or "0" * 64, + extra=json.loads(row["extra"] or "{}"), + status=row["status"], + # TODO should be removed in the future, backward compatibility + pending=row["status"] == PaymentState.PENDING.value, + amount=row["amount"], + fee=row["fee"], + memo=row["memo"], + time=row["time"], + expiry=row["expiry"], + wallet_id=row["wallet"], + webhook=row["webhook"], + webhook_status=row["webhook_status"], + ) + + @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 + + @property + def sat(self) -> int: + return self.amount // 1000 + + @property + def is_in(self) -> bool: + return self.amount > 0 + + @property + def is_out(self) -> bool: + return self.amount < 0 + + @property + def is_expired(self) -> bool: + return self.expiry < time.time() if self.expiry else False + + @property + def is_uncheckable(self) -> bool: + return self.checking_id.startswith("internal_") + + async def check_status(self) -> PaymentStatus: + if self.is_uncheckable: + return PaymentPendingStatus() + funding_source = get_funding_source() + if self.is_out: + status = await funding_source.get_payment_status(self.checking_id) + else: + status = await funding_source.get_invoice_status(self.checking_id) + return status + + +class PaymentFilters(FilterModel): + __search_fields__ = ["memo", "amount"] + + checking_id: str + amount: int + fee: int + memo: Optional[str] + time: datetime.datetime + bolt11: str + preimage: str + payment_hash: str + expiry: Optional[datetime.datetime] + extra: dict = {} + wallet_id: str + webhook: Optional[str] + webhook_status: Optional[int] + + +class PaymentHistoryPoint(BaseModel): + date: datetime.datetime + income: int + spending: int + balance: int + + +def _do_nothing(*_): + pass + + +class CoreAppExtra: + register_new_ext_routes: Callable = _do_nothing + register_new_ratelimiter: Callable + + +class TinyURL(BaseModel): + id: str + url: str + endless: bool + wallet: str + time: float + + @classmethod + def from_row(cls, row: Row): + return cls(**dict(row)) + + +class ConversionData(BaseModel): + from_: str = "sat" + amount: float + to: str = "usd" + + +class Callback(BaseModel): + callback: str + + +class DecodePayment(BaseModel): + data: str + filter_fields: Optional[list[str]] = [] + + +class CreateLnurl(BaseModel): + description_hash: str + callback: str + amount: int + comment: Optional[str] = None + description: Optional[str] = None + unit: Optional[str] = None + + +class CreateInvoice(BaseModel): + unit: str = "sat" + internal: bool = False + out: bool = True + amount: float = Query(None, ge=0) + memo: Optional[str] = None + description_hash: Optional[str] = None + unhashed_description: Optional[str] = None + expiry: Optional[int] = None + extra: Optional[dict] = None + webhook: Optional[str] = None + bolt11: Optional[str] = None + lnurl_callback: Optional[str] = None + + @validator("unit") + @classmethod + 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 + + +class CreateHoldInvoice(BaseModel): + unit: str = "sat" + amount: float = Query(None, ge=0) + hash: str + memo: Optional[str] = None + description_hash: Optional[str] = None + extra: Optional[dict] = None + webhook: Optional[str] = None + + @validator("unit") + @classmethod + 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 + + +class CreateTopup(BaseModel): + id: str + amount: int + + +class CreateLnurlAuth(BaseModel): + callback: str + + +class CreateWallet(BaseModel): + name: Optional[str] = None + + +class CreateWebPushSubscription(BaseModel): + subscription: str + + +class WebPushSubscription(BaseModel): + endpoint: str + user: str + data: str + host: str + timestamp: str + + +class BalanceDelta(BaseModel): + lnbits_balance_msats: int + node_balance_msats: int + + @property + def delta_msats(self): + return self.node_balance_msats - self.lnbits_balance_msats + + +class SimpleStatus(BaseModel): + success: bool + message: str + + +class SettleInvoice(BaseModel): + preimage: str + + +class CancelInvoice(BaseModel): + payment_hash: str diff --git a/lnbits/core/models/payments.py b/lnbits/core/models/payments.py index ed7073e49..3b3353326 100644 --- a/lnbits/core/models/payments.py +++ b/lnbits/core/models/payments.py @@ -272,3 +272,11 @@ class PaymentsStatusCount(BaseModel): outgoing: int = 0 failed: int = 0 pending: int = 0 + + +class SettleInvoice(BaseModel): + preimage: str + + +class CancelInvoice(BaseModel): + payment_hash: str diff --git a/lnbits/core/services.py b/lnbits/core/services.py new file mode 100644 index 000000000..ee4047b24 --- /dev/null +++ b/lnbits/core/services.py @@ -0,0 +1,1034 @@ +import asyncio +import datetime +import json +import time +from io import BytesIO +from pathlib import Path +from typing import Dict, List, Optional, Tuple, TypedDict +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 +from loguru import logger +from passlib.context import CryptContext +from py_vapid import Vapid +from py_vapid.utils import b64urlencode + +from lnbits.core.db import db +from lnbits.db import Connection +from lnbits.decorators import ( + WalletTypeInfo, + check_user_extension_access, + require_admin_key, +) +from lnbits.exceptions import InvoiceError, PaymentError +from lnbits.helpers import url_for +from lnbits.lnurl import LnurlErrorResponse +from lnbits.lnurl import decode as decode_lnurl +from lnbits.settings import ( + EditableSettings, + SuperSettings, + readonly_variables, + send_admin_user_to_saas, + settings, +) +from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat +from lnbits.wallets import fake_wallet, get_funding_source, set_funding_source +from lnbits.wallets.base import ( + PaymentPendingStatus, + PaymentResponse, + PaymentStatus, + PaymentSuccessStatus, +) + +from .crud import ( + check_internal, + check_internal_pending, + create_account, + create_admin_settings, + create_payment, + create_wallet, + get_account, + get_account_by_email, + get_account_by_username, + get_payments, + get_standalone_payment, + get_super_settings, + get_total_balance, + get_wallet, + get_wallet_payment, + update_admin_settings, + update_payment_details, + update_payment_status, + update_super_user, + update_user_extension, +) +from .helpers import to_valid_user_id +from .models import BalanceDelta, Payment, PaymentState, User, UserConfig, Wallet + + +async def calculate_fiat_amounts( + amount: float, + wallet_id: str, + 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" + wallet_currency = wallet.currency or settings.lnbits_default_accounting_currency + + 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 + else: + amount_sat = int(amount) + + if wallet_currency: + if wallet_currency == currency: + 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 + + logger.debug( + f"Calculated fiat amounts {wallet.id=} {amount=} {currency=}: {extra=}" + ) + + return amount_sat, extra + + +async def create_invoice( + *, + wallet_id: str, + amount: float, + currency: Optional[str] = "sat", + memo: str, + description_hash: Optional[bytes] = None, + unhashed_description: Optional[bytes] = None, + expiry: Optional[int] = None, + extra: Optional[Dict] = None, + webhook: Optional[str] = None, + internal: Optional[bool] = False, + conn: Optional[Connection] = None, +) -> Tuple[str, str]: + if not amount > 0: + raise InvoiceError("Amountless invoices not supported.", status="failed") + + user_wallet = await get_wallet(wallet_id, conn=conn) + if not user_wallet: + raise InvoiceError(f"Could not fetch wallet '{wallet_id}'.", status="failed") + + invoice_memo = None if description_hash else memo + + # 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 + ) + + if settings.is_wallet_max_balance_exceeded( + user_wallet.balance_msat / 1000 + amount_sat + ): + raise InvoiceError( + f"Wallet balance cannot exceed " + f"{settings.lnbits_wallet_limit_max_balance} sats.", + status="failed", + ) + + ( + ok, + checking_id, + payment_request, + error_message, + ) = await funding_source.create_invoice( + amount=amount_sat, + memo=invoice_memo, + description_hash=description_hash, + unhashed_description=unhashed_description, + expiry=expiry or settings.lightning_invoice_expiry, + ) + if not ok or not payment_request or not checking_id: + raise InvoiceError( + error_message or "unexpected backend error.", status="pending" + ) + + invoice = bolt11_decode(payment_request) + + amount_msat = 1000 * amount_sat + await create_payment( + wallet_id=wallet_id, + checking_id=checking_id, + payment_request=payment_request, + payment_hash=invoice.payment_hash, + amount=amount_msat, + expiry=invoice.expiry_date, + memo=memo, + extra=extra, + webhook=webhook, + conn=conn, + ) + + return invoice.payment_hash, payment_request + + +async def create_hold_invoice( + *, + wallet_id: str, + amount: int, # in satoshis + memo: str, + rhash: bytes, # Hash the HTLC of the hold invoice is locked to. + description_hash: Optional[bytes] = None, + extra: Optional[Dict] = None, + webhook: Optional[str] = None, + conn: Optional[Connection] = None, +) -> Tuple[str, str]: + invoice_memo = None if description_hash else memo + + funding_source = get_funding_source() + if funding_source.__class__.__name__ not in ["LndRestWallet", "LndWallet"]: + raise InvoiceError( + "Hold invoices are only supported with LND.", status="failed" + ) + + ( + ok, + checking_id, + payment_request, + error_message, + ) = await funding_source.create_hold_invoice( + amount=amount, + memo=invoice_memo, + rhash=rhash, + description_hash=description_hash, + ) + + if not ok: + raise InvoiceError( + error_message or "Unexpected backend error.", status="failed" + ) + + invoice = bolt11_decode(payment_request) + + amount_msat = amount * 1000 + await create_payment( + wallet_id=wallet_id, + checking_id=checking_id, + payment_request=payment_request, + payment_hash=invoice.payment_hash, + amount=amount_msat, + memo=memo, + extra=extra, + webhook=webhook, + conn=conn, + ) + + return invoice.payment_hash, payment_request + + +async def settle_hold_invoice( + *, + preimage: bytes, +) -> bool: + if len(preimage) != 32: + raise InvoiceError( + "Invalid preimage length. Must be 32 bytes", + status="failed", + ) + + funding_source = get_funding_source() + if funding_source.__class__.__name__ not in ["LndRestWallet", "LndWallet"]: + raise InvoiceError( + "Hold invoices are only supported with LND.", status="failed" + ) + + response = await funding_source.settle_hold_invoice(preimage=preimage) + + if not response.ok: + raise InvoiceError("Unexpected backend error.", status="failed") + + return True + + +async def cancel_hold_invoice( + *, + wallet_id: str, + payment_hash: bytes, + conn: Optional[Connection] = None, +) -> bool: + + funding_source = get_funding_source() + if funding_source.__class__.__name__ not in ["LndRestWallet", "LndWallet"]: + raise InvoiceError( + "Hold invoices are only supported with LND.", status="failed" + ) + response = await funding_source.cancel_hold_invoice(payment_hash=payment_hash) + if not response.ok: + raise InvoiceError("Unexpected backend error.", status="failed") + + return True + + +async def subscribe_hold_invoice( + *, + payment_hash: bytes, +) -> bool: + + payment = await get_standalone_payment(payment_hash, incoming=True) + + funding_source = get_funding_source() + if funding_source.__class__.__name__ not in ["LndRestWallet", "LndWallet"]: + raise InvoiceError( + "Hold invoices are only supported with LND.", status="failed" + ) + + if payment.webhook: + asyncio.create_task( + funding_source.hold_invoices_stream( + payment_hash=payment_hash, webhook=payment.webhook + ) + ) + + return True + + +async def pay_invoice( + *, + wallet_id: str, + payment_request: str, + max_sat: Optional[int] = None, + extra: Optional[Dict] = None, + 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. + """ + try: + invoice = bolt11_decode(payment_request) + except Exception as exc: + raise PaymentError("Bolt11 decoding failed.", status="failed") from exc + + if not invoice.amount_msat or not invoice.amount_msat > 0: + raise PaymentError("Amountless invoices not supported.", status="failed") + 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 + ) + + # put all parameters that don't change here + class PaymentKwargs(TypedDict): + wallet_id: str + payment_request: str + payment_hash: str + amount: int + memo: str + expiry: Optional[datetime.datetime] + extra: Optional[Dict] + + payment_kwargs: PaymentKwargs = PaymentKwargs( + wallet_id=wallet_id, + payment_request=payment_request, + payment_hash=invoice.payment_hash, + amount=-invoice.amount_msat, + expiry=invoice.expiry_date, + memo=description or invoice.description or "", + extra=extra, + ) + + # we check if an internal invoice exists that has already been paid + # (not pending anymore) + if not await check_internal_pending(invoice.payment_hash, conn=conn): + raise PaymentError("Internal invoice already paid.", status="failed") + + # check_internal() returns the checking_id of the invoice we're waiting for + # (pending only) + internal_checking_id = await check_internal(invoice.payment_hash, conn=conn) + if internal_checking_id: + fee_reserve_total_msat = fee_reserve_total( + invoice.amount_msat, internal=True + ) + # 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_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}") + # create a new payment from this wallet + new_payment = await create_payment( + checking_id=internal_id, + fee=0 + abs(fee_reserve_total_msat), + status=PaymentState.SUCCESS, + conn=conn, + **payment_kwargs, + ) + else: + new_payment = await _create_external_payment( + temp_id, invoice.amount_msat, conn=conn, **payment_kwargs + ) + + # 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_checking_id) + + 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_checking_id: + service_fee_msat = service_fee(invoice.amount_msat, internal=True) + logger.debug(f"marking temporary payment as not pending {internal_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: + await update_payment_status( + checking_id=internal_checking_id, + status=PaymentState.SUCCESS, + 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_checking_id}") + await internal_invoice_queue.put(internal_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: PaymentResponse = await funding_source.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:" + f" {payment.checking_id})" + ) + + logger.debug(f"backend: pay_invoice finished {temp_id}, {payment}") + if payment.checking_id and payment.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: + await update_payment_details( + checking_id=temp_id, + status=( + PaymentState.SUCCESS + if payment.ok is True + else PaymentState.PENDING + ), + fee=-( + abs(payment.fee_msat if payment.fee_msat else 0) + + abs(service_fee_msat) + ), + preimage=payment.preimage, + new_checking_id=payment.checking_id, + conn=conn, + ) + wallet = await get_wallet(wallet_id, conn=conn) + updated = await get_wallet_payment( + wallet_id, payment.checking_id, conn=conn + ) + if wallet and updated: + await send_payment_notification(wallet, updated) + logger.success(f"payment successful {payment.checking_id}") + elif payment.checking_id is None and payment.ok is False: + # payment failed + logger.debug(f"payment failed {temp_id}, {payment.error_message}") + async with db.connect() as conn: + await update_payment_status( + checking_id=temp_id, + status=PaymentState.FAILED, + conn=conn, + ) + raise PaymentError( + f"Payment failed: {payment.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: + new_payment = await create_payment( + wallet_id=settings.lnbits_service_fee_wallet, + fee=0, + amount=abs(service_fee_msat), + memo="Service fee", + checking_id="service_fee" + temp_id, + payment_request=payment_request, + payment_hash=invoice.payment_hash, + status=PaymentState.SUCCESS, + ) + return invoice.payment_hash + + +async def _create_external_payment( + temp_id: str, + amount_msat: MilliSatoshi, + conn: Optional[Connection], + **payment_kwargs, +) -> 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 + await update_payment_status( + checking_id=temp_id, status=PaymentState.SUCCESS, 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: + new_payment = await create_payment( + checking_id=temp_id, + fee=-abs(fee_reserve_total_msat), + conn=conn, + **payment_kwargs, + ) + 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_checking_id: Optional[str] = None, +): + if wallet.balance_msat < 0: + logger.debug("balance is too low, deleting temporary payment") + if not internal_checking_id 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): + 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): + 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): + limit = settings.lnbits_wallet_limit_daily_max_withdraw + if not limit or limit <= 0: + return + + payments = await get_payments( + since=int(time.time()) - 60 * 60 * 24, + outgoing=True, + wallet_id=wallet_id, + limit=1, + conn=conn, + ) + if len(payments) == 0: + return + + total = 0 + for pay in payments: + total += pay.amount + total = total - amount_msat + if limit * 1000 + total < 0: + raise ValueError( + "Daily withdrawal limit of " + + str(settings.lnbits_wallet_limit_daily_max_withdraw) + + " sats reached." + ) + + +async def redeem_lnurl_withdraw( + wallet_id: str, + lnurl_request: str, + memo: Optional[str] = None, + extra: Optional[Dict] = None, + wait_seconds: int = 0, + conn: Optional[Connection] = None, +) -> None: + if not lnurl_request: + return None + + res = {} + + headers = {"User-Agent": settings.user_agent} + async with httpx.AsyncClient(headers=headers) as client: + lnurl = decode_lnurl(lnurl_request) + r = await client.get(str(lnurl)) + res = r.json() + + try: + _, payment_request = await create_invoice( + wallet_id=wallet_id, + amount=int(res["maxWithdrawable"] / 1000), + memo=memo or res["defaultDescription"] or "", + extra=extra, + conn=conn, + ) + except Exception: + logger.warning( + f"failed to create invoice on redeem_lnurl_withdraw " + f"from {lnurl}. params: {res}" + ) + return None + + if wait_seconds: + await asyncio.sleep(wait_seconds) + + params = {"k1": res["k1"], "pr": payment_request} + + try: + params["balanceNotify"] = url_for( + f"/withdraw/notify/{urlparse(lnurl_request).netloc}", + external=True, + wal=wallet_id, + ) + except Exception: + pass + + headers = {"User-Agent": settings.user_agent} + async with httpx.AsyncClient(headers=headers) as client: + try: + await client.get(res["callback"], params=params) + except Exception: + pass + + +async def perform_lnurlauth( + callback: str, + wallet: WalletTypeInfo = Depends(require_admin_key), +) -> Optional[LnurlErrorResponse]: + cb = urlparse(callback) + + k1 = bytes.fromhex(parse_qs(cb.query)["k1"][0]) + + key = wallet.wallet.lnurlauth_key(cb.netloc) + + def int_to_bytes_suitable_der(x: int) -> bytes: + """for strict DER we need to encode the integer with some quirks""" + b = x.to_bytes((x.bit_length() + 7) // 8, "big") + + if len(b) == 0: + # ensure there's at least one byte when the int is zero + return bytes([0]) + + if b[0] & 0x80 != 0: + # ensure it doesn't start with a 0x80 and so it isn't + # interpreted as a negative number + return bytes([0]) + b + + return b + + def encode_strict_der(r: int, s: int, order: int): + # if s > order/2 verification will fail sometimes + # so we must fix it here see: + # https://github.com/indutny/elliptic/blob/e71b2d9359c5fe9437fbf46f1f05096de447de57/lib/elliptic/ec/index.js#L146-L147 + if s > order // 2: + s = order - s + + # now we do the strict DER encoding copied from + # https://github.com/KiriKiri/bip66 (without any checks) + r_temp = int_to_bytes_suitable_der(r) + s_temp = int_to_bytes_suitable_der(s) + + r_len = len(r_temp) + s_len = len(s_temp) + sign_len = 6 + r_len + s_len + + signature = BytesIO() + signature.write(0x30.to_bytes(1, "big", signed=False)) + signature.write((sign_len - 2).to_bytes(1, "big", signed=False)) + signature.write(0x02.to_bytes(1, "big", signed=False)) + signature.write(r_len.to_bytes(1, "big", signed=False)) + signature.write(r_temp) + signature.write(0x02.to_bytes(1, "big", signed=False)) + signature.write(s_len.to_bytes(1, "big", signed=False)) + signature.write(s_temp) + + return signature.getvalue() + + sig = key.sign_digest_deterministic(k1, sigencode=encode_strict_der) + + headers = {"User-Agent": settings.user_agent} + async with httpx.AsyncClient(headers=headers) as client: + assert key.verifying_key, "LNURLauth verifying_key does not exist" + r = await client.get( + callback, + params={ + "k1": k1.hex(), + "key": key.verifying_key.to_string("compressed").hex(), + "sig": sig.hex(), + }, + ) + try: + resp = json.loads(r.text) + if resp["status"] == "OK": + return None + + return LnurlErrorResponse(reason=resp["reason"]) + except (KeyError, json.decoder.JSONDecodeError): + return LnurlErrorResponse( + reason=r.text[:200] + "..." if len(r.text) > 200 else r.text + ) + + +async def check_transaction_status( + wallet_id: str, payment_hash: str, conn: Optional[Connection] = None +) -> PaymentStatus: + payment: Optional[Payment] = await get_wallet_payment( + wallet_id, payment_hash, conn=conn + ) + if not payment: + return PaymentPendingStatus() + + if payment.status == PaymentState.SUCCESS.value: + return PaymentSuccessStatus(fee_msat=payment.fee) + + return await payment.check_status() + + +# WARN: this same value must be used for balance check and passed to +# funding_source.pay_invoice(), it may cause a vulnerability if the values differ +def fee_reserve(amount_msat: int, internal: bool = False) -> int: + if internal: + return 0 + reserve_min = settings.lnbits_reserve_fee_min + reserve_percent = settings.lnbits_reserve_fee_percent + return max(int(reserve_min), int(amount_msat * reserve_percent / 100.0)) + + +def service_fee(amount_msat: int, internal: bool = False) -> int: + service_fee_percent = settings.lnbits_service_fee + fee_max = settings.lnbits_service_fee_max * 1000 + if settings.lnbits_service_fee_wallet: + if internal and settings.lnbits_service_fee_ignore_internal: + return 0 + fee_percentage = int(amount_msat / 100 * service_fee_percent) + if fee_max > 0 and fee_percentage > fee_max: + return fee_max + else: + return fee_percentage + else: + return 0 + + +def fee_reserve_total(amount_msat: int, internal: bool = False) -> int: + return fee_reserve(amount_msat, internal) + service_fee(amount_msat, internal) + + +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}) + ) + + +async def update_wallet_balance(wallet_id: str, amount: int): + payment_hash, _ = await create_invoice( + wallet_id=wallet_id, + amount=amount, + memo="Admin top up", + internal=True, + ) + async with db.connect() as conn: + checking_id = await check_internal(payment_hash, conn=conn) + assert checking_id, "newly created checking_id cannot be retrieved" + await update_payment_status( + checking_id=checking_id, status=PaymentState.SUCCESS, conn=conn + ) + # notify receiver asynchronously + from lnbits.tasks import internal_invoice_queue + + await internal_invoice_queue.put(checking_id) + + +async def check_admin_settings(): + if settings.super_user: + settings.super_user = to_valid_user_id(settings.super_user).hex + + if settings.lnbits_admin_ui: + settings_db = await get_super_settings() + if not settings_db: + # create new settings if table is empty + logger.warning("Settings DB empty. Inserting default settings.") + settings_db = await init_admin_settings(settings.super_user) + logger.warning("Initialized settings from environment variables.") + + if settings.super_user and settings.super_user != settings_db.super_user: + # .env super_user overwrites DB super_user + settings_db = await update_super_user(settings.super_user) + + update_cached_settings(settings_db.dict()) + + # saving superuser to {data_dir}/.super_user file + with open(Path(settings.lnbits_data_folder) / ".super_user", "w") as file: + file.write(settings.super_user) + + # callback for saas + if ( + settings.lnbits_saas_callback + and settings.lnbits_saas_secret + and settings.lnbits_saas_instance_id + ): + send_admin_user_to_saas() + + account = await get_account(settings.super_user) + if account and account.config and account.config.provider == "env": + settings.first_install = True + + logger.success( + "✔️ Admin UI is enabled. run `poetry run lnbits-cli superuser` " + "to get the superuser." + ) + + +async def check_webpush_settings(): + if not settings.lnbits_webpush_privkey: + vapid = Vapid() + vapid.generate_keys() + privkey = vapid.private_pem() + assert vapid.public_key, "VAPID public key does not exist" + pubkey = b64urlencode( + vapid.public_key.public_bytes( + serialization.Encoding.X962, + serialization.PublicFormat.UncompressedPoint, + ) + ) + push_settings = { + "lnbits_webpush_privkey": privkey.decode(), + "lnbits_webpush_pubkey": pubkey, + } + update_cached_settings(push_settings) + await update_admin_settings(EditableSettings(**push_settings)) + + logger.info("Initialized webpush settings with generated VAPID key pair.") + logger.info(f"Pubkey: {settings.lnbits_webpush_pubkey}") + + +def update_cached_settings(sets_dict: dict): + for key, value in sets_dict.items(): + if key in readonly_variables: + continue + if key not in settings.dict().keys(): + continue + try: + setattr(settings, key, value) + except Exception: + logger.warning(f"Failed overriding setting: {key}, value: {value}") + if "super_user" in sets_dict: + settings.super_user = sets_dict["super_user"] + + +async def init_admin_settings(super_user: Optional[str] = None) -> SuperSettings: + account = None + if super_user: + account = await get_account(super_user) + if not account: + account = await create_account( + user_id=super_user, user_config=UserConfig(provider="env") + ) + if not account.wallets or len(account.wallets) == 0: + await create_wallet(user_id=account.id) + + editable_settings = EditableSettings.from_dict(settings.dict()) + + return await create_admin_settings(account.id, editable_settings.dict()) + + +async def create_user_account( + user_id: Optional[str] = None, + email: Optional[str] = None, + username: Optional[str] = None, + password: Optional[str] = None, + wallet_name: Optional[str] = None, + user_config: Optional[UserConfig] = None, +) -> User: + if not settings.new_accounts_allowed: + raise ValueError("Account creation is disabled.") + if username and await get_account_by_username(username): + raise ValueError("Username already exists.") + + if email and await get_account_by_email(email): + raise ValueError("Email already exists.") + + if user_id: + user_uuid4 = UUID(hex=user_id, version=4) + assert user_uuid4.hex == user_id, "User ID is not valid UUID4 hex string" + else: + user_id = uuid4().hex + + pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + password = pwd_context.hash(password) if password else None + + account = await create_account(user_id, username, email, password, user_config) + wallet = await create_wallet(user_id=account.id, wallet_name=wallet_name) + account.wallets = [wallet] + + for ext_id in settings.lnbits_user_default_extensions: + await update_user_extension(user_id=account.id, extension=ext_id, active=True) + + return account + + +class WebsocketConnectionManager: + def __init__(self) -> None: + self.active_connections: List[WebSocket] = [] + + async def connect(self, websocket: WebSocket, item_id: str): + logger.debug(f"Websocket connected to {item_id}") + await websocket.accept() + self.active_connections.append(websocket) + + def disconnect(self, websocket: WebSocket): + self.active_connections.remove(websocket) + + async def send_data(self, message: str, item_id: str): + for connection in self.active_connections: + if connection.path_params["item_id"] == item_id: + await connection.send_text(message) + + +websocket_manager = WebsocketConnectionManager() + + +async def websocket_updater(item_id, data): + return await websocket_manager.send_data(f"{data}", item_id) + + +async def switch_to_voidwallet() -> None: + funding_source = get_funding_source() + if funding_source.__class__.__name__ == "VoidWallet": + return + set_funding_source("VoidWallet") + settings.lnbits_backend_wallet_class = "VoidWallet" + + +async def get_balance_delta() -> BalanceDelta: + funding_source = get_funding_source() + status = await funding_source.status() + lnbits_balance = await get_total_balance() + return BalanceDelta( + lnbits_balance_msats=lnbits_balance, + node_balance_msats=status.balance_msat, + ) + + +async def update_pending_payments(wallet_id: str): + pending_payments = await get_payments( + wallet_id=wallet_id, + pending=True, + exclude_uncheckable=True, + ) + for payment in pending_payments: + status = await payment.check_status() + if status.failed: + await update_payment_status( + checking_id=payment.checking_id, + status=PaymentState.FAILED, + ) + elif status.success: + await update_payment_status( + checking_id=payment.checking_id, + status=PaymentState.SUCCESS, + ) diff --git a/lnbits/core/views/hold_invoice_api.py b/lnbits/core/views/hold_invoice_api.py new file mode 100644 index 000000000..a4799864d --- /dev/null +++ b/lnbits/core/views/hold_invoice_api.py @@ -0,0 +1,194 @@ +import binascii +from http import HTTPStatus + +from bolt11 import decode as bolt11_decode +from fastapi import ( + APIRouter, + Body, + Depends, + HTTPException, +) + +from lnbits.core.db import db +from lnbits.core.models import ( + CancelInvoice, + CreateHoldInvoice, + SettleInvoice, +) +from lnbits.core.services import ( + cancel_hold_invoice, + create_hold_invoice, + settle_hold_invoice, + subscribe_hold_invoice, +) +from lnbits.decorators import ( + WalletTypeInfo, + require_invoice_key, + require_admin_key, +) +from lnbits.exceptions import InvoiceError +from lnbits.settings import settings +from lnbits.utils.exchange_rates import fiat_amount_as_satoshis + +hold_invoice_router = APIRouter(prefix="/api/v1/hold_invoice", tags=["Hold Invoice"]) + + +@hold_invoice_router.post( + "/create", + summary="Create a hold invoice", + description=""" + This endpoint can be used to generate a BOLT11 hold invoice. + Specify at least the first four fields in the POST body: `out: false`, + `amount`, `unit`, and `memo`. + """, + status_code=HTTPStatus.CREATED, + responses={ + 400: {"description": "Missing fields."}, + 520: {"description": "Payment or Invoice error."}, + }, +) +async def api_hold_invoice_create( + wallet: WalletTypeInfo = Depends(require_invoice_key), + data: CreateHoldInvoice = Body(...), +): + + if data.description_hash: + description_hash = binascii.unhexlify(data.description_hash) + memo = "" + else: + description_hash = b"" + memo = data.memo or settings.lnbits_site_title + if data.unit == "sat": + amount = int(data.amount) + else: + price_in_sats = await fiat_amount_as_satoshis(data.amount, data.unit) + amount = price_in_sats + if not data.hash: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Hash is required for hold invoices", + ) + rhash = binascii.unhexlify(data.hash) + + async with db.connect() as conn: + try: + _, payment_request = await create_hold_invoice( + wallet_id=wallet.wallet.id, + amount=amount, + rhash=rhash, + memo=memo, + description_hash=description_hash, + extra=data.extra, + webhook=data.webhook, + conn=conn, + ) + except InvoiceError as e: + raise HTTPException(status_code=520, detail=str(e)) + except Exception as exc: + raise exc + + invoice = bolt11_decode(payment_request) + + await subscribe_hold_invoice_internal( + payment_hash=data.hash, + ) + + return { + "payment_hash": invoice.payment_hash, + "payment_request": payment_request, + } + + +@hold_invoice_router.post( + "/settle", + summary="Settle a hold invoice", + description=""" + This endpoint can be used to settle a hold invoice. + """, + status_code=HTTPStatus.OK, + responses={ + 400: {"description": "Invalid preimage."}, + 520: {"description": "Invoice error."}, + }, +) +async def api_hold_invoice_settle( + wallet: WalletTypeInfo = Depends(require_admin_key), + data: SettleInvoice = Body(...), +): + + # Validate preimage length (32 bytes = 64 hex characters) + if len(data.preimage) != 64: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Invalid preimage length. Must be 32 bytes (64 hex characters)", + ) + + try: + settle_result = await settle_hold_invoice( + preimage=binascii.unhexlify(data.preimage), + ) + except InvoiceError as e: + raise HTTPException(status_code=520, detail=str(e)) + except Exception as exc: + raise exc + + return { + "settle_result": str(settle_result), + } + + +@hold_invoice_router.post( + "/cancel", + summary="Cancel a hold invoice", + description=""" + This endpoint can be used to cancel a hold invoice. + """, + status_code=HTTPStatus.OK, + responses={ + 400: {"description": "Invalid payment hash."}, + 520: {"description": "Invoice error."}, + }, +) +async def api_hold_invoice_cancel( + wallet: WalletTypeInfo = Depends(require_admin_key), + data: CancelInvoice = Body(...), +): + + # Validate payment_hash length (32 bytes = 64 hex characters) + if len(data.payment_hash) != 64: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Invalid payment hash length. Must be 32 bytes (64 hex characters)", + ) + + try: + cancel_result = await cancel_hold_invoice( + wallet_id=wallet.wallet.id, + payment_hash=binascii.unhexlify(data.payment_hash), + ) + except InvoiceError as e: + raise HTTPException(status_code=520, detail=str(e)) + except Exception as exc: + raise exc + + return { + "cancel_result": str(cancel_result), + } + + +# Subscribe to a hold invoice is not exposed to the public API but only for +# internal use. It fetches the payment from the database and then reports to +# webhook which was supplied in the create_hold_invoice call. +async def subscribe_hold_invoice_internal( + payment_hash: str, +): + try: + subscribe_result = await subscribe_hold_invoice( + payment_hash=payment_hash, + ) + except Exception as exc: + raise exc + + return { + "subscribe_result": str(subscribe_result), + }